Skip to main content

wisp/components/
tool_call_status_view.rs

1use agent_client_protocol as acp;
2use std::collections::HashMap;
3use std::path::Path;
4
5use crate::components::sub_agent_tracker::{SUB_AGENT_VISIBLE_TOOL_LIMIT, SubAgentState, SubAgentTracker};
6use crate::components::tracked_tool_call::TrackedToolCall;
7use tui::BRAILLE_FRAMES as FRAMES;
8use tui::{DiffPreview, FitOptions, Frame, Line, Style, ViewContext, render_diff};
9
10pub const MAX_TOOL_ARG_LENGTH: usize = 200;
11
12/// Render a tool call and its sub-agent hierarchy (if any) as a frame.
13pub(crate) fn render_tool_tree(
14    id: &str,
15    tool_calls: &HashMap<String, TrackedToolCall>,
16    sub_agents: &SubAgentTracker,
17    tick: u16,
18    context: &ViewContext,
19) -> Frame {
20    let has_sub_agents = sub_agents.has_sub_agents(id);
21
22    let mut frames: Vec<Frame> = Vec::new();
23    if !has_sub_agents && let Some(tc) = tool_calls.get(id) {
24        frames.push(tool_call_view(tc, tick).render(context));
25    }
26
27    if let Some(agents) = sub_agents.get(id) {
28        for (i, agent) in agents.iter().enumerate() {
29            if i > 0 {
30                frames.push(Frame::new(vec![Line::default()]));
31            }
32            frames.push(render_agent_header(agent, tick, context));
33
34            let hidden_count = agent.tool_order.len().saturating_sub(SUB_AGENT_VISIBLE_TOOL_LIMIT);
35
36            if hidden_count > 0 {
37                let mut summary = Line::default();
38                summary.push_styled(format!("  … {hidden_count} earlier tool calls"), context.theme.muted());
39                frames.push(Frame::new(vec![summary]));
40            }
41
42            let mut visible = agent
43                .tool_order
44                .iter()
45                .skip(hidden_count)
46                .filter_map(|tool_id| agent.tool_calls.get(tool_id))
47                .peekable();
48
49            let muted = Style::fg(context.theme.muted());
50            while let Some(tc) = visible.next() {
51                let is_last = visible.peek().is_none();
52                let (head_str, tail_str) = if is_last { ("  └─ ", "     ") } else { ("  ├─ ", "  │  ") };
53                let head = Line::with_style(head_str, muted);
54                let tail = Line::with_style(tail_str, muted);
55
56                frames.push(tool_call_view(tc, tick).render(context).prefix(&head, &tail));
57            }
58        }
59    }
60
61    Frame::vstack(frames).fit(context.size.width, FitOptions::wrap())
62}
63
64pub(crate) fn tool_call_view(tc: &TrackedToolCall, tick: u16) -> ToolCallStatusView<'_> {
65    ToolCallStatusView {
66        name: &tc.name,
67        arguments: &tc.arguments,
68        display_value: tc.display_value.as_deref(),
69        diff_preview: tc.diff_preview.as_ref(),
70        status: &tc.status,
71        tick,
72    }
73}
74
75/// Renders a single tool call status line.
76pub struct ToolCallStatusView<'a> {
77    pub name: &'a str,
78    pub arguments: &'a str,
79    pub display_value: Option<&'a str>,
80    pub diff_preview: Option<&'a DiffPreview>,
81    pub status: &'a ToolCallStatus,
82    pub tick: u16,
83}
84
85#[derive(Clone)]
86pub enum ToolCallStatus {
87    Running,
88    Success,
89    Error(String),
90}
91
92impl ToolCallStatusView<'_> {
93    pub fn render(&self, context: &ViewContext) -> Frame {
94        let (indicator, indicator_color) = match &self.status {
95            ToolCallStatus::Running => {
96                let frame = FRAMES[self.tick as usize % FRAMES.len()];
97                (frame.to_string(), context.theme.info())
98            }
99            ToolCallStatus::Success => ("✓".to_string(), context.theme.success()),
100            ToolCallStatus::Error(_) => ("✗".to_string(), context.theme.error()),
101        };
102
103        let mut line = Line::default();
104        line.push_styled(indicator, indicator_color);
105        line.push_text(" ");
106        line.push_text(self.name);
107
108        let display_text = self.display_value.filter(|v| !v.is_empty()).map_or_else(
109            || match self.status {
110                ToolCallStatus::Running => String::new(),
111                _ => format_arguments(self.arguments),
112            },
113            |v| format!(" ({v})"),
114        );
115        line.push_styled(display_text, context.theme.muted());
116
117        if let ToolCallStatus::Error(msg) = &self.status {
118            line.push_text(" ");
119            line.push_styled(msg, context.theme.error());
120        }
121
122        let mut lines = vec![line];
123
124        if matches!(self.status, ToolCallStatus::Success)
125            && let Some(preview) = self.diff_preview
126        {
127            lines.extend(render_diff(preview, context));
128        }
129
130        Frame::new(lines).fit(context.size.width, FitOptions::wrap())
131    }
132}
133
134pub(super) fn diff_preview_from_acp(diff: &acp::Diff) -> DiffPreview {
135    let old_text = diff.old_text.as_deref().unwrap_or("");
136    let lang_hint = Path::new(&diff.path).extension().and_then(|ext| ext.to_str()).unwrap_or("").to_lowercase();
137    DiffPreview::compute_trimmed(old_text, &diff.new_text, &lang_hint)
138}
139
140fn render_agent_header(agent: &SubAgentState, tick: u16, context: &ViewContext) -> Frame {
141    let mut line = Line::default();
142    line.push_text("  ");
143    if agent.done {
144        line.push_styled("✓".to_string(), context.theme.success());
145    } else {
146        let frame = FRAMES[tick as usize % FRAMES.len()];
147        line.push_styled(frame.to_string(), context.theme.info());
148    }
149    line.push_text(" ");
150    line.push_text(&agent.agent_name);
151    Frame::new(vec![line])
152}
153
154fn format_arguments(arguments: &str) -> String {
155    let mut formatted = format!(" {arguments}");
156    if formatted.len() > MAX_TOOL_ARG_LENGTH {
157        let mut new_len = MAX_TOOL_ARG_LENGTH;
158        while !formatted.is_char_boundary(new_len) {
159            new_len -= 1;
160        }
161        formatted.truncate(new_len);
162    }
163    formatted
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use tui::{DiffTag, SplitDiffRow};
170
171    fn is_context_row(row: &SplitDiffRow) -> bool {
172        row.left.as_ref().is_none_or(|c| c.tag == DiffTag::Context)
173            && row.right.as_ref().is_none_or(|c| c.tag == DiffTag::Context)
174    }
175
176    fn make_large_file(num_lines: usize) -> String {
177        (1..=num_lines).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n")
178    }
179
180    fn replace_line(text: &str, line_num: usize, replacement: &str) -> String {
181        text.lines()
182            .enumerate()
183            .map(|(i, l)| if i + 1 == line_num { replacement } else { l })
184            .collect::<Vec<_>>()
185            .join("\n")
186    }
187
188    #[test]
189    fn diff_preview_for_edit_near_end_contains_change() {
190        let old = make_large_file(50);
191        let new = replace_line(&old, 45, "CHANGED LINE 45");
192
193        let diff = acp::Diff::new("test.rs", new).old_text(old);
194        let preview = diff_preview_from_acp(&diff);
195
196        let has_change = preview.lines.iter().any(|l| l.tag != DiffTag::Context);
197        assert!(has_change, "preview must contain the changed lines");
198    }
199
200    #[test]
201    fn diff_preview_trims_leading_context() {
202        let old = make_large_file(50);
203        let new = replace_line(&old, 45, "CHANGED LINE 45");
204
205        let diff = acp::Diff::new("test.rs", new).old_text(old);
206        let preview = diff_preview_from_acp(&diff);
207
208        assert!(
209            preview.lines.len() <= 10,
210            "expected at most ~10 lines (3 context + change + 3 context), got {}",
211            preview.lines.len()
212        );
213    }
214
215    #[test]
216    fn diff_preview_start_line_adjusted_after_trim() {
217        let old = make_large_file(50);
218        let new = replace_line(&old, 45, "CHANGED LINE 45");
219
220        let diff = acp::Diff::new("test.rs", new).old_text(old);
221        let preview = diff_preview_from_acp(&diff);
222
223        let start = preview.start_line.expect("start_line should be set");
224        assert!(start >= 42, "start_line should be near the edit (line 45), got {start}");
225    }
226
227    #[test]
228    fn compute_diff_preview_produces_nonempty_rows_with_correct_pairing() {
229        let old = "aaa\nbbb\nccc\n";
230        let new = "aaa\nBBB\nccc\n";
231        let diff = acp::Diff::new("test.txt", new).old_text(old);
232        let preview = diff_preview_from_acp(&diff);
233
234        assert!(!preview.rows.is_empty(), "rows should not be empty");
235        // The replace op should produce a paired row with both left (removed) and right (added)
236        let paired = preview.rows.iter().find(|r| r.left.is_some() && r.right.is_some() && !is_context_row(r));
237        assert!(paired.is_some(), "should have a paired replace row");
238        let row = paired.unwrap();
239        assert_eq!(row.left.as_ref().unwrap().tag, DiffTag::Removed);
240        assert_eq!(row.right.as_ref().unwrap().tag, DiffTag::Added);
241        assert_eq!(row.left.as_ref().unwrap().content, "bbb");
242        assert_eq!(row.right.as_ref().unwrap().content, "BBB");
243    }
244
245    #[test]
246    fn delete_only_produces_rows_with_right_none() {
247        let old = "aaa\nbbb\nccc\n";
248        let new = "aaa\nccc\n";
249        let diff = acp::Diff::new("test.txt", new).old_text(old);
250        let preview = diff_preview_from_acp(&diff);
251
252        let delete_row = preview.rows.iter().find(|r| r.left.as_ref().is_some_and(|c| c.tag == DiffTag::Removed));
253        assert!(delete_row.is_some(), "should have a delete row");
254        assert!(delete_row.unwrap().right.is_none());
255    }
256
257    #[test]
258    fn insert_only_produces_rows_with_left_none() {
259        let old = "aaa\nccc\n";
260        let new = "aaa\nbbb\nccc\n";
261        let diff = acp::Diff::new("test.txt", new).old_text(old);
262        let preview = diff_preview_from_acp(&diff);
263
264        let insert_row = preview.rows.iter().find(|r| r.right.as_ref().is_some_and(|c| c.tag == DiffTag::Added));
265        assert!(insert_row.is_some(), "should have an insert row");
266        assert!(insert_row.unwrap().left.is_none());
267    }
268
269    #[test]
270    fn context_trimming_applies_consistently_to_lines_and_rows() {
271        let old = make_large_file(50);
272        let new = replace_line(&old, 25, "CHANGED LINE 25");
273        let diff = acp::Diff::new("test.rs", new).old_text(old);
274        let preview = diff_preview_from_acp(&diff);
275
276        // Both should be trimmed to roughly the same size (3 context + changes + 3 context)
277        assert!(preview.lines.len() <= 10, "lines should be trimmed, got {}", preview.lines.len());
278        assert!(preview.rows.len() <= 10, "rows should be trimmed, got {}", preview.rows.len());
279
280        // Both should contain change indicators
281        let has_line_change = preview.lines.iter().any(|l| l.tag != DiffTag::Context);
282        let has_row_change = preview.rows.iter().any(|r| !is_context_row(r));
283        assert!(has_line_change, "lines should contain changes");
284        assert!(has_row_change, "rows should contain changes");
285    }
286}