syncable_cli/agent/ui/
shell_output.rs1use colored::Colorize;
7use crossterm::{cursor, execute, terminal};
8use std::collections::VecDeque;
9use std::io::{self, Write};
10use std::time::{Duration, Instant};
11
12const DEFAULT_MAX_LINES: usize = 5;
14
15pub 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 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 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 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 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 fn render_header(&self) {
76 let elapsed = self.format_elapsed();
77 let timeout = self.format_timeout();
78
79 let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
81 let prefix_len = 2 + timeout.len() + elapsed.len() + 10; 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 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); 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 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 }
123
124 fn clear_previous(&mut self) {
126 if self.lines_rendered > 0 {
127 let mut stdout = io::stdout();
128 for _ in 0..self.lines_rendered {
130 let _ = execute!(stdout, cursor::MoveUp(1), terminal::Clear(terminal::ClearType::CurrentLine));
131 }
132 }
133 }
134
135 pub fn push_line(&mut self, line: &str) {
137 if self.lines.is_empty() && line.trim().is_empty() {
139 return;
140 }
141
142 let cleaned = strip_ansi_codes(line);
144
145 self.lines.push_back(cleaned);
147
148 while self.lines.len() > self.max_lines {
150 self.lines.pop_front();
151 }
152
153 self.render();
155 }
156
157 pub fn push_lines(&mut self, text: &str) {
159 for line in text.lines() {
160 self.push_line(line);
161 }
162 }
163
164 pub fn render(&mut self) {
166 self.clear_previous();
167
168 let mut stdout = io::stdout();
169
170 self.render_header();
172 println!();
173
174 let lines_count = self.lines.len();
176 self.render_output();
177
178 self.lines_rendered = 1 + lines_count;
180
181 let _ = stdout.flush();
182 }
183
184 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 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 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 pub fn elapsed(&self) -> Duration {
240 self.start_time.elapsed()
241 }
242}
243
244fn 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 if chars.peek() == Some(&'[') {
253 chars.next(); 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 for i in 0..10 {
289 stream.push_line(&format!("line {}", i));
290 }
291 assert_eq!(stream.lines.len(), DEFAULT_MAX_LINES);
292 }
293}