1use 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, Theme, ThemeColors};
24use crate::tool_panel::ToolPanel;
25use crate::utils::{truncate_to_width, wrap_text_with_ansi};
26use crate::visual_truncate::truncate_to_visual_lines;
27
28const PREVIEW_LINES: usize = 20;
30
31const LONG_RUNNING_HINT_AFTER: Duration = Duration::from_secs(60);
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum BashStatus {
39 Running,
40 Complete,
41 Cancelled,
42 Error,
43}
44
45#[derive(Debug, Clone, Default)]
47pub struct BashTruncation {
48 pub truncated: bool,
50 pub full_output_path: Option<String>,
52}
53
54#[derive(Clone)]
57struct BashRenderCache {
58 width: usize,
59 revision: u64,
60 theme_revision: u64,
61 lines: Vec<String>,
62}
63
64pub struct BashExecutionComponent {
66 command: Mutex<String>,
67 output_lines: Mutex<Vec<String>>,
68 status: Mutex<BashStatus>,
69 exit_code: Mutex<Option<i32>>,
70 expanded: Mutex<bool>,
71 truncation: Mutex<BashTruncation>,
72 exclude_from_context: Mutex<bool>,
75 started_at: Mutex<Option<Instant>>,
78 finished_at: Mutex<Option<Instant>>,
79 revision: AtomicU64,
82 cache: Mutex<Option<BashRenderCache>>,
83}
84
85impl BashExecutionComponent {
86 pub fn new(command: impl Into<String>) -> Self {
88 Self::new_with_context(command, false)
89 }
90
91 pub fn new_excluded(command: impl Into<String>) -> Self {
94 Self::new_with_context(command, true)
95 }
96
97 pub fn new_with_context(command: impl Into<String>, exclude_from_context: bool) -> Self {
99 Self {
100 command: Mutex::new(command.into()),
101 output_lines: Mutex::new(Vec::new()),
102 status: Mutex::new(BashStatus::Running),
103 exit_code: Mutex::new(None),
104 expanded: Mutex::new(false),
105 truncation: Mutex::new(BashTruncation::default()),
106 exclude_from_context: Mutex::new(exclude_from_context),
107 started_at: Mutex::new(Some(Instant::now())),
108 finished_at: Mutex::new(None),
109 revision: AtomicU64::new(0),
110 cache: Mutex::new(None),
111 }
112 }
113
114 fn bump(&self) {
116 self.revision.fetch_add(1, Ordering::AcqRel);
117 }
118
119 fn elapsed(&self) -> Duration {
121 let started = self.started_at.lock().ok().and_then(|g| *g);
122 let Some(started) = started else {
123 return Duration::default();
124 };
125 match self.finished_at.lock().ok().and_then(|g| *g) {
126 Some(finished) => finished.saturating_duration_since(started),
127 None => started.elapsed(),
128 }
129 }
130
131 pub fn set_exclude_from_context(&self, exclude: bool) {
134 if let Ok(mut e) = self.exclude_from_context.lock() {
135 *e = exclude;
136 }
137 self.bump();
138 }
139
140 pub fn is_excluded_from_context(&self) -> bool {
142 *self.exclude_from_context.lock().unwrap()
143 }
144
145 pub fn set_command(&self, command: &str) {
149 if !command.trim().is_empty() {
150 let mut c = self.command.lock().unwrap();
151 *c = command.to_string();
152 drop(c);
153 self.bump();
154 }
155 }
156
157 pub fn append_output(&self, chunk: &str) {
163 let clean = strip_ansi(chunk).replace("\r\n", "\n").replace('\r', "\n");
164 if let Ok(mut out) = self.output_lines.lock() {
165 *out = if clean.is_empty() {
166 Vec::new()
167 } else {
168 clean
169 .split('\n')
170 .map(|line| line.replace('\t', " "))
171 .collect()
172 };
173 }
174 self.bump();
175 }
176
177 pub fn set_complete(
179 &self,
180 exit_code: Option<i32>,
181 cancelled: bool,
182 truncation: BashTruncation,
183 ) {
184 if let Ok(mut e) = self.exit_code.lock() {
185 *e = exit_code;
186 }
187 let new_status = if cancelled {
188 BashStatus::Cancelled
189 } else if exit_code.map(|c| c != 0).unwrap_or(false) {
190 BashStatus::Error
191 } else {
192 BashStatus::Complete
193 };
194 if let Ok(mut s) = self.status.lock() {
195 *s = new_status;
196 }
197 if let Ok(mut t) = self.truncation.lock() {
198 *t = truncation;
199 }
200 if let Ok(mut f) = self.finished_at.lock() {
201 *f = Some(Instant::now());
202 }
203 self.bump();
204 }
205
206 pub fn set_expanded(&self, expanded: bool) {
208 if let Ok(mut e) = self.expanded.lock() {
209 *e = expanded;
210 }
211 self.bump();
212 }
213
214 pub fn is_expanded(&self) -> bool {
216 *self.expanded.lock().unwrap()
217 }
218
219 pub fn get_output(&self) -> String {
221 self.output_lines
222 .lock()
223 .map(|o| o.join("\n"))
224 .unwrap_or_default()
225 }
226
227 fn status_line(&self, hidden: usize, colors: &ThemeColors) -> Vec<String> {
229 let status = *self.status.lock().unwrap();
230 let truncation = self.truncation.lock().unwrap().clone();
231 let exit_code = *self.exit_code.lock().unwrap();
232 let expanded = *self.expanded.lock().unwrap();
233
234 let mut parts: Vec<String> = Vec::new();
235 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![parts.join(" ")]
279 }
280 }
281
282 fn render_panel(&self, width: usize, status: BashStatus, theme: &Theme) -> Vec<String> {
284 let colors = &theme.colors;
285 let bg = match status {
286 BashStatus::Running => colors.tool_pending_bg,
287 BashStatus::Error | BashStatus::Cancelled => colors.tool_error_bg,
288 BashStatus::Complete => colors.tool_success_bg,
289 };
290 let Some(panel) = ToolPanel::new(width, theme.spacing, bg) else {
291 return Vec::new();
292 };
293 let content_width = panel.content_width;
294 let panel_line = |content: &str| panel.line(content);
295 let blank = panel.blank();
296 let mut lines = Spacer::new(1).render(width);
297 lines.extend(std::iter::repeat(blank.clone()).take(panel.padding_y));
298
299 let excluded = *self.exclude_from_context.lock().unwrap();
300 let color_key = if excluded {
301 colors.dim
302 } else {
303 colors.bash_mode
304 };
305 let command = self.command.lock().unwrap().clone();
306 for part in wrap_text_with_ansi(&format!("$ {command}"), content_width) {
307 lines.push(panel_line(&color_key.fg(&bold(&part))));
308 }
309
310 let (display_lines, hidden) = {
311 let out = self.output_lines.lock().unwrap();
312 let expanded = *self.expanded.lock().unwrap();
313 if out.is_empty() {
314 (Vec::new(), 0usize)
315 } else {
316 let styled = format!(
317 "\n{}",
318 out.iter()
319 .map(|l| colors.muted.fg(l))
320 .collect::<Vec<_>>()
321 .join("\n")
322 );
323 if expanded {
324 (
325 crate::text::Text::new(&styled, 0, 0).render(content_width),
326 0,
327 )
328 } else {
329 let r = truncate_to_visual_lines(&styled, PREVIEW_LINES, content_width, 0);
330 (r.visual_lines, r.skipped_count)
331 }
332 }
333 };
334 for line in &display_lines {
335 lines.push(panel_line(line));
336 }
337
338 if status == BashStatus::Running {
339 let elapsed = self.elapsed();
340 let row = format!(
341 "{} {}",
342 colors.accent.fg("●"),
343 colors
344 .muted
345 .fg(&crate::tool_execution::format_elapsed(elapsed)),
346 );
347 lines.push(panel_line(&truncate_to_width(&row, content_width, "…")));
348 if elapsed > LONG_RUNNING_HINT_AFTER {
349 let hint = format!("{} Esc / Ctrl+C 中止", colors.muted.fg("⏸"));
350 for part in wrap_text_with_ansi(&hint, content_width) {
351 lines.push(panel_line(&part));
352 }
353 }
354 } else {
355 let status_lines = self.status_line(hidden, colors);
356 if !status_lines.is_empty() {
357 lines.push(blank.clone());
358 for line in &status_lines {
359 for part in wrap_text_with_ansi(line, content_width) {
360 lines.push(panel_line(&part));
361 }
362 }
363 }
364 }
365 lines.extend(std::iter::repeat(blank).take(panel.padding_y));
366 lines
367 }
368}
369
370impl Component for BashExecutionComponent {
371 fn render(&self, width: usize) -> Vec<String> {
372 let status = *self.status.lock().unwrap();
373 let revision = self.revision.load(Ordering::Acquire);
374 let theme_revision = theme_revision();
375
376 if status != BashStatus::Running {
380 if let Ok(cache) = self.cache.lock() {
381 if let Some(cached) = cache.as_ref() {
382 if cached.width == width
383 && cached.revision == revision
384 && cached.theme_revision == theme_revision
385 {
386 return cached.lines.clone();
387 }
388 }
389 }
390 }
391
392 let lines = self.render_panel(width, status, &theme());
393
394 if status != BashStatus::Running {
395 if let Ok(mut cache) = self.cache.lock() {
396 *cache = Some(BashRenderCache {
397 width,
398 revision,
399 theme_revision,
400 lines: lines.clone(),
401 });
402 }
403 } else if let Ok(mut cache) = self.cache.lock() {
404 *cache = None;
405 }
406
407 lines
408 }
409
410 fn invalidate(&self) {
411 self.bump();
412 }
413
414 fn as_any(&self) -> &dyn Any {
415 self
416 }
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422
423 #[test]
424 fn panel_has_themed_insets_in_every_execution_state() {
425 let theme = Theme::default();
426 let width = 40;
427 for (exit_code, cancelled) in [
428 (None, false),
429 (Some(0), false),
430 (Some(1), false),
431 (Some(0), true),
432 ] {
433 let c = BashExecutionComponent::new("echo hi");
434 c.append_output("hi");
435 if exit_code.is_some() {
436 c.set_complete(exit_code, cancelled, BashTruncation::default());
437 }
438 let status = *c.status.lock().unwrap();
439 let bg = match status {
440 BashStatus::Running => theme.colors.tool_pending_bg,
441 BashStatus::Complete => theme.colors.tool_success_bg,
442 BashStatus::Error | BashStatus::Cancelled => theme.colors.tool_error_bg,
443 };
444 let lines = c.render_panel(width, status, &theme);
445 assert_eq!(lines[0], "", "outer spacer must remain uncolored");
446 assert_eq!(lines[1], bg.bg(&" ".repeat(width)));
447 assert_eq!(lines.last().unwrap(), &lines[1]);
448 let plain: Vec<_> = lines[1..].iter().map(|line| strip_ansi(line)).collect();
449 assert_eq!(plain[1].trim_end(), " $ echo hi");
450 assert!(plain.iter().any(|line| line.trim_end() == " hi"));
451 for (styled, text) in lines[1..].iter().zip(&plain) {
452 assert!(styled.starts_with(&bg.to_bg()));
453 assert_eq!(crate::utils::visible_width(text), width);
454 assert!(text.starts_with(" ") && text.ends_with(" "), "{text:?}");
455 }
456 }
457 }
458
459 #[test]
460 fn custom_theme_spacing_controls_panel_without_global_mutation() {
461 let mut theme = Theme::default();
462 theme.spacing.tool_panel_padding_x = 4;
463 theme.spacing.tool_panel_padding_y = 2;
464 let c = BashExecutionComponent::new("echo hi");
465 *c.started_at.lock().unwrap() = None;
467 c.append_output("hi");
468 c.set_complete(Some(0), false, BashTruncation::default());
469 let lines = c.render_panel(40, BashStatus::Complete, &theme);
470 let blank = theme.colors.tool_success_bg.bg(&" ".repeat(40));
471 assert_eq!(lines[1..3], [blank.clone(), blank.clone()]);
472 assert_eq!(lines[lines.len() - 2..], [blank.clone(), blank]);
473 assert_eq!(strip_ansi(&lines[3]).trim_end(), " $ echo hi");
474 assert!(lines
475 .iter()
476 .any(|line| strip_ansi(line).trim_end() == " hi"));
477
478 theme.spacing.tool_panel_padding_x = 0;
479 theme.spacing.tool_panel_padding_y = 0;
480 let lines = c.render_panel(40, BashStatus::Complete, &theme);
481 assert_eq!(strip_ansi(&lines[1]).trim_end(), "$ echo hi");
482 assert_eq!(strip_ansi(lines.last().unwrap()).trim_end(), "hi");
483 }
484
485 #[test]
486 fn output_wraps_inside_both_insets_collapsed_and_expanded() {
487 let theme = Theme::default();
488 let output = "abcdefghijklmnop你好世界qrstuvwxyz";
489 let c = BashExecutionComponent::new("echo");
490 *c.started_at.lock().unwrap() = None;
491 c.append_output(output);
492 c.set_complete(Some(0), false, BashTruncation::default());
493 for expanded in [false, true] {
494 c.set_expanded(expanded);
495 let lines = c.render_panel(12, BashStatus::Complete, &theme);
496 let output_rows = &lines[3..lines.len() - 1];
497 let reconstructed: String = output_rows
498 .iter()
499 .map(|line| strip_ansi(line).trim().to_string())
500 .collect();
501 assert_eq!(reconstructed, output);
502 for line in output_rows {
503 let plain = strip_ansi(line);
504 assert_eq!(crate::utils::visible_width(&plain), 12);
505 assert!(plain.starts_with(" ") && plain.ends_with(" "));
506 }
507 }
508 }
509
510 #[test]
511 fn long_status_paths_wrap_inside_panel() {
512 let path = "/tmp/very-long-directory/full-command-output.log";
513 let c = BashExecutionComponent::new("false");
514 c.set_complete(
515 Some(1),
516 false,
517 BashTruncation {
518 truncated: true,
519 full_output_path: Some(path.to_string()),
520 },
521 );
522 let lines = c.render_panel(16, BashStatus::Error, &Theme::default());
523 let compact: String = strip_ansi(&lines.join("\n"))
524 .chars()
525 .filter(|ch| !ch.is_whitespace())
526 .collect();
527 assert!(compact.contains(path));
528 assert!(lines
529 .iter()
530 .all(|line| crate::utils::visible_width(line) <= 16));
531 }
532
533 #[test]
534 fn narrow_panels_reduce_padding_without_overflow() {
535 let c = BashExecutionComponent::new("echo 你好");
536 c.append_output("你好世界");
537 let mut theme = Theme::default();
538 theme.spacing.tool_panel_padding_x = usize::MAX;
539 for width in 0..=8 {
540 let lines = c.render_panel(width, BashStatus::Running, &theme);
541 assert!(
542 lines
543 .iter()
544 .all(|line| crate::utils::visible_width(line) <= width),
545 "width {width}: {lines:?}"
546 );
547 if width == 0 {
548 assert!(lines.is_empty());
549 }
550 }
551 }
552
553 #[test]
554 fn test_new_renders_header_and_live_elapsed() {
555 let c = BashExecutionComponent::new("ls -la");
556 let lines = c.render(40);
557 let joined = lines.join("\n");
558 assert!(joined.contains("$ ls -la"));
559 let plain = strip_ansi(&joined);
562 assert!(
563 plain.contains('●'),
564 "running panel needs the status glyph: {plain}"
565 );
566 assert!(
567 plain.contains('s'),
568 "running panel needs an elapsed readout: {plain}"
569 );
570 assert!(
571 !plain.contains("Running"),
572 "the `Running...` label was replaced by the elapsed timer: {plain}"
573 );
574 }
575
576 #[test]
577 fn completed_panel_reports_total_elapsed() {
578 let c = BashExecutionComponent::new("sleep 1");
579 c.append_output("done\n");
580 std::thread::sleep(std::time::Duration::from_millis(120));
581 c.set_complete(Some(0), false, BashTruncation::default());
582 let plain = strip_ansi(&c.render(60).join("\n"));
583 assert!(
585 plain.contains("0.1s") || plain.contains("0.2s"),
586 "total elapsed missing: {plain}"
587 );
588 let first = c.render(60).join("\n");
590 std::thread::sleep(std::time::Duration::from_millis(60));
591 assert_eq!(
592 first,
593 c.render(60).join("\n"),
594 "elapsed kept counting after completion"
595 );
596 }
597
598 #[test]
599 fn test_append_and_complete() {
600 let c = BashExecutionComponent::new("echo hi");
601 c.append_output("hi\n");
602 c.set_complete(Some(0), false, BashTruncation::default());
603 let lines = c.render(40);
604 let joined = lines.join("\n");
605 assert!(strip_ansi(&joined).contains("hi"), "joined: {joined}");
607 }
608
609 #[test]
610 fn excluded_from_context_uses_dim_border_and_header() {
611 let normal = BashExecutionComponent::new("echo hi");
614 let excluded = BashExecutionComponent::new_excluded("echo hi");
615 assert!(!normal.is_excluded_from_context());
616 assert!(excluded.is_excluded_from_context());
617
618 let colors = theme().colors;
619 let bash_start = colors
622 .bash_mode
623 .fg("X")
624 .split('X')
625 .next()
626 .unwrap()
627 .to_string();
628 let dim_start = colors.dim.fg("X").split('X').next().unwrap().to_string();
629
630 let normal_out = normal.render(40).join("\n");
631 let excluded_out = excluded.render(40).join("\n");
632 assert!(normal_out.contains(&bash_start), "normal: {normal_out:?}");
633 assert!(
634 excluded_out.contains(&dim_start),
635 "excluded: {excluded_out:?}"
636 );
637
638 assert!(strip_ansi(&excluded_out).contains("$ echo hi"));
640
641 excluded.set_exclude_from_context(false);
643 assert!(!excluded.is_excluded_from_context());
644 assert!(excluded.render(40).join("\n").contains(&bash_start));
645 }
646
647 #[test]
648 fn streamed_output_updates_replace_snapshots_instead_of_duplicating() {
649 let c = BashExecutionComponent::new("printf test");
650 c.append_output("one\n");
651 c.append_output("one\ntwo\n");
652 assert_eq!(c.get_output(), "one\ntwo\n");
653 }
654
655 #[test]
656 fn authoritative_start_command_replaces_partial_streamed_command() {
657 let c = BashExecutionComponent::new("printf");
658 c.set_command("printf '\\n--- agent skills ---\\n' && find skills");
659 let rendered = strip_ansi(&c.render(120).join("\n"));
660 assert!(rendered.contains("printf '\\n--- agent skills ---\\n' && find skills"));
661 assert!(!rendered.contains("$ printf\n"));
662 }
663
664 #[test]
665 fn long_running_command_wraps_instead_of_truncating() {
666 let command = "printf 'this command must remain fully visible while it runs'";
667 let c = BashExecutionComponent::new(command);
668 let lines = c.render(20);
669 let rendered = strip_ansi(&lines.join("\n"));
670 let compact: String = rendered.chars().filter(|ch| !ch.is_whitespace()).collect();
671 let expected: String = command.chars().filter(|ch| !ch.is_whitespace()).collect();
672 assert!(
673 compact.contains(&expected),
674 "command was truncated: {rendered}"
675 );
676 assert!(lines
677 .iter()
678 .all(|line| crate::utils::visible_width(line) <= 20));
679 }
680
681 #[test]
682 fn test_error_exit_status() {
683 let c = BashExecutionComponent::new("false");
684 c.set_complete(Some(1), false, BashTruncation::default());
685 let lines = c.render(40);
686 let joined = lines.join("\n");
687 assert!(joined.contains("exit 1"));
688 }
689
690 #[test]
691 fn test_truncation_path_shown() {
692 let c = BashExecutionComponent::new("big-output");
693 c.set_complete(
694 Some(0),
695 false,
696 BashTruncation {
697 truncated: true,
698 full_output_path: Some("/tmp/out.log".to_string()),
699 },
700 );
701 let lines = c.render(40);
702 let joined: String = lines
704 .iter()
705 .map(|line| strip_ansi(line).trim().to_string())
706 .collect();
707 assert!(joined.contains("/tmp/out.log"), "joined: {joined}");
708 }
709}