Skip to main content

rpi_tui/
bash_execution.rs

1//! Bash execution component — streaming display port of `bash-execution.ts`.
2//!
3//! This is a **display-only** consumer of the bash tool's existing `on_update`
4//! stream: it renders a command header, a spinner while running, a tailed
5//! preview of captured output, and a status/truncation line on completion.
6//! There is no async/signal plumbing — the caller drives lifecycle via
7//! `append_output` / `set_complete`.
8//!
9//! It backs both the `bash` tool panels and the user-initiated `!command` /
10//! `!!command` shell mode. Commands submitted with `!!` are excluded from the
11//! model context; those panels render with the dim border/header color so the
12//! distinction is visible, matching `bash-execution.ts`.
13
14use std::any::Any;
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::sync::Mutex;
17use std::time::{Duration, Instant};
18
19use super::component::Component;
20use crate::ansi::bold;
21use crate::ansi::strip_ansi;
22use crate::spacer::Spacer;
23use crate::theme::{theme, theme_revision, Theme, ThemeColors};
24use crate::tool_panel::ToolPanel;
25use crate::utils::{truncate_to_width, wrap_text_with_ansi};
26use crate::visual_truncate::truncate_to_visual_lines;
27
28/// Preview line limit when collapsed (matches the TS `PREVIEW_LINES`).
29const PREVIEW_LINES: usize = 20;
30
31/// After this long without a result, a running bash panel shows the
32/// "Esc / Ctrl+C 中止" hint (the tool has no default timeout — the model must
33/// pass one, and a stuck command otherwise looks frozen).
34const LONG_RUNNING_HINT_AFTER: Duration = Duration::from_secs(60);
35
36/// Bash execution status.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum BashStatus {
39    Running,
40    Complete,
41    Cancelled,
42    Error,
43}
44
45/// Truncation info extracted from `BashToolDetails` at completion.
46#[derive(Debug, Clone, Default)]
47pub struct BashTruncation {
48    /// Whether the captured output was truncated for context limits.
49    pub truncated: bool,
50    /// Path to the full output file, if truncation wrote one.
51    pub full_output_path: Option<String>,
52}
53
54/// A finalized bash panel is immutable, so its rendered lines can be memoized
55/// by width. Mirrors upstream `bash-execution.ts` `cachedWidth/cachedLines`.
56#[derive(Clone)]
57struct BashRenderCache {
58    width: usize,
59    revision: u64,
60    theme_revision: u64,
61    lines: Vec<String>,
62}
63
64/// Component that displays a bash command execution with streaming output.
65pub struct BashExecutionComponent {
66    command: Mutex<String>,
67    output_lines: Mutex<Vec<String>>,
68    status: Mutex<BashStatus>,
69    exit_code: Mutex<Option<i32>>,
70    expanded: Mutex<bool>,
71    truncation: Mutex<BashTruncation>,
72    /// `true` for a `!!`-prefixed user command: render dim and keep out of the
73    /// model context (`bash-execution.ts` `excludeFromContext`).
74    exclude_from_context: Mutex<bool>,
75    /// When the command started / finished, for the live elapsed readout while
76    /// running and the total elapsed after completion.
77    started_at: Mutex<Option<Instant>>,
78    finished_at: Mutex<Option<Instant>>,
79    /// Bumped by every mutator so a completed panel's cached render is dropped
80    /// when the user expands it or a late update lands.
81    revision: AtomicU64,
82    cache: Mutex<Option<BashRenderCache>>,
83}
84
85impl BashExecutionComponent {
86    /// Create a new component for the given command (without the leading `$`).
87    pub fn new(command: impl Into<String>) -> Self {
88        Self::new_with_context(command, false)
89    }
90
91    /// Create a component whose command is excluded from the model context
92    /// (the `!!` prefix). Renders with the dim border/header color.
93    pub fn new_excluded(command: impl Into<String>) -> Self {
94        Self::new_with_context(command, true)
95    }
96
97    /// Shared constructor. `exclude_from_context` selects the dim color key.
98    pub fn new_with_context(command: impl Into<String>, exclude_from_context: bool) -> Self {
99        Self {
100            command: Mutex::new(command.into()),
101            output_lines: Mutex::new(Vec::new()),
102            status: Mutex::new(BashStatus::Running),
103            exit_code: Mutex::new(None),
104            expanded: Mutex::new(false),
105            truncation: Mutex::new(BashTruncation::default()),
106            exclude_from_context: Mutex::new(exclude_from_context),
107            started_at: Mutex::new(Some(Instant::now())),
108            finished_at: Mutex::new(None),
109            revision: AtomicU64::new(0),
110            cache: Mutex::new(None),
111        }
112    }
113
114    /// Invalidate the memoized render (state changed).
115    fn bump(&self) {
116        self.revision.fetch_add(1, Ordering::AcqRel);
117    }
118
119    /// Total elapsed time, or the elapsed time so far while still running.
120    fn elapsed(&self) -> Duration {
121        let started = self.started_at.lock().ok().and_then(|g| *g);
122        let Some(started) = started else {
123            return Duration::default();
124        };
125        match self.finished_at.lock().ok().and_then(|g| *g) {
126            Some(finished) => finished.saturating_duration_since(started),
127            None => started.elapsed(),
128        }
129    }
130
131    /// Set whether this command is excluded from the model context. The border
132    /// and header recolor on the next render.
133    pub fn set_exclude_from_context(&self, exclude: bool) {
134        if let Ok(mut e) = self.exclude_from_context.lock() {
135            *e = exclude;
136        }
137        self.bump();
138    }
139
140    /// Whether this command is excluded from the model context.
141    pub fn is_excluded_from_context(&self) -> bool {
142        *self.exclude_from_context.lock().unwrap()
143    }
144
145    /// Apply the authoritative command from `ToolExecutionStart`. A panel may
146    /// have been created from an earlier streaming snapshot, so this must
147    /// replace a partial command as well as an empty one.
148    pub fn set_command(&self, command: &str) {
149        if !command.trim().is_empty() {
150            let mut c = self.command.lock().unwrap();
151            *c = command.to_string();
152            drop(c);
153            self.bump();
154        }
155    }
156
157    /// Replace the displayed output with the latest streamed snapshot.
158    ///
159    /// The bash tool's `on_update` contract sends the complete captured output
160    /// so far, not a delta. Appending each update duplicated all prior lines
161    /// (`a`, then `a\nb` became `aa\nb`). Keep the latest snapshot instead.
162    pub fn append_output(&self, chunk: &str) {
163        let clean = strip_ansi(chunk).replace("\r\n", "\n").replace('\r', "\n");
164        if let Ok(mut out) = self.output_lines.lock() {
165            *out = if clean.is_empty() {
166                Vec::new()
167            } else {
168                clean
169                    .split('\n')
170                    .map(|line| line.replace('\t', "    "))
171                    .collect()
172            };
173        }
174        self.bump();
175    }
176
177    /// Mark execution complete.
178    pub fn set_complete(
179        &self,
180        exit_code: Option<i32>,
181        cancelled: bool,
182        truncation: BashTruncation,
183    ) {
184        if let Ok(mut e) = self.exit_code.lock() {
185            *e = exit_code;
186        }
187        let new_status = if cancelled {
188            BashStatus::Cancelled
189        } else if exit_code.map(|c| c != 0).unwrap_or(false) {
190            BashStatus::Error
191        } else {
192            BashStatus::Complete
193        };
194        if let Ok(mut s) = self.status.lock() {
195            *s = new_status;
196        }
197        if let Ok(mut t) = self.truncation.lock() {
198            *t = truncation;
199        }
200        if let Ok(mut f) = self.finished_at.lock() {
201            *f = Some(Instant::now());
202        }
203        self.bump();
204    }
205
206    /// Toggle expanded (full output) vs collapsed (preview) display.
207    pub fn set_expanded(&self, expanded: bool) {
208        if let Ok(mut e) = self.expanded.lock() {
209            *e = expanded;
210        }
211        self.bump();
212    }
213
214    /// Whether currently expanded.
215    pub fn is_expanded(&self) -> bool {
216        *self.expanded.lock().unwrap()
217    }
218
219    /// Get the raw joined output.
220    pub fn get_output(&self) -> String {
221        self.output_lines
222            .lock()
223            .map(|o| o.join("\n"))
224            .unwrap_or_default()
225    }
226
227    /// Build the trailing status/truncation line(s), if any.
228    fn status_line(&self, hidden: usize, colors: &ThemeColors) -> Vec<String> {
229        let status = *self.status.lock().unwrap();
230        let truncation = self.truncation.lock().unwrap().clone();
231        let exit_code = *self.exit_code.lock().unwrap();
232        let expanded = *self.expanded.lock().unwrap();
233
234        let mut parts: Vec<String> = Vec::new();
235        // Total elapsed up front: it is the first thing a reader looks for on a
236        // finished command.
237        let elapsed = self.elapsed();
238        if elapsed > Duration::from_millis(100) {
239            parts.push(
240                colors
241                    .muted
242                    .fg(&crate::tool_execution::format_elapsed(elapsed)),
243            );
244        }
245        if hidden > 0 {
246            if expanded {
247                parts.push(colors.muted.fg("(Ctrl+O to collapse)"));
248            } else {
249                parts.push(
250                    colors
251                        .muted
252                        .fg(&format!("... {} more lines (Ctrl+O to expand)", hidden)),
253                );
254            }
255        }
256        match status {
257            BashStatus::Cancelled => parts.push(colors.warning.fg("(cancelled)")),
258            BashStatus::Error => {
259                parts.push(
260                    colors
261                        .error
262                        .fg(&format!("(exit {})", exit_code.unwrap_or(-1))),
263                );
264            }
265            _ => {}
266        }
267        if truncation.truncated {
268            let path = truncation.full_output_path.as_deref().unwrap_or("");
269            parts.push(
270                colors
271                    .warning
272                    .fg(&format!("Output truncated. Full output: {}", path)),
273            );
274        }
275        if parts.is_empty() {
276            Vec::new()
277        } else {
278            vec![parts.join(" ")]
279        }
280    }
281
282    /// Render against one theme snapshot; keep all insets inside the background.
283    fn render_panel(&self, width: usize, status: BashStatus, theme: &Theme) -> Vec<String> {
284        let colors = &theme.colors;
285        let bg = match status {
286            BashStatus::Running => colors.tool_pending_bg,
287            BashStatus::Error | BashStatus::Cancelled => colors.tool_error_bg,
288            BashStatus::Complete => colors.tool_success_bg,
289        };
290        let Some(panel) = ToolPanel::new(width, theme.spacing, bg) else {
291            return Vec::new();
292        };
293        let content_width = panel.content_width;
294        let panel_line = |content: &str| panel.line(content);
295        let blank = panel.blank();
296        let mut lines = Spacer::new(1).render(width);
297        lines.extend(std::iter::repeat(blank.clone()).take(panel.padding_y));
298
299        let excluded = *self.exclude_from_context.lock().unwrap();
300        let color_key = if excluded {
301            colors.dim
302        } else {
303            colors.bash_mode
304        };
305        let command = self.command.lock().unwrap().clone();
306        for part in wrap_text_with_ansi(&format!("$ {command}"), content_width) {
307            lines.push(panel_line(&color_key.fg(&bold(&part))));
308        }
309
310        let (display_lines, hidden) = {
311            let out = self.output_lines.lock().unwrap();
312            let expanded = *self.expanded.lock().unwrap();
313            if out.is_empty() {
314                (Vec::new(), 0usize)
315            } else {
316                let styled = format!(
317                    "\n{}",
318                    out.iter()
319                        .map(|l| colors.muted.fg(l))
320                        .collect::<Vec<_>>()
321                        .join("\n")
322                );
323                if expanded {
324                    (
325                        crate::text::Text::new(&styled, 0, 0).render(content_width),
326                        0,
327                    )
328                } else {
329                    let r = truncate_to_visual_lines(&styled, PREVIEW_LINES, content_width, 0);
330                    (r.visual_lines, r.skipped_count)
331                }
332            }
333        };
334        for line in &display_lines {
335            lines.push(panel_line(line));
336        }
337
338        if status == BashStatus::Running {
339            let elapsed = self.elapsed();
340            let row = format!(
341                "{} {}",
342                colors.accent.fg("●"),
343                colors
344                    .muted
345                    .fg(&crate::tool_execution::format_elapsed(elapsed)),
346            );
347            lines.push(panel_line(&truncate_to_width(&row, content_width, "…")));
348            if elapsed > LONG_RUNNING_HINT_AFTER {
349                let hint = format!("{} Esc / Ctrl+C 中止", colors.muted.fg("⏸"));
350                for part in wrap_text_with_ansi(&hint, content_width) {
351                    lines.push(panel_line(&part));
352                }
353            }
354        } else {
355            let status_lines = self.status_line(hidden, colors);
356            if !status_lines.is_empty() {
357                lines.push(blank.clone());
358                for line in &status_lines {
359                    for part in wrap_text_with_ansi(line, content_width) {
360                        lines.push(panel_line(&part));
361                    }
362                }
363            }
364        }
365        lines.extend(std::iter::repeat(blank).take(panel.padding_y));
366        lines
367    }
368}
369
370impl Component for BashExecutionComponent {
371    fn render(&self, width: usize) -> Vec<String> {
372        let status = *self.status.lock().unwrap();
373        let revision = self.revision.load(Ordering::Acquire);
374        let theme_revision = theme_revision();
375
376        // A finished panel never changes (no live elapsed timer), so serve it
377        // from the cache. A running panel is skipped — its elapsed timer must
378        // keep advancing.
379        if status != BashStatus::Running {
380            if let Ok(cache) = self.cache.lock() {
381                if let Some(cached) = cache.as_ref() {
382                    if cached.width == width
383                        && cached.revision == revision
384                        && cached.theme_revision == theme_revision
385                    {
386                        return cached.lines.clone();
387                    }
388                }
389            }
390        }
391
392        let lines = self.render_panel(width, status, &theme());
393
394        if status != BashStatus::Running {
395            if let Ok(mut cache) = self.cache.lock() {
396                *cache = Some(BashRenderCache {
397                    width,
398                    revision,
399                    theme_revision,
400                    lines: lines.clone(),
401                });
402            }
403        } else if let Ok(mut cache) = self.cache.lock() {
404            *cache = None;
405        }
406
407        lines
408    }
409
410    fn invalidate(&self) {
411        self.bump();
412    }
413
414    fn as_any(&self) -> &dyn Any {
415        self
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn panel_has_themed_insets_in_every_execution_state() {
425        let theme = Theme::default();
426        let width = 40;
427        for (exit_code, cancelled) in [
428            (None, false),
429            (Some(0), false),
430            (Some(1), false),
431            (Some(0), true),
432        ] {
433            let c = BashExecutionComponent::new("echo hi");
434            c.append_output("hi");
435            if exit_code.is_some() {
436                c.set_complete(exit_code, cancelled, BashTruncation::default());
437            }
438            let status = *c.status.lock().unwrap();
439            let bg = match status {
440                BashStatus::Running => theme.colors.tool_pending_bg,
441                BashStatus::Complete => theme.colors.tool_success_bg,
442                BashStatus::Error | BashStatus::Cancelled => theme.colors.tool_error_bg,
443            };
444            let lines = c.render_panel(width, status, &theme);
445            assert_eq!(lines[0], "", "outer spacer must remain uncolored");
446            assert_eq!(lines[1], bg.bg(&" ".repeat(width)));
447            assert_eq!(lines.last().unwrap(), &lines[1]);
448            let plain: Vec<_> = lines[1..].iter().map(|line| strip_ansi(line)).collect();
449            assert_eq!(plain[1].trim_end(), "  $ echo hi");
450            assert!(plain.iter().any(|line| line.trim_end() == "  hi"));
451            for (styled, text) in lines[1..].iter().zip(&plain) {
452                assert!(styled.starts_with(&bg.to_bg()));
453                assert_eq!(crate::utils::visible_width(text), width);
454                assert!(text.starts_with("  ") && text.ends_with("  "), "{text:?}");
455            }
456        }
457    }
458
459    #[test]
460    fn custom_theme_spacing_controls_panel_without_global_mutation() {
461        let mut theme = Theme::default();
462        theme.spacing.tool_panel_padding_x = 4;
463        theme.spacing.tool_panel_padding_y = 2;
464        let c = BashExecutionComponent::new("echo hi");
465        // Keep this layout-only test independent of scheduler delays.
466        *c.started_at.lock().unwrap() = None;
467        c.append_output("hi");
468        c.set_complete(Some(0), false, BashTruncation::default());
469        let lines = c.render_panel(40, BashStatus::Complete, &theme);
470        let blank = theme.colors.tool_success_bg.bg(&" ".repeat(40));
471        assert_eq!(lines[1..3], [blank.clone(), blank.clone()]);
472        assert_eq!(lines[lines.len() - 2..], [blank.clone(), blank]);
473        assert_eq!(strip_ansi(&lines[3]).trim_end(), "    $ echo hi");
474        assert!(lines
475            .iter()
476            .any(|line| strip_ansi(line).trim_end() == "    hi"));
477
478        theme.spacing.tool_panel_padding_x = 0;
479        theme.spacing.tool_panel_padding_y = 0;
480        let lines = c.render_panel(40, BashStatus::Complete, &theme);
481        assert_eq!(strip_ansi(&lines[1]).trim_end(), "$ echo hi");
482        assert_eq!(strip_ansi(lines.last().unwrap()).trim_end(), "hi");
483    }
484
485    #[test]
486    fn output_wraps_inside_both_insets_collapsed_and_expanded() {
487        let theme = Theme::default();
488        let output = "abcdefghijklmnop你好世界qrstuvwxyz";
489        let c = BashExecutionComponent::new("echo");
490        *c.started_at.lock().unwrap() = None;
491        c.append_output(output);
492        c.set_complete(Some(0), false, BashTruncation::default());
493        for expanded in [false, true] {
494            c.set_expanded(expanded);
495            let lines = c.render_panel(12, BashStatus::Complete, &theme);
496            let output_rows = &lines[3..lines.len() - 1];
497            let reconstructed: String = output_rows
498                .iter()
499                .map(|line| strip_ansi(line).trim().to_string())
500                .collect();
501            assert_eq!(reconstructed, output);
502            for line in output_rows {
503                let plain = strip_ansi(line);
504                assert_eq!(crate::utils::visible_width(&plain), 12);
505                assert!(plain.starts_with("  ") && plain.ends_with("  "));
506            }
507        }
508    }
509
510    #[test]
511    fn long_status_paths_wrap_inside_panel() {
512        let path = "/tmp/very-long-directory/full-command-output.log";
513        let c = BashExecutionComponent::new("false");
514        c.set_complete(
515            Some(1),
516            false,
517            BashTruncation {
518                truncated: true,
519                full_output_path: Some(path.to_string()),
520            },
521        );
522        let lines = c.render_panel(16, BashStatus::Error, &Theme::default());
523        let compact: String = strip_ansi(&lines.join("\n"))
524            .chars()
525            .filter(|ch| !ch.is_whitespace())
526            .collect();
527        assert!(compact.contains(path));
528        assert!(lines
529            .iter()
530            .all(|line| crate::utils::visible_width(line) <= 16));
531    }
532
533    #[test]
534    fn narrow_panels_reduce_padding_without_overflow() {
535        let c = BashExecutionComponent::new("echo 你好");
536        c.append_output("你好世界");
537        let mut theme = Theme::default();
538        theme.spacing.tool_panel_padding_x = usize::MAX;
539        for width in 0..=8 {
540            let lines = c.render_panel(width, BashStatus::Running, &theme);
541            assert!(
542                lines
543                    .iter()
544                    .all(|line| crate::utils::visible_width(line) <= width),
545                "width {width}: {lines:?}"
546            );
547            if width == 0 {
548                assert!(lines.is_empty());
549            }
550        }
551    }
552
553    #[test]
554    fn test_new_renders_header_and_live_elapsed() {
555        let c = BashExecutionComponent::new("ls -la");
556        let lines = c.render(40);
557        let joined = lines.join("\n");
558        assert!(joined.contains("$ ls -la"));
559        // A running panel reports a live elapsed timer and a static status
560        // glyph — no spinner, matching tool panels.
561        let plain = strip_ansi(&joined);
562        assert!(
563            plain.contains('●'),
564            "running panel needs the status glyph: {plain}"
565        );
566        assert!(
567            plain.contains('s'),
568            "running panel needs an elapsed readout: {plain}"
569        );
570        assert!(
571            !plain.contains("Running"),
572            "the `Running...` label was replaced by the elapsed timer: {plain}"
573        );
574    }
575
576    #[test]
577    fn completed_panel_reports_total_elapsed() {
578        let c = BashExecutionComponent::new("sleep 1");
579        c.append_output("done\n");
580        std::thread::sleep(std::time::Duration::from_millis(120));
581        c.set_complete(Some(0), false, BashTruncation::default());
582        let plain = strip_ansi(&c.render(60).join("\n"));
583        // The completion line leads with the total elapsed time.
584        assert!(
585            plain.contains("0.1s") || plain.contains("0.2s"),
586            "total elapsed missing: {plain}"
587        );
588        // And it stops advancing once the command is finished.
589        let first = c.render(60).join("\n");
590        std::thread::sleep(std::time::Duration::from_millis(60));
591        assert_eq!(
592            first,
593            c.render(60).join("\n"),
594            "elapsed kept counting after completion"
595        );
596    }
597
598    #[test]
599    fn test_append_and_complete() {
600        let c = BashExecutionComponent::new("echo hi");
601        c.append_output("hi\n");
602        c.set_complete(Some(0), false, BashTruncation::default());
603        let lines = c.render(40);
604        let joined = lines.join("\n");
605        // After completion the spinner is gone; output "hi" is visible.
606        assert!(strip_ansi(&joined).contains("hi"), "joined: {joined}");
607    }
608
609    #[test]
610    fn excluded_from_context_uses_dim_border_and_header() {
611        // `!!cmd` renders dim rather than bashMode so the exclusion is visible
612        // (mirrors `bash-execution.ts` `excludeFromContext`).
613        let normal = BashExecutionComponent::new("echo hi");
614        let excluded = BashExecutionComponent::new_excluded("echo hi");
615        assert!(!normal.is_excluded_from_context());
616        assert!(excluded.is_excluded_from_context());
617
618        let colors = theme().colors;
619        // The command header uses the bash accent (or dim when excluded), so
620        // compare the foreground escape each color produces.
621        let bash_start = colors
622            .bash_mode
623            .fg("X")
624            .split('X')
625            .next()
626            .unwrap()
627            .to_string();
628        let dim_start = colors.dim.fg("X").split('X').next().unwrap().to_string();
629
630        let normal_out = normal.render(40).join("\n");
631        let excluded_out = excluded.render(40).join("\n");
632        assert!(normal_out.contains(&bash_start), "normal: {normal_out:?}");
633        assert!(
634            excluded_out.contains(&dim_start),
635            "excluded: {excluded_out:?}"
636        );
637
638        // Both still show the command itself.
639        assert!(strip_ansi(&excluded_out).contains("$ echo hi"));
640
641        // Flipping the flag in place recolors without recreating the component.
642        excluded.set_exclude_from_context(false);
643        assert!(!excluded.is_excluded_from_context());
644        assert!(excluded.render(40).join("\n").contains(&bash_start));
645    }
646
647    #[test]
648    fn streamed_output_updates_replace_snapshots_instead_of_duplicating() {
649        let c = BashExecutionComponent::new("printf test");
650        c.append_output("one\n");
651        c.append_output("one\ntwo\n");
652        assert_eq!(c.get_output(), "one\ntwo\n");
653    }
654
655    #[test]
656    fn authoritative_start_command_replaces_partial_streamed_command() {
657        let c = BashExecutionComponent::new("printf");
658        c.set_command("printf '\\n--- agent skills ---\\n' && find skills");
659        let rendered = strip_ansi(&c.render(120).join("\n"));
660        assert!(rendered.contains("printf '\\n--- agent skills ---\\n' && find skills"));
661        assert!(!rendered.contains("$ printf\n"));
662    }
663
664    #[test]
665    fn long_running_command_wraps_instead_of_truncating() {
666        let command = "printf 'this command must remain fully visible while it runs'";
667        let c = BashExecutionComponent::new(command);
668        let lines = c.render(20);
669        let rendered = strip_ansi(&lines.join("\n"));
670        let compact: String = rendered.chars().filter(|ch| !ch.is_whitespace()).collect();
671        let expected: String = command.chars().filter(|ch| !ch.is_whitespace()).collect();
672        assert!(
673            compact.contains(&expected),
674            "command was truncated: {rendered}"
675        );
676        assert!(lines
677            .iter()
678            .all(|line| crate::utils::visible_width(line) <= 20));
679    }
680
681    #[test]
682    fn test_error_exit_status() {
683        let c = BashExecutionComponent::new("false");
684        c.set_complete(Some(1), false, BashTruncation::default());
685        let lines = c.render(40);
686        let joined = lines.join("\n");
687        assert!(joined.contains("exit 1"));
688    }
689
690    #[test]
691    fn test_truncation_path_shown() {
692        let c = BashExecutionComponent::new("big-output");
693        c.set_complete(
694            Some(0),
695            false,
696            BashTruncation {
697                truncated: true,
698                full_output_path: Some("/tmp/out.log".to_string()),
699            },
700        );
701        let lines = c.render(40);
702        // Paths may wrap now that status rows reserve both panel insets.
703        let joined: String = lines
704            .iter()
705            .map(|line| strip_ansi(line).trim().to_string())
706            .collect();
707        assert!(joined.contains("/tmp/out.log"), "joined: {joined}");
708    }
709}