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};
24use crate::utils::{truncate_to_width, wrap_text_with_ansi};
25use crate::visual_truncate::truncate_to_visual_lines;
26
27const PREVIEW_LINES: usize = 20;
29
30const LONG_RUNNING_HINT_AFTER: Duration = Duration::from_secs(60);
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum BashStatus {
38 Running,
39 Complete,
40 Cancelled,
41 Error,
42}
43
44#[derive(Debug, Clone, Default)]
46pub struct BashTruncation {
47 pub truncated: bool,
49 pub full_output_path: Option<String>,
51}
52
53#[derive(Clone)]
56struct BashRenderCache {
57 width: usize,
58 revision: u64,
59 theme_revision: u64,
60 lines: Vec<String>,
61}
62
63pub struct BashExecutionComponent {
65 command: Mutex<String>,
66 output_lines: Mutex<Vec<String>>,
67 status: Mutex<BashStatus>,
68 exit_code: Mutex<Option<i32>>,
69 expanded: Mutex<bool>,
70 truncation: Mutex<BashTruncation>,
71 exclude_from_context: Mutex<bool>,
74 started_at: Mutex<Option<Instant>>,
77 finished_at: Mutex<Option<Instant>>,
78 revision: AtomicU64,
81 cache: Mutex<Option<BashRenderCache>>,
82}
83
84impl BashExecutionComponent {
85 pub fn new(command: impl Into<String>) -> Self {
87 Self::new_with_context(command, false)
88 }
89
90 pub fn new_excluded(command: impl Into<String>) -> Self {
93 Self::new_with_context(command, true)
94 }
95
96 pub fn new_with_context(command: impl Into<String>, exclude_from_context: bool) -> Self {
98 Self {
99 command: Mutex::new(command.into()),
100 output_lines: Mutex::new(Vec::new()),
101 status: Mutex::new(BashStatus::Running),
102 exit_code: Mutex::new(None),
103 expanded: Mutex::new(false),
104 truncation: Mutex::new(BashTruncation::default()),
105 exclude_from_context: Mutex::new(exclude_from_context),
106 started_at: Mutex::new(Some(Instant::now())),
107 finished_at: Mutex::new(None),
108 revision: AtomicU64::new(0),
109 cache: Mutex::new(None),
110 }
111 }
112
113 fn bump(&self) {
115 self.revision.fetch_add(1, Ordering::AcqRel);
116 }
117
118 fn elapsed(&self) -> Duration {
120 let started = self.started_at.lock().ok().and_then(|g| *g);
121 let Some(started) = started else {
122 return Duration::default();
123 };
124 match self.finished_at.lock().ok().and_then(|g| *g) {
125 Some(finished) => finished.saturating_duration_since(started),
126 None => started.elapsed(),
127 }
128 }
129
130 pub fn set_exclude_from_context(&self, exclude: bool) {
133 if let Ok(mut e) = self.exclude_from_context.lock() {
134 *e = exclude;
135 }
136 self.bump();
137 }
138
139 pub fn is_excluded_from_context(&self) -> bool {
141 *self.exclude_from_context.lock().unwrap()
142 }
143
144 pub fn set_command(&self, command: &str) {
148 if !command.trim().is_empty() {
149 let mut c = self.command.lock().unwrap();
150 *c = command.to_string();
151 drop(c);
152 self.bump();
153 }
154 }
155
156 pub fn append_output(&self, chunk: &str) {
162 let clean = strip_ansi(chunk).replace("\r\n", "\n").replace('\r', "\n");
163 if let Ok(mut out) = self.output_lines.lock() {
164 *out = if clean.is_empty() {
165 Vec::new()
166 } else {
167 clean
168 .split('\n')
169 .map(|line| line.replace('\t', " "))
170 .collect()
171 };
172 }
173 self.bump();
174 }
175
176 pub fn set_complete(
178 &self,
179 exit_code: Option<i32>,
180 cancelled: bool,
181 truncation: BashTruncation,
182 ) {
183 if let Ok(mut e) = self.exit_code.lock() {
184 *e = exit_code;
185 }
186 let new_status = if cancelled {
187 BashStatus::Cancelled
188 } else if exit_code.map(|c| c != 0).unwrap_or(false) {
189 BashStatus::Error
190 } else {
191 BashStatus::Complete
192 };
193 if let Ok(mut s) = self.status.lock() {
194 *s = new_status;
195 }
196 if let Ok(mut t) = self.truncation.lock() {
197 *t = truncation;
198 }
199 if let Ok(mut f) = self.finished_at.lock() {
200 *f = Some(Instant::now());
201 }
202 self.bump();
203 }
204
205 pub fn set_expanded(&self, expanded: bool) {
207 if let Ok(mut e) = self.expanded.lock() {
208 *e = expanded;
209 }
210 self.bump();
211 }
212
213 pub fn is_expanded(&self) -> bool {
215 *self.expanded.lock().unwrap()
216 }
217
218 pub fn get_output(&self) -> String {
220 self.output_lines
221 .lock()
222 .map(|o| o.join("\n"))
223 .unwrap_or_default()
224 }
225
226 fn status_line(&self, hidden: usize) -> Vec<String> {
228 let status = *self.status.lock().unwrap();
229 let truncation = self.truncation.lock().unwrap().clone();
230 let exit_code = *self.exit_code.lock().unwrap();
231 let expanded = *self.expanded.lock().unwrap();
232 let colors = theme().colors;
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![format!(" {}", parts.join(" "))]
279 }
280 }
281}
282
283impl Component for BashExecutionComponent {
284 fn render(&self, width: usize) -> Vec<String> {
285 let status = *self.status.lock().unwrap();
286 let revision = self.revision.load(Ordering::Acquire);
287 let theme_revision = theme_revision();
288
289 if status != BashStatus::Running {
293 if let Ok(cache) = self.cache.lock() {
294 if let Some(cached) = cache.as_ref() {
295 if cached.width == width
296 && cached.revision == revision
297 && cached.theme_revision == theme_revision
298 {
299 return cached.lines.clone();
300 }
301 }
302 }
303 }
304
305 let colors = theme().colors;
306 let mut lines: Vec<String> = Vec::new();
307
308 let excluded = *self.exclude_from_context.lock().unwrap();
311 let color_key = if excluded {
312 colors.dim
313 } else {
314 colors.bash_mode
315 };
316
317 let bg = match *self.status.lock().unwrap() {
320 BashStatus::Running => colors.tool_pending_bg,
321 BashStatus::Error | BashStatus::Cancelled => colors.tool_error_bg,
322 BashStatus::Complete => colors.tool_success_bg,
323 };
324
325 lines.extend(Spacer::new(1).render(width));
327
328 let command = self.command.lock().unwrap().clone();
330 let header_text = format!("$ {command}");
331 let header_width = width.saturating_sub(2).max(1);
332 for part in wrap_text_with_ansi(&header_text, header_width) {
333 let line = format!(" {}", color_key.fg(&bold(&part)));
334 lines.push(crate::utils::apply_background_to_line(&line, width, |s| {
335 bg.bg(s)
336 }));
337 }
338
339 let (display_lines, hidden) = {
341 let out = self.output_lines.lock().unwrap();
342 let expanded = *self.expanded.lock().unwrap();
343 if out.is_empty() {
344 (Vec::new(), 0usize)
345 } else {
346 let muted = colors.muted;
347 let styled = format!(
348 "
349{}",
350 out.iter().map(|l| muted.fg(l)).collect::<Vec<_>>().join(
351 "
352"
353 )
354 );
355 if expanded {
356 let all = crate::text::Text::new(&styled, 1, 0).render(width);
357 (all, 0)
358 } else {
359 let r = truncate_to_visual_lines(&styled, PREVIEW_LINES, width, 1);
360 (r.visual_lines, r.skipped_count)
361 }
362 }
363 };
364 for dl in &display_lines {
365 lines.push(crate::utils::apply_background_to_line(dl, width, |s| {
366 bg.bg(s)
367 }));
368 }
369
370 if status == BashStatus::Running {
372 let elapsed = self.elapsed();
376 let row = format!(
377 " {} {}",
378 colors.accent.fg("●"),
379 colors
380 .muted
381 .fg(&crate::tool_execution::format_elapsed(elapsed)),
382 );
383 let row = truncate_to_width(&row, width, "…");
384 lines.push(crate::utils::apply_background_to_line(&row, width, |s| {
385 bg.bg(s)
386 }));
387 if elapsed > LONG_RUNNING_HINT_AFTER {
388 let hint = format!(" {} Esc / Ctrl+C 中止", colors.muted.fg("⏸"));
389 let hint = truncate_to_width(&hint, width, "…");
390 lines.push(crate::utils::apply_background_to_line(&hint, width, |s| {
391 bg.bg(s)
392 }));
393 }
394 } else {
395 let sl = self.status_line(hidden);
396 if !sl.is_empty() {
397 lines.push(crate::utils::apply_background_to_line("", width, |s| {
398 bg.bg(s)
399 }));
400 for sline in &sl {
401 lines.push(crate::utils::apply_background_to_line(sline, width, |s| {
402 bg.bg(s)
403 }));
404 }
405 }
406 }
407
408 if status != BashStatus::Running {
409 if let Ok(mut cache) = self.cache.lock() {
410 *cache = Some(BashRenderCache {
411 width,
412 revision,
413 theme_revision,
414 lines: lines.clone(),
415 });
416 }
417 } else if let Ok(mut cache) = self.cache.lock() {
418 *cache = None;
419 }
420
421 lines
422 }
423
424 fn invalidate(&self) {
425 self.bump();
426 }
427
428 fn as_any(&self) -> &dyn Any {
429 self
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 #[test]
438 fn test_new_renders_header_and_live_elapsed() {
439 let c = BashExecutionComponent::new("ls -la");
440 let lines = c.render(40);
441 let joined = lines.join("\n");
442 assert!(joined.contains("$ ls -la"));
443 let plain = strip_ansi(&joined);
446 assert!(
447 plain.contains('●'),
448 "running panel needs the status glyph: {plain}"
449 );
450 assert!(
451 plain.contains('s'),
452 "running panel needs an elapsed readout: {plain}"
453 );
454 assert!(
455 !plain.contains("Running"),
456 "the `Running...` label was replaced by the elapsed timer: {plain}"
457 );
458 }
459
460 #[test]
461 fn completed_panel_reports_total_elapsed() {
462 let c = BashExecutionComponent::new("sleep 1");
463 c.append_output("done\n");
464 std::thread::sleep(std::time::Duration::from_millis(120));
465 c.set_complete(Some(0), false, BashTruncation::default());
466 let plain = strip_ansi(&c.render(60).join("\n"));
467 assert!(
469 plain.contains("0.1s") || plain.contains("0.2s"),
470 "total elapsed missing: {plain}"
471 );
472 let first = c.render(60).join("\n");
474 std::thread::sleep(std::time::Duration::from_millis(60));
475 assert_eq!(
476 first,
477 c.render(60).join("\n"),
478 "elapsed kept counting after completion"
479 );
480 }
481
482 #[test]
483 fn test_append_and_complete() {
484 let c = BashExecutionComponent::new("echo hi");
485 c.append_output("hi\n");
486 c.set_complete(Some(0), false, BashTruncation::default());
487 let lines = c.render(40);
488 let joined = lines.join("\n");
489 assert!(strip_ansi(&joined).contains("hi"), "joined: {joined}");
491 }
492
493 #[test]
494 fn excluded_from_context_uses_dim_border_and_header() {
495 let normal = BashExecutionComponent::new("echo hi");
498 let excluded = BashExecutionComponent::new_excluded("echo hi");
499 assert!(!normal.is_excluded_from_context());
500 assert!(excluded.is_excluded_from_context());
501
502 let colors = theme().colors;
503 let bash_start = colors
506 .bash_mode
507 .fg("X")
508 .split('X')
509 .next()
510 .unwrap()
511 .to_string();
512 let dim_start = colors.dim.fg("X").split('X').next().unwrap().to_string();
513
514 let normal_out = normal.render(40).join("\n");
515 let excluded_out = excluded.render(40).join("\n");
516 assert!(normal_out.contains(&bash_start), "normal: {normal_out:?}");
517 assert!(
518 excluded_out.contains(&dim_start),
519 "excluded: {excluded_out:?}"
520 );
521
522 assert!(strip_ansi(&excluded_out).contains("$ echo hi"));
524
525 excluded.set_exclude_from_context(false);
527 assert!(!excluded.is_excluded_from_context());
528 assert!(excluded.render(40).join("\n").contains(&bash_start));
529 }
530
531 #[test]
532 fn streamed_output_updates_replace_snapshots_instead_of_duplicating() {
533 let c = BashExecutionComponent::new("printf test");
534 c.append_output("one\n");
535 c.append_output("one\ntwo\n");
536 assert_eq!(c.get_output(), "one\ntwo\n");
537 }
538
539 #[test]
540 fn authoritative_start_command_replaces_partial_streamed_command() {
541 let c = BashExecutionComponent::new("printf");
542 c.set_command("printf '\\n--- agent skills ---\\n' && find skills");
543 let rendered = strip_ansi(&c.render(120).join("\n"));
544 assert!(rendered.contains("printf '\\n--- agent skills ---\\n' && find skills"));
545 assert!(!rendered.contains("$ printf\n"));
546 }
547
548 #[test]
549 fn long_running_command_wraps_instead_of_truncating() {
550 let command = "printf 'this command must remain fully visible while it runs'";
551 let c = BashExecutionComponent::new(command);
552 let lines = c.render(20);
553 let rendered = strip_ansi(&lines.join("\n"));
554 let compact: String = rendered.chars().filter(|ch| !ch.is_whitespace()).collect();
555 let expected: String = command.chars().filter(|ch| !ch.is_whitespace()).collect();
556 assert!(
557 compact.contains(&expected),
558 "command was truncated: {rendered}"
559 );
560 assert!(lines
561 .iter()
562 .all(|line| crate::utils::visible_width(line) <= 20));
563 }
564
565 #[test]
566 fn test_error_exit_status() {
567 let c = BashExecutionComponent::new("false");
568 c.set_complete(Some(1), false, BashTruncation::default());
569 let lines = c.render(40);
570 let joined = lines.join("\n");
571 assert!(joined.contains("exit 1"));
572 }
573
574 #[test]
575 fn test_truncation_path_shown() {
576 let c = BashExecutionComponent::new("big-output");
577 c.set_complete(
578 Some(0),
579 false,
580 BashTruncation {
581 truncated: true,
582 full_output_path: Some("/tmp/out.log".to_string()),
583 },
584 );
585 let lines = c.render(40);
586 let joined = lines.join("\n");
587 assert!(joined.contains("/tmp/out.log"), "joined: {joined}");
588 }
589}