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::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/// A tool call ready for display.
66#[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    /// Rolling buffer of recent streaming output lines for inline chat display.
76    pub streaming_lines: Vec<String>,
77    /// Full streaming output collected while the tool is still running.
78    pub streaming_output: String,
79}
80
81impl DisplayToolCall {
82    /// Build a compact one-line summary for the tool call header.
83    pub fn header_line(&self, theme: &Theme) -> Line<'static> {
84        self.header_line_animated(theme, 0, AnimationLevel::Minimal)
85    }
86
87    /// Header with animated spinner for running tools.
88    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    /// Header with animated spinner and optional focus indicator.
98    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        // Focus indicator prepended before the status icon
118        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        // Result summary when collapsed — just line count (icon already shows status)
146        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    /// Build compact inline spans for multi-tool-per-line rendering: "✓ name args"
164    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    /// Build a compact args summary from tool name and arguments.
183    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
465/// Renders a single tool call (header + optionally expanded output).
466pub 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        // Render header line
484        let header = self.tool_call.header_line(self.theme);
485        buf.set_line(area.x, area.y, &header, area.width);
486
487        // Render expanded output
488        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
509/// Calculate the rendered height of a tool call.
510pub fn tool_call_height(tc: &DisplayToolCall) -> u16 {
511    let mut h: u16 = 1; // header
512    if tc.expanded {
513        if let Some(ref output) = tc.output {
514            h += output.lines().count().min(50) as u16; // cap at 50 lines
515        }
516    }
517    h
518}
519
520/// Check whether a tool call can be rendered in compact (inline) mode.
521/// Compactable = completed successfully, not expanded, not an error.
522pub fn is_compactable(tc: &DisplayToolCall) -> bool {
523    tc.output.is_some() && !tc.is_error && !tc.expanded
524}
525
526/// Calculate the rendered height of a slice of tool calls using compact grouping.
527/// Consecutive compactable calls share lines; others get their own full-height row.
528pub 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
547/// Calculate how many lines a group of compact tool calls takes.
548/// Each call renders as "✓ name args" and we pack as many as fit per line.
549fn 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); // rail = 4 chars
554    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; // 2 for "  " separator
566        } else {
567            col = span_len;
568        }
569    }
570    lines
571}
572
573/// Width of a compact tool call span: "✓ name args" character count.
574fn compact_span_width(tc: &DisplayToolCall) -> usize {
575    let args_short = short_args(&tc.args_summary);
576    let w = 2 + tc.name.len(); // "✓ name"
577    if args_short.is_empty() {
578        w
579    } else {
580        w + 1 + args_short.len()
581    }
582}
583
584/// Shorten args_summary for compact display (just the filename or first word).
585fn short_args(args: &str) -> String {
586    if args.is_empty() {
587        return String::new();
588    }
589    // For paths, show just the filename
590    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    // For "$ command" bash summaries, take first 20 chars
598    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    // For quoted grep patterns, keep as-is if short
607    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), // running
763            make_tc("read", "c.rs", Some("ok"), false),
764        ];
765        let h = tool_calls_compact_height(&tcs, 80);
766        // First 2 compact (1 line) + 1 running (1 line) + 1 compact (1 line) = 3
767        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}