opendev_tui/widgets/nested_tool/
mod.rs1mod state;
9
10pub use state::{CompletedToolCall, NestedToolCallState, SubagentDisplayState};
11
12use ratatui::{
13 buffer::Buffer,
14 layout::Rect,
15 style::{Modifier, Style},
16 text::{Line, Span},
17 widgets::{Block, Borders, Paragraph, Widget, Wrap},
18};
19
20use crate::formatters::style_tokens;
21use crate::formatters::tool_line::{
22 ToolLineStyle, format_elapsed, tool_line_active, tool_line_completed,
23};
24use crate::formatters::tool_registry::format_tool_call_parts_short;
25use crate::widgets::spinner::{
26 FAILURE_CHAR, SPINNER_FRAMES, SUCCESS_CHAR, TREE_BRANCH, TREE_LAST, TREE_VERTICAL,
27};
28
29pub struct NestedToolWidget<'a> {
31 subagents: &'a [SubagentDisplayState],
32 working_dir: Option<&'a str>,
33 shortener: Option<&'a crate::formatters::PathShortener>,
34}
35
36impl<'a> NestedToolWidget<'a> {
37 pub fn new(subagents: &'a [SubagentDisplayState]) -> Self {
38 Self {
39 subagents,
40 working_dir: None,
41 shortener: None,
42 }
43 }
44
45 pub fn working_dir(mut self, wd: &'a str) -> Self {
46 self.working_dir = Some(wd);
47 self
48 }
49
50 pub fn path_shortener(mut self, shortener: &'a crate::formatters::PathShortener) -> Self {
51 self.shortener = Some(shortener);
52 self
53 }
54}
55
56impl Widget for NestedToolWidget<'_> {
57 fn render(self, area: Rect, buf: &mut Buffer) {
58 if self.subagents.is_empty() {
59 return;
60 }
61
62 let owned_shortener;
63 let shortener = if let Some(s) = self.shortener {
64 s
65 } else {
66 owned_shortener = crate::formatters::PathShortener::new(self.working_dir);
67 &owned_shortener
68 };
69
70 let block = Block::default()
71 .borders(Borders::TOP)
72 .border_style(Style::default().fg(style_tokens::BORDER))
73 .title(Span::styled(
74 " Subagents ",
75 Style::default()
76 .fg(style_tokens::HEADING_1)
77 .add_modifier(Modifier::BOLD),
78 ));
79
80 let mut lines: Vec<Line> = Vec::new();
81
82 for (i, subagent) in self.subagents.iter().enumerate() {
83 let is_last = i == self.subagents.len() - 1;
84
85 let connector = if is_last { TREE_LAST } else { TREE_BRANCH };
87 let (status_str, status_color) = if subagent.finished {
88 if subagent.success {
89 (SUCCESS_CHAR.to_string(), style_tokens::SUCCESS)
90 } else {
91 (FAILURE_CHAR.to_string(), style_tokens::ERROR)
92 }
93 } else {
94 let slow_tick = subagent.tick / 3;
95 let spinner_idx = slow_tick % SPINNER_FRAMES.len();
96 (
97 SPINNER_FRAMES[spinner_idx].to_string(),
98 style_tokens::BLUE_BRIGHT,
99 )
100 };
101
102 let elapsed = subagent.elapsed_secs();
103 let task_text = shortener.shorten_text(&subagent.task);
104 let task_preview = if task_text.len() > 60 {
105 format!("{}...", &task_text[..60])
106 } else {
107 task_text
108 };
109
110 let elapsed_str = format_elapsed(elapsed);
111
112 let token_str = if subagent.token_count > 0 {
114 let k = subagent.token_count as f64 / 1000.0;
115 format!(" \u{00b7} {k:.1}k tokens")
116 } else {
117 String::new()
118 };
119
120 let stats = format!(
122 " ({} tool uses{} \u{00b7} {})",
123 subagent.tool_call_count, token_str, elapsed_str
124 );
125
126 lines.push(Line::from(vec![
127 Span::styled(
128 format!(" {connector} "),
129 Style::default().fg(style_tokens::SUBTLE),
130 ),
131 Span::styled(format!("{status_str} "), Style::default().fg(status_color)),
132 Span::styled(
133 subagent.name.clone(),
134 Style::default()
135 .fg(style_tokens::CYAN)
136 .add_modifier(Modifier::BOLD),
137 ),
138 Span::styled(
139 format!(": {task_preview}"),
140 Style::default().fg(style_tokens::SUBTLE),
141 ),
142 Span::styled(stats, Style::default().fg(style_tokens::SUBTLE)),
143 ]));
144
145 let vertical = if is_last {
147 " "
148 } else {
149 &format!(" {TREE_VERTICAL} ")
150 };
151 let active_count = subagent.active_tools.len();
152
153 for (j, tool_state) in subagent.active_tools.values().enumerate() {
154 let tool_is_last = j == active_count - 1 && subagent.completed_tools.is_empty();
155 let tool_connector = if tool_is_last { TREE_LAST } else { TREE_BRANCH };
156 let slow_tick = tool_state.tick / 3;
157 let spinner_idx = slow_tick % SPINNER_FRAMES.len();
158 let spinner_ch = SPINNER_FRAMES[spinner_idx];
159 let tool_elapsed = tool_state.started_at.elapsed().as_secs();
160 let (verb, arg) = format_tool_call_parts_short(
161 &tool_state.tool_name,
162 &tool_state.args,
163 shortener,
164 );
165
166 let tree_prefix = vec![Span::styled(
167 format!(" {vertical}{tool_connector} "),
168 Style::default().fg(style_tokens::SUBTLE),
169 )];
170 lines.push(tool_line_active(
171 tree_prefix,
172 spinner_ch,
173 verb,
174 arg,
175 Some(format!("({})", format_elapsed(tool_elapsed))),
176 ToolLineStyle::Nested,
177 ));
178 }
179
180 let completed_start = subagent.completed_tools.len().saturating_sub(3);
183 let visible_completed = &subagent.completed_tools[completed_start..];
184 for (j, completed) in visible_completed.iter().enumerate() {
185 let is_last_tool = j == visible_completed.len() - 1;
186 let tool_connector = if is_last_tool { TREE_LAST } else { TREE_BRANCH };
187 let (verb, arg) =
188 format_tool_call_parts_short(&completed.tool_name, &completed.args, shortener);
189
190 let tree_prefix = vec![Span::styled(
191 format!(" {vertical}{tool_connector} "),
192 Style::default().fg(style_tokens::SUBTLE),
193 )];
194 lines.push(tool_line_completed(
195 tree_prefix,
196 completed.success,
197 verb,
198 arg,
199 Some(format!("({})", format_elapsed(completed.elapsed.as_secs()))),
200 ToolLineStyle::Nested,
201 ));
202 }
203
204 let total_completed = subagent
207 .tool_call_count
208 .saturating_sub(subagent.active_tools.len());
209 let visible_count = visible_completed.len();
210 let hidden_count = total_completed.saturating_sub(visible_count);
211 if hidden_count > 0 {
212 lines.push(Line::from(Span::styled(
213 format!(" {vertical} +{hidden_count} more tool uses (ctrl+b to run in background)"),
214 Style::default()
215 .fg(style_tokens::SUBTLE)
216 .add_modifier(Modifier::ITALIC),
217 )));
218 }
219
220 if let Some(ref warning) = subagent.shallow_warning {
222 lines.push(Line::from(Span::styled(
223 format!(" {vertical} {warning}"),
224 Style::default().fg(style_tokens::WARNING),
225 )));
226 }
227 }
228
229 let paragraph = Paragraph::new(lines).block(block).wrap(Wrap { trim: true });
230
231 paragraph.render(area, buf);
232 }
233}
234
235#[cfg(test)]
236mod tests;