Skip to main content

rab/agent/ui/components/
tool_messages.rs

1use std::cell::RefCell;
2use std::rc::Rc;
3use std::time::Instant;
4
5use crate::agent::extension::{ToolRenderContext, ToolRenderer};
6use crate::agent::ui::theme::{RabTheme, current_theme};
7use crate::tui::Component;
8use crate::tui::component::{RenderCache, RenderCacheKey};
9use crate::tui::components::Text;
10use crate::tui::components::r#box::TuiBox;
11use crate::tui::keybindings::{self, ACTION_APP_TOOLS_EXPAND};
12use crate::tui::util::truncate_to_width;
13
14/// Maximum preview lines when collapsed (matching pi's collapsible tool result).
15const PREVIEW_LINES: usize = 10;
16
17/// Preview line limit for bash tools (matching pi's BASH_PREVIEW_LINES).
18const BASH_PREVIEW_LINES: usize = 5;
19
20/// Combined tool execution component - matches pi's `ToolExecutionComponent`.
21///
22/// Renders tool call + result as ONE component with background transitions:
23/// - Pending (call only, no result) → `toolPendingBg`
24/// - Success (call + result, !is_error) → `toolSuccessBg`
25/// - Error (call + result, is_error) → `toolErrorBg`
26///
27/// Delegates actual rendering to the tool-specific ToolRenderer when available.
28pub struct ToolExecComponent {
29    name: String,
30    renderer: Option<Box<dyn ToolRenderer>>,
31    args: serde_json::Value,
32    output: Option<String>,
33    is_error: bool,
34    is_complete: bool,
35    expanded: bool,
36    /// When execution started (for live duration display).
37    started_at: Option<Instant>,
38    /// Final duration in seconds, captured when the tool completes.
39    /// While running, duration is computed at render time from `started_at` (pi pattern).
40    final_duration: Option<f64>,
41    /// Tracks when to next invalidate for re-render (1s tick, matching pi's setInterval).
42    last_timer_tick: Option<Instant>,
43    // ── Bash-specific fields (used when no renderer) ──
44    is_bash: bool,
45    was_truncated: bool,
46    full_output_path: Option<String>,
47    exit_code: Option<i32>,
48    cancelled: bool,
49    // ── Read-specific (used when no renderer) ──
50    file_path: Option<String>,
51    // ── Dirty tracking for efficient re-render ──
52    dirty: bool,
53    // ── Render cache ──
54    cache: Option<RenderCache>,
55}
56
57impl ToolExecComponent {
58    pub fn new(
59        name: impl Into<String>,
60        renderer: Option<Box<dyn ToolRenderer>>,
61        args: serde_json::Value,
62    ) -> Self {
63        Self {
64            name: name.into(),
65            renderer,
66            args,
67            output: None,
68            is_error: false,
69            is_complete: false,
70            expanded: false,
71            started_at: None,
72            final_duration: None,
73            last_timer_tick: None,
74            is_bash: false,
75            was_truncated: false,
76            full_output_path: None,
77            exit_code: None,
78            cancelled: false,
79            file_path: None,
80            dirty: true,
81            cache: None,
82        }
83    }
84
85    // ── Legacy setters (used by app.rs for non-renderer paths) ──
86
87    /// Set the execution start time (for live duration display).
88    pub fn set_started_at(&mut self, instant: std::time::Instant) {
89        self.started_at = Some(instant);
90        // Initialize invalidation timer so first tick fires after ~1s
91        self.last_timer_tick = Some(instant);
92        self.mark_dirty();
93    }
94
95    pub fn set_file_path(&mut self, path: impl Into<String>) {
96        self.file_path = Some(path.into());
97        self.mark_dirty();
98    }
99
100    pub fn set_bash(&mut self, is_bash: bool) {
101        self.is_bash = is_bash;
102        self.mark_dirty();
103    }
104
105    /// Set the final duration in seconds (used when the tool completes, e.g. bash).
106    /// Freezes the timer at this exact value (no more live computation).
107    pub fn set_final_duration(&mut self, secs: f64) {
108        self.final_duration = Some(secs);
109        self.mark_dirty();
110    }
111
112    pub fn set_truncated(&mut self, truncated: bool, full_output_path: Option<String>) {
113        self.was_truncated = truncated;
114        self.full_output_path = full_output_path;
115        self.mark_dirty();
116    }
117
118    pub fn set_exit_code(&mut self, code: i32) {
119        self.exit_code = Some(code);
120        self.mark_dirty();
121    }
122
123    pub fn set_cancelled(&mut self, cancelled: bool) {
124        self.cancelled = cancelled;
125        self.mark_dirty();
126    }
127
128    pub fn set_result(&mut self, output: impl Into<String>, is_error: bool) {
129        self.output = Some(output.into());
130        self.is_error = is_error;
131        self.is_complete = true;
132        // Capture final duration from started_at if not explicitly set via set_final_duration
133        // (covers non-bash tools and fast commands that complete before any render).
134        if self.final_duration.is_none()
135            && let Some(start) = self.started_at
136        {
137            self.final_duration = Some(start.elapsed().as_secs_f64());
138        }
139        self.mark_dirty();
140    }
141
142    pub fn set_args(&mut self, args: serde_json::Value) {
143        self.args = args;
144        self.mark_dirty();
145    }
146
147    /// Mark this component as needing re-render.
148    fn mark_dirty(&mut self) {
149        self.dirty = true;
150        self.cache = None;
151    }
152
153    /// Returns the current duration for display.
154    /// - If completed: returns `final_duration` (frozen at completion).
155    /// - If running: computes live elapsed time from `started_at` (pi pattern).
156    fn live_duration(&self) -> Option<f64> {
157        if let Some(dur) = self.final_duration {
158            return Some(dur);
159        }
160        self.started_at.map(|t| t.elapsed().as_secs_f64())
161    }
162
163    /// Tick the timer: marks dirty every 1s to trigger re-render.
164    /// Matches pi's `setInterval(() => context.invalidate(), 1000)` in renderResult.
165    /// Duration is computed at render time via `live_duration()`, not stored here.
166    /// Returns true if this tick caused a re-render (caller should update dirty).
167    pub fn tick_timer(&mut self) -> bool {
168        if self.is_complete || self.started_at.is_none() {
169            return false;
170        }
171        let now = Instant::now();
172        let should_invalidate = self
173            .last_timer_tick
174            .is_none_or(|last| now.duration_since(last) >= std::time::Duration::from_secs(1));
175        if should_invalidate {
176            self.last_timer_tick = Some(now);
177            self.mark_dirty();
178            return true;
179        }
180        false
181    }
182
183    /// Compute a hash of the current state for cache key.
184    fn state_hash(&self) -> u64 {
185        use std::collections::hash_map::DefaultHasher;
186        use std::hash::{Hash, Hasher};
187        let mut hasher = DefaultHasher::new();
188        self.name.hash(&mut hasher);
189        self.args.to_string().hash(&mut hasher);
190        self.is_error.hash(&mut hasher);
191        self.is_complete.hash(&mut hasher);
192        // Include live duration in hash so cache invalidates when elapsed time changes
193        // (component is re-rendered every frame by Container, but cache_check is a no-op).
194        self.live_duration().map(|s| s.to_bits()).hash(&mut hasher);
195        self.exit_code.hash(&mut hasher);
196        self.cancelled.hash(&mut hasher);
197        self.was_truncated.hash(&mut hasher);
198        self.output.hash(&mut hasher);
199        hasher.finish()
200    }
201}
202
203impl Component for ToolExecComponent {
204    fn set_expanded(&mut self, expanded: bool) {
205        self.expanded = expanded;
206        self.mark_dirty();
207    }
208
209    fn render(&self, width: usize) -> Vec<String> {
210        let theme = current_theme();
211
212        // If tool has a renderer, delegate to it
213        if let Some(ref renderer) = self.renderer {
214            return self.render_with_renderer(renderer.as_ref(), &theme, width);
215        }
216
217        // ── Generic fallback rendering (no tool-specific renderer) ──
218        self.render_generic(&theme, width)
219    }
220
221    fn invalidate(&mut self) {
222        self.mark_dirty();
223    }
224
225    fn is_dirty(&self) -> bool {
226        self.dirty
227    }
228
229    fn clear_dirty(&mut self) {
230        self.dirty = false;
231    }
232
233    fn cache_key(&self, width: usize) -> Option<RenderCacheKey> {
234        // Duration is computed at render time via live_duration(); cache includes the current
235        // value so it's invalidated on each render. Container::render() doesn't use caching,
236        // so this is effectively a no-op — kept for correctness if caching is added later.
237        Some(RenderCacheKey {
238            width,
239            expanded: self.expanded,
240            state_hash: self.state_hash(),
241        })
242    }
243
244    fn get_cached_render(&self) -> Option<&RenderCache> {
245        self.cache.as_ref()
246    }
247
248    fn set_cached_render(&mut self, cache: RenderCache) {
249        self.cache = Some(cache);
250        self.dirty = false;
251    }
252}
253
254impl ToolExecComponent {
255    /// Render using the tool-specific renderer (pi pattern).
256    fn render_with_renderer(
257        &self,
258        renderer: &dyn ToolRenderer,
259        theme: &RabTheme,
260        width: usize,
261    ) -> Vec<String> {
262        let is_partial = !self.is_complete;
263
264        // Build render context (matching pi's ToolRenderContext)
265        let expand_key = crate::agent::ui::components::tool_messages::format_key_hint(
266            crate::tui::keybindings::ACTION_APP_TOOLS_EXPAND,
267        );
268        let ctx = ToolRenderContext {
269            expanded: self.expanded,
270            args_complete: self.is_complete,
271            is_partial,
272            is_error: self.is_error,
273            cwd: String::new(),
274            duration_secs: self.live_duration(),
275            exit_code: self.exit_code,
276            cancelled: self.cancelled,
277            was_truncated: self.was_truncated,
278            full_output_path: self.full_output_path.clone(),
279            file_path: self.file_path.clone(),
280            expand_key,
281        };
282
283        // For `renderShell: "self"` tools (like edit), no colored box wrapping
284        if renderer.render_self() {
285            let mut lines: Vec<String> = Vec::new();
286            // Spacer above
287            lines.push(String::new());
288
289            let call_lines = renderer.render_call(&self.args, width, theme, &ctx);
290            if !call_lines.is_empty() {
291                lines.extend(call_lines);
292            }
293
294            // Result body
295            if let Some(ref output) = self.output {
296                let result_lines = renderer.render_result(output, width, theme, &ctx);
297                if !result_lines.is_empty() {
298                    lines.extend(result_lines);
299                }
300            }
301            return lines;
302        }
303
304        // ── Default shell: colored box wrapping ──
305        let bg_key = if !self.is_complete {
306            "toolPendingBg"
307        } else if self.is_error {
308            "toolErrorBg"
309        } else {
310            "toolSuccessBg"
311        };
312        let bg_ansi = theme.bg_ansi(bg_key).to_string();
313        let theme_clone = theme.clone();
314
315        let mut msg_box = TuiBox::new(
316            1,
317            1,
318            Some(std::boxed::Box::new(move |s: &str| -> String {
319                format!("{}{}\x1b[49m", bg_ansi, s)
320            })),
321        );
322
323        // Call header
324        let call_lines = renderer.render_call(&self.args, width, &theme_clone, &ctx);
325        let header_text = Text::new(call_lines.join("\n"), 0, 0, None);
326        msg_box.add_child(std::boxed::Box::new(header_text));
327
328        // Result body
329        if let Some(ref output) = self.output {
330            let result_lines = renderer.render_result(output, width, &theme_clone, &ctx);
331            if !result_lines.is_empty() {
332                let result_text = Text::new(result_lines.join("\n"), 0, 0, None);
333                msg_box.add_child(std::boxed::Box::new(result_text));
334            }
335        }
336
337        msg_box.render(width)
338    }
339
340    /// Generic fallback rendering (no tool-specific renderer).
341    fn render_generic(&self, theme: &RabTheme, width: usize) -> Vec<String> {
342        let bg_key = if !self.is_complete {
343            "toolPendingBg"
344        } else if self.is_error {
345            "toolErrorBg"
346        } else {
347            "toolSuccessBg"
348        };
349        let bg_ansi = theme.bg_ansi(bg_key).to_string();
350
351        let mut msg_box = TuiBox::new(
352            1,
353            1,
354            Some(std::boxed::Box::new(move |s: &str| -> String {
355                format!("{}{}\x1b[49m", bg_ansi, s)
356            })),
357        );
358
359        // ── Header ──
360        let header_styled = format_generic_call_header(&self.name, &self.args, theme);
361        let header_text = Text::new(header_styled, 0, 0, None);
362        msg_box.add_child(std::boxed::Box::new(header_text));
363
364        // ── Result output ──
365        let skip_output = self.name == "write" && self.is_complete && !self.is_error;
366        if let Some(ref output) = self.output
367            && !skip_output
368        {
369            if self.is_bash {
370                msg_box.add_child(std::boxed::Box::new(BashResult::new(
371                    output,
372                    self.is_error,
373                    self.expanded,
374                    self.live_duration(),
375                    self.was_truncated,
376                    self.full_output_path.as_deref(),
377                    self.exit_code,
378                    self.cancelled,
379                    theme,
380                )));
381            } else {
382                // Check if this is an image (data URL)
383                if crate::tui::util::is_image_line(output) {
384                    let kitty_seq = crate::tui::image::kitty_image_sequence(output);
385                    if !kitty_seq.is_empty() {
386                        // Image rendered via Kitty protocol
387                        msg_box.add_child(std::boxed::Box::new(Text::new(kitty_seq, 0, 0, None)));
388                        // Add a blank line after the image
389                        msg_box.add_child(std::boxed::Box::new(Text::new(
390                            String::new(),
391                            0,
392                            0,
393                            None,
394                        )));
395                    } else {
396                        msg_box.add_child(std::boxed::Box::new(Text::new(
397                            output.clone(),
398                            0,
399                            0,
400                            None,
401                        )));
402                    }
403                } else {
404                    let fg_key = if self.is_error { "error" } else { "toolOutput" };
405                    let fg_ansi = theme.fg_ansi(fg_key).to_string();
406
407                    let display_text = if self.expanded {
408                        output.clone()
409                    } else {
410                        let lines: Vec<&str> = output.lines().collect();
411                        if lines.len() > PREVIEW_LINES {
412                            let preview = lines[..PREVIEW_LINES].join("\n");
413                            format!(
414                                "{}\n... ({} more lines)",
415                                preview,
416                                lines.len() - PREVIEW_LINES
417                            )
418                        } else {
419                            output.clone()
420                        }
421                    };
422
423                    // Apply syntax highlighting for read results
424                    let styled_lines: Vec<String> = if self.name == "read" && !self.is_error {
425                        if let Some(ref path) = self.file_path {
426                            let lang = crate::tui::components::path_to_language(path);
427                            #[cfg(feature = "syntect")]
428                            if lang.is_some() {
429                                let hl =
430                                    crate::tui::components::highlight_code(&display_text, lang);
431                                if !hl.is_empty() {
432                                    hl
433                                } else {
434                                    display_text
435                                        .lines()
436                                        .map(|line| format!("{}{}\x1b[39m", fg_ansi, line))
437                                        .collect()
438                                }
439                            } else {
440                                display_text
441                                    .lines()
442                                    .map(|line| format!("{}{}\x1b[39m", fg_ansi, line))
443                                    .collect()
444                            }
445                        } else {
446                            display_text
447                                .lines()
448                                .map(|line| format!("{}{}\x1b[39m", fg_ansi, line))
449                                .collect()
450                        }
451                    } else {
452                        display_text
453                            .lines()
454                            .map(|line| format!("{}{}\x1b[39m", fg_ansi, line))
455                            .collect()
456                    };
457
458                    let result_text = Text::new(styled_lines.join("\n"), 0, 0, None);
459                    msg_box.add_child(std::boxed::Box::new(result_text));
460                }
461            }
462        }
463
464        msg_box.render(width)
465    }
466}
467
468/// Format a generic tool call header (fallback when no tool-specific renderer).
469fn format_generic_call_header(name: &str, args: &serde_json::Value, theme: &RabTheme) -> String {
470    match name {
471        "bash" => {
472            let cmd = args
473                .get("command")
474                .and_then(|v| v.as_str())
475                .unwrap_or("...");
476            let timeout = args.get("timeout").and_then(|v| v.as_i64());
477            let timeout_suffix = timeout
478                .map(|t| theme.fg("muted", &format!(" (timeout {}s)", t)))
479                .unwrap_or_default();
480            format!(
481                "{}{}",
482                theme.fg("toolTitle", &theme.bold(&format!("$ {}", cmd))),
483                timeout_suffix
484            )
485        }
486        "read" => {
487            let path = args
488                .get("file_path")
489                .or_else(|| args.get("path"))
490                .and_then(|v| v.as_str())
491                .unwrap_or("");
492            let short = shorten_path(path);
493            let path_disp = if short.is_empty() {
494                String::new()
495            } else {
496                theme.fg("accent", &short)
497            };
498            let range = format_line_range(args, theme);
499            format!(
500                "{} {} {}",
501                theme.fg("toolTitle", &theme.bold("read")),
502                path_disp,
503                range
504            )
505        }
506        "write" => {
507            let path = args
508                .get("file_path")
509                .or_else(|| args.get("path"))
510                .and_then(|v| v.as_str())
511                .unwrap_or("");
512            let short = shorten_path(path);
513            let path_disp = if short.is_empty() {
514                String::new()
515            } else {
516                theme.fg("accent", &short)
517            };
518            format!(
519                "{} {}",
520                theme.fg("toolTitle", &theme.bold("write")),
521                path_disp
522            )
523        }
524        "edit" => {
525            let path = args
526                .get("file_path")
527                .or_else(|| args.get("path"))
528                .and_then(|v| v.as_str())
529                .unwrap_or("");
530            let short = shorten_path(path);
531            let path_disp = if short.is_empty() {
532                String::new()
533            } else {
534                theme.fg("accent", &short)
535            };
536            format!(
537                "{} {}",
538                theme.fg("toolTitle", &theme.bold("edit")),
539                path_disp
540            )
541        }
542        "ls" => {
543            let path = args
544                .get("file_path")
545                .or_else(|| args.get("path"))
546                .and_then(|v| v.as_str())
547                .unwrap_or(".");
548            let limit = args.get("limit").and_then(|v| v.as_u64());
549            let short = shorten_path(path);
550            let limit_str = limit.map(|l| format!(" (limit {})", l)).unwrap_or_default();
551            format!(
552                "{} {}{}",
553                theme.fg("toolTitle", &theme.bold("ls")),
554                theme.fg("accent", &short),
555                limit_str
556            )
557        }
558        _ => {
559            let args_str = serde_json::to_string(args).unwrap_or_default();
560            let suffix = if args_str.is_empty() || args_str == "{}" {
561                String::new()
562            } else {
563                format!("  {}", theme.fg("muted", &args_str))
564            };
565            format!("{}{}", theme.fg("toolTitle", &theme.bold(name)), suffix)
566        }
567    }
568}
569
570/// Format line range for read tool (e.g. ":1-10" in warning color).
571fn format_line_range(args: &serde_json::Value, theme: &RabTheme) -> String {
572    let offset = args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0);
573    let limit = args.get("limit").and_then(|v| v.as_u64());
574    if offset == 0 && limit.is_none() {
575        return String::new();
576    }
577    let start = if offset > 0 { offset } else { 1 };
578    let range_str = match limit {
579        Some(l) => format!(":{}-{}", start, start + l - 1),
580        None => format!(":{}", start),
581    };
582    theme.fg("warning", &range_str)
583}
584
585/// Shorten a path (replace home with ~).
586fn shorten_path(path: &str) -> String {
587    if let Ok(home) = std::env::var("HOME") {
588        path.replacen(&home, "~", 1)
589    } else {
590        path.to_string()
591    }
592}
593
594/// Format a keybinding action as a concise key hint string.
595fn format_key_hint(action_id: &str) -> String {
596    // Pi-style key formatting: returns the raw key ID string (e.g. "ctrl+o")
597    // rather than Emacs-style notation ("C-o"). Matches pi's keyText().
598    let keys = keybindings::get_keybindings().get_keys(action_id);
599    if keys.is_empty() {
600        return String::new();
601    }
602    keys[0].clone()
603}
604
605// ═══════════════════════════════════════════════════════════════════
606// Bash-specific result rendering (legacy fallback when no renderer)
607// ═══════════════════════════════════════════════════════════════════
608
609struct BashResult {
610    output: String,
611    is_error: bool,
612    expanded: bool,
613    duration_secs: Option<f64>,
614    was_truncated: bool,
615    full_output_path: Option<String>,
616    exit_code: Option<i32>,
617    cancelled: bool,
618    theme: RabTheme,
619}
620
621impl BashResult {
622    #[allow(clippy::too_many_arguments)]
623    fn new(
624        output: &str,
625        is_error: bool,
626        expanded: bool,
627        duration_secs: Option<f64>,
628        was_truncated: bool,
629        full_output_path: Option<&str>,
630        exit_code: Option<i32>,
631        cancelled: bool,
632        theme: &RabTheme,
633    ) -> Self {
634        let clean_output = strip_context_truncation_footer(output);
635        Self {
636            output: clean_output,
637            is_error,
638            expanded,
639            duration_secs,
640            was_truncated,
641            full_output_path: full_output_path.map(|s| s.to_string()),
642            exit_code,
643            cancelled,
644            theme: theme.clone(),
645        }
646    }
647}
648
649impl Component for BashResult {
650    fn render(&self, width: usize) -> Vec<String> {
651        let theme = &self.theme;
652        let fg_ansi = if self.is_error {
653            theme.fg_ansi("error")
654        } else {
655            theme.fg_ansi("toolOutput")
656        }
657        .to_string();
658        let dim_ansi = theme.fg_ansi("muted").to_string();
659        let warning_ansi = theme.fg_ansi("warning").to_string();
660        let expand_key = format_key_hint(ACTION_APP_TOOLS_EXPAND);
661
662        let mut lines: Vec<String> = Vec::new();
663
664        let all_lines: Vec<&str> = self.output.split('\n').collect();
665
666        if all_lines.is_empty() || (all_lines.len() == 1 && all_lines[0].is_empty()) {
667            return lines;
668        }
669
670        // Use visual-line-aware truncation for preview
671        let (preview_lines, hidden_line_count) = if self.expanded {
672            (all_lines.clone(), 0)
673        } else {
674            truncate_to_visual_lines(&all_lines, width, BASH_PREVIEW_LINES)
675        };
676
677        if !self.expanded && hidden_line_count > 0 {
678            let hint = if expand_key.is_empty() {
679                format!(
680                    "\x1b[0m{}... {} earlier lines\x1b[39m",
681                    dim_ansi, hidden_line_count
682                )
683            } else {
684                format!(
685                    "\x1b[0m{}... ({} earlier lines, {} to expand)\x1b[39m",
686                    dim_ansi, hidden_line_count, expand_key,
687                )
688            };
689            let truncated = truncate_to_width(&hint, width, "...", false);
690            lines.push(truncated);
691        }
692
693        for line in &preview_lines {
694            let styled = if line.is_empty() {
695                String::new()
696            } else {
697                format!("{}{}\x1b[39m", fg_ansi, line)
698            };
699            let truncated = truncate_to_width(&styled, width, "...", false);
700            lines.push(truncated);
701        }
702
703        let is_complete = self.exit_code.is_some() || self.cancelled;
704        if let Some(secs) = self.duration_secs {
705            let label = if is_complete { "Took" } else { "Elapsed" };
706            let duration_text = format!("{}{} {:.1}s\x1b[39m", dim_ansi, label, secs);
707            lines.push(duration_text);
708        }
709
710        if self.cancelled {
711            lines.push(format!("{} (cancelled)\x1b[39m", warning_ansi));
712        } else if let Some(code) = self.exit_code
713            && code != 0
714        {
715            lines.push(format!("{} (exit {})\x1b[39m", warning_ansi, code));
716        }
717
718        if self.was_truncated {
719            if let Some(ref path) = self.full_output_path {
720                lines.push(format!(
721                    "{}Output truncated. Full output: {}\x1b[39m",
722                    warning_ansi, path
723                ));
724            } else {
725                lines.push(format!("{}Output truncated.\x1b[39m", warning_ansi));
726            }
727        }
728
729        lines
730    }
731
732    fn invalidate(&mut self) {}
733}
734
735// ═══════════════════════════════════════════════════════════════════
736// Visual-line-aware truncation (delegated to shared tui::visual_truncate)
737// ═══════════════════════════════════════════════════════════════════
738
739use crate::tui::visual_truncate::truncate_to_visual_lines;
740
741fn strip_context_truncation_footer(output: &str) -> String {
742    let lines: Vec<&str> = output.lines().collect();
743    if lines.len() < 3 {
744        return output.to_string();
745    }
746
747    let last = lines.last().map_or("", |v| v).trim();
748    if last.starts_with('[')
749        && (last.contains("Showing lines") || last.contains("Showing last"))
750        && last.contains("Full output:")
751    {
752        let before: Vec<&str> = lines[..lines.len() - 1].to_vec();
753        if !before.is_empty() && before[before.len() - 1].is_empty() {
754            before[..before.len() - 1].join("\n")
755        } else {
756            before.join("\n")
757        }
758    } else {
759        output.to_string()
760    }
761}
762
763// ═══════════════════════════════════════════════════════════════════
764// Rc wrapper for shared ownership
765// ═══════════════════════════════════════════════════════════════════
766
767pub struct RcToolExec(pub Rc<RefCell<ToolExecComponent>>);
768
769impl Clone for RcToolExec {
770    fn clone(&self) -> Self {
771        Self(self.0.clone())
772    }
773}
774
775impl Component for RcToolExec {
776    fn render(&self, width: usize) -> Vec<String> {
777        self.0.borrow().render(width)
778    }
779
780    fn set_expanded(&mut self, expanded: bool) {
781        self.0.borrow_mut().set_expanded(expanded);
782    }
783
784    fn invalidate(&mut self) {
785        self.0.borrow_mut().invalidate();
786    }
787
788    fn is_dirty(&self) -> bool {
789        self.0.borrow().is_dirty()
790    }
791
792    fn clear_dirty(&mut self) {
793        self.0.borrow_mut().clear_dirty();
794    }
795
796    fn cache_key(&self, width: usize) -> Option<RenderCacheKey> {
797        self.0.borrow().cache_key(width)
798    }
799
800    fn get_cached_render(&self) -> Option<&RenderCache> {
801        // Can't return reference into RefCell - cache is managed by inner component
802        None
803    }
804
805    fn set_cached_render(&mut self, cache: RenderCache) {
806        self.0.borrow_mut().set_cached_render(cache);
807    }
808}
809
810// ═══════════════════════════════════════════════════════════════════
811// Backward-compatible old types
812// ═══════════════════════════════════════════════════════════════════
813
814pub struct ToolCallComponent {
815    name: String,
816    args: String,
817    expanded: bool,
818}
819
820impl ToolCallComponent {
821    pub fn new(name: impl Into<String>, args: impl Into<String>) -> Self {
822        Self {
823            name: name.into(),
824            args: args.into(),
825            expanded: false,
826        }
827    }
828}
829
830impl Component for ToolCallComponent {
831    fn set_expanded(&mut self, expanded: bool) {
832        self.expanded = expanded;
833    }
834    fn render(&self, width: usize) -> Vec<String> {
835        let theme = current_theme();
836        let bg_ansi = theme.bg_ansi("toolPendingBg").to_string();
837
838        let mut styled = String::new();
839        styled.push_str("\x1b[1m");
840        styled.push_str(theme.fg_ansi("toolTitle"));
841        styled.push_str(&self.name);
842        styled.push_str("\x1b[22m");
843
844        if !self.args.is_empty() && self.args != "{}" {
845            styled.push_str("  ");
846            styled.push_str(theme.fg_ansi("muted"));
847            styled.push_str(&self.args);
848        }
849        styled.push_str("\x1b[39m");
850
851        let mut msg_box = TuiBox::new(
852            1,
853            1,
854            Some(std::boxed::Box::new(move |s: &str| -> String {
855                format!("{}{}\x1b[49m", bg_ansi, s)
856            })),
857        );
858        msg_box.add_child(std::boxed::Box::new(Text::new(styled, 0, 0, None)));
859        msg_box.render(width)
860    }
861    fn invalidate(&mut self) {}
862}
863
864pub struct ToolResultComponent {
865    content: String,
866    is_error: bool,
867    expanded: bool,
868}
869
870impl ToolResultComponent {
871    pub fn new(content: impl Into<String>, is_error: bool) -> Self {
872        Self {
873            content: content.into(),
874            is_error,
875            expanded: false,
876        }
877    }
878}
879
880impl Component for ToolResultComponent {
881    fn set_expanded(&mut self, expanded: bool) {
882        self.expanded = expanded;
883    }
884    fn render(&self, width: usize) -> Vec<String> {
885        let theme = current_theme();
886        let bg_key = if self.is_error {
887            "toolErrorBg"
888        } else {
889            "toolSuccessBg"
890        };
891        let fg_key = if self.is_error { "error" } else { "toolOutput" };
892        let bg_ansi = theme.bg_ansi(bg_key).to_string();
893        let styled = theme.fg(fg_key, &self.content);
894
895        let mut msg_box = TuiBox::new(
896            1,
897            0,
898            Some(std::boxed::Box::new(move |s: &str| -> String {
899                format!("{}{}\x1b[49m", bg_ansi, s)
900            })),
901        );
902        msg_box.add_child(std::boxed::Box::new(Text::new(styled, 0, 0, None)));
903        msg_box.render(width)
904    }
905    fn invalidate(&mut self) {}
906}
907
908#[cfg(test)]
909mod tests {
910    use crate::tui::visual_truncate::{truncate_to_visual_lines, visual_line_count};
911
912    #[test]
913    fn test_visual_line_count_ascii() {
914        assert_eq!(visual_line_count("hello", 80), 1);
915        assert_eq!(visual_line_count("", 80), 1);
916    }
917
918    #[test]
919    fn test_visual_line_count_wrapping() {
920        // 100 chars at width 80 = 2 visual lines
921        let line = "a".repeat(100);
922        assert_eq!(visual_line_count(&line, 80), 2);
923
924        // 160 chars at width 80 = 2 visual lines
925        let line = "a".repeat(160);
926        assert_eq!(visual_line_count(&line, 80), 2);
927
928        // 161 chars at width 80 = 3 visual lines
929        let line = "a".repeat(161);
930        assert_eq!(visual_line_count(&line, 80), 3);
931    }
932
933    #[test]
934    fn test_visual_line_count_zero_width() {
935        assert_eq!(visual_line_count("hello", 0), 1);
936    }
937
938    #[test]
939    fn test_truncate_to_visual_lines_no_truncation() {
940        let lines = vec!["short", "also short"];
941        let (selected, hidden) = truncate_to_visual_lines(&lines, 80, 10);
942        assert_eq!(selected.len(), 2);
943        assert_eq!(hidden, 0);
944    }
945
946    #[test]
947    fn test_truncate_to_visual_lines_with_wrapping() {
948        // Create lines that wrap: each is 100 chars at width 80 = 2 visual lines each
949        let line1 = "a".repeat(100);
950        let line2 = "b".repeat(100);
951        let line3 = "c".repeat(100);
952        let lines = vec![line1.as_str(), line2.as_str(), line3.as_str()];
953
954        // 3 lines × 2 visual lines each = 6 visual lines total
955        // Request only 4 visual lines -> should show last 2 logical lines (4 visual)
956        let (selected, hidden) = truncate_to_visual_lines(&lines, 80, 4);
957        assert_eq!(selected.len(), 2);
958        assert_eq!(hidden, 1);
959        assert_eq!(selected[0], line2.as_str());
960        assert_eq!(selected[1], line3.as_str());
961    }
962
963    #[test]
964    fn test_truncate_to_visual_lines_exact_fit() {
965        // 2 lines × 2 visual lines = 4 visual lines, request 4
966        let line1 = "a".repeat(100);
967        let line2 = "b".repeat(100);
968        let lines = vec![line1.as_str(), line2.as_str()];
969
970        let (selected, hidden) = truncate_to_visual_lines(&lines, 80, 4);
971        assert_eq!(selected.len(), 2);
972        assert_eq!(hidden, 0);
973    }
974
975    #[test]
976    fn test_truncate_to_visual_lines_empty() {
977        let lines: Vec<&str> = vec![];
978        let (selected, hidden) = truncate_to_visual_lines(&lines, 80, 5);
979        assert!(selected.is_empty());
980        assert_eq!(hidden, 0);
981    }
982
983    #[test]
984    fn test_truncate_to_visual_lines_mixed_widths() {
985        // Mix of short (1 visual) and long (2 visual) lines
986        let short1 = "short";
987        let long = "x".repeat(100); // 2 visual lines
988        let short2 = "also short";
989        let lines = vec![short1, long.as_str(), short2];
990
991        // Total: 1 + 2 + 1 = 4 visual lines
992        // Request 3 visual lines -> should skip short1 (1 visual) and show long + short2 (3 visual)
993        let (selected, hidden) = truncate_to_visual_lines(&lines, 80, 3);
994        assert_eq!(selected.len(), 2);
995        assert_eq!(hidden, 1);
996        assert_eq!(selected[0], long.as_str());
997        assert_eq!(selected[1], short2);
998    }
999}