1use crate::tui::Component;
2use crate::tui::components::loader::Loader;
3use crate::tui::util::wrap_text_with_ansi;
4
5const DEFAULT_MAX_LINES: usize = 1000;
7const DEFAULT_MAX_BYTES: usize = 16_385;
9
10const PREVIEW_LINES: usize = 20;
12
13pub struct BashExecution {
25 command: String,
26 output_lines: Vec<String>,
27 status: BashStatus,
28 expanded: bool,
29 exclude_from_context: bool,
30 full_output_path: Option<String>,
32 was_truncated: bool,
34 duration_secs: Option<f64>,
36 loader: Loader,
38}
39
40#[derive(Debug, Clone, PartialEq)]
41pub enum BashStatus {
42 Running,
43 Complete { exit_code: i32 },
44 Cancelled,
45 Error(String),
46}
47
48impl BashExecution {
49 pub fn new(command: impl Into<String>) -> Self {
50 let command = command.into();
51
52 let theme = crate::agent::ui::theme::current_theme();
54 let spinner_ansi = theme.fg_ansi("bashMode").to_string();
55 let msg_ansi = theme.fg_ansi("muted").to_string();
56 drop(theme);
57 let loader = Loader::new(
58 Box::new(move |s| format!("{}{}\x1b[39m", spinner_ansi, s)),
59 Box::new(move |s| format!("{}{}\x1b[39m", msg_ansi, s)),
60 "Running... (Esc to cancel)",
61 );
62
63 Self {
64 command,
65 output_lines: Vec::new(),
66 status: BashStatus::Running,
67 expanded: false,
68 exclude_from_context: false,
69 full_output_path: None,
70 was_truncated: false,
71 duration_secs: None,
72 loader,
73 }
74 }
75
76 pub fn append_output(&mut self, line: impl Into<String>) {
77 self.output_lines.push(line.into());
78 }
79
80 pub fn append_chunk(&mut self, chunk: &str) {
83 let clean = strip_ansi(chunk).replace("\r\n", "\n").replace('\r', "\n");
85
86 let new_lines: Vec<&str> = clean.split('\n').collect();
87 if new_lines.is_empty() {
88 return;
89 }
90
91 if !self.output_lines.is_empty() && !new_lines.is_empty() {
92 let last_idx = self.output_lines.len() - 1;
94 self.output_lines[last_idx].push_str(new_lines[0]);
95 self.output_lines
96 .extend(new_lines[1..].iter().map(|s| s.to_string()));
97 } else {
98 self.output_lines
99 .extend(new_lines.iter().map(|s| s.to_string()));
100 }
101 }
102
103 pub fn set_complete(&mut self, exit_code: i32) {
104 self.status = if exit_code == 0 {
105 BashStatus::Complete { exit_code: 0 }
106 } else {
107 BashStatus::Complete { exit_code }
108 };
109 self.stop_loader();
110 }
111
112 pub fn set_cancelled(&mut self) {
113 self.status = BashStatus::Cancelled;
114 self.stop_loader();
115 }
116
117 pub fn set_error(&mut self, msg: impl Into<String>) {
118 self.status = BashStatus::Error(msg.into());
119 self.stop_loader();
120 }
121
122 pub fn set_expanded(&mut self, expanded: bool) {
123 self.expanded = expanded;
124 }
125
126 pub fn set_exclude_from_context(&mut self, exclude: bool) {
127 self.exclude_from_context = exclude;
128 }
129
130 pub fn set_full_output_path(&mut self, path: impl Into<String>) {
131 self.full_output_path = Some(path.into());
132 }
133
134 pub fn set_truncated(&mut self, truncated: bool) {
135 self.was_truncated = truncated;
136 }
137
138 pub fn set_duration_secs(&mut self, secs: f64) {
140 self.duration_secs = Some(secs);
141 }
142
143 pub fn set_duration_from_content(&mut self, content: &str) {
145 if let Some(end_bracket) = content.rfind(']')
146 && let Some(start_bracket) = content[..end_bracket].rfind('[')
147 {
148 let num_str = &content[start_bracket + 1..end_bracket];
149 if let Ok(secs) = num_str.parse::<f64>() {
150 self.duration_secs = Some(secs);
151 }
152 }
153 }
154
155 pub fn is_expanded(&self) -> bool {
156 self.expanded
157 }
158
159 fn stop_loader(&mut self) {
160 self.loader.stop();
161 }
162
163 fn border_color_key(&self) -> &'static str {
164 if self.exclude_from_context {
165 return "dim";
166 }
167 match self.status {
168 BashStatus::Running => "bashMode",
169 BashStatus::Complete { exit_code: 0 } => "bashMode",
170 BashStatus::Complete { .. } => "error",
171 BashStatus::Cancelled => "warning",
172 BashStatus::Error(_) => "error",
173 }
174 }
175
176 fn context_truncated_output(&self) -> (String, bool) {
178 let output = self.output_lines.join("\n");
179
180 let lines: Vec<&str> = output.split('\n').collect();
182 let total_lines = lines.len();
183 let truncated_lines: Vec<&str> = if total_lines > DEFAULT_MAX_LINES {
184 lines[lines.len() - DEFAULT_MAX_LINES..].to_vec()
185 } else {
186 lines
187 };
188
189 let joined = truncated_lines.join("\n");
190 let bytes = joined.len();
191 if bytes > DEFAULT_MAX_BYTES {
192 let mut byte_end = DEFAULT_MAX_BYTES;
194 while byte_end > 0 && !joined.is_char_boundary(byte_end) {
196 byte_end -= 1;
197 }
198 let truncated: String = joined[..byte_end].to_string();
199 (truncated, true)
200 } else {
201 (
202 joined,
203 total_lines > DEFAULT_MAX_LINES || bytes > DEFAULT_MAX_BYTES,
204 )
205 }
206 }
207
208 pub fn get_output(&self) -> String {
210 self.output_lines.join("\n")
211 }
212
213 pub fn get_command(&self) -> String {
215 self.command.clone()
216 }
217}
218
219impl Component for BashExecution {
220 fn set_expanded(&mut self, expanded: bool) {
221 BashExecution::set_expanded(self, expanded);
222 }
223
224 fn render(&self, width: usize) -> Vec<String> {
225 let theme = crate::agent::ui::theme::current_theme();
226 let border_key = self.border_color_key();
227 let border_fn = |s: &str| theme.fg(border_key, s);
228
229 let mut lines: Vec<String> = Vec::new();
230
231 lines.push(String::new());
233
234 let top_border = "─".repeat(width.max(1));
236 lines.push(border_fn(&top_border));
237
238 let header = format!(
240 "{} {}",
241 theme.bold_fg(border_key, "$"),
242 theme.fg(border_key, &self.command)
243 );
244 lines.push(header);
245
246 let (context_output, context_truncated) = self.context_truncated_output();
248 let available_lines: Vec<&str> = if context_output.is_empty() {
249 Vec::new()
250 } else {
251 context_output.split('\n').collect()
252 };
253
254 let preview_lines: Vec<&str> = if self.expanded {
256 available_lines.clone()
257 } else if available_lines.len() > PREVIEW_LINES {
258 available_lines[..PREVIEW_LINES].to_vec()
259 } else {
260 available_lines.clone()
261 };
262
263 let hidden_line_count = available_lines.len().saturating_sub(preview_lines.len());
264
265 if !self.expanded && hidden_line_count > 0 {
267 let hint = theme.fg("muted", &format!("... {} more lines", hidden_line_count));
268 lines.push(hint);
269 }
270
271 if !preview_lines.is_empty() {
273 for line in &preview_lines {
274 let styled = theme.fg("toolOutput", line);
275 let wrapped = wrap_text_with_ansi(&styled, width);
276 lines.extend(wrapped);
277 }
278 }
279
280 let mut status_parts: Vec<String> = Vec::new();
282
283 if !preview_lines.is_empty() {
285 status_parts.push(String::new());
286 }
287
288 if let Some(secs) = self.duration_secs {
290 let label = match self.status {
291 BashStatus::Running => "Elapsed",
292 _ => "Took",
293 };
294 status_parts.push(theme.fg("muted", &format!("{} {:.1}s", label, secs)));
295 }
296
297 match &self.status {
299 BashStatus::Running => {
300 }
302 BashStatus::Complete { exit_code } if *exit_code != 0 => {
303 status_parts.push(theme.fg("error", &format!("(exit {})", exit_code)));
304 }
305 BashStatus::Cancelled => {
306 status_parts.push(theme.fg("warning", "(cancelled)"));
307 }
308 BashStatus::Error(msg) => {
309 status_parts.push(theme.fg("error", &format!("Error: {}", msg)));
310 }
311 _ => {}
312 }
313
314 let was_truncated = context_truncated || self.was_truncated;
316 if was_truncated {
317 if let Some(ref path) = self.full_output_path {
318 status_parts.push(theme.fg(
319 "warning",
320 &format!("Output truncated. Full output: {}", path),
321 ));
322 } else {
323 status_parts.push(theme.fg("warning", "Output truncated."));
324 }
325 }
326
327 match &self.status {
329 BashStatus::Running => {
330 let loader_lines = self.loader.render(width);
332 lines.extend(loader_lines);
333 }
334 _ => {
335 if !status_parts.is_empty() {
336 let status_line = if status_parts.len() == 1 && status_parts[0].is_empty() {
338 String::new()
339 } else {
340 status_parts.join(" ")
341 };
342 if !status_line.is_empty() {
343 lines.push(status_line);
344 }
345 }
346 }
347 }
348
349 let bottom_border = "─".repeat(width.max(1));
351 lines.push(border_fn(&bottom_border));
352
353 lines
354 }
355
356 fn invalidate(&mut self) {
357 self.loader.invalidate();
358 }
359}
360
361fn strip_ansi(s: &str) -> String {
365 let mut result = String::with_capacity(s.len());
366 let mut chars = s.chars();
367 while let Some(c) = chars.next() {
368 if c == '\x1b' {
369 for n in chars.by_ref() {
372 if n == '\x07' || n.is_ascii_uppercase() || n.is_ascii_lowercase() {
373 break;
374 }
375 if n == '\x1b' {
376 break;
378 }
379 }
380 } else {
381 result.push(c);
382 }
383 }
384 result
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390 use crate::agent::ui::theme::init_theme;
391
392 #[test]
393 fn test_bash_execution_new() {
394 let bash = BashExecution::new("echo hello");
395 assert_eq!(bash.command, "echo hello");
396 assert!(bash.output_lines.is_empty());
397 assert_eq!(bash.status, BashStatus::Running);
398 assert!(!bash.expanded);
399 assert!(!bash.exclude_from_context);
400 }
401
402 #[test]
403 fn test_bash_execution_append_output() {
404 let mut bash = BashExecution::new("echo hello");
405 bash.append_output("hello");
406 bash.append_output("world");
407 assert_eq!(bash.output_lines.len(), 2);
408 assert_eq!(bash.output_lines[0], "hello");
409 assert_eq!(bash.output_lines[1], "world");
410 }
411
412 #[test]
413 fn test_bash_execution_append_chunk() {
414 let mut bash = BashExecution::new("echo hello");
415 bash.append_chunk("line1\nline2\nline3");
416 assert_eq!(bash.output_lines.len(), 3);
417 assert_eq!(bash.output_lines[0], "line1");
418 assert_eq!(bash.output_lines[1], "line2");
419 assert_eq!(bash.output_lines[2], "line3");
420 }
421
422 #[test]
423 fn test_bash_execution_append_chunk_continues_last_line() {
424 let mut bash = BashExecution::new("echo hello");
425 bash.append_output("partial");
426 bash.append_chunk(" continuation\nnext");
427 assert_eq!(bash.output_lines.len(), 2);
428 assert_eq!(bash.output_lines[0], "partial continuation");
429 assert_eq!(bash.output_lines[1], "next");
430 }
431
432 #[test]
433 fn test_bash_execution_append_chunk_strips_ansi() {
434 let mut bash = BashExecution::new("echo hello");
435 bash.append_chunk("\x1b[31mcolored\x1b[0m");
436 assert_eq!(bash.output_lines.len(), 1);
437 assert_eq!(bash.output_lines[0], "colored");
438 }
439
440 #[test]
441 fn test_bash_execution_set_complete() {
442 let mut bash = BashExecution::new("echo hello");
443 bash.set_complete(0);
444 assert_eq!(bash.status, BashStatus::Complete { exit_code: 0 });
445
446 bash.set_complete(1);
447 assert_eq!(bash.status, BashStatus::Complete { exit_code: 1 });
448 }
449
450 #[test]
451 fn test_bash_execution_set_cancelled() {
452 let mut bash = BashExecution::new("echo hello");
453 bash.set_cancelled();
454 assert_eq!(bash.status, BashStatus::Cancelled);
455 }
456
457 #[test]
458 fn test_bash_execution_set_error() {
459 let mut bash = BashExecution::new("echo hello");
460 bash.set_error("something went wrong");
461 assert_eq!(
462 bash.status,
463 BashStatus::Error("something went wrong".into())
464 );
465 }
466
467 #[test]
468 fn test_bash_execution_set_expanded() {
469 let mut bash = BashExecution::new("echo hello");
470 assert!(!bash.expanded);
471 bash.set_expanded(true);
472 assert!(bash.expanded);
473 bash.set_expanded(false);
474 assert!(!bash.expanded);
475 }
476
477 #[test]
478 fn test_bash_execution_exclude_from_context() {
479 let mut bash = BashExecution::new("echo hello");
480 assert!(!bash.exclude_from_context);
481 bash.set_exclude_from_context(true);
482 assert!(bash.exclude_from_context);
483 }
484
485 #[test]
486 fn test_bash_execution_get_output() {
487 let mut bash = BashExecution::new("echo hello");
488 bash.append_output("line1");
489 bash.append_output("line2");
490 assert_eq!(bash.get_output(), "line1\nline2");
491 }
492
493 #[test]
494 fn test_bash_execution_get_command() {
495 let bash = BashExecution::new("echo hello");
496 assert_eq!(bash.get_command(), "echo hello");
497 }
498
499 #[test]
500 fn test_bash_execution_render_has_borders() {
501 init_theme(Some("dark"), false);
502 let bash = BashExecution::new("echo hello");
503 let lines = bash.render(80);
504 let all = lines.join("\n");
505 assert!(lines[1].contains('─'), "Top border should contain ─");
507 assert!(
509 lines[lines.len() - 1].contains('─'),
510 "Bottom border should contain ─"
511 );
512 assert!(all.contains("echo hello"), "Should show command");
513 assert!(lines[0].is_empty(), "First line should be empty (spacer)");
515 }
516
517 #[test]
518 fn test_bash_execution_render_status() {
519 init_theme(Some("dark"), false);
520 let mut bash = BashExecution::new("echo hello");
521 bash.append_output("hello world");
522
523 bash.set_complete(0);
525 let lines = bash.render(80);
526 let all = lines.join("\n");
527 assert!(all.contains("hello world"), "Should show output");
528 assert!(!all.contains("exit 0"), "No exit code for success");
529
530 bash.set_complete(1);
532 let lines = bash.render(80);
533 let all = lines.join("\n");
534 assert!(all.contains("exit 1"), "Should show exit code");
535 }
536
537 #[test]
538 fn test_collapsed_preview_shows_first_lines() {
539 init_theme(Some("dark"), false);
540 let mut bash = BashExecution::new("test");
541 for i in 0..50 {
542 bash.append_output(format!("line {}", i));
543 }
544 bash.set_complete(0);
545
546 let lines = bash.render(80);
547 let all = lines.join("\n");
548 assert!(all.contains("line 0"), "Collapsed: show first line");
549 assert!(all.contains("line 19"), "Collapsed: show line 20");
550 assert!(!all.contains("line 20"), "Collapsed: hide line 21");
551 assert!(!all.contains("line 49"), "Collapsed: hide last line");
552 assert!(all.contains("30 more lines"), "Should show remaining count");
553 }
554
555 #[test]
556 fn test_expanded_shows_all_lines() {
557 init_theme(Some("dark"), false);
558 let mut bash = BashExecution::new("test");
559 for i in 0..50 {
560 bash.append_output(format!("line {}", i));
561 }
562 bash.set_expanded(true);
563 bash.set_complete(0);
564
565 let lines = bash.render(80);
566 let all = lines.join("\n");
567 assert!(all.contains("line 0"), "Expanded: show first line");
568 assert!(all.contains("line 49"), "Expanded: show last line");
569 assert!(
570 !all.contains("more lines"),
571 "No 'more lines' indicator when expanded"
572 );
573 }
574
575 #[test]
576 fn test_exclude_from_context_uses_dim_border() {
577 init_theme(Some("dark"), false);
578 let mut bash = BashExecution::new("hidden command");
579 bash.set_exclude_from_context(true);
580 let lines = bash.render(80);
581 let all = lines.join("\n");
582 assert!(all.contains("hidden command"), "Should show command");
583 }
584
585 #[test]
586 fn test_cancelled_shows_warning() {
587 init_theme(Some("dark"), false);
588 let mut bash = BashExecution::new("sleep 10");
589 bash.set_cancelled();
590 let lines = bash.render(80);
591 let all = lines.join("\n");
592 assert!(all.contains("cancelled"), "Should show cancelled status");
593 }
594
595 #[test]
596 fn test_context_truncation() {
597 let mut bash = BashExecution::new("test");
598 for i in 0..DEFAULT_MAX_LINES + 10 {
600 bash.append_output(format!("line {}", i));
601 }
602 let (output, truncated) = bash.context_truncated_output();
603 assert!(truncated, "Should be truncated");
604 let line_count = output.split('\n').count();
605 assert_eq!(line_count, DEFAULT_MAX_LINES, "Should have MAX_LINES lines");
606 }
607
608 #[test]
609 fn test_append_chunk_preserves_incomplete_last_line() {
610 let mut bash = BashExecution::new("echo test");
611 bash.append_chunk("first\nsecond\nincomplete");
612 assert_eq!(bash.output_lines.len(), 3);
613 assert_eq!(bash.output_lines[0], "first");
614 assert_eq!(bash.output_lines[1], "second");
615 assert_eq!(bash.output_lines[2], "incomplete");
616 }
617
618 #[test]
619 fn test_strip_ansi_basic() {
620 assert_eq!(strip_ansi("\x1b[31mred\x1b[0m"), "red");
621 assert_eq!(strip_ansi("no ansi"), "no ansi");
622 assert_eq!(strip_ansi(""), "");
623 }
624
625 #[test]
626 fn test_strip_ansi_complex() {
627 assert_eq!(strip_ansi("\x1b[1;31mbold red\x1b[0m"), "bold red");
628 assert_eq!(
629 strip_ansi("\x1b[38;2;255;0;0mtruecolor\x1b[39m"),
630 "truecolor"
631 );
632 }
633}