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