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 (using safe UTF-8 truncation)
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 = truncate_safe(&self.command, max_cmd_len);
84
85        print!(
86            "{} {}({}) {} ({})",
87            "●".cyan().bold(),
88            "Bash".cyan(),
89            cmd_display.cyan(),
90            timeout.dimmed(),
91            elapsed.yellow()
92        );
93    }
94
95    /// Render the output box with lines
96    fn render_output(&self) {
97        let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
98        let content_width = term_width.saturating_sub(5); // "  │ " prefix
99
100        for (i, line) in self.lines.iter().enumerate() {
101            let is_last = i == self.lines.len() - 1;
102            let prefix = if is_last { "└" } else { "│" };
103
104            // Truncate line if needed (using safe UTF-8 truncation)
105            let display = truncate_safe(line, content_width);
106
107            println!("  {} {}", prefix.dimmed(), display);
108        }
109        // Note: Removed the "Running..." status line - elapsed time is shown in header
110    }
111
112    /// Clear previously rendered lines
113    fn clear_previous(&mut self) {
114        if self.lines_rendered > 0 {
115            let mut stdout = io::stdout();
116            // Move cursor up and clear lines
117            for _ in 0..self.lines_rendered {
118                let _ = execute!(
119                    stdout,
120                    cursor::MoveUp(1),
121                    terminal::Clear(terminal::ClearType::CurrentLine)
122                );
123            }
124        }
125    }
126
127    /// Push a new line of output
128    pub fn push_line(&mut self, line: &str) {
129        // Skip empty lines at the start
130        if self.lines.is_empty() && line.trim().is_empty() {
131            return;
132        }
133
134        // Clean the line - remove ANSI codes for storage but keep content
135        let cleaned = strip_ansi_codes(line);
136
137        // Add line to buffer
138        self.lines.push_back(cleaned);
139
140        // Keep only max_lines
141        while self.lines.len() > self.max_lines {
142            self.lines.pop_front();
143        }
144
145        // Re-render
146        self.render();
147    }
148
149    /// Push multiple lines (e.g., from splitting on newlines)
150    pub fn push_lines(&mut self, text: &str) {
151        for line in text.lines() {
152            self.push_line(line);
153        }
154    }
155
156    /// Full render with header and output
157    pub fn render(&mut self) {
158        self.clear_previous();
159
160        let mut stdout = io::stdout();
161
162        // Render header
163        self.render_header();
164        println!();
165
166        // Render output lines
167        let lines_count = self.lines.len();
168        self.render_output();
169
170        // Calculate total lines rendered (header + output lines)
171        self.lines_rendered = 1 + lines_count;
172
173        let _ = stdout.flush();
174    }
175
176    /// Finish rendering - show final state
177    pub fn finish(&mut self, success: bool, exit_code: Option<i32>) {
178        self.clear_previous();
179
180        let elapsed = self.format_elapsed();
181        let status_icon = if success { "✓" } else { "✗" };
182
183        // Final header (using safe UTF-8 truncation)
184        let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
185        let max_cmd_len = term_width.saturating_sub(30);
186        let cmd_display = truncate_safe(&self.command, max_cmd_len);
187
188        let exit_info = match exit_code {
189            Some(code) if code != 0 => format!(" (exit {})", code),
190            _ => String::new(),
191        };
192
193        if success {
194            println!(
195                "{} {}({}) {} {}{}",
196                status_icon.green().bold(),
197                "Bash".green(),
198                cmd_display.dimmed(),
199                "completed".green(),
200                elapsed.dimmed(),
201                exit_info.red()
202            );
203        } else {
204            println!(
205                "{} {}({}) {} {}{}",
206                status_icon.red().bold(),
207                "Bash".red(),
208                cmd_display.dimmed(),
209                "failed".red(),
210                elapsed.dimmed(),
211                exit_info.red()
212            );
213        }
214
215        // Show last few lines of output on failure
216        if !success && !self.lines.is_empty() {
217            for line in self.lines.iter().take(3) {
218                println!("  {} {}", "│".dimmed(), line.dimmed());
219            }
220        }
221
222        let _ = io::stdout().flush();
223        self.lines_rendered = 0;
224    }
225
226    /// Get elapsed duration
227    pub fn elapsed(&self) -> Duration {
228        self.start_time.elapsed()
229    }
230}
231
232/// Simple ANSI code stripping (basic implementation)
233fn strip_ansi_codes(s: &str) -> String {
234    let mut result = String::with_capacity(s.len());
235    let mut chars = s.chars().peekable();
236
237    while let Some(c) = chars.next() {
238        if c == '\x1b' {
239            // Skip escape sequence
240            if chars.peek() == Some(&'[') {
241                chars.next(); // consume '['
242                // Skip until we hit a letter
243                while let Some(&c) = chars.peek() {
244                    chars.next();
245                    if c.is_ascii_alphabetic() {
246                        break;
247                    }
248                }
249            }
250        } else {
251            result.push(c);
252        }
253    }
254
255    result
256}
257
258/// Safely truncate a string to a maximum visual width, handling UTF-8 properly.
259/// Adds "..." suffix when truncation occurs.
260/// This prevents panics from slicing multi-byte UTF-8 characters.
261fn truncate_safe(s: &str, max_width: usize) -> String {
262    // Strip ANSI codes first to get accurate visual width
263    let stripped = strip_ansi_codes(s);
264
265    // Calculate visual width (count characters, not bytes)
266    let visual_len: usize = stripped.chars().count();
267
268    if visual_len <= max_width {
269        return s.to_string();
270    }
271
272    // Need to truncate - work with stripped version
273    // Reserve space for "..."
274    let truncate_to = max_width.saturating_sub(3);
275
276    let mut result = String::new();
277
278    for (char_count, ch) in stripped.chars().enumerate() {
279        if char_count >= truncate_to {
280            result.push_str("...");
281            break;
282        }
283        result.push(ch);
284    }
285
286    result
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn test_strip_ansi_codes() {
295        let input = "\x1b[32mgreen\x1b[0m text";
296        assert_eq!(strip_ansi_codes(input), "green text");
297    }
298
299    #[test]
300    fn test_truncate_safe_ascii() {
301        // Basic ASCII truncation
302        assert_eq!(truncate_safe("hello world", 8), "hello...");
303        assert_eq!(truncate_safe("short", 10), "short");
304        assert_eq!(truncate_safe("exactly10!", 10), "exactly10!");
305    }
306
307    #[test]
308    fn test_truncate_safe_utf8_box_drawing() {
309        // Box drawing characters (multi-byte UTF-8) - the exact case that caused the panic
310        let box_line = "╭ Warning ──────────────────────────────────╮";
311        // Should NOT panic and should truncate properly
312        let result = truncate_safe(box_line, 20);
313        assert!(result.ends_with("..."));
314        assert!(result.chars().count() <= 20);
315    }
316
317    #[test]
318    fn test_truncate_safe_utf8_emoji() {
319        // Emoji (multi-byte UTF-8)
320        let emoji_str = "🚀 Building project 📦 with dependencies 🔧";
321        let result = truncate_safe(emoji_str, 15);
322        assert!(result.ends_with("..."));
323        // Should not panic
324    }
325
326    #[test]
327    fn test_truncate_safe_mixed_content() {
328        // Mixed ASCII and multi-byte characters
329        let mixed = "#9 3.304 ╭ Warning ───";
330        let result = truncate_safe(mixed, 15);
331        assert!(result.ends_with("..."));
332        assert!(result.chars().count() <= 15);
333    }
334
335    #[test]
336    fn test_truncate_safe_no_truncation_needed() {
337        let short = "hello";
338        assert_eq!(truncate_safe(short, 100), "hello");
339
340        let exact = "12345";
341        assert_eq!(truncate_safe(exact, 5), "12345");
342    }
343
344    #[test]
345    fn test_streaming_output_buffer() {
346        let mut stream = StreamingShellOutput::new("test", 60);
347        stream.push_line("line 1");
348        stream.push_line("line 2");
349        assert_eq!(stream.lines.len(), 2);
350
351        // Fill beyond max
352        for i in 0..10 {
353            stream.push_line(&format!("line {}", i));
354        }
355        assert_eq!(stream.lines.len(), DEFAULT_MAX_LINES);
356    }
357
358    #[test]
359    fn test_streaming_output_with_utf8_content() {
360        // Ensure the buffer doesn't panic with UTF-8 content
361        let mut stream = StreamingShellOutput::new("docker build", 60);
362        stream.push_line("╭ Warning ────────────────╮");
363        stream.push_line("│ This is a warning message │");
364        stream.push_line("╰────────────────────────────╯");
365        assert_eq!(stream.lines.len(), 3);
366    }
367}