1use imp_core::config::AnimationLevel;
2use imp_llm::truncate_chars_with_suffix;
3use ratatui::buffer::Buffer;
4use ratatui::layout::Rect;
5use ratatui::style::{Modifier, Style};
6use ratatui::text::{Line, Span};
7use ratatui::widgets::Widget;
8use serde_json::Value;
9
10use crate::animation::spinner_frame;
11use crate::theme::Theme;
12
13fn abbreviate_home_path(path: &str) -> String {
14 for prefix in ["/Users/", "/home/"] {
15 if let Some(rest) = path.strip_prefix(prefix) {
16 if let Some((_, suffix)) = rest.split_once('/') {
17 return format!("~/{suffix}");
18 }
19 return "~".to_string();
20 }
21 }
22 path.to_string()
23}
24
25fn abbreviate_path_list(items: &[Value]) -> String {
26 items
27 .iter()
28 .filter_map(|v| v.as_str())
29 .map(abbreviate_home_path)
30 .collect::<Vec<_>>()
31 .join(", ")
32}
33
34fn shell_summary(args: &Value) -> String {
35 let command = args
36 .get("command")
37 .and_then(|v| v.as_str())
38 .unwrap_or("")
39 .trim_start();
40 if command.is_empty() {
41 return String::new();
42 }
43
44 let label = if command.starts_with("rg ")
45 || command.starts_with("grep ")
46 || command.starts_with("fd ")
47 || command.starts_with("find ")
48 || command == "find"
49 || command.starts_with("ls ")
50 || command == "ls"
51 {
52 "search"
53 } else if command.contains("check")
54 || command.contains("test")
55 || command.contains("verify")
56 || command.contains("lint")
57 {
58 "check"
59 } else {
60 "run"
61 };
62
63 label.to_string()
64}
65
66#[derive(Debug, Clone)]
68pub struct DisplayToolCall {
69 pub id: String,
70 pub name: String,
71 pub args_summary: String,
72 pub output: Option<String>,
73 pub details: serde_json::Value,
74 pub is_error: bool,
75 pub expanded: bool,
76 pub streaming_lines: Vec<String>,
78 pub streaming_output: String,
80}
81
82impl DisplayToolCall {
83 pub fn header_line(&self, theme: &Theme) -> Line<'static> {
85 self.header_line_animated(theme, 0, AnimationLevel::Minimal)
86 }
87
88 pub fn header_line_animated(
90 &self,
91 theme: &Theme,
92 tick: u64,
93 animation_level: AnimationLevel,
94 ) -> Line<'static> {
95 self.header_line_animated_focused(theme, tick, false, animation_level)
96 }
97
98 pub fn header_line_animated_focused(
100 &self,
101 theme: &Theme,
102 tick: u64,
103 focused: bool,
104 animation_level: AnimationLevel,
105 ) -> Line<'static> {
106 let is_running = self.output.is_none() && !self.is_error;
107 let icon = if self.is_error {
108 "✗".to_string()
109 } else if is_running {
110 match animation_level {
111 AnimationLevel::None => "•".to_string(),
112 AnimationLevel::Spinner | AnimationLevel::Minimal => {
113 spinner_frame(tick).to_string()
114 }
115 }
116 } else {
117 "✓".to_string()
118 };
119 let icon_style = if self.is_error {
120 theme.error_style()
121 } else if is_running {
122 Style::default().fg(theme.accent)
123 } else {
124 theme.success_style()
125 };
126
127 let focus_span = if focused {
129 Span::styled(
130 "▸",
131 Style::default()
132 .fg(theme.accent)
133 .add_modifier(Modifier::BOLD),
134 )
135 } else {
136 Span::raw(" ")
137 };
138
139 let mut spans = vec![
140 focus_span,
141 Span::styled(format!(" {icon} "), icon_style),
142 Span::styled(
143 self.name.clone(),
144 Style::default()
145 .fg(theme.tool_name)
146 .add_modifier(Modifier::BOLD),
147 ),
148 ];
149
150 if !self.args_summary.is_empty() {
151 spans.push(Span::raw(" "));
152 spans.push(Span::styled(self.args_summary.clone(), theme.muted_style()));
153 }
154
155 if !self.expanded {
157 if let Some(ref output) = self.output {
158 if self.is_error {
159 spans.push(Span::styled(" error", theme.error_style()));
160 } else {
161 let line_count = output.lines().count();
162 spans.push(Span::styled(
163 format!(" {line_count} lines"),
164 theme.muted_style(),
165 ));
166 }
167 }
168 }
169
170 Line::from(spans)
171 }
172
173 pub fn compact_spans(&self, theme: &Theme) -> Vec<Span<'static>> {
175 let icon_style = theme.success_style();
176 let args_short = short_args(&self.args_summary);
177 let mut spans = vec![
178 Span::styled("✓ ", icon_style),
179 Span::styled(
180 self.name.clone(),
181 Style::default()
182 .fg(theme.tool_name)
183 .add_modifier(Modifier::BOLD),
184 ),
185 ];
186 if !args_short.is_empty() {
187 spans.push(Span::styled(format!(" {args_short}"), theme.muted_style()));
188 }
189 spans
190 }
191
192 pub fn make_args_summary(name: &str, args: &serde_json::Value) -> String {
194 match name {
195 "read" => args
196 .get("path")
197 .and_then(|v| v.as_str())
198 .map(abbreviate_home_path)
199 .unwrap_or_default(),
200 "bash" => shell_summary(args),
201 "edit" | "write" => args
202 .get("path")
203 .and_then(|v| v.as_str())
204 .map(abbreviate_home_path)
205 .unwrap_or_default(),
206 "scan" => {
207 let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("");
208 match action {
209 "extract" => args
210 .get("files")
211 .and_then(|v| v.as_array())
212 .map(|items| abbreviate_path_list(items))
213 .unwrap_or_else(|| "extract".to_string()),
214 "scan" => args
215 .get("directory")
216 .and_then(|v| v.as_str())
217 .map(abbreviate_home_path)
218 .unwrap_or_default(),
219 _ => {
220 if action == name {
221 String::new()
222 } else {
223 action.to_string()
224 }
225 }
226 }
227 }
228 "mana" => format_mana_args(args),
229 _ => summarize_json_object(args),
230 }
231 }
232}
233
234fn format_mana_args(args: &Value) -> String {
235 let action = args.get("action").and_then(Value::as_str).unwrap_or("?");
236 let mut fields = Vec::new();
237
238 match action {
239 "create" => {
240 push_field(
241 &mut fields,
242 "title",
243 args.get("title")
244 .and_then(Value::as_str)
245 .map(str::to_string),
246 );
247 push_field(
248 &mut fields,
249 "priority",
250 args.get("priority").and_then(value_to_short_string),
251 );
252 push_field(
253 &mut fields,
254 "parent",
255 args.get("parent")
256 .and_then(Value::as_str)
257 .map(str::to_string),
258 );
259 push_field(
260 &mut fields,
261 "verify",
262 args.get("verify")
263 .and_then(Value::as_str)
264 .map(str::to_string),
265 );
266 push_field(
267 &mut fields,
268 "deps",
269 args.get("deps").and_then(Value::as_str).map(str::to_string),
270 );
271 }
272 "update" => {
273 push_field(
274 &mut fields,
275 "id",
276 args.get("id").and_then(Value::as_str).map(str::to_string),
277 );
278 push_field(
279 &mut fields,
280 "status",
281 args.get("status")
282 .and_then(Value::as_str)
283 .map(str::to_string),
284 );
285 push_field(
286 &mut fields,
287 "title",
288 args.get("title")
289 .and_then(Value::as_str)
290 .map(str::to_string),
291 );
292 push_field(
293 &mut fields,
294 "priority",
295 args.get("priority").and_then(value_to_short_string),
296 );
297 push_field(
298 &mut fields,
299 "notes",
300 args.get("notes")
301 .and_then(Value::as_str)
302 .map(str::to_string),
303 );
304 }
305 "run" => {
306 push_field(
307 &mut fields,
308 "id",
309 args.get("id").and_then(Value::as_str).map(str::to_string),
310 );
311 push_field(
312 &mut fields,
313 "jobs",
314 args.get("jobs").and_then(value_to_short_string),
315 );
316 push_field(
317 &mut fields,
318 "background",
319 args.get("background").and_then(value_to_short_string),
320 );
321 push_field(
322 &mut fields,
323 "dry_run",
324 args.get("dry_run").and_then(value_to_short_string),
325 );
326 push_field(
327 &mut fields,
328 "review",
329 args.get("review").and_then(value_to_short_string),
330 );
331 }
332 "show" | "close" | "claim" | "release" | "logs" | "tree" => {
333 push_field(
334 &mut fields,
335 "id",
336 args.get("id").and_then(Value::as_str).map(str::to_string),
337 );
338 push_field(
339 &mut fields,
340 "run_id",
341 args.get("run_id")
342 .and_then(Value::as_str)
343 .map(str::to_string),
344 );
345 push_field(
346 &mut fields,
347 "reason",
348 args.get("reason")
349 .and_then(Value::as_str)
350 .map(str::to_string),
351 );
352 push_field(
353 &mut fields,
354 "by",
355 args.get("by").and_then(Value::as_str).map(str::to_string),
356 );
357 }
358 "list" => {
359 push_field(
360 &mut fields,
361 "status",
362 args.get("status")
363 .and_then(Value::as_str)
364 .map(str::to_string),
365 );
366 push_field(
367 &mut fields,
368 "parent",
369 args.get("parent")
370 .and_then(Value::as_str)
371 .map(str::to_string),
372 );
373 push_field(
374 &mut fields,
375 "priority",
376 args.get("priority").and_then(value_to_short_string),
377 );
378 push_field(
379 &mut fields,
380 "all",
381 args.get("all").and_then(value_to_short_string),
382 );
383 }
384 "next" => {
385 push_field(
386 &mut fields,
387 "count",
388 args.get("count").and_then(value_to_short_string),
389 );
390 }
391 "status" | "agents" | "run_state" | "evaluate" => {
392 push_field(
393 &mut fields,
394 "run_id",
395 args.get("run_id")
396 .and_then(Value::as_str)
397 .map(str::to_string),
398 );
399 }
400 _ => {
401 for key in [
402 "id", "title", "status", "priority", "run_id", "reason", "count",
403 ] {
404 push_field(
405 &mut fields,
406 key,
407 args.get(key).and_then(value_to_short_string),
408 );
409 }
410 }
411 }
412
413 if fields.is_empty() {
414 action.to_string()
415 } else {
416 format!("{action} {}", fields.join(" "))
417 }
418}
419
420fn summarize_json_object(args: &Value) -> String {
421 let Some(obj) = args.as_object() else {
422 let json = serde_json::to_string(args).unwrap_or_default();
423 return truncate_chars_with_suffix(&json, 80, "…");
424 };
425
426 let mut fields = Vec::new();
427 for (key, value) in obj {
428 if let Some(short) = value_to_short_string(value) {
429 fields.push(format!("{key}={short}"));
430 }
431 }
432
433 if fields.is_empty() {
434 "{}".to_string()
435 } else {
436 fields.join(" ")
437 }
438}
439
440fn push_field(fields: &mut Vec<String>, key: &str, value: Option<String>) {
441 if let Some(value) = value {
442 if !value.is_empty() {
443 fields.push(format!("{key}={value}"));
444 }
445 }
446}
447
448fn value_to_short_string(value: &Value) -> Option<String> {
449 match value {
450 Value::Null => None,
451 Value::String(s) => Some(truncate_chars_with_suffix(
452 &abbreviate_home_path(s),
453 32,
454 "…",
455 )),
456 Value::Bool(b) => Some(b.to_string()),
457 Value::Number(n) => Some(n.to_string()),
458 Value::Array(items) => {
459 let joined = items
460 .iter()
461 .filter_map(value_to_short_string)
462 .collect::<Vec<_>>()
463 .join(",");
464 if joined.is_empty() {
465 None
466 } else {
467 Some(truncate_chars_with_suffix(&joined, 32, "…"))
468 }
469 }
470 Value::Object(_) => Some("{…}".to_string()),
471 }
472}
473
474pub struct ToolCallView<'a> {
476 tool_call: &'a DisplayToolCall,
477 theme: &'a Theme,
478}
479
480impl<'a> ToolCallView<'a> {
481 pub fn new(tool_call: &'a DisplayToolCall, theme: &'a Theme) -> Self {
482 Self { tool_call, theme }
483 }
484}
485
486impl Widget for ToolCallView<'_> {
487 fn render(self, area: Rect, buf: &mut Buffer) {
488 if area.height == 0 {
489 return;
490 }
491
492 let header = self.tool_call.header_line(self.theme);
494 buf.set_line(area.x, area.y, &header, area.width);
495
496 if self.tool_call.expanded {
498 if let Some(ref output) = self.tool_call.output {
499 let output_style = if self.tool_call.is_error {
500 self.theme.error_style()
501 } else {
502 self.theme.muted_style()
503 };
504
505 for (i, line_str) in output.lines().enumerate() {
506 let y = area.y + 1 + i as u16;
507 if y >= area.y + area.height {
508 break;
509 }
510 let line = Line::from(Span::styled(format!(" {line_str}"), output_style));
511 buf.set_line(area.x, y, &line, area.width);
512 }
513 }
514 }
515 }
516}
517
518pub fn tool_call_height(tc: &DisplayToolCall) -> u16 {
520 let mut h: u16 = 1; if tc.expanded {
522 if let Some(ref output) = tc.output {
523 h += output.lines().count().min(50) as u16; }
525 }
526 h
527}
528
529pub fn is_compactable(tc: &DisplayToolCall) -> bool {
532 tc.output.is_some() && !tc.is_error && !tc.expanded
533}
534
535pub fn tool_calls_compact_height(tcs: &[DisplayToolCall], width: u16) -> u16 {
538 let mut h: u16 = 0;
539 let mut i = 0;
540 while i < tcs.len() {
541 let tc = &tcs[i];
542 if is_compactable(tc) {
543 let group_start = i;
544 while i < tcs.len() && is_compactable(&tcs[i]) {
545 i += 1;
546 }
547 h += compact_group_line_count(&tcs[group_start..i], width);
548 } else {
549 h += tool_call_height(tc);
550 i += 1;
551 }
552 }
553 h
554}
555
556fn compact_group_line_count(tcs: &[DisplayToolCall], width: u16) -> u16 {
559 if tcs.is_empty() {
560 return 0;
561 }
562 let usable = (width as usize).saturating_sub(4); if usable == 0 {
564 return tcs.len() as u16;
565 }
566 let mut lines: u16 = 1;
567 let mut col: usize = 0;
568 for tc in tcs {
569 let span_len = compact_span_width(tc);
570 if col > 0 && col + 2 + span_len > usable {
571 lines += 1;
572 col = span_len;
573 } else if col > 0 {
574 col += 2 + span_len; } else {
576 col = span_len;
577 }
578 }
579 lines
580}
581
582fn compact_span_width(tc: &DisplayToolCall) -> usize {
584 let args_short = short_args(&tc.args_summary);
585 let w = 2 + tc.name.len(); if args_short.is_empty() {
587 w
588 } else {
589 w + 1 + args_short.len()
590 }
591}
592
593fn short_args(args: &str) -> String {
595 if args.is_empty() {
596 return String::new();
597 }
598 if args.contains('/') {
600 if let Some(name) = args.rsplit('/').next() {
601 if !name.is_empty() {
602 return name.to_string();
603 }
604 }
605 }
606 if let Some(cmd) = args.strip_prefix("$ ") {
608 let short = if cmd.len() > 20 {
609 format!("$ {}", truncate_chars_with_suffix(cmd, 17, "…"))
610 } else {
611 format!("$ {cmd}")
612 };
613 return short;
614 }
615 if args.len() <= 24 {
617 return args.to_string();
618 }
619 truncate_chars_with_suffix(args, 21, "…")
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625
626 fn make_tc(name: &str, args: &str, output: Option<&str>, is_error: bool) -> DisplayToolCall {
627 DisplayToolCall {
628 id: "test".into(),
629 name: name.into(),
630 args_summary: args.into(),
631 output: output.map(String::from),
632 details: serde_json::Value::Null,
633 is_error,
634 expanded: false,
635 streaming_lines: Vec::new(),
636 streaming_output: String::new(),
637 }
638 }
639
640 #[test]
641 fn make_args_summary_formats_mana_compactly() {
642 let summary = DisplayToolCall::make_args_summary(
643 "mana",
644 &serde_json::json!({
645 "action": "create",
646 "title": "Fix hotkeys",
647 "priority": 1,
648 "verify": "cargo check -p imp-tui",
649 "deps": "1.2,1.3"
650 }),
651 );
652
653 assert!(summary.starts_with("create "));
654 assert!(summary.contains("title=Fix hotkeys"));
655 assert!(summary.contains("priority=1"));
656 assert!(summary.contains("verify=cargo check -p imp-tui"));
657 assert!(summary.contains("deps=1.2,1.3"));
658 }
659
660 #[test]
661 fn compactable_completed_success() {
662 let tc = make_tc("read", "file.rs", Some("contents"), false);
663 assert!(is_compactable(&tc));
664 }
665
666 #[test]
667 fn not_compactable_running() {
668 let tc = make_tc("read", "file.rs", None, false);
669 assert!(!is_compactable(&tc));
670 }
671
672 #[test]
673 fn not_compactable_error() {
674 let tc = make_tc("read", "file.rs", Some("err"), true);
675 assert!(!is_compactable(&tc));
676 }
677
678 #[test]
679 fn not_compactable_expanded() {
680 let mut tc = make_tc("read", "file.rs", Some("data"), false);
681 tc.expanded = true;
682 assert!(!is_compactable(&tc));
683 }
684
685 #[test]
686 fn short_args_path() {
687 assert_eq!(short_args("src/views/tools.rs"), "tools.rs");
688 }
689
690 #[test]
691 fn short_args_bash() {
692 assert_eq!(short_args("check"), "check");
693 }
694
695 #[test]
696 fn short_args_bash_short() {
697 assert_eq!(short_args("run"), "run");
698 }
699
700 #[test]
701 fn short_args_empty() {
702 assert_eq!(short_args(""), "");
703 }
704
705 #[test]
706 fn short_args_short_text() {
707 assert_eq!(short_args("pattern"), "pattern");
708 }
709
710 #[test]
711 fn abbreviates_user_home_paths() {
712 assert_eq!(
713 abbreviate_home_path("/Users/test/src/main.rs"),
714 "~/src/main.rs"
715 );
716 assert_eq!(
717 abbreviate_home_path("/home/test/src/main.rs"),
718 "~/src/main.rs"
719 );
720 }
721
722 #[test]
723 fn make_args_summary_hides_bash_command_text() {
724 let summary = DisplayToolCall::make_args_summary(
725 "bash",
726 &serde_json::json!({"command": "cargo test -p imp-tui"}),
727 );
728 assert_eq!(summary, "check");
729 }
730
731 #[test]
732 fn make_args_summary_abbreviates_scan_directory() {
733 let summary = DisplayToolCall::make_args_summary(
734 "scan",
735 &serde_json::json!({"action": "scan", "directory": "/Users/test/project"}),
736 );
737 assert_eq!(summary, "~/project");
738 }
739
740 #[test]
741 fn compact_group_fits_one_line() {
742 let tcs = vec![
743 make_tc("read", "file.rs", Some("ok"), false),
744 make_tc("bash", "$ grep foo .", Some("ok"), false),
745 ];
746 assert_eq!(compact_group_line_count(&tcs, 80), 1);
747 }
748
749 #[test]
750 fn compact_group_wraps() {
751 let tcs: Vec<_> = (0..10)
752 .map(|i| {
753 make_tc(
754 "read",
755 &format!("long/path/to/file_{i}.rs"),
756 Some("ok"),
757 false,
758 )
759 })
760 .collect();
761 let lines = compact_group_line_count(&tcs, 80);
762 assert!(lines > 1);
763 assert!(lines < 10);
764 }
765
766 #[test]
767 fn compact_height_mixed() {
768 let tcs = vec![
769 make_tc("read", "a.rs", Some("ok"), false),
770 make_tc("read", "b.rs", Some("ok"), false),
771 make_tc("bash", "$ cmd", None, false), make_tc("read", "c.rs", Some("ok"), false),
773 ];
774 let h = tool_calls_compact_height(&tcs, 80);
775 assert_eq!(h, 3);
777 }
778
779 #[test]
780 fn compact_height_all_compactable() {
781 let tcs = vec![
782 make_tc("read", "a.rs", Some("ok"), false),
783 make_tc("bash", "$ grep foo .", Some("ok"), false),
784 make_tc("edit", "b.rs", Some("ok"), false),
785 ];
786 let h = tool_calls_compact_height(&tcs, 80);
787 assert_eq!(h, 1);
788 }
789}