//! SubagentBlock — scrollback entries for subagent lifecycle. //! //! Similar to BgTaskBlock: always collapsed, animated bullet while running, //! colored bullet when done. Enter / Ctrl-F opens the subagent view. //! //! Two modes: //! - **Blocking** (sync): Single `Started` block. Blinks while running, //! turns green/red when done. Text: `Subagent "description"` //! - **Background** (async): `Started` block stays forever (turns gray). //! A separate `Completed`/`Failed` block is added when done. //! Started text: `Subagent started: "description"` //! Completed text: `Subagent completed in 43s: "description"` use std::time::Duration; use ratatui::style::Modifier; use ratatui::text::{Line, Span}; use unicode_width::UnicodeWidthStr; use crate::app::subagent::format_subagent_meta; use crate::render::color::blend_color; use crate::render::line_utils::truncate_str; 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 subagent lifecycle event this block represents. #[derive(Debug, Clone)] pub enum SubagentBlockKind { /// Subagent is running (or was running — `finish_running` stops animation). Started, /// Subagent completed successfully. Completed { elapsed: Duration }, /// Subagent failed. Failed { elapsed: Duration, error: Option, }, /// Subagent was cancelled. Cancelled { elapsed: Duration }, } /// Subagent scrollback block. /// /// Always collapsed, not foldable, groupable, selectable. /// Enter / Ctrl-F opens the subagent view. #[derive(Debug, Clone)] pub struct SubagentBlock { pub description: String, /// Child session ID (for opening the subagent view). pub child_session_id: String, /// Subagent type (e.g. "general-purpose", "explore"). pub subagent_type: String, pub persona: Option, /// Role that supplied defaults for this subagent, if any. pub role: Option, pub model: Option, pub is_background: bool, pub kind: SubagentBlockKind, /// Live activity label from the child session's turn tracker. /// /// Updated on each `SubagentProgress` tick while the subagent is running. /// Shown inline in the collapsed scrollback line (e.g. "Thinking", /// "Running: cargo build") so the user sees interactive progress without /// opening the subagent view. pub activity_label: Option, } impl SubagentBlock { /// Create a "Subagent started" block (for both sync and async). pub fn started( description: impl Into, child_session_id: impl Into, subagent_type: impl Into, persona: Option, role: Option, model: Option, is_background: bool, ) -> Self { Self { description: description.into(), child_session_id: child_session_id.into(), subagent_type: subagent_type.into(), persona, role, model, is_background, kind: SubagentBlockKind::Started, activity_label: None, } } /// Create a "Subagent completed" block (background mode only). pub fn completed( description: impl Into, child_session_id: impl Into, elapsed: Duration, ) -> Self { Self { description: description.into(), child_session_id: child_session_id.into(), subagent_type: String::new(), persona: None, role: None, model: None, is_background: true, kind: SubagentBlockKind::Completed { elapsed }, activity_label: None, } } /// Create a "Subagent failed" block (background mode only). pub fn failed( description: impl Into, child_session_id: impl Into, elapsed: Duration, error: Option, ) -> Self { Self { description: description.into(), child_session_id: child_session_id.into(), subagent_type: String::new(), persona: None, role: None, model: None, is_background: true, kind: SubagentBlockKind::Failed { elapsed, error }, activity_label: None, } } /// Create a "Subagent cancelled" block (background mode only). pub fn cancelled( description: impl Into, child_session_id: impl Into, elapsed: Duration, ) -> Self { Self { description: description.into(), child_session_id: child_session_id.into(), subagent_type: String::new(), persona: None, role: None, model: None, is_background: true, kind: SubagentBlockKind::Cancelled { elapsed }, activity_label: None, } } pub fn is_running(&self) -> bool { matches!(self.kind, SubagentBlockKind::Started) } } fn quoted_desc(desc: &str, max_width: usize) -> String { // Reserve 2 chars for quotes if max_width <= 2 { // "…" return "\u{201C}\u{2026}\u{201D}".to_string(); } let inner = truncate_str(desc, max_width - 2); format!("\u{201C}{inner}\u{201D}") } impl BlockContent for SubagentBlock { fn output(&self, ctx: &BlockContext) -> BlockOutput { let theme = Theme::current(); // When selected, lift only the bold "Subagent" 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 + meta) 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(); let w = ctx.width as usize; let line = match (&self.kind, self.is_background) { (SubagentBlockKind::Started, bg) => { let verb = if bg { "started: " } else { "running: " }; let activity_suffix: String = self .activity_label .as_deref() .filter(|s| !s.is_empty()) .map(|a| format!(" \u{2014} {a}")) .unwrap_or_default(); let meta = format_subagent_meta( self.persona.as_deref(), self.role.as_deref(), self.model.as_deref(), ); // "Subagent running: " / "Subagent started: " = 18 chars let overhead = 18 + meta.width() + activity_suffix.width(); let desc = quoted_desc(&self.description, w.saturating_sub(overhead)); let mut spans = vec![ Span::styled("Subagent ", bold), Span::styled(verb, muted), Span::styled(desc, muted), ]; if !activity_suffix.is_empty() { spans.push(Span::styled(activity_suffix, muted)); } spans.push(Span::styled(meta, muted)); Line::from(spans) } (SubagentBlockKind::Completed { elapsed }, _) => { let time_str = format_duration(*elapsed); // "Subagent completed in Xs: " = 26 + time_str.len() let prefix_len = 26 + time_str.len(); let desc = quoted_desc(&self.description, w.saturating_sub(prefix_len)); Line::from(vec![ Span::styled("Subagent ", bold), Span::styled(format!("completed in {time_str}: "), muted), Span::styled(desc, muted), ]) } (SubagentBlockKind::Failed { elapsed, error }, _) => { let time_str = format_duration(*elapsed); let detail = error .as_deref() .map(|e| format!(" ({e})")) .unwrap_or_default(); let prefix_len = 21 + time_str.len() + detail.len(); let desc = quoted_desc(&self.description, w.saturating_sub(prefix_len)); Line::from(vec![ Span::styled("Subagent ", bold), Span::styled(format!("failed in {time_str}{detail}: "), muted), Span::styled(desc, muted), ]) } (SubagentBlockKind::Cancelled { elapsed }, _) => { let time_str = format_duration(*elapsed); // "Subagent cancelled in Xs: " = 26 + time_str.len() let prefix_len = 26 + time_str.len(); let desc = quoted_desc(&self.description, w.saturating_sub(prefix_len)); Line::from(vec![ Span::styled("Subagent ", bold), Span::styled(format!("cancelled in {time_str}: "), muted), Span::styled(desc, muted), ]) } }; BlockOutput { lines: vec![line.into()], } } fn accent(&self, ctx: &BlockContext) -> Option { let theme = Theme::current(); match &self.kind { SubagentBlockKind::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 { SubagentBlockKind::Started => { if ctx.is_running { 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 { // Finished — gray bullet (same as bg task "started" after completion) None } } SubagentBlockKind::Completed { .. } => { Some(AccentStyle::static_color(theme.accent_success)) } SubagentBlockKind::Failed { .. } | SubagentBlockKind::Cancelled { .. } => { 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 } }