Skip to main content

opendev_tui/widgets/nested_tool/
mod.rs

1//! Nested tool display widget for subagent progress.
2//!
3//! Renders a tree-structured view of subagent tool calls,
4//! showing which subagent is running, its active tool calls,
5//! and completion status with tree connectors (similar to the
6//! Python `NestedToolMixin`).
7
8mod 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
29/// Widget that renders the nested subagent tool display.
30pub 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            // Subagent header line
86            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            // Format token count
113            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            // Build stats suffix
121            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            // Show active tool calls
146            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            // Show last few completed tools (max 3)
181            // Use actual completed_tools len for slicing (it's capped at 100)
182            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            // Show hidden count if there are more completed tools
205            // Use tool_call_count (actual total) since completed_tools is capped at 100
206            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            // Show shallow warning if present
221            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;