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
17pub(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
98pub 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
157pub(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 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 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 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}