Skip to main content

wisp/components/
tool_call_status_view.rs

1use agent_client_protocol as acp;
2use similar::{DiffOp, TextDiff};
3use std::collections::HashMap;
4use std::path::Path;
5
6use crate::components::sub_agent_tracker::{SUB_AGENT_VISIBLE_TOOL_LIMIT, SubAgentState, SubAgentTracker};
7use crate::components::tracked_tool_call::TrackedToolCall;
8use tui::BRAILLE_FRAMES as FRAMES;
9use tui::{DiffLine, DiffPreview, DiffTag, Line, SplitDiffCell, SplitDiffRow, ViewContext, render_diff};
10
11pub const MAX_TOOL_ARG_LENGTH: usize = 200;
12
13/// Render a tool call and its sub-agent hierarchy (if any) as status lines.
14pub(crate) fn render_tool_tree(
15    id: &str,
16    tool_calls: &HashMap<String, TrackedToolCall>,
17    sub_agents: &SubAgentTracker,
18    tick: u16,
19    context: &ViewContext,
20) -> Vec<Line> {
21    let has_sub_agents = sub_agents.has_sub_agents(id);
22
23    let mut lines = if has_sub_agents {
24        Vec::new()
25    } else {
26        tool_calls.get(id).map(|tc| tool_call_view(tc, tick).render(context)).unwrap_or_default()
27    };
28
29    if let Some(agents) = sub_agents.get(id) {
30        for (i, agent) in agents.iter().enumerate() {
31            if i > 0 {
32                lines.push(Line::default());
33            }
34            lines.push(render_agent_header(agent, tick, context));
35
36            let hidden_count = agent.tool_order.len().saturating_sub(SUB_AGENT_VISIBLE_TOOL_LIMIT);
37
38            if hidden_count > 0 {
39                let mut summary = Line::default();
40                summary.push_styled(format!("  … {hidden_count} earlier tool calls"), context.theme.muted());
41                lines.push(summary);
42            }
43
44            let mut visible = agent
45                .tool_order
46                .iter()
47                .skip(hidden_count)
48                .filter_map(|tool_id| agent.tool_calls.get(tool_id))
49                .peekable();
50
51            while let Some(tc) = visible.next() {
52                let connector = if visible.peek().is_some() { "  ├─ " } else { "  └─ " };
53
54                let view = tool_call_view(tc, tick);
55                for tool_line in view.render(context) {
56                    let mut indented = Line::default();
57                    indented.push_styled(connector, context.theme.muted());
58                    for span in tool_line.spans() {
59                        indented.push_with_style(span.text(), span.style());
60                    }
61                    lines.push(indented);
62                }
63            }
64        }
65    }
66
67    lines
68}
69
70pub(crate) fn tool_call_view(tc: &TrackedToolCall, tick: u16) -> ToolCallStatusView<'_> {
71    ToolCallStatusView {
72        name: &tc.name,
73        arguments: &tc.arguments,
74        display_value: tc.display_value.as_deref(),
75        diff_preview: tc.diff_preview.as_ref(),
76        status: &tc.status,
77        tick,
78    }
79}
80
81/// Renders a single tool call status line.
82pub struct ToolCallStatusView<'a> {
83    pub name: &'a str,
84    pub arguments: &'a str,
85    pub display_value: Option<&'a str>,
86    pub diff_preview: Option<&'a DiffPreview>,
87    pub status: &'a ToolCallStatus,
88    pub tick: u16,
89}
90
91#[derive(Clone)]
92pub enum ToolCallStatus {
93    Running,
94    Success,
95    Error(String),
96}
97
98impl ToolCallStatusView<'_> {
99    pub fn render(&self, context: &ViewContext) -> Vec<Line> {
100        let (indicator, indicator_color) = match &self.status {
101            ToolCallStatus::Running => {
102                let frame = FRAMES[self.tick as usize % FRAMES.len()];
103                (frame.to_string(), context.theme.info())
104            }
105            ToolCallStatus::Success => ("✓".to_string(), context.theme.success()),
106            ToolCallStatus::Error(_) => ("✗".to_string(), context.theme.error()),
107        };
108
109        let mut line = Line::default();
110        line.push_styled(indicator, indicator_color);
111        line.push_text(" ");
112        line.push_text(self.name);
113
114        let display_text = self.display_value.filter(|v| !v.is_empty()).map_or_else(
115            || match self.status {
116                ToolCallStatus::Running => String::new(),
117                _ => format_arguments(self.arguments),
118            },
119            |v| format!(" ({v})"),
120        );
121        line.push_styled(display_text, context.theme.muted());
122
123        if let ToolCallStatus::Error(msg) = &self.status {
124            line.push_text(" ");
125            line.push_styled(msg, context.theme.error());
126        }
127
128        let mut lines = vec![line];
129
130        if matches!(self.status, ToolCallStatus::Success)
131            && let Some(preview) = self.diff_preview
132        {
133            lines.extend(render_diff(preview, context));
134        }
135
136        lines
137    }
138}
139
140/// Compute a visual diff preview from an ACP `Diff` (full old/new text).
141///
142/// Produces both a flat `lines` list (for the unified renderer) and structurally
143/// paired `rows` (for the split side-by-side renderer) using `similar::TextDiff::ops()`.
144pub(super) fn compute_diff_preview(diff: &acp::Diff) -> DiffPreview {
145    let old_text = diff.old_text.as_deref().unwrap_or("");
146    let new_text = &diff.new_text;
147    let text_diff = TextDiff::from_lines(old_text, new_text);
148
149    let old_lines: Vec<&str> = old_text.lines().collect();
150    let new_lines: Vec<&str> = new_text.lines().collect();
151
152    let mut state = DiffBuildState::default();
153    for op in text_diff.ops() {
154        process_diff_op(*op, &old_lines, &new_lines, &mut state);
155    }
156
157    let DiffBuildState { mut lines, mut rows, mut first_change_line, .. } = state;
158
159    trim_context(&mut lines, &mut rows, &mut first_change_line);
160
161    let lang_hint = Path::new(&diff.path).extension().and_then(|ext| ext.to_str()).unwrap_or("").to_lowercase();
162
163    DiffPreview { lines, rows, lang_hint, start_line: first_change_line }
164}
165
166#[derive(Default)]
167struct DiffBuildState {
168    lines: Vec<DiffLine>,
169    rows: Vec<SplitDiffRow>,
170    first_change_line: Option<usize>,
171    old_line_num: usize,
172    new_line_num: usize,
173}
174
175fn get_line<'a>(lines: &[&'a str], index: usize) -> &'a str {
176    lines.get(index).unwrap_or(&"").trim_end_matches('\n')
177}
178
179#[allow(clippy::too_many_lines)]
180fn process_diff_op(op: DiffOp, old: &[&str], new: &[&str], s: &mut DiffBuildState) {
181    match op {
182        DiffOp::Equal { old_index, len, .. } => {
183            for i in 0..len {
184                s.old_line_num += 1;
185                s.new_line_num += 1;
186                let content = get_line(old, old_index + i).to_string();
187                s.lines.push(DiffLine { tag: DiffTag::Context, content: content.clone() });
188                s.rows.push(SplitDiffRow {
189                    left: Some(SplitDiffCell {
190                        tag: DiffTag::Context,
191                        content: content.clone(),
192                        line_number: Some(s.old_line_num),
193                    }),
194                    right: Some(SplitDiffCell { tag: DiffTag::Context, content, line_number: Some(s.new_line_num) }),
195                });
196            }
197        }
198        DiffOp::Delete { old_index, old_len, .. } => {
199            if s.first_change_line.is_none() {
200                s.first_change_line = Some(s.old_line_num + 1);
201            }
202            for i in 0..old_len {
203                s.old_line_num += 1;
204                let content = get_line(old, old_index + i).to_string();
205                s.lines.push(DiffLine { tag: DiffTag::Removed, content: content.clone() });
206                s.rows.push(SplitDiffRow {
207                    left: Some(SplitDiffCell { tag: DiffTag::Removed, content, line_number: Some(s.old_line_num) }),
208                    right: None,
209                });
210            }
211        }
212        DiffOp::Insert { new_index, new_len, .. } => {
213            if s.first_change_line.is_none() {
214                s.first_change_line = Some(s.old_line_num + 1);
215            }
216            for i in 0..new_len {
217                s.new_line_num += 1;
218                let content = get_line(new, new_index + i).to_string();
219                s.lines.push(DiffLine { tag: DiffTag::Added, content: content.clone() });
220                s.rows.push(SplitDiffRow {
221                    left: None,
222                    right: Some(SplitDiffCell { tag: DiffTag::Added, content, line_number: Some(s.new_line_num) }),
223                });
224            }
225        }
226        DiffOp::Replace { old_index, old_len, new_index, new_len } => {
227            if s.first_change_line.is_none() {
228                s.first_change_line = Some(s.old_line_num + 1);
229            }
230            for i in 0..old_len {
231                s.lines.push(DiffLine { tag: DiffTag::Removed, content: get_line(old, old_index + i).to_string() });
232            }
233            for i in 0..new_len {
234                s.lines.push(DiffLine { tag: DiffTag::Added, content: get_line(new, new_index + i).to_string() });
235            }
236            for i in 0..old_len.max(new_len) {
237                let left = (i < old_len).then(|| {
238                    s.old_line_num += 1;
239                    SplitDiffCell {
240                        tag: DiffTag::Removed,
241                        content: get_line(old, old_index + i).to_string(),
242                        line_number: Some(s.old_line_num),
243                    }
244                });
245                let right = (i < new_len).then(|| {
246                    s.new_line_num += 1;
247                    SplitDiffCell {
248                        tag: DiffTag::Added,
249                        content: get_line(new, new_index + i).to_string(),
250                        line_number: Some(s.new_line_num),
251                    }
252                });
253                s.rows.push(SplitDiffRow { left, right });
254            }
255        }
256    }
257}
258
259fn trim_context(lines: &mut Vec<DiffLine>, rows: &mut Vec<SplitDiffRow>, first_change_line: &mut Option<usize>) {
260    const CONTEXT_LINES: usize = 3;
261
262    let first_change_idx = lines.iter().position(|l| l.tag != DiffTag::Context);
263    let last_change_idx = lines.iter().rposition(|l| l.tag != DiffTag::Context);
264
265    if let (Some(first), Some(last)) = (first_change_idx, last_change_idx) {
266        let start = first.saturating_sub(CONTEXT_LINES);
267        let end = (last + CONTEXT_LINES + 1).min(lines.len());
268        lines.drain(..start);
269        lines.truncate(end - start);
270        let trimmed_context = first - start;
271        *first_change_line = first_change_line.map(|l| l - trimmed_context);
272    }
273
274    let first_row = rows.iter().position(|r| !is_context_row(r));
275    let last_row = rows.iter().rposition(|r| !is_context_row(r));
276
277    if let (Some(first), Some(last)) = (first_row, last_row) {
278        let start = first.saturating_sub(CONTEXT_LINES);
279        let end = (last + CONTEXT_LINES + 1).min(rows.len());
280        rows.drain(..start);
281        rows.truncate(end - start);
282    }
283}
284
285fn is_context_row(row: &SplitDiffRow) -> bool {
286    row.left.as_ref().is_none_or(|c| c.tag == DiffTag::Context)
287        && row.right.as_ref().is_none_or(|c| c.tag == DiffTag::Context)
288}
289
290fn render_agent_header(agent: &SubAgentState, tick: u16, context: &ViewContext) -> Line {
291    let mut line = Line::default();
292    line.push_text("  ");
293    if agent.done {
294        line.push_styled("✓".to_string(), context.theme.success());
295    } else {
296        let frame = FRAMES[tick as usize % FRAMES.len()];
297        line.push_styled(frame.to_string(), context.theme.info());
298    }
299    line.push_text(" ");
300    line.push_text(&agent.agent_name);
301    line
302}
303
304fn format_arguments(arguments: &str) -> String {
305    let mut formatted = format!(" {arguments}");
306    if formatted.len() > MAX_TOOL_ARG_LENGTH {
307        let mut new_len = MAX_TOOL_ARG_LENGTH;
308        while !formatted.is_char_boundary(new_len) {
309            new_len -= 1;
310        }
311        formatted.truncate(new_len);
312    }
313    formatted
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    fn make_large_file(num_lines: usize) -> String {
321        (1..=num_lines).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n")
322    }
323
324    fn replace_line(text: &str, line_num: usize, replacement: &str) -> String {
325        text.lines()
326            .enumerate()
327            .map(|(i, l)| if i + 1 == line_num { replacement } else { l })
328            .collect::<Vec<_>>()
329            .join("\n")
330    }
331
332    #[test]
333    fn diff_preview_for_edit_near_end_contains_change() {
334        let old = make_large_file(50);
335        let new = replace_line(&old, 45, "CHANGED LINE 45");
336
337        let diff = acp::Diff::new("test.rs", new).old_text(old);
338        let preview = compute_diff_preview(&diff);
339
340        let has_change = preview.lines.iter().any(|l| l.tag != DiffTag::Context);
341        assert!(has_change, "preview must contain the changed lines");
342    }
343
344    #[test]
345    fn diff_preview_trims_leading_context() {
346        let old = make_large_file(50);
347        let new = replace_line(&old, 45, "CHANGED LINE 45");
348
349        let diff = acp::Diff::new("test.rs", new).old_text(old);
350        let preview = compute_diff_preview(&diff);
351
352        assert!(
353            preview.lines.len() <= 10,
354            "expected at most ~10 lines (3 context + change + 3 context), got {}",
355            preview.lines.len()
356        );
357    }
358
359    #[test]
360    fn diff_preview_start_line_adjusted_after_trim() {
361        let old = make_large_file(50);
362        let new = replace_line(&old, 45, "CHANGED LINE 45");
363
364        let diff = acp::Diff::new("test.rs", new).old_text(old);
365        let preview = compute_diff_preview(&diff);
366
367        let start = preview.start_line.expect("start_line should be set");
368        assert!(start >= 42, "start_line should be near the edit (line 45), got {start}");
369    }
370
371    #[test]
372    fn compute_diff_preview_produces_nonempty_rows_with_correct_pairing() {
373        let old = "aaa\nbbb\nccc\n";
374        let new = "aaa\nBBB\nccc\n";
375        let diff = acp::Diff::new("test.txt", new).old_text(old);
376        let preview = compute_diff_preview(&diff);
377
378        assert!(!preview.rows.is_empty(), "rows should not be empty");
379        // The replace op should produce a paired row with both left (removed) and right (added)
380        let paired = preview.rows.iter().find(|r| r.left.is_some() && r.right.is_some() && !is_context_row(r));
381        assert!(paired.is_some(), "should have a paired replace row");
382        let row = paired.unwrap();
383        assert_eq!(row.left.as_ref().unwrap().tag, DiffTag::Removed);
384        assert_eq!(row.right.as_ref().unwrap().tag, DiffTag::Added);
385        assert_eq!(row.left.as_ref().unwrap().content, "bbb");
386        assert_eq!(row.right.as_ref().unwrap().content, "BBB");
387    }
388
389    #[test]
390    fn delete_only_produces_rows_with_right_none() {
391        let old = "aaa\nbbb\nccc\n";
392        let new = "aaa\nccc\n";
393        let diff = acp::Diff::new("test.txt", new).old_text(old);
394        let preview = compute_diff_preview(&diff);
395
396        let delete_row = preview.rows.iter().find(|r| r.left.as_ref().is_some_and(|c| c.tag == DiffTag::Removed));
397        assert!(delete_row.is_some(), "should have a delete row");
398        assert!(delete_row.unwrap().right.is_none());
399    }
400
401    #[test]
402    fn insert_only_produces_rows_with_left_none() {
403        let old = "aaa\nccc\n";
404        let new = "aaa\nbbb\nccc\n";
405        let diff = acp::Diff::new("test.txt", new).old_text(old);
406        let preview = compute_diff_preview(&diff);
407
408        let insert_row = preview.rows.iter().find(|r| r.right.as_ref().is_some_and(|c| c.tag == DiffTag::Added));
409        assert!(insert_row.is_some(), "should have an insert row");
410        assert!(insert_row.unwrap().left.is_none());
411    }
412
413    #[test]
414    fn context_trimming_applies_consistently_to_lines_and_rows() {
415        let old = make_large_file(50);
416        let new = replace_line(&old, 25, "CHANGED LINE 25");
417        let diff = acp::Diff::new("test.rs", new).old_text(old);
418        let preview = compute_diff_preview(&diff);
419
420        // Both should be trimmed to roughly the same size (3 context + changes + 3 context)
421        assert!(preview.lines.len() <= 10, "lines should be trimmed, got {}", preview.lines.len());
422        assert!(preview.rows.len() <= 10, "rows should be trimmed, got {}", preview.rows.len());
423
424        // Both should contain change indicators
425        let has_line_change = preview.lines.iter().any(|l| l.tag != DiffTag::Context);
426        let has_row_change = preview.rows.iter().any(|r| !is_context_row(r));
427        assert!(has_line_change, "lines should contain changes");
428        assert!(has_row_change, "rows should contain changes");
429    }
430}