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