Skip to main content

imp_tui/views/
tools.rs

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/// A tool call ready for display.
67#[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    /// Rolling buffer of recent streaming output lines for inline chat display.
77    pub streaming_lines: Vec<String>,
78    /// Full streaming output collected while the tool is still running.
79    pub streaming_output: String,
80}
81
82impl DisplayToolCall {
83    /// Build a compact one-line summary for the tool call header.
84    pub fn header_line(&self, theme: &Theme) -> Line<'static> {
85        self.header_line_animated(theme, 0, AnimationLevel::Minimal)
86    }
87
88    /// Header with animated spinner for running tools.
89    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    /// Header with animated spinner and optional focus indicator.
99    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        // Focus indicator prepended before the status icon
128        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        // Result summary when collapsed — just line count (icon already shows status)
156        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    /// Build compact inline spans for multi-tool-per-line rendering: "✓ name args"
174    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    /// Build a compact args summary from tool name and arguments.
193    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
474/// Renders a single tool call (header + optionally expanded output).
475pub 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        // Render header line
493        let header = self.tool_call.header_line(self.theme);
494        buf.set_line(area.x, area.y, &header, area.width);
495
496        // Render expanded output
497        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
518/// Calculate the rendered height of a tool call.
519pub fn tool_call_height(tc: &DisplayToolCall) -> u16 {
520    let mut h: u16 = 1; // header
521    if tc.expanded {
522        if let Some(ref output) = tc.output {
523            h += output.lines().count().min(50) as u16; // cap at 50 lines
524        }
525    }
526    h
527}
528
529/// Check whether a tool call can be rendered in compact (inline) mode.
530/// Compactable = completed successfully, not expanded, not an error.
531pub fn is_compactable(tc: &DisplayToolCall) -> bool {
532    tc.output.is_some() && !tc.is_error && !tc.expanded
533}
534
535/// Calculate the rendered height of a slice of tool calls using compact grouping.
536/// Consecutive compactable calls share lines; others get their own full-height row.
537pub 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
556/// Calculate how many lines a group of compact tool calls takes.
557/// Each call renders as "✓ name args" and we pack as many as fit per line.
558fn 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); // rail = 4 chars
563    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; // 2 for "  " separator
575        } else {
576            col = span_len;
577        }
578    }
579    lines
580}
581
582/// Width of a compact tool call span: "✓ name args" character count.
583fn compact_span_width(tc: &DisplayToolCall) -> usize {
584    let args_short = short_args(&tc.args_summary);
585    let w = 2 + tc.name.len(); // "✓ name"
586    if args_short.is_empty() {
587        w
588    } else {
589        w + 1 + args_short.len()
590    }
591}
592
593/// Shorten args_summary for compact display (just the filename or first word).
594fn short_args(args: &str) -> String {
595    if args.is_empty() {
596        return String::new();
597    }
598    // For paths, show just the filename
599    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    // For "$ command" bash summaries, take first 20 chars
607    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    // For quoted grep patterns, keep as-is if short
616    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), // running
772            make_tc("read", "c.rs", Some("ok"), false),
773        ];
774        let h = tool_calls_compact_height(&tcs, 80);
775        // First 2 compact (1 line) + 1 running (1 line) + 1 compact (1 line) = 3
776        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}