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
13pub(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
81pub 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
140pub(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 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 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 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}