Skip to main content

rab/agent/ui/components/
bash_execution.rs

1use crate::tui::Component;
2use crate::tui::components::loader::Loader;
3use crate::tui::util::wrap_text_with_ansi;
4
5/// Maximum lines of output to keep for LLM context truncation (matching pi's DEFAULT_MAX_LINES).
6const DEFAULT_MAX_LINES: usize = 1000;
7/// Maximum bytes of output to keep (matching pi's DEFAULT_MAX_BYTES).
8const DEFAULT_MAX_BYTES: usize = 16_385;
9
10/// Preview line limit when not expanded (matches pi's PREVIEW_LINES).
11const PREVIEW_LINES: usize = 20;
12
13/// Bash execution component - renders a bash command with borders, spinner, and output.
14///
15/// Matches pi's BashExecutionComponent design:
16/// - Spacer (1 blank line) above top border
17/// - Top/bottom borders in `bashMode` color (or `dim` for !! commands)
18/// - Command header with `$` prefix
19/// - Spinner while running (uses Loader component)
20/// - Streaming output in muted color (no ANSI)
21/// - Collapse/expand support showing FIRST N lines (preview truncation)
22/// - Status line with exit code, duration, cancellation, truncation warnings
23/// - Width-aware visual truncation for collapsed preview
24pub struct BashExecution {
25    command: String,
26    output_lines: Vec<String>,
27    status: BashStatus,
28    expanded: bool,
29    exclude_from_context: bool,
30    /// Full output path for truncation warning.
31    full_output_path: Option<String>,
32    /// Whether output was truncated for LLM context limits.
33    was_truncated: bool,
34    /// Execution duration in seconds (parsed from result content or set externally).
35    duration_secs: Option<f64>,
36    /// Loader component for spinner animation.
37    loader: Loader,
38}
39
40#[derive(Debug, Clone, PartialEq)]
41pub enum BashStatus {
42    Running,
43    Complete { exit_code: i32 },
44    Cancelled,
45    Error(String),
46}
47
48impl BashExecution {
49    pub fn new(command: impl Into<String>) -> Self {
50        let command = command.into();
51
52        // Create a loader matching pi's style: spinner in bashMode color, message in muted color
53        let theme = crate::agent::ui::theme::current_theme();
54        let spinner_ansi = theme.fg_ansi("bashMode").to_string();
55        let msg_ansi = theme.fg_ansi("muted").to_string();
56        drop(theme);
57        let loader = Loader::new(
58            Box::new(move |s| format!("{}{}\x1b[39m", spinner_ansi, s)),
59            Box::new(move |s| format!("{}{}\x1b[39m", msg_ansi, s)),
60            "Running... (Esc to cancel)",
61        );
62
63        Self {
64            command,
65            output_lines: Vec::new(),
66            status: BashStatus::Running,
67            expanded: false,
68            exclude_from_context: false,
69            full_output_path: None,
70            was_truncated: false,
71            duration_secs: None,
72            loader,
73        }
74    }
75
76    pub fn append_output(&mut self, line: impl Into<String>) {
77        self.output_lines.push(line.into());
78    }
79
80    /// Append a chunk of output that may contain newlines.
81    /// Handles splitting into lines similar to pi's appendOutput (preserving incomplete last line).
82    pub fn append_chunk(&mut self, chunk: &str) {
83        // Strip ANSI codes and normalize line endings (matching pi)
84        let clean = strip_ansi(chunk).replace("\r\n", "\n").replace('\r', "\n");
85
86        let new_lines: Vec<&str> = clean.split('\n').collect();
87        if new_lines.is_empty() {
88            return;
89        }
90
91        if !self.output_lines.is_empty() && !new_lines.is_empty() {
92            // Append first chunk to last line (incomplete line continuation, matching pi)
93            let last_idx = self.output_lines.len() - 1;
94            self.output_lines[last_idx].push_str(new_lines[0]);
95            self.output_lines
96                .extend(new_lines[1..].iter().map(|s| s.to_string()));
97        } else {
98            self.output_lines
99                .extend(new_lines.iter().map(|s| s.to_string()));
100        }
101    }
102
103    pub fn set_complete(&mut self, exit_code: i32) {
104        self.status = if exit_code == 0 {
105            BashStatus::Complete { exit_code: 0 }
106        } else {
107            BashStatus::Complete { exit_code }
108        };
109        self.stop_loader();
110    }
111
112    pub fn set_cancelled(&mut self) {
113        self.status = BashStatus::Cancelled;
114        self.stop_loader();
115    }
116
117    pub fn set_error(&mut self, msg: impl Into<String>) {
118        self.status = BashStatus::Error(msg.into());
119        self.stop_loader();
120    }
121
122    pub fn set_expanded(&mut self, expanded: bool) {
123        self.expanded = expanded;
124    }
125
126    pub fn set_exclude_from_context(&mut self, exclude: bool) {
127        self.exclude_from_context = exclude;
128    }
129
130    pub fn set_full_output_path(&mut self, path: impl Into<String>) {
131        self.full_output_path = Some(path.into());
132    }
133
134    pub fn set_truncated(&mut self, truncated: bool) {
135        self.was_truncated = truncated;
136    }
137
138    /// Set the execution duration in seconds.
139    pub fn set_duration_secs(&mut self, secs: f64) {
140        self.duration_secs = Some(secs);
141    }
142
143    /// Parse and set duration from result content (format: `[Xs]` at end of string).
144    pub fn set_duration_from_content(&mut self, content: &str) {
145        if let Some(end_bracket) = content.rfind(']')
146            && let Some(start_bracket) = content[..end_bracket].rfind('[')
147        {
148            let num_str = &content[start_bracket + 1..end_bracket];
149            if let Ok(secs) = num_str.parse::<f64>() {
150                self.duration_secs = Some(secs);
151            }
152        }
153    }
154
155    pub fn is_expanded(&self) -> bool {
156        self.expanded
157    }
158
159    fn stop_loader(&mut self) {
160        self.loader.stop();
161    }
162
163    fn border_color_key(&self) -> &'static str {
164        if self.exclude_from_context {
165            return "dim";
166        }
167        match self.status {
168            BashStatus::Running => "bashMode",
169            BashStatus::Complete { exit_code: 0 } => "bashMode",
170            BashStatus::Complete { .. } => "error",
171            BashStatus::Cancelled => "warning",
172            BashStatus::Error(_) => "error",
173        }
174    }
175
176    /// Apply context truncation matching pi's truncateTail logic.
177    fn context_truncated_output(&self) -> (String, bool) {
178        let output = self.output_lines.join("\n");
179
180        // Simulate pi's truncateTail: truncate by maxLines, then by maxBytes
181        let lines: Vec<&str> = output.split('\n').collect();
182        let total_lines = lines.len();
183        let truncated_lines: Vec<&str> = if total_lines > DEFAULT_MAX_LINES {
184            lines[lines.len() - DEFAULT_MAX_LINES..].to_vec()
185        } else {
186            lines
187        };
188
189        let joined = truncated_lines.join("\n");
190        let bytes = joined.len();
191        if bytes > DEFAULT_MAX_BYTES {
192            // Truncate bytes from the end
193            let mut byte_end = DEFAULT_MAX_BYTES;
194            // Ensure we don't cut in the middle of a UTF-8 character
195            while byte_end > 0 && !joined.is_char_boundary(byte_end) {
196                byte_end -= 1;
197            }
198            let truncated: String = joined[..byte_end].to_string();
199            (truncated, true)
200        } else {
201            (
202                joined,
203                total_lines > DEFAULT_MAX_LINES || bytes > DEFAULT_MAX_BYTES,
204            )
205        }
206    }
207
208    /// Get the raw output (for building messages sent to the LLM).
209    pub fn get_output(&self) -> String {
210        self.output_lines.join("\n")
211    }
212
213    /// Get the command that was executed.
214    pub fn get_command(&self) -> String {
215        self.command.clone()
216    }
217}
218
219impl Component for BashExecution {
220    fn set_expanded(&mut self, expanded: bool) {
221        BashExecution::set_expanded(self, expanded);
222    }
223
224    fn render(&self, width: usize) -> Vec<String> {
225        let theme = crate::agent::ui::theme::current_theme();
226        let border_key = self.border_color_key();
227        let border_fn = |s: &str| theme.fg(border_key, s);
228
229        let mut lines: Vec<String> = Vec::new();
230
231        // ── Spacer (1 blank line above, matching pi) ──
232        lines.push(String::new());
233
234        // ── Top border (pi-style: just ─ repeated) ──
235        let top_border = "─".repeat(width.max(1));
236        lines.push(border_fn(&top_border));
237
238        // ── Command header (pi-style: bold $ command in border color) ──
239        let header = format!(
240            "{} {}",
241            theme.bold_fg(border_key, "$"),
242            theme.fg(border_key, &self.command)
243        );
244        lines.push(header);
245
246        // ── Apply context truncation (same limits as bash tool, matching pi) ──
247        let (context_output, context_truncated) = self.context_truncated_output();
248        let available_lines: Vec<&str> = if context_output.is_empty() {
249            Vec::new()
250        } else {
251            context_output.split('\n').collect()
252        };
253
254        // ── Preview truncation (pi-style: first PREVIEW_LINES when collapsed, hint at top) ──
255        let preview_lines: Vec<&str> = if self.expanded {
256            available_lines.clone()
257        } else if available_lines.len() > PREVIEW_LINES {
258            available_lines[..PREVIEW_LINES].to_vec()
259        } else {
260            available_lines.clone()
261        };
262
263        let hidden_line_count = available_lines.len().saturating_sub(preview_lines.len());
264
265        // ── "N earlier lines" hint at top when collapsed (matching pi) ──
266        if !self.expanded && hidden_line_count > 0 {
267            let hint = theme.fg("muted", &format!("... {} more lines", hidden_line_count));
268            lines.push(hint);
269        }
270
271        // ── Output ──
272        if !preview_lines.is_empty() {
273            for line in &preview_lines {
274                let styled = theme.fg("toolOutput", line);
275                let wrapped = wrap_text_with_ansi(&styled, width);
276                lines.extend(wrapped);
277            }
278        }
279
280        // ── Status / hints ──
281        let mut status_parts: Vec<String> = Vec::new();
282
283        // Empty line before status (matching pi)
284        if !preview_lines.is_empty() {
285            status_parts.push(String::new());
286        }
287
288        // Duration (pi: "Elapsed X.Xs" during, "Took X.Xs" after)
289        if let Some(secs) = self.duration_secs {
290            let label = match self.status {
291                BashStatus::Running => "Elapsed",
292                _ => "Took",
293            };
294            status_parts.push(theme.fg("muted", &format!("{} {:.1}s", label, secs)));
295        }
296
297        // Status text
298        match &self.status {
299            BashStatus::Running => {
300                // Loader handles the spinner display
301            }
302            BashStatus::Complete { exit_code } if *exit_code != 0 => {
303                status_parts.push(theme.fg("error", &format!("(exit {})", exit_code)));
304            }
305            BashStatus::Cancelled => {
306                status_parts.push(theme.fg("warning", "(cancelled)"));
307            }
308            BashStatus::Error(msg) => {
309                status_parts.push(theme.fg("error", &format!("Error: {}", msg)));
310            }
311            _ => {}
312        }
313
314        // Truncation warning (context truncation, not preview truncation)
315        let was_truncated = context_truncated || self.was_truncated;
316        if was_truncated {
317            if let Some(ref path) = self.full_output_path {
318                status_parts.push(theme.fg(
319                    "warning",
320                    &format!("Output truncated. Full output: {}", path),
321                ));
322            } else {
323                status_parts.push(theme.fg("warning", "Output truncated."));
324            }
325        }
326
327        // Render loader or status
328        match &self.status {
329            BashStatus::Running => {
330                // Render the loader (pi-style: spinner with "Running... (Esc to cancel)" message)
331                let loader_lines = self.loader.render(width);
332                lines.extend(loader_lines);
333            }
334            _ => {
335                if !status_parts.is_empty() {
336                    // Skip leading empty line
337                    let status_line = if status_parts.len() == 1 && status_parts[0].is_empty() {
338                        String::new()
339                    } else {
340                        status_parts.join("  ")
341                    };
342                    if !status_line.is_empty() {
343                        lines.push(status_line);
344                    }
345                }
346            }
347        }
348
349        // ── Bottom border (pi-style: just ─ repeated) ──
350        let bottom_border = "─".repeat(width.max(1));
351        lines.push(border_fn(&bottom_border));
352
353        lines
354    }
355
356    fn invalidate(&mut self) {
357        self.loader.invalidate();
358    }
359}
360
361/// Simple truncation for output lines: if a line's visible width exceeds the terminal width,
362/// truncate it. This is a simplified version of pi's truncateToVisualLines.
363/// Strip ANSI escape codes from a string.
364fn strip_ansi(s: &str) -> String {
365    let mut result = String::with_capacity(s.len());
366    let mut chars = s.chars();
367    while let Some(c) = chars.next() {
368        if c == '\x1b' {
369            // Skip until we hit a letter in range 0x40-0x7E (end of CSI/OSC)
370            // Or until we hit BEL (0x07) for OSC sequences
371            for n in chars.by_ref() {
372                if n == '\x07' || n.is_ascii_uppercase() || n.is_ascii_lowercase() {
373                    break;
374                }
375                if n == '\x1b' {
376                    // Nested escape? Put it back conceptually, but we've already consumed
377                    break;
378                }
379            }
380        } else {
381            result.push(c);
382        }
383    }
384    result
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::agent::ui::theme::init_theme;
391
392    #[test]
393    fn test_bash_execution_new() {
394        let bash = BashExecution::new("echo hello");
395        assert_eq!(bash.command, "echo hello");
396        assert!(bash.output_lines.is_empty());
397        assert_eq!(bash.status, BashStatus::Running);
398        assert!(!bash.expanded);
399        assert!(!bash.exclude_from_context);
400    }
401
402    #[test]
403    fn test_bash_execution_append_output() {
404        let mut bash = BashExecution::new("echo hello");
405        bash.append_output("hello");
406        bash.append_output("world");
407        assert_eq!(bash.output_lines.len(), 2);
408        assert_eq!(bash.output_lines[0], "hello");
409        assert_eq!(bash.output_lines[1], "world");
410    }
411
412    #[test]
413    fn test_bash_execution_append_chunk() {
414        let mut bash = BashExecution::new("echo hello");
415        bash.append_chunk("line1\nline2\nline3");
416        assert_eq!(bash.output_lines.len(), 3);
417        assert_eq!(bash.output_lines[0], "line1");
418        assert_eq!(bash.output_lines[1], "line2");
419        assert_eq!(bash.output_lines[2], "line3");
420    }
421
422    #[test]
423    fn test_bash_execution_append_chunk_continues_last_line() {
424        let mut bash = BashExecution::new("echo hello");
425        bash.append_output("partial");
426        bash.append_chunk(" continuation\nnext");
427        assert_eq!(bash.output_lines.len(), 2);
428        assert_eq!(bash.output_lines[0], "partial continuation");
429        assert_eq!(bash.output_lines[1], "next");
430    }
431
432    #[test]
433    fn test_bash_execution_append_chunk_strips_ansi() {
434        let mut bash = BashExecution::new("echo hello");
435        bash.append_chunk("\x1b[31mcolored\x1b[0m");
436        assert_eq!(bash.output_lines.len(), 1);
437        assert_eq!(bash.output_lines[0], "colored");
438    }
439
440    #[test]
441    fn test_bash_execution_set_complete() {
442        let mut bash = BashExecution::new("echo hello");
443        bash.set_complete(0);
444        assert_eq!(bash.status, BashStatus::Complete { exit_code: 0 });
445
446        bash.set_complete(1);
447        assert_eq!(bash.status, BashStatus::Complete { exit_code: 1 });
448    }
449
450    #[test]
451    fn test_bash_execution_set_cancelled() {
452        let mut bash = BashExecution::new("echo hello");
453        bash.set_cancelled();
454        assert_eq!(bash.status, BashStatus::Cancelled);
455    }
456
457    #[test]
458    fn test_bash_execution_set_error() {
459        let mut bash = BashExecution::new("echo hello");
460        bash.set_error("something went wrong");
461        assert_eq!(
462            bash.status,
463            BashStatus::Error("something went wrong".into())
464        );
465    }
466
467    #[test]
468    fn test_bash_execution_set_expanded() {
469        let mut bash = BashExecution::new("echo hello");
470        assert!(!bash.expanded);
471        bash.set_expanded(true);
472        assert!(bash.expanded);
473        bash.set_expanded(false);
474        assert!(!bash.expanded);
475    }
476
477    #[test]
478    fn test_bash_execution_exclude_from_context() {
479        let mut bash = BashExecution::new("echo hello");
480        assert!(!bash.exclude_from_context);
481        bash.set_exclude_from_context(true);
482        assert!(bash.exclude_from_context);
483    }
484
485    #[test]
486    fn test_bash_execution_get_output() {
487        let mut bash = BashExecution::new("echo hello");
488        bash.append_output("line1");
489        bash.append_output("line2");
490        assert_eq!(bash.get_output(), "line1\nline2");
491    }
492
493    #[test]
494    fn test_bash_execution_get_command() {
495        let bash = BashExecution::new("echo hello");
496        assert_eq!(bash.get_command(), "echo hello");
497    }
498
499    #[test]
500    fn test_bash_execution_render_has_borders() {
501        init_theme(Some("dark"), false);
502        let bash = BashExecution::new("echo hello");
503        let lines = bash.render(80);
504        let all = lines.join("\n");
505        // Should have top border (just ─ with ANSI color codes)
506        assert!(lines[1].contains('─'), "Top border should contain ─");
507        // Should have bottom border (just ─ with ANSI color codes)
508        assert!(
509            lines[lines.len() - 1].contains('─'),
510            "Bottom border should contain ─"
511        );
512        assert!(all.contains("echo hello"), "Should show command");
513        // Spacer should be first line
514        assert!(lines[0].is_empty(), "First line should be empty (spacer)");
515    }
516
517    #[test]
518    fn test_bash_execution_render_status() {
519        init_theme(Some("dark"), false);
520        let mut bash = BashExecution::new("echo hello");
521        bash.append_output("hello world");
522
523        // Complete with exit 0
524        bash.set_complete(0);
525        let lines = bash.render(80);
526        let all = lines.join("\n");
527        assert!(all.contains("hello world"), "Should show output");
528        assert!(!all.contains("exit 0"), "No exit code for success");
529
530        // Complete with exit 1
531        bash.set_complete(1);
532        let lines = bash.render(80);
533        let all = lines.join("\n");
534        assert!(all.contains("exit 1"), "Should show exit code");
535    }
536
537    #[test]
538    fn test_collapsed_preview_shows_first_lines() {
539        init_theme(Some("dark"), false);
540        let mut bash = BashExecution::new("test");
541        for i in 0..50 {
542            bash.append_output(format!("line {}", i));
543        }
544        bash.set_complete(0);
545
546        let lines = bash.render(80);
547        let all = lines.join("\n");
548        assert!(all.contains("line 0"), "Collapsed: show first line");
549        assert!(all.contains("line 19"), "Collapsed: show line 20");
550        assert!(!all.contains("line 20"), "Collapsed: hide line 21");
551        assert!(!all.contains("line 49"), "Collapsed: hide last line");
552        assert!(all.contains("30 more lines"), "Should show remaining count");
553    }
554
555    #[test]
556    fn test_expanded_shows_all_lines() {
557        init_theme(Some("dark"), false);
558        let mut bash = BashExecution::new("test");
559        for i in 0..50 {
560            bash.append_output(format!("line {}", i));
561        }
562        bash.set_expanded(true);
563        bash.set_complete(0);
564
565        let lines = bash.render(80);
566        let all = lines.join("\n");
567        assert!(all.contains("line 0"), "Expanded: show first line");
568        assert!(all.contains("line 49"), "Expanded: show last line");
569        assert!(
570            !all.contains("more lines"),
571            "No 'more lines' indicator when expanded"
572        );
573    }
574
575    #[test]
576    fn test_exclude_from_context_uses_dim_border() {
577        init_theme(Some("dark"), false);
578        let mut bash = BashExecution::new("hidden command");
579        bash.set_exclude_from_context(true);
580        let lines = bash.render(80);
581        let all = lines.join("\n");
582        assert!(all.contains("hidden command"), "Should show command");
583    }
584
585    #[test]
586    fn test_cancelled_shows_warning() {
587        init_theme(Some("dark"), false);
588        let mut bash = BashExecution::new("sleep 10");
589        bash.set_cancelled();
590        let lines = bash.render(80);
591        let all = lines.join("\n");
592        assert!(all.contains("cancelled"), "Should show cancelled status");
593    }
594
595    #[test]
596    fn test_context_truncation() {
597        let mut bash = BashExecution::new("test");
598        // Add more lines than MAX_LINES
599        for i in 0..DEFAULT_MAX_LINES + 10 {
600            bash.append_output(format!("line {}", i));
601        }
602        let (output, truncated) = bash.context_truncated_output();
603        assert!(truncated, "Should be truncated");
604        let line_count = output.split('\n').count();
605        assert_eq!(line_count, DEFAULT_MAX_LINES, "Should have MAX_LINES lines");
606    }
607
608    #[test]
609    fn test_append_chunk_preserves_incomplete_last_line() {
610        let mut bash = BashExecution::new("echo test");
611        bash.append_chunk("first\nsecond\nincomplete");
612        assert_eq!(bash.output_lines.len(), 3);
613        assert_eq!(bash.output_lines[0], "first");
614        assert_eq!(bash.output_lines[1], "second");
615        assert_eq!(bash.output_lines[2], "incomplete");
616    }
617
618    #[test]
619    fn test_strip_ansi_basic() {
620        assert_eq!(strip_ansi("\x1b[31mred\x1b[0m"), "red");
621        assert_eq!(strip_ansi("no ansi"), "no ansi");
622        assert_eq!(strip_ansi(""), "");
623    }
624
625    #[test]
626    fn test_strip_ansi_complex() {
627        assert_eq!(strip_ansi("\x1b[1;31mbold red\x1b[0m"), "bold red");
628        assert_eq!(
629            strip_ansi("\x1b[38;2;255;0;0mtruecolor\x1b[39m"),
630            "truecolor"
631        );
632    }
633}