//! BgTaskBlock — scrollback entries for background task lifecycle. //! //! Three kinds: Started, Completed, Failed. All render as always-collapsed, //! groupable blocks with dimmed colored bullets (same dimming as execute blocks). //! Enter / Ctrl-F opens block viewer with stdout from central store. use std::time::Duration; use ratatui::style::Modifier; use ratatui::text::{Line, Span, Text}; use crate::render::color::blend_color; use crate::scrollback::block::BlockContent; use crate::scrollback::types::{AccentStyle, BlockContext, BlockOutput, DisplayMode}; use crate::theme::Theme; use crate::util::format_duration; /// What kind of bg task lifecycle event this block represents. #[derive(Debug, Clone)] pub enum BgTaskKind { /// Task was started (process is running). Started, /// Task completed successfully. Completed { elapsed: Duration }, /// Task failed (non-zero exit, signal, timeout, etc.). Failed { elapsed: Duration, exit_code: Option, signal: Option, }, } /// Background task scrollback block. /// /// Always collapsed, not foldable, groupable, selectable. /// Enter / Ctrl-F opens block viewer with stdout from the central store. #[derive(Debug, Clone)] pub struct BgTaskBlock { /// The command that was run. pub command: String, /// Background task ID (for looking up stdout in central store). pub task_id: String, /// Lifecycle kind (started / completed / failed). pub kind: BgTaskKind, /// Optional description (from the tool call's description field). pub description: Option, } impl BgTaskBlock { /// Create a "Task started" block. pub fn started(command: impl Into, task_id: impl Into) -> Self { Self { command: command.into(), task_id: task_id.into(), kind: BgTaskKind::Started, description: None, } } /// Create a "Task completed" block. pub fn completed( command: impl Into, task_id: impl Into, elapsed: Duration, ) -> Self { Self { command: command.into(), task_id: task_id.into(), kind: BgTaskKind::Completed { elapsed }, description: None, } } /// Create a "Task failed" block. pub fn failed( command: impl Into, task_id: impl Into, elapsed: Duration, exit_code: Option, signal: Option, ) -> Self { Self { command: command.into(), task_id: task_id.into(), kind: BgTaskKind::Failed { elapsed, exit_code, signal, }, description: None, } } /// Set the description (builder pattern). pub fn with_description(mut self, description: Option) -> Self { self.description = description; self } /// Whether this block represents a running task (Started kind). pub fn is_running(&self) -> bool { matches!(self.kind, BgTaskKind::Started) } /// Mark a Started block as completed (called when task finishes). pub fn mark_completed(&mut self, elapsed: Duration) { self.kind = BgTaskKind::Completed { elapsed }; } /// Mark a Started block as failed (called when task fails). pub fn mark_failed( &mut self, elapsed: Duration, exit_code: Option, signal: Option, ) { self.kind = BgTaskKind::Failed { elapsed, exit_code, signal, }; } } impl BlockContent for BgTaskBlock { fn output(&self, ctx: &BlockContext) -> BlockOutput { let theme = Theme::current(); // When selected, lift only the bold "Task" label to `text_primary` // so it reads as undimmed (mirrors `read.rs` / `search.rs`, which // bump only the label and leave the rest at `muted`). The detail // text (verb + description) stays muted in every state. let bold = if ctx.is_selected { theme.primary().add_modifier(Modifier::BOLD) } else { theme.muted().add_modifier(Modifier::BOLD) }; let muted = theme.muted(); // Collapse newlines for single-line display (ratatui drops '\n' as zero-width, // merging adjacent lines without spacing). let command = self.command.replace('\n', " "); // Prefer description over raw command for the collapsed one-line display. // The full command is always available in the block viewer (preamble). let display = match &self.description { Some(d) if !d.trim().is_empty() => d.replace('\n', " "), _ => command, }; let line = match &self.kind { BgTaskKind::Started => Line::from(vec![ Span::styled("Task ", bold), Span::styled("started: ", muted), Span::styled(display, muted), ]), BgTaskKind::Completed { elapsed } => Line::from(vec![ Span::styled("Task ", bold), Span::styled( format!("completed in {}: ", format_duration(*elapsed)), muted, ), Span::styled(display, muted), ]), BgTaskKind::Failed { elapsed, exit_code, signal, } => { // Detect kill signals to show "killed" instead of "failed" let is_killed = signal .as_deref() .is_some_and(|s| matches!(s, "killed" | "SIGTERM" | "SIGKILL" | "oom")); let verb = if is_killed { "killed" } else { "failed" }; let detail = if is_killed { String::new() } else { match (exit_code, signal) { (_, Some(sig)) => format!(" ({})", sig), (Some(code), None) => format!(" (exit {})", code), (None, None) => String::new(), } }; Line::from(vec![ Span::styled("Task ", bold), Span::styled(format!("{verb} in {}: ", format_duration(*elapsed)), muted), Span::styled(format!("{}{}", display, detail), muted), ]) } }; BlockOutput { lines: vec![line.into()], } } fn accent(&self, ctx: &BlockContext) -> Option { let theme = Theme::current(); match &self.kind { BgTaskKind::Started if ctx.is_running => { Some(AccentStyle::static_color(theme.accent_running)) } _ => None, } } fn bullet(&self, ctx: &BlockContext) -> Option { let theme = Theme::current(); match &self.kind { BgTaskKind::Started => { if ctx.is_running { // Animated pulse between bg and dimmed magenta. // Pre-dim using the same ratio as collapsed execute bullets // so the peak brightness matches other collapsed blocks. let dim = ctx.appearance.scrollback.display.dim_accent; let dimmed = blend_color(theme.bg_base, theme.accent_running, dim) .unwrap_or(theme.accent_running); Some(AccentStyle::animated(dimmed)) } else { // Normal gray after finish_running() is called None } } BgTaskKind::Completed { .. } => Some(AccentStyle::static_color(theme.accent_success)), BgTaskKind::Failed { .. } => Some(AccentStyle::static_color(theme.accent_error)), } } fn has_vpad(&self, _ctx: &BlockContext) -> bool { false } fn has_raw_mode(&self) -> bool { false } fn is_foldable(&self) -> bool { false } fn default_display_mode(&self) -> DisplayMode { DisplayMode::Collapsed } fn is_selectable(&self) -> bool { true } fn has_bullet(&self, _ctx: &BlockContext) -> bool { true } fn is_groupable(&self) -> bool { true } fn preamble(&self, ctx: &BlockContext) -> Option> { let theme = Theme::current(); let mut lines = Vec::new(); // Description first (primary text), then a blank separator, then // the `$ command` with bash syntax highlighting. When there is no // description, the command stands alone with no leading blank row. let description = self .description .as_deref() .map(str::trim) .filter(|s| !s.is_empty()); if let Some(desc) = description { // Trim trailing whitespace per line and collapse runs of blank // lines to a single blank — multi-line descriptions can carry // noisy internal blank rows that would otherwise stretch the // preamble. let mut prev_blank = false; for line in desc.lines() { let trimmed = line.trim_end(); if trimmed.is_empty() { if prev_blank { continue; } prev_blank = true; } else { prev_blank = false; } lines.push(Line::from(Span::styled( trimmed.to_string(), theme.primary(), ))); } lines.push(Line::from("")); } // Multi-line `$ command` must be separate ratatui Lines. A single Line // drops '\n' as zero-width, which smashes `cmd1\ncmd2` into `cmd1cmd2` // (visible when expanding a started bg task in the block viewer). // Match execute / permission-panel soft-wrap so physical newlines and // long lines render the same way as foreground shell tool calls. push_shell_command_preamble_lines(&mut lines, &self.command, ctx.width as usize, &theme); Some(Text::from(lines)) } } /// Append a soft-wrapped `$ command` block to `lines` (first row prefixed with /// `$ `, continuations hang-indented under the command body). fn push_shell_command_preamble_lines( lines: &mut Vec>, command: &str, width: usize, theme: &Theme, ) { use unicode_width::UnicodeWidthStr; let prefix = "$ "; let hang = UnicodeWidthStr::width(prefix); let cmd_width = width.saturating_sub(hang).max(1); let command = if command.trim().is_empty() { "\u{2026}" } else { command }; let cmd_rows = crate::views::permission_view::render_bash_command_display_lines(command, cmd_width); let hang_indent: String = " ".repeat(hang); if cmd_rows.is_empty() { lines.push(Line::from(vec![Span::styled( prefix.to_string(), theme.dim(), )])); return; } for (i, row) in cmd_rows.into_iter().enumerate() { let mut spans = if i == 0 { vec![Span::styled(prefix.to_string(), theme.dim())] } else { vec![Span::raw(hang_indent.clone())] }; spans.extend(row.spans); lines.push(Line::from(spans)); } } #[cfg(test)] mod tests { use super::*; use crate::appearance::AppearanceConfig; fn test_ctx() -> BlockContext { BlockContext { mode: DisplayMode::Collapsed, is_running: false, width: 120, raw: false, max_lines: None, appearance: AppearanceConfig::default(), is_selected: false, cwd: None, } } fn line_text(block: &BgTaskBlock) -> String { block.output(&test_ctx()).lines[0] .content .spans .iter() .map(|s| s.content.as_ref()) .collect() } #[test] fn multiline_command_collapses_newlines() { let block = BgTaskBlock::started("echo foo\necho bar", "t1"); let text = line_text(&block); assert!( text.contains("foo echo bar"), "newlines should become spaces, got: {text:?}" ); assert!( !text.contains('\n'), "output line must not contain literal newlines" ); } #[test] fn started_and_completed_prefer_description_over_command() { let started = BgTaskBlock::started("sleep 20", "t1") .with_description(Some("Wait twenty seconds".into())); let started_text = line_text(&started); assert!( started_text.contains("Wait twenty seconds"), "started={started_text:?}" ); assert!( !started_text.contains("sleep 20"), "started should not show raw command when description present: {started_text:?}" ); let completed = BgTaskBlock::completed("sleep 20", "t1", Duration::from_secs(20)) .with_description(Some("Wait twenty seconds".into())); let completed_text = line_text(&completed); assert!( completed_text.contains("completed"), "completed={completed_text:?}" ); assert!( completed_text.contains("Wait twenty seconds"), "completed={completed_text:?}" ); assert!( !completed_text.contains("sleep 20"), "completed should not show raw command when description present: {completed_text:?}" ); let failed = BgTaskBlock::failed("sleep 20", "t1", Duration::from_secs(1), Some(1), None) .with_description(Some("Wait twenty seconds".into())); let failed_text = line_text(&failed); assert!( failed_text.contains("Wait twenty seconds"), "failed={failed_text:?}" ); assert!(!failed_text.contains("sleep 20"), "failed={failed_text:?}"); } #[test] fn completed_multiline_command_collapses_newlines() { let block = BgTaskBlock::completed("cmd1\ncmd2", "t1", std::time::Duration::from_secs(1)); let output = block.output(&test_ctx()); let text: String = output.lines[0] .content .spans .iter() .map(|s| s.content.as_ref()) .collect(); assert!( text.contains("cmd1 cmd2"), "newlines should become spaces in completed variant, got: {text:?}" ); } fn preamble_plain(block: &BgTaskBlock) -> Vec { let text = block.preamble(&test_ctx()).expect("preamble"); text.lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.as_ref()) .collect::() }) .collect() } #[test] fn preamble_description_first_then_blank_then_command() { let block = BgTaskBlock::started("cargo test --release", "t1") .with_description(Some("Run release tests".into())); let plain = preamble_plain(&block); assert_eq!(plain.len(), 3, "expected description + blank + command"); assert_eq!(plain[0], "Run release tests"); assert_eq!(plain[1], ""); assert_eq!(plain[2], "$ cargo test --release"); } #[test] fn preamble_uses_primary_text_color_for_description() { // Pin theme to avoid races with parallel tests that call `cache::set`. crate::theme::cache::set(crate::theme::ThemeKind::KigiNight); let block = BgTaskBlock::started("ls", "t1").with_description(Some("List the files".into())); let text = block.preamble(&test_ctx()).expect("preamble"); let theme = Theme::current(); let span = &text.lines[0].spans[0]; assert_eq!(span.content.as_ref(), "List the files"); assert_eq!(span.style.fg, Some(theme.text_primary)); } #[test] fn preamble_without_description_renders_only_command() { let block = BgTaskBlock::started("ls -la", "t1"); let plain = preamble_plain(&block); assert_eq!(plain.len(), 1); assert_eq!(plain[0], "$ ls -la"); } #[test] fn preamble_blank_description_falls_back_to_command_only() { let block = BgTaskBlock::started("ls", "t1").with_description(Some(" \n ".into())); let plain = preamble_plain(&block); assert_eq!(plain.len(), 1); assert_eq!(plain[0], "$ ls"); } #[test] fn preamble_multiline_description_keeps_separate_lines() { let block = BgTaskBlock::started("ls", "t1") .with_description(Some("First line\nSecond line".into())); let plain = preamble_plain(&block); assert_eq!(plain.len(), 4); assert_eq!(plain[0], "First line"); assert_eq!(plain[1], "Second line"); assert_eq!(plain[2], ""); assert_eq!(plain[3], "$ ls"); } #[test] fn preamble_collapses_blank_run_and_trims_trailing_whitespace() { // Runs of blank lines collapse to one; trailing whitespace on a // line is stripped so the rendered row doesn't carry stray spaces. let block = BgTaskBlock::started("ls", "t1") .with_description(Some("First \n\n\n\nSecond ".into())); let plain = preamble_plain(&block); // First, single blank (collapsed from 3 internal blanks), Second, // separator blank, command. assert_eq!(plain, vec!["First", "", "Second", "", "$ ls"]); } #[test] fn preamble_multiline_command_keeps_separate_lines() { // Regression: a single ratatui Line drops '\n' as zero-width, which // smashed multi-line bg-task commands into one unreadable blob when // expanded in the block viewer. let block = BgTaskBlock::started( "export XAI_ROOT=/tmp\ncd /tmp\necho start\nprod-run start backend", "t1", ) .with_description(Some("Start backend".into())); let plain = preamble_plain(&block); assert_eq!( plain, vec![ "Start backend", "", "$ export XAI_ROOT=/tmp", " cd /tmp", " echo start", " prod-run start backend", ] ); // Sanity: must not be the smashed single-line form. let joined = plain.join(""); assert!( !joined.contains("/tmpcd") && !joined.contains("/tmpecho"), "newlines must not be dropped: {plain:?}" ); } #[test] fn preamble_multiline_command_without_description() { let block = BgTaskBlock::started("echo a\necho b", "t1"); let plain = preamble_plain(&block); assert_eq!(plain, vec!["$ echo a", " echo b"]); } #[test] fn failed_multiline_command_collapses_newlines() { let block = BgTaskBlock::failed( "a\nb", "t1", std::time::Duration::from_secs(2), Some(1), None, ); let output = block.output(&test_ctx()); let text: String = output.lines[0] .content .spans .iter() .map(|s| s.content.as_ref()) .collect(); assert!( text.contains("a b"), "newlines should become spaces in failed variant, got: {text:?}" ); } }