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};
24use crate::utils::{truncate_to_width, wrap_text_with_ansi};
25use crate::visual_truncate::truncate_to_visual_lines;
26
27/// Preview line limit when collapsed (matches the TS `PREVIEW_LINES`).
28const PREVIEW_LINES: usize = 20;
29
30/// After this long without a result, a running bash panel shows the
31/// "Esc / Ctrl+C 中止" hint (the tool has no default timeout — the model must
32/// pass one, and a stuck command otherwise looks frozen).
33const LONG_RUNNING_HINT_AFTER: Duration = Duration::from_secs(60);
34
35/// Bash execution status.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum BashStatus {
38    Running,
39    Complete,
40    Cancelled,
41    Error,
42}
43
44/// Truncation info extracted from `BashToolDetails` at completion.
45#[derive(Debug, Clone, Default)]
46pub struct BashTruncation {
47    /// Whether the captured output was truncated for context limits.
48    pub truncated: bool,
49    /// Path to the full output file, if truncation wrote one.
50    pub full_output_path: Option<String>,
51}
52
53/// A finalized bash panel is immutable, so its rendered lines can be memoized
54/// by width. Mirrors upstream `bash-execution.ts` `cachedWidth/cachedLines`.
55#[derive(Clone)]
56struct BashRenderCache {
57    width: usize,
58    revision: u64,
59    theme_revision: u64,
60    lines: Vec<String>,
61}
62
63/// Component that displays a bash command execution with streaming output.
64pub struct BashExecutionComponent {
65    command: Mutex<String>,
66    output_lines: Mutex<Vec<String>>,
67    status: Mutex<BashStatus>,
68    exit_code: Mutex<Option<i32>>,
69    expanded: Mutex<bool>,
70    truncation: Mutex<BashTruncation>,
71    /// `true` for a `!!`-prefixed user command: render dim and keep out of the
72    /// model context (`bash-execution.ts` `excludeFromContext`).
73    exclude_from_context: Mutex<bool>,
74    /// When the command started / finished, for the live elapsed readout while
75    /// running and the total elapsed after completion.
76    started_at: Mutex<Option<Instant>>,
77    finished_at: Mutex<Option<Instant>>,
78    /// Bumped by every mutator so a completed panel's cached render is dropped
79    /// when the user expands it or a late update lands.
80    revision: AtomicU64,
81    cache: Mutex<Option<BashRenderCache>>,
82}
83
84impl BashExecutionComponent {
85    /// Create a new component for the given command (without the leading `$`).
86    pub fn new(command: impl Into<String>) -> Self {
87        Self::new_with_context(command, false)
88    }
89
90    /// Create a component whose command is excluded from the model context
91    /// (the `!!` prefix). Renders with the dim border/header color.
92    pub fn new_excluded(command: impl Into<String>) -> Self {
93        Self::new_with_context(command, true)
94    }
95
96    /// Shared constructor. `exclude_from_context` selects the dim color key.
97    pub fn new_with_context(command: impl Into<String>, exclude_from_context: bool) -> Self {
98        Self {
99            command: Mutex::new(command.into()),
100            output_lines: Mutex::new(Vec::new()),
101            status: Mutex::new(BashStatus::Running),
102            exit_code: Mutex::new(None),
103            expanded: Mutex::new(false),
104            truncation: Mutex::new(BashTruncation::default()),
105            exclude_from_context: Mutex::new(exclude_from_context),
106            started_at: Mutex::new(Some(Instant::now())),
107            finished_at: Mutex::new(None),
108            revision: AtomicU64::new(0),
109            cache: Mutex::new(None),
110        }
111    }
112
113    /// Invalidate the memoized render (state changed).
114    fn bump(&self) {
115        self.revision.fetch_add(1, Ordering::AcqRel);
116    }
117
118    /// Total elapsed time, or the elapsed time so far while still running.
119    fn elapsed(&self) -> Duration {
120        let started = self.started_at.lock().ok().and_then(|g| *g);
121        let Some(started) = started else {
122            return Duration::default();
123        };
124        match self.finished_at.lock().ok().and_then(|g| *g) {
125            Some(finished) => finished.saturating_duration_since(started),
126            None => started.elapsed(),
127        }
128    }
129
130    /// Set whether this command is excluded from the model context. The border
131    /// and header recolor on the next render.
132    pub fn set_exclude_from_context(&self, exclude: bool) {
133        if let Ok(mut e) = self.exclude_from_context.lock() {
134            *e = exclude;
135        }
136        self.bump();
137    }
138
139    /// Whether this command is excluded from the model context.
140    pub fn is_excluded_from_context(&self) -> bool {
141        *self.exclude_from_context.lock().unwrap()
142    }
143
144    /// Apply the authoritative command from `ToolExecutionStart`. A panel may
145    /// have been created from an earlier streaming snapshot, so this must
146    /// replace a partial command as well as an empty one.
147    pub fn set_command(&self, command: &str) {
148        if !command.trim().is_empty() {
149            let mut c = self.command.lock().unwrap();
150            *c = command.to_string();
151            drop(c);
152            self.bump();
153        }
154    }
155
156    /// Replace the displayed output with the latest streamed snapshot.
157    ///
158    /// The bash tool's `on_update` contract sends the complete captured output
159    /// so far, not a delta. Appending each update duplicated all prior lines
160    /// (`a`, then `a\nb` became `aa\nb`). Keep the latest snapshot instead.
161    pub fn append_output(&self, chunk: &str) {
162        let clean = strip_ansi(chunk).replace("\r\n", "\n").replace('\r', "\n");
163        if let Ok(mut out) = self.output_lines.lock() {
164            *out = if clean.is_empty() {
165                Vec::new()
166            } else {
167                clean
168                    .split('\n')
169                    .map(|line| line.replace('\t', "    "))
170                    .collect()
171            };
172        }
173        self.bump();
174    }
175
176    /// Mark execution complete.
177    pub fn set_complete(
178        &self,
179        exit_code: Option<i32>,
180        cancelled: bool,
181        truncation: BashTruncation,
182    ) {
183        if let Ok(mut e) = self.exit_code.lock() {
184            *e = exit_code;
185        }
186        let new_status = if cancelled {
187            BashStatus::Cancelled
188        } else if exit_code.map(|c| c != 0).unwrap_or(false) {
189            BashStatus::Error
190        } else {
191            BashStatus::Complete
192        };
193        if let Ok(mut s) = self.status.lock() {
194            *s = new_status;
195        }
196        if let Ok(mut t) = self.truncation.lock() {
197            *t = truncation;
198        }
199        if let Ok(mut f) = self.finished_at.lock() {
200            *f = Some(Instant::now());
201        }
202        self.bump();
203    }
204
205    /// Toggle expanded (full output) vs collapsed (preview) display.
206    pub fn set_expanded(&self, expanded: bool) {
207        if let Ok(mut e) = self.expanded.lock() {
208            *e = expanded;
209        }
210        self.bump();
211    }
212
213    /// Whether currently expanded.
214    pub fn is_expanded(&self) -> bool {
215        *self.expanded.lock().unwrap()
216    }
217
218    /// Get the raw joined output.
219    pub fn get_output(&self) -> String {
220        self.output_lines
221            .lock()
222            .map(|o| o.join("\n"))
223            .unwrap_or_default()
224    }
225
226    /// Build the trailing status/truncation line(s), if any.
227    fn status_line(&self, hidden: usize) -> Vec<String> {
228        let status = *self.status.lock().unwrap();
229        let truncation = self.truncation.lock().unwrap().clone();
230        let exit_code = *self.exit_code.lock().unwrap();
231        let expanded = *self.expanded.lock().unwrap();
232        let colors = theme().colors;
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![format!("  {}", parts.join(" "))]
279        }
280    }
281}
282
283impl Component for BashExecutionComponent {
284    fn render(&self, width: usize) -> Vec<String> {
285        let status = *self.status.lock().unwrap();
286        let revision = self.revision.load(Ordering::Acquire);
287        let theme_revision = theme_revision();
288
289        // A finished panel never changes (no live elapsed timer), so serve it
290        // from the cache. A running panel is skipped — its elapsed timer must
291        // keep advancing.
292        if status != BashStatus::Running {
293            if let Ok(cache) = self.cache.lock() {
294                if let Some(cached) = cache.as_ref() {
295                    if cached.width == width
296                        && cached.revision == revision
297                        && cached.theme_revision == theme_revision
298                    {
299                        return cached.lines.clone();
300                    }
301                }
302            }
303        }
304
305        let colors = theme().colors;
306        let mut lines: Vec<String> = Vec::new();
307
308        // `!!` commands are excluded from the model context, so they render
309        // dim instead of the normal bash color (mirrors `bash-execution.ts`).
310        let excluded = *self.exclude_from_context.lock().unwrap();
311        let color_key = if excluded {
312            colors.dim
313        } else {
314            colors.bash_mode
315        };
316
317        // Background tint for the bash panel — mirrors the tool execution
318        // panel backgrounds so bash blocks visually match tool blocks.
319        let bg = match *self.status.lock().unwrap() {
320            BashStatus::Running => colors.tool_pending_bg,
321            BashStatus::Error | BashStatus::Cancelled => colors.tool_error_bg,
322            BashStatus::Complete => colors.tool_success_bg,
323        };
324
325        // Spacer
326        lines.extend(Spacer::new(1).render(width));
327
328        // Command header: "$ {command}" in accent.
329        let command = self.command.lock().unwrap().clone();
330        let header_text = format!("$ {command}");
331        let header_width = width.saturating_sub(2).max(1);
332        for part in wrap_text_with_ansi(&header_text, header_width) {
333            let line = format!("  {}", color_key.fg(&bold(&part)));
334            lines.push(crate::utils::apply_background_to_line(&line, width, |s| {
335                bg.bg(s)
336            }));
337        }
338
339        // Output preview
340        let (display_lines, hidden) = {
341            let out = self.output_lines.lock().unwrap();
342            let expanded = *self.expanded.lock().unwrap();
343            if out.is_empty() {
344                (Vec::new(), 0usize)
345            } else {
346                let muted = colors.muted;
347                let styled = format!(
348                    "
349{}",
350                    out.iter().map(|l| muted.fg(l)).collect::<Vec<_>>().join(
351                        "
352"
353                    )
354                );
355                if expanded {
356                    let all = crate::text::Text::new(&styled, 1, 0).render(width);
357                    (all, 0)
358                } else {
359                    let r = truncate_to_visual_lines(&styled, PREVIEW_LINES, width, 1);
360                    (r.visual_lines, r.skipped_count)
361                }
362            }
363        };
364        for dl in &display_lines {
365            lines.push(crate::utils::apply_background_to_line(dl, width, |s| {
366                bg.bg(s)
367            }));
368        }
369
370        // Spinner (while running) or status line (when complete)
371        if status == BashStatus::Running {
372            // A live elapsed timer, not a spinner: tool panels report progress
373            // the same way (static status glyph + elapsed), so two concurrent
374            // operations never look like two different kinds of "busy".
375            let elapsed = self.elapsed();
376            let row = format!(
377                "  {} {}",
378                colors.accent.fg("●"),
379                colors
380                    .muted
381                    .fg(&crate::tool_execution::format_elapsed(elapsed)),
382            );
383            let row = truncate_to_width(&row, width, "…");
384            lines.push(crate::utils::apply_background_to_line(&row, width, |s| {
385                bg.bg(s)
386            }));
387            if elapsed > LONG_RUNNING_HINT_AFTER {
388                let hint = format!("  {} Esc / Ctrl+C 中止", colors.muted.fg("⏸"));
389                let hint = truncate_to_width(&hint, width, "…");
390                lines.push(crate::utils::apply_background_to_line(&hint, width, |s| {
391                    bg.bg(s)
392                }));
393            }
394        } else {
395            let sl = self.status_line(hidden);
396            if !sl.is_empty() {
397                lines.push(crate::utils::apply_background_to_line("", width, |s| {
398                    bg.bg(s)
399                }));
400                for sline in &sl {
401                    lines.push(crate::utils::apply_background_to_line(sline, width, |s| {
402                        bg.bg(s)
403                    }));
404                }
405            }
406        }
407
408        if status != BashStatus::Running {
409            if let Ok(mut cache) = self.cache.lock() {
410                *cache = Some(BashRenderCache {
411                    width,
412                    revision,
413                    theme_revision,
414                    lines: lines.clone(),
415                });
416            }
417        } else if let Ok(mut cache) = self.cache.lock() {
418            *cache = None;
419        }
420
421        lines
422    }
423
424    fn invalidate(&self) {
425        self.bump();
426    }
427
428    fn as_any(&self) -> &dyn Any {
429        self
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn test_new_renders_header_and_live_elapsed() {
439        let c = BashExecutionComponent::new("ls -la");
440        let lines = c.render(40);
441        let joined = lines.join("\n");
442        assert!(joined.contains("$ ls -la"));
443        // A running panel reports a live elapsed timer and a static status
444        // glyph — no spinner, matching tool panels.
445        let plain = strip_ansi(&joined);
446        assert!(
447            plain.contains('●'),
448            "running panel needs the status glyph: {plain}"
449        );
450        assert!(
451            plain.contains('s'),
452            "running panel needs an elapsed readout: {plain}"
453        );
454        assert!(
455            !plain.contains("Running"),
456            "the `Running...` label was replaced by the elapsed timer: {plain}"
457        );
458    }
459
460    #[test]
461    fn completed_panel_reports_total_elapsed() {
462        let c = BashExecutionComponent::new("sleep 1");
463        c.append_output("done\n");
464        std::thread::sleep(std::time::Duration::from_millis(120));
465        c.set_complete(Some(0), false, BashTruncation::default());
466        let plain = strip_ansi(&c.render(60).join("\n"));
467        // The completion line leads with the total elapsed time.
468        assert!(
469            plain.contains("0.1s") || plain.contains("0.2s"),
470            "total elapsed missing: {plain}"
471        );
472        // And it stops advancing once the command is finished.
473        let first = c.render(60).join("\n");
474        std::thread::sleep(std::time::Duration::from_millis(60));
475        assert_eq!(
476            first,
477            c.render(60).join("\n"),
478            "elapsed kept counting after completion"
479        );
480    }
481
482    #[test]
483    fn test_append_and_complete() {
484        let c = BashExecutionComponent::new("echo hi");
485        c.append_output("hi\n");
486        c.set_complete(Some(0), false, BashTruncation::default());
487        let lines = c.render(40);
488        let joined = lines.join("\n");
489        // After completion the spinner is gone; output "hi" is visible.
490        assert!(strip_ansi(&joined).contains("hi"), "joined: {joined}");
491    }
492
493    #[test]
494    fn excluded_from_context_uses_dim_border_and_header() {
495        // `!!cmd` renders dim rather than bashMode so the exclusion is visible
496        // (mirrors `bash-execution.ts` `excludeFromContext`).
497        let normal = BashExecutionComponent::new("echo hi");
498        let excluded = BashExecutionComponent::new_excluded("echo hi");
499        assert!(!normal.is_excluded_from_context());
500        assert!(excluded.is_excluded_from_context());
501
502        let colors = theme().colors;
503        // The command header uses the bash accent (or dim when excluded), so
504        // compare the foreground escape each color produces.
505        let bash_start = colors
506            .bash_mode
507            .fg("X")
508            .split('X')
509            .next()
510            .unwrap()
511            .to_string();
512        let dim_start = colors.dim.fg("X").split('X').next().unwrap().to_string();
513
514        let normal_out = normal.render(40).join("\n");
515        let excluded_out = excluded.render(40).join("\n");
516        assert!(normal_out.contains(&bash_start), "normal: {normal_out:?}");
517        assert!(
518            excluded_out.contains(&dim_start),
519            "excluded: {excluded_out:?}"
520        );
521
522        // Both still show the command itself.
523        assert!(strip_ansi(&excluded_out).contains("$ echo hi"));
524
525        // Flipping the flag in place recolors without recreating the component.
526        excluded.set_exclude_from_context(false);
527        assert!(!excluded.is_excluded_from_context());
528        assert!(excluded.render(40).join("\n").contains(&bash_start));
529    }
530
531    #[test]
532    fn streamed_output_updates_replace_snapshots_instead_of_duplicating() {
533        let c = BashExecutionComponent::new("printf test");
534        c.append_output("one\n");
535        c.append_output("one\ntwo\n");
536        assert_eq!(c.get_output(), "one\ntwo\n");
537    }
538
539    #[test]
540    fn authoritative_start_command_replaces_partial_streamed_command() {
541        let c = BashExecutionComponent::new("printf");
542        c.set_command("printf '\\n--- agent skills ---\\n' && find skills");
543        let rendered = strip_ansi(&c.render(120).join("\n"));
544        assert!(rendered.contains("printf '\\n--- agent skills ---\\n' && find skills"));
545        assert!(!rendered.contains("$ printf\n"));
546    }
547
548    #[test]
549    fn long_running_command_wraps_instead_of_truncating() {
550        let command = "printf 'this command must remain fully visible while it runs'";
551        let c = BashExecutionComponent::new(command);
552        let lines = c.render(20);
553        let rendered = strip_ansi(&lines.join("\n"));
554        let compact: String = rendered.chars().filter(|ch| !ch.is_whitespace()).collect();
555        let expected: String = command.chars().filter(|ch| !ch.is_whitespace()).collect();
556        assert!(
557            compact.contains(&expected),
558            "command was truncated: {rendered}"
559        );
560        assert!(lines
561            .iter()
562            .all(|line| crate::utils::visible_width(line) <= 20));
563    }
564
565    #[test]
566    fn test_error_exit_status() {
567        let c = BashExecutionComponent::new("false");
568        c.set_complete(Some(1), false, BashTruncation::default());
569        let lines = c.render(40);
570        let joined = lines.join("\n");
571        assert!(joined.contains("exit 1"));
572    }
573
574    #[test]
575    fn test_truncation_path_shown() {
576        let c = BashExecutionComponent::new("big-output");
577        c.set_complete(
578            Some(0),
579            false,
580            BashTruncation {
581                truncated: true,
582                full_output_path: Some("/tmp/out.log".to_string()),
583            },
584        );
585        let lines = c.render(40);
586        let joined = lines.join("\n");
587        assert!(joined.contains("/tmp/out.log"), "joined: {joined}");
588    }
589}