syncable_cli/agent/ui/
shell_output.rs

1//! Streaming shell output display
2//!
3//! Shows the last N lines of shell command output, overwriting previous
4//! lines as new output arrives. Creates a compact, live-updating view.
5
6use colored::Colorize;
7use crossterm::{cursor, execute, terminal};
8use std::collections::VecDeque;
9use std::io::{self, Write};
10use std::time::{Duration, Instant};
11
12/// Default number of lines to display
13const DEFAULT_MAX_LINES: usize = 5;
14
15/// Streaming output buffer that overwrites previous display
16pub struct StreamingShellOutput {
17    lines: VecDeque<String>,
18    max_lines: usize,
19    command: String,
20    start_time: Instant,
21    lines_rendered: usize,
22    timeout_secs: u64,
23}
24
25impl StreamingShellOutput {
26    /// Create a new streaming output buffer
27    pub fn new(command: &str, timeout_secs: u64) -> Self {
28        Self {
29            lines: VecDeque::with_capacity(DEFAULT_MAX_LINES + 1),
30            max_lines: DEFAULT_MAX_LINES,
31            command: command.to_string(),
32            start_time: Instant::now(),
33            lines_rendered: 0,
34            timeout_secs,
35        }
36    }
37
38    /// Create with custom max lines
39    pub fn with_max_lines(command: &str, timeout_secs: u64, max_lines: usize) -> Self {
40        Self {
41            lines: VecDeque::with_capacity(max_lines + 1),
42            max_lines,
43            command: command.to_string(),
44            start_time: Instant::now(),
45            lines_rendered: 0,
46            timeout_secs,
47        }
48    }
49
50    /// Format elapsed time display
51    fn format_elapsed(&self) -> String {
52        let elapsed = self.start_time.elapsed();
53        let secs = elapsed.as_secs();
54        if secs >= 60 {
55            let mins = secs / 60;
56            let remaining_secs = secs % 60;
57            format!("{}m {}s", mins, remaining_secs)
58        } else {
59            format!("{}s", secs)
60        }
61    }
62
63    /// Format timeout display
64    fn format_timeout(&self) -> String {
65        let mins = self.timeout_secs / 60;
66        let secs = self.timeout_secs % 60;
67        if mins > 0 {
68            format!("timeout: {}m {}s", mins, secs)
69        } else {
70            format!("timeout: {}s", secs)
71        }
72    }
73
74    /// Render the header line
75    fn render_header(&self) {
76        let elapsed = self.format_elapsed();
77        let timeout = self.format_timeout();
78
79        // Truncate command if needed
80        let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
81        let prefix_len = 2 + timeout.len() + elapsed.len() + 10; // "● Bash(" + ") " + times
82        let max_cmd_len = term_width.saturating_sub(prefix_len);
83        let cmd_display = if self.command.len() > max_cmd_len {
84            format!("{}...", &self.command[..max_cmd_len.saturating_sub(3)])
85        } else {
86            self.command.clone()
87        };
88
89        print!(
90            "{} {}({}) {} ({})",
91            "●".cyan().bold(),
92            "Bash".cyan(),
93            cmd_display.cyan(),
94            timeout.dimmed(),
95            elapsed.yellow()
96        );
97    }
98
99    /// Render the output box with lines
100    fn render_output(&self) {
101        let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
102        let content_width = term_width.saturating_sub(5); // "  │ " prefix
103
104        for (i, line) in self.lines.iter().enumerate() {
105            let is_last = i == self.lines.len() - 1;
106            let prefix = if is_last { "└" } else { "│" };
107
108            // Truncate line if needed
109            let display = if line.len() > content_width {
110                format!("{}...", &line[..content_width.saturating_sub(3)])
111            } else {
112                line.clone()
113            };
114
115            println!(
116                "  {} {}",
117                prefix.dimmed(),
118                display
119            );
120        }
121        // Note: Removed the "Running..." status line - elapsed time is shown in header
122    }
123
124    /// Clear previously rendered lines
125    fn clear_previous(&mut self) {
126        if self.lines_rendered > 0 {
127            let mut stdout = io::stdout();
128            // Move cursor up and clear lines
129            for _ in 0..self.lines_rendered {
130                let _ = execute!(stdout, cursor::MoveUp(1), terminal::Clear(terminal::ClearType::CurrentLine));
131            }
132        }
133    }
134
135    /// Push a new line of output
136    pub fn push_line(&mut self, line: &str) {
137        // Skip empty lines at the start
138        if self.lines.is_empty() && line.trim().is_empty() {
139            return;
140        }
141
142        // Clean the line - remove ANSI codes for storage but keep content
143        let cleaned = strip_ansi_codes(line);
144
145        // Add line to buffer
146        self.lines.push_back(cleaned);
147
148        // Keep only max_lines
149        while self.lines.len() > self.max_lines {
150            self.lines.pop_front();
151        }
152
153        // Re-render
154        self.render();
155    }
156
157    /// Push multiple lines (e.g., from splitting on newlines)
158    pub fn push_lines(&mut self, text: &str) {
159        for line in text.lines() {
160            self.push_line(line);
161        }
162    }
163
164    /// Full render with header and output
165    pub fn render(&mut self) {
166        self.clear_previous();
167
168        let mut stdout = io::stdout();
169
170        // Render header
171        self.render_header();
172        println!();
173
174        // Render output lines
175        let lines_count = self.lines.len();
176        self.render_output();
177
178        // Calculate total lines rendered (header + output lines)
179        self.lines_rendered = 1 + lines_count;
180
181        let _ = stdout.flush();
182    }
183
184    /// Finish rendering - show final state
185    pub fn finish(&mut self, success: bool, exit_code: Option<i32>) {
186        self.clear_previous();
187
188        let elapsed = self.format_elapsed();
189        let status_icon = if success { "✓" } else { "✗" };
190
191        // Final header
192        let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
193        let max_cmd_len = term_width.saturating_sub(30);
194        let cmd_display = if self.command.len() > max_cmd_len {
195            format!("{}...", &self.command[..max_cmd_len.saturating_sub(3)])
196        } else {
197            self.command.clone()
198        };
199
200        let exit_info = match exit_code {
201            Some(code) if code != 0 => format!(" (exit {})", code),
202            _ => String::new(),
203        };
204
205        if success {
206            println!(
207                "{} {}({}) {} {}{}",
208                status_icon.green().bold(),
209                "Bash".green(),
210                cmd_display.dimmed(),
211                "completed".green(),
212                elapsed.dimmed(),
213                exit_info.red()
214            );
215        } else {
216            println!(
217                "{} {}({}) {} {}{}",
218                status_icon.red().bold(),
219                "Bash".red(),
220                cmd_display.dimmed(),
221                "failed".red(),
222                elapsed.dimmed(),
223                exit_info.red()
224            );
225        }
226
227        // Show last few lines of output on failure
228        if !success && !self.lines.is_empty() {
229            for line in self.lines.iter().take(3) {
230                println!("  {} {}", "│".dimmed(), line.dimmed());
231            }
232        }
233
234        let _ = io::stdout().flush();
235        self.lines_rendered = 0;
236    }
237
238    /// Get elapsed duration
239    pub fn elapsed(&self) -> Duration {
240        self.start_time.elapsed()
241    }
242}
243
244/// Simple ANSI code stripping (basic implementation)
245fn strip_ansi_codes(s: &str) -> String {
246    let mut result = String::with_capacity(s.len());
247    let mut chars = s.chars().peekable();
248
249    while let Some(c) = chars.next() {
250        if c == '\x1b' {
251            // Skip escape sequence
252            if chars.peek() == Some(&'[') {
253                chars.next(); // consume '['
254                // Skip until we hit a letter
255                while let Some(&c) = chars.peek() {
256                    chars.next();
257                    if c.is_ascii_alphabetic() {
258                        break;
259                    }
260                }
261            }
262        } else {
263            result.push(c);
264        }
265    }
266
267    result
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn test_strip_ansi_codes() {
276        let input = "\x1b[32mgreen\x1b[0m text";
277        assert_eq!(strip_ansi_codes(input), "green text");
278    }
279
280    #[test]
281    fn test_streaming_output_buffer() {
282        let mut stream = StreamingShellOutput::new("test", 60);
283        stream.push_line("line 1");
284        stream.push_line("line 2");
285        assert_eq!(stream.lines.len(), 2);
286
287        // Fill beyond max
288        for i in 0..10 {
289            stream.push_line(&format!("line {}", i));
290        }
291        assert_eq!(stream.lines.len(), DEFAULT_MAX_LINES);
292    }
293}