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
12pub(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
75pub 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 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 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 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}