//! Combined tasks pane — unified overlay panel showing both background tasks //! and subagents in a single interleaved list. //! //! Replaces the separate `BgTaskPane` and `SubagentPane`. Items are sorted //! running-first, then by start time (newest first). Each entry dispatches //! to the correct action type (kill task vs kill agent, view output vs view //! session) based on its variant. use crossterm::event::{KeyEvent, MouseEventKind}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::StatefulWidget; use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::time::{Instant, SystemTime}; use unicode_width::UnicodeWidthStr; use crate::app::agent::{BgTaskState, BgTaskStatus, ScheduledTaskInfo}; use crate::app::subagent::{SubagentInfo, format_context_badge, format_subagent_label}; use crate::appearance::LayoutConfig; use crate::scrollback::layout::HorizontalLayout; use crate::syntax::get_syntect; use crate::theme::{Theme, ThemeKind}; use crate::util::format_duration; use chrono::{DateTime, Utc}; use super::list_pane::{ ListItem, ListPane, ListPaneConfig, ListPaneState, ListPaneStyle, WrapMode, }; use super::overlay::OverlayState; // --------------------------------------------------------------------------- // Spinner // --------------------------------------------------------------------------- const SPINNER_DIVISOR: u64 = 4; // --------------------------------------------------------------------------- // Shell command syntax highlighting (used by other modules too) // --------------------------------------------------------------------------- /// Highlight a shell command string into styled spans. /// /// Uses syntect with the best available grammar for the platform: tries /// "powershell" first on Windows, falls back to "bash". Returns plain /// `theme.command` color if no grammar matches. Results should be cached. pub fn highlight_bash_command(command: &str) -> Vec> { let syntect = get_syntect(); let grammar = if cfg!(windows) { "powershell" } else { "bash" }; let Some(mut hl) = syntect .highlight_lines_for_token(grammar) .or_else(|| syntect.highlight_lines_for_token("bash")) else { let theme = Theme::current(); return vec![Span::styled( command.to_string(), Style::default().fg(theme.command), )]; }; let line = format!("{command}\n"); match hl.highlight_line(&line, &syntect.syntax_set) { Ok(ranges) => { let mut spans = Vec::new(); for (style, segment) in ranges { let mut text = segment.to_owned(); while text.ends_with('\n') || text.ends_with('\r') { text.pop(); } if text.is_empty() { continue; } // Raw syntect RGB here used to bypass quantization and leak // polarity-tuned tmTheme colors into minimal. spans.push(Span::styled( text, crate::syntax::syntect_to_ratatui_fg(style), )); } if spans.is_empty() { let theme = Theme::current(); vec![Span::styled( command.to_string(), Style::default().fg(theme.command), )] } else { spans } } Err(_) => { let theme = Theme::current(); vec![Span::styled( command.to_string(), Style::default().fg(theme.command), )] } } } /// Dim highlighted spans by blending each color toward background. fn dim_spans(spans: &[Span<'static>], blend_factor: f32) -> Vec> { let theme = Theme::current(); spans .iter() .map(|span| { let fg = span .style .fg .and_then(|fg| crate::render::color::blend_color(theme.bg_base, fg, blend_factor)) .or(span.style.fg); let style = Style::default().fg(fg.unwrap_or(theme.gray)); Span::styled(span.content.clone(), style) }) .collect() } // --------------------------------------------------------------------------- // Line count badge formatting // --------------------------------------------------------------------------- /// Format an stdout line count as a compact `(N)` badge with SI scaling. /// /// Returns an empty string for `0` so callers can treat that as "no badge". /// Truncation (not rounding) is used throughout so the badge never /// overstates the count — e.g. `1999` renders as `(1.9k)` (not `(2.0k)`), /// `999_999` renders as `(999k)` (not `(1.0M)`), and `9_999_999` renders as /// `(9.9M)` (not `(10M)`). Each branch boundary is exact: `1_000_000` is the /// first count to render with an `M` suffix. /// /// When `truncated` is `true`, a `+` is inserted before the closing paren /// (`(2.0k+)`) to signal "at least this many" — the rolling buffer has /// dropped data so the real total is larger than `count`. /// /// - `<1000`: `(42)`, `(999)` /// - `<10_000`: `(1.0k)`, `(9.9k)` — one decimal /// - `<1M`: `(10k)`, `(999k)` — whole thousands /// - `<10M`: `(1.0M)`, `(9.9M)` — one decimal /// - `≥10M`: `(10M)`, `(999M)` — whole millions fn format_line_count_badge(count: usize, truncated: bool) -> String { if count == 0 { return String::new(); } let suffix = if truncated { "+" } else { "" }; if count < 1_000 { return format!("({count}{suffix})"); } if count < 10_000 { let tenths = count / 100; return format!("({}.{}k{suffix})", tenths / 10, tenths % 10); } if count < 1_000_000 { return format!("({}k{suffix})", count / 1_000); } if count < 10_000_000 { let tenths = count / 100_000; return format!("({}.{}M{suffix})", tenths / 10, tenths % 10); } format!("({}M{suffix})", count / 1_000_000) } // --------------------------------------------------------------------------- // TaskEntryId — identifies which entry a button belongs to // --------------------------------------------------------------------------- #[derive(Debug, Clone, PartialEq, Eq)] pub enum TaskEntryId { BgTask(String), Agent(String), Scheduled(String), } /// Logical group a [`TaskEntry`] belongs to. Drives both the sort order (so /// each kind is contiguous) and the collapsible group headers. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum GroupKind { Subagents, Tasks, /// Recurring background processes: `monitor` tasks and `/loop` scheduled /// tasks share one section. They stay contiguous (monitors first, then /// loops) via [`TaskEntry::type_order`]. Watchers, } impl GroupKind { /// Display label shown in the group header. fn label(self) -> &'static str { match self { GroupKind::Subagents => "Subagents", GroupKind::Tasks => "Tasks", GroupKind::Watchers => "Watchers", } } /// Sort/render order: subagents → tasks → watchers (monitors + loops). fn order(self) -> u8 { match self { GroupKind::Subagents => 0, GroupKind::Tasks => 1, GroupKind::Watchers => 2, } } } // --------------------------------------------------------------------------- // TaskEntry — unified entry for the combined list // --------------------------------------------------------------------------- #[derive(Debug, Clone)] pub enum TaskEntry { BgTask { id: u64, task_id: String, label: String, styled: Line<'static>, running: bool, start_time: SystemTime, /// True for `monitor` tool tasks. Used to sort monitors into their /// own contiguous group (separate from one-shot bg commands). is_monitor: bool, }, Agent { id: u64, subagent_id: String, child_session_id: String, label: String, styled: Line<'static>, running: bool, started_at: Instant, /// Capitalized agent-type / persona label (e.g. `Explore`, `Plan`, /// `General`). Used to order subagents by type within their group. type_label: String, }, Scheduled { id: u64, task_id: String, label: String, styled: Line<'static>, started_at: Instant, linked_subagent: Option, }, /// Collapsible group header row (e.g. `▾ Subagents 2`). Not a task — /// selecting it and pressing Enter (or clicking it) toggles the group's /// collapse state. Header { group: GroupKind, styled: Line<'static>, }, } impl TaskEntry { fn from_bg_task( task: &BgTaskState, highlight_cache: &mut HashMap>>, ) -> Self { // Prefer the tool call's description over the raw command for the // pane label. The full command is always available via the block // viewer (preamble of the BgTaskBlock). let description = task .description .as_deref() .map(str::trim) .filter(|s| !s.is_empty()); let running = task.status == BgTaskStatus::Running; let (label, styled) = if task.is_monitor { // Monitor: blue "Monitor" tag + neutral description, mirroring // scheduled `/loop` rows. Falls back to the command if the // description is somehow empty. The description (not the raw // command) is what we show, so it never gets bash-highlighted. let theme = Theme::current(); let text = description .map(|d| d.replace('\n', " ")) .unwrap_or_else(|| task.command.trim().replace('\n', " ")); const TAG: &str = "Monitor"; let desc_style = if running { Style::default().fg(theme.text_secondary) } else { Style::default().fg(theme.gray_bright) }; let label = format!("{TAG} {text}"); let styled = Line::from(vec![ Span::styled(format!("{TAG} "), Style::default().fg(theme.accent_system)), Span::styled(text, desc_style), ]); (label, styled) } else if let Some(desc) = description { // Collapse newlines so multi-line descriptions render on one row. let one_line = desc.replace('\n', " "); let theme = Theme::current(); // Prefix the description with a constant `Task` tag in the // theme's secondary text color so the entry type is identifiable // at a glance, the same way subagent rows lead with their // persona/role label. The prefix is included in `label` so it // is searchable (the tasks-pane filter matches against `label`). const PREFIX: &str = "Task "; let desc_style = if running { Style::default().fg(theme.text_primary) } else { Style::default().fg(theme.gray_bright) }; let label = format!("{PREFIX}{one_line}"); let styled = Line::from(vec![ Span::styled(PREFIX, Style::default().fg(theme.text_secondary)), Span::styled(one_line, desc_style), ]); (label, styled) } else { let trimmed = task.command.trim(); let label = if let Some(nl) = trimmed.find('\n') { let first_line = trimmed[..nl].trim_end(); format!("{first_line}\u{2026}") } else { trimmed.to_string() }; let base_spans = highlight_cache .entry(label.clone()) .or_insert_with(|| highlight_bash_command(&label)) .clone(); let spans = if running { base_spans } else { dim_spans(&base_spans, 0.45) }; (label, Line::from(spans)) }; let mut hasher = std::collections::hash_map::DefaultHasher::new(); task.task_id.hash(&mut hasher); let id = hasher.finish(); TaskEntry::BgTask { id, task_id: task.task_id.clone(), label, styled, running, start_time: task.start_time, is_monitor: task.is_monitor, } } fn from_subagent(info: &SubagentInfo) -> Self { let theme = Theme::current(); // Single consolidated label (persona > role > subagent_type > tag > // "general") plus description with any `[tag]` prefix stripped. let (type_label, description) = format_subagent_label(info); let model_suffix = info .model .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) .unwrap_or(""); // Label color is state-driven: pending_kill / running stay vivid; // completed / failed keep their hue (green / red) but blend toward // the background so finished entries recede without losing their // success-vs-failure signal. let raw_type_color = if info.pending_kill { theme.accent_error } else if info.is_running() { theme.accent_running } else if info.status.as_deref() == Some("completed") { theme.accent_success } else { theme.accent_error }; let type_color = if info.is_running() || info.pending_kill { raw_type_color } else { crate::render::color::blend_color(theme.bg_base, raw_type_color, 0.45) .unwrap_or(raw_type_color) }; let type_style = Style::default().fg(type_color); let desc_style = if info.is_running() { Style::default().fg(theme.text_primary) } else { Style::default().fg(theme.gray_bright) }; // Live activity suffix, running rows only. The description is capped // so the live part survives typical pane widths (the right overlay's // end-truncation remains the final safety net); the suffix stays out // of `label` so filter matches don't flicker as activity changes. const ACTIVITY_DESC_MAX_WIDTH: usize = 40; let activity = info .is_running() .then_some(info.activity_label.as_deref()) .flatten() .map(str::trim) .filter(|s| !s.is_empty()); let shown_desc = match activity { Some(_) => { crate::render::line_utils::truncate_str(&description, ACTIVITY_DESC_MAX_WIDTH) } None => description.clone(), }; // Skip the trailing-space separator when the cleaned description is // empty (reachable when `info.description == "[tag]"`); otherwise we // render `"Tag "` with a stray trailing space. // // The model is NOT rendered inline here — it's drawn right-aligned in // the overlay (just to the left of the elapsed/duration). The label // string below still includes the model so it remains searchable. let type_sep = if description.is_empty() { "" } else { " " }; let mut spans = vec![ Span::styled(format!("{type_label}{type_sep}"), type_style), Span::styled(shown_desc, desc_style), ]; if let Some(activity) = activity { spans.push(Span::styled( format!(" \u{2014} {activity}"), Style::default().fg(theme.gray), )); } let label = match (description.is_empty(), model_suffix.is_empty()) { (true, true) => type_label.clone(), (true, false) => format!("{type_label} {model_suffix}"), (false, true) => format!("{type_label} {description}"), (false, false) => format!("{type_label} {description} {model_suffix}"), }; let styled = Line::from(spans); // Use a different hash namespace to avoid collisions with bg tasks let mut hasher = std::collections::hash_map::DefaultHasher::new(); "agent:".hash(&mut hasher); info.child_session_id.hash(&mut hasher); let id = hasher.finish(); TaskEntry::Agent { id, subagent_id: info.subagent_id.to_string(), child_session_id: info.child_session_id.to_string(), label, styled, running: info.is_running(), started_at: info.started_at, type_label, } } fn from_scheduled( info: &ScheduledTaskInfo, current_cron: Option<&str>, is_queued: bool, linked: Option<(String, bool)>, ) -> Self { let linked_running = linked.as_ref().is_some_and(|(_, running)| *running); let theme = Theme::current(); let prompt_preview = if info.prompt.chars().count() > 60 { info.prompt.chars().take(57).collect::() + "..." } else { info.prompt.clone() }; let countdown = |schedule: &str, created: std::time::Instant| -> String { if let Some(secs) = crate::util::parse_schedule_interval_secs(schedule) { let approx = created + std::time::Duration::from_secs(secs); let now = std::time::Instant::now(); if approx > now { format!(" (next in {})", format_duration(approx.duration_since(now))) } else { " (due now)".to_string() } } else { String::new() } }; let is_provisional = info.task_id.starts_with("provisional-"); let suffix = if current_cron == Some(&info.task_id) || linked_running { " (running)".to_string() } else if is_queued { " (queued)".to_string() } else if is_provisional { " (starting)".to_string() } else if let Some(n) = &info.next_fire_at { if let Ok(dt) = DateTime::::parse_from_rfc3339(n) { let dt = dt.with_timezone(&Utc); let now = Utc::now(); if dt > now { let dur = (dt - now).to_std().unwrap_or_default(); format!(" (next in {})", format_duration(dur)) } else { " (due now)".to_string() } } else { countdown(&info.human_schedule, info.created_at) } } else { countdown(&info.human_schedule, info.created_at) }; // Capitalize the tag for display (`loop` → `Loop`) so it reads as a // proper label, matching the monitor row's `Monitor` tag. let tag_display = { let mut chars = info.tag.chars(); match chars.next() { Some(first) => first.to_uppercase().chain(chars).collect::(), None => String::new(), } }; let label = format!( "{} {} \u{b7} {}{}", tag_display, info.human_schedule, &prompt_preview, &suffix ); // Only the tag (e.g. `Loop`) carries color — the blue system accent. // The schedule, prompt preview, and status suffix all render in the // neutral secondary text color so the row reads calmly with a single // point of color. No surrounding `[ ]` brackets: the color alone // sets the tag apart from the schedule that follows it. let schedule_style = format!("{} \u{b7} ", info.human_schedule); let neutral = Style::default().fg(theme.text_secondary); let styled = Line::from(vec![ Span::styled( format!("{} ", tag_display), Style::default().fg(theme.accent_system), ), Span::styled(schedule_style, neutral), Span::styled(prompt_preview, neutral), if !suffix.is_empty() { Span::styled(suffix, neutral) } else { Span::raw("") }, ]); let mut hasher = std::collections::hash_map::DefaultHasher::new(); "sched:".hash(&mut hasher); info.task_id.hash(&mut hasher); let id = hasher.finish(); TaskEntry::Scheduled { id, task_id: info.task_id.clone(), label, styled, started_at: info.created_at, linked_subagent: linked.map(|(sid, _)| sid), } } /// Build a collapsible group header row, e.g. `▾ Subagents 2` (expanded) /// or `▸ Subagents 2` (collapsed). The chevron + count are baked into the /// styled line; the label aligns with item labels (the `chevron + space` /// prefix is the same width as an item's 2-space indent). fn header(group: GroupKind, count: usize, collapsed: bool) -> Self { let theme = Theme::current(); let chevron = if collapsed { "\u{25B8} " } else { "\u{25BE} " }; let styled = Line::from(vec![ Span::styled(chevron, Style::default().fg(theme.gray)), Span::styled( group.label(), Style::default() .fg(theme.gray_bright) .add_modifier(Modifier::BOLD), ), Span::styled(format!(" {count}"), Style::default().fg(theme.gray)), ]); TaskEntry::Header { group, styled } } /// Which collapsible group this entry belongs to. fn group_kind(&self) -> GroupKind { match self { TaskEntry::Agent { .. } => GroupKind::Subagents, TaskEntry::BgTask { is_monitor: false, .. } => GroupKind::Tasks, TaskEntry::BgTask { is_monitor: true, .. } => GroupKind::Watchers, TaskEntry::Scheduled { .. } => GroupKind::Watchers, TaskEntry::Header { group, .. } => *group, } } fn is_running(&self) -> bool { match self { TaskEntry::BgTask { running, .. } | TaskEntry::Agent { running, .. } => *running, TaskEntry::Scheduled { .. } => true, TaskEntry::Header { .. } => false, } } /// Fine-grained sort rank, distinct per task kind so each renders as a /// contiguous block: subagents (0) → one-shot bg tasks (1) → monitors /// (2) → scheduled/loops (3). Monitors and loops share the `Watchers` /// group/header but keep distinct ranks so monitors always sort before /// loops within that section. fn type_order(&self) -> u8 { match self { TaskEntry::Agent { .. } => 0, TaskEntry::BgTask { is_monitor: false, .. } => 1, TaskEntry::BgTask { is_monitor: true, .. } => 2, TaskEntry::Scheduled { .. } => 3, // Headers never appear in the sorted `items` list; fall back to // the group's coarse order for completeness. TaskEntry::Header { group, .. } => group.order(), } } } impl ListItem for TaskEntry { fn content(&self) -> &Line<'_> { match self { TaskEntry::BgTask { styled, .. } | TaskEntry::Agent { styled, .. } | TaskEntry::Scheduled { styled, .. } | TaskEntry::Header { styled, .. } => styled, } } fn prefix(&self) -> Option> { match self { // Headers sit flush-left; their chevron occupies the same two // columns as an item's indent, so labels still line up. TaskEntry::Header { .. } => None, _ => Some(Line::from(Span::raw(" "))), } } fn stable_id(&self) -> u64 { match self { TaskEntry::BgTask { id, .. } | TaskEntry::Agent { id, .. } | TaskEntry::Scheduled { id, .. } => *id, TaskEntry::Header { group, .. } => { let mut hasher = std::collections::hash_map::DefaultHasher::new(); "header:".hash(&mut hasher); group.order().hash(&mut hasher); hasher.finish() } } } fn is_selectable(&self) -> bool { true } fn search_text(&self) -> &str { match self { TaskEntry::BgTask { label, .. } | TaskEntry::Agent { label, .. } | TaskEntry::Scheduled { label, .. } => label, TaskEntry::Header { group, .. } => group.label(), } } } // --------------------------------------------------------------------------- // TasksPane // --------------------------------------------------------------------------- /// Temporary data for the overlay pass (avoids borrowing entries during mutation). enum OverlayEntryData { BgTask(String), Agent(String, String), Scheduled(String, Option), } const MAX_TASKS_HEIGHT: u16 = 8; const MAX_TASKS_FRACTION: f32 = 0.15; pub struct TasksPane { /// Display list: sorted `items` with group headers inserted and /// collapsed groups' items removed. This is what the `ListPane` renders. entries: Vec, /// Sorted task items only (no headers). `entries` is derived from this by /// [`Self::rebuild_entries`]; kept so collapse toggles can rebuild the /// display list without re-reading the live task data. items: Vec, /// Groups the user has collapsed (header shown, items hidden). collapsed_groups: std::collections::HashSet, pub list_state: ListPaneState, list_style: ListPaneStyle, show_done: bool, pub overlay: OverlayState, tick: u64, pub kill_button_rects: Vec<(TaskEntryId, Rect)>, pub view_button_rects: Vec<(TaskEntryId, Rect)>, pub hovered_kill: Option, pub hovered_view: Option, prev_running_count: usize, opened_by_auto: bool, highlight_cache: HashMap>>, last_theme: ThemeKind, } impl Default for TasksPane { fn default() -> Self { Self::new() } } /// Fill overlay cells with spaces so label text doesn't bleed through. /// /// When the label extends into the area we're about to clear, place an /// ellipsis `…` at the cell immediately to the left of the clear boundary so /// the user sees that the row was truncated rather than just clipped silently. /// The ellipsis inherits the style of the cell it lands on so it blends with /// the surrounding label text. fn clear_overlay_area(buf: &mut Buffer, area: Rect, y: u16, overlay_w: u16) { let clamped = overlay_w.min(area.width); if clamped == 0 { return; } let clear_x = area.x + area.width - clamped; // Detect truncation BEFORE clearing: if the cell at `clear_x` contains // non-blank label content, the label is wider than the row minus the // overlay reservation. Capture the style at `clear_x - 1` (the cell that // will host the ellipsis) so the inserted `…` matches the label color. let needs_ellipsis = clear_x > area.x && buf .cell((clear_x, y)) .map(|c| !c.symbol().trim().is_empty()) .unwrap_or(false); let ellipsis_style = if needs_ellipsis { buf.cell((clear_x - 1, y)) .map(|c| c.style()) .unwrap_or_default() } else { Style::default() }; let blanks = " ".repeat(clamped as usize); buf.set_span(clear_x, y, &Span::raw(blanks), clamped); if needs_ellipsis { buf.set_span(clear_x - 1, y, &Span::styled("\u{2026}", ellipsis_style), 1); } } /// Draw a single scroll indicator glyph centered on row `y`, blanking the rest /// of the row so it reads as a dedicated, easy-to-see indicator row — the same /// ▲/▼ as the corner indicators, just centered for visibility. fn draw_centered_arrow(buf: &mut Buffer, area: Rect, y: u16, arrow: &str, color: Color) { if area.width == 0 { return; } let blanks = " ".repeat(area.width as usize); buf.set_span(area.x, y, &Span::raw(blanks), area.width); let cx = area.x + area.width / 2; buf.set_span( cx, y, &Span::styled(arrow.to_string(), Style::default().fg(color)), 1, ); } impl TasksPane { pub fn new() -> Self { let config = ListPaneConfig { follow_enabled: false, wrap_toggle_enabled: false, search_enabled: true, copy_enabled: true, show_selection_when_unfocused: false, visual_select_enabled: false, filter_enabled: true, goto_line_enabled: false, }; let mut list_state = ListPaneState::new_with_config(WrapMode::NoWrap, false, config); list_state.set_clipboard_provider(Box::new(crate::clipboard::SystemClipboard)); // This pane draws the scroll indicators (▲/▼) centered on dedicated // rows, so the generic right-corner indicators are suppressed. let list_style = ListPaneStyle { show_corner_indicators: false, ..ListPaneStyle::default() }; Self { entries: Vec::new(), items: Vec::new(), collapsed_groups: std::collections::HashSet::new(), list_state, list_style, show_done: false, overlay: OverlayState::hidden(), tick: 0, kill_button_rects: Vec::new(), view_button_rects: Vec::new(), hovered_kill: None, hovered_view: None, prev_running_count: 0, opened_by_auto: false, highlight_cache: HashMap::new(), last_theme: Theme::current_kind(), } } // -- Data sync ----------------------------------------------------------- /// Sync entries from bg tasks, subagent sessions, and scheduled tasks. pub fn sync( &mut self, bg_tasks: &std::collections::BTreeMap, subagents: &HashMap, scheduled: &HashMap, current_cron_task_id: Option<&str>, queued_cron_ids: &std::collections::HashSet<&str>, ) { // Detect theme switch and refresh caches. let current_theme = Theme::current_kind(); if current_theme != self.last_theme { self.last_theme = current_theme; self.list_style = ListPaneStyle { show_corner_indicators: false, ..ListPaneStyle::default() }; self.highlight_cache.clear(); } self.items.clear(); // Add bg task items for task in bg_tasks.values() { if self.show_done || task.status == BgTaskStatus::Running { self.items .push(TaskEntry::from_bg_task(task, &mut self.highlight_cache)); } } // Add subagent items for info in subagents.values() { if self.show_done || info.is_running() { self.items.push(TaskEntry::from_subagent(info)); } } // Add scheduled task items (always "running") for info in scheduled.values() { let linked = info.last_subagent_id.as_deref().and_then(|sid| { subagents .values() .find(|s| s.subagent_id.as_ref() == sid) .map(|s| (sid.to_string(), s.is_running())) }); self.items.push(TaskEntry::from_scheduled( info, current_cron_task_id, queued_cron_ids.contains(info.task_id.as_str()), linked, )); } // Sort: group by type first (subagents → tasks → monitors → // scheduled) so each kind is one contiguous block, then running // before done within each group, then newest-first, then a stable // id tiebreak. Monitors and scheduled/loops render under one shared // "Watchers" header but keep distinct ranks (monitors first). self.items.sort_by(|a, b| { // 1. Group by type so each kind is one contiguous block: // subagents → tasks → monitors → scheduled. a.type_order() .cmp(&b.type_order()) // 2. Running before done *within* each group. .then_with(|| b.is_running().cmp(&a.is_running())) // 3. Within a (group, run-state): subagents order by agent // type (alphabetical) then newest-first; tasks/monitors/ // loops order newest-first. Avoids mixing SystemTime and // Instant across types. .then_with(|| match (a, b) { ( TaskEntry::Agent { type_label: ta, started_at: sa, .. }, TaskEntry::Agent { type_label: tb, started_at: sb, .. }, ) => ta.cmp(tb).then_with(|| sb.cmp(sa)), ( TaskEntry::Scheduled { started_at: a, .. }, TaskEntry::Scheduled { started_at: b, .. }, ) => b.cmp(a), ( TaskEntry::BgTask { start_time: a, .. }, TaskEntry::BgTask { start_time: b, .. }, ) => b.cmp(a), _ => std::cmp::Ordering::Equal, }) // 4. Stable tiebreak so equal-timestamp rows don't reshuffle // frame-to-frame. .then_with(|| a.stable_id().cmp(&b.stable_id())) }); // Auto-expand: forget the collapse state of any group that no longer // has items, so when items later return (e.g. a new subagent spawns // after the group emptied) the group reappears expanded instead of // hidden under a stale collapsed header. if !self.collapsed_groups.is_empty() { let mut present = [false; 3]; for it in &self.items { present[it.group_kind().order() as usize] = true; } self.collapsed_groups .retain(|g| present[g.order() as usize]); } // Build the display list: group headers + (non-collapsed) items. self.rebuild_entries(); // Count running for edge detection. Replay-restored bg tasks are // excluded: on resume they are historical context (cold loads // reconcile them away within the same `session/load`; on warm // reconnects they are ambient, not new activity), so they must not // flash the overlay open on every resume. let running_count = bg_tasks .values() .filter(|t| t.status == BgTaskStatus::Running && !t.restored_from_replay) .count() + subagents.values().filter(|s| s.is_running()).count() + scheduled.len(); // Auto-show: running went from 0 to N if running_count > 0 && self.prev_running_count == 0 { self.overlay.show(); self.opened_by_auto = true; } // Auto-close: running went from N to 0, auto-shown, not focused if running_count == 0 && self.prev_running_count > 0 && self.overlay.visible && !self.overlay.focused && self.opened_by_auto && !self.show_done { self.overlay.visible = false; self.opened_by_auto = false; } self.prev_running_count = running_count; } /// Rebuild the display `entries` from the sorted `items`: insert a header /// at each group boundary (with the group's item count), and include a /// group's items only when the group is not collapsed. Relies on `items` /// already being sorted so each group is contiguous. fn rebuild_entries(&mut self) { self.entries.clear(); // Per-group item counts (indexed by `GroupKind::order`). let mut counts: [usize; 3] = [0; 3]; for it in &self.items { counts[it.group_kind().order() as usize] += 1; } let mut last: Option = None; for it in &self.items { let group = it.group_kind(); if last != Some(group) { let collapsed = self.collapsed_groups.contains(&group); self.entries.push(TaskEntry::header( group, counts[group.order() as usize], collapsed, )); last = Some(group); } if !self.collapsed_groups.contains(&group) { self.entries.push(it.clone()); } } } /// Toggle a group's collapse state (used by Enter / Ctrl-F / click). pub fn toggle_group(&mut self, group: GroupKind) { let collapsed = self.collapsed_groups.contains(&group); self.set_group_collapsed(group, !collapsed); } /// Explicitly set a group's collapse state (used by ← / →, which collapse /// and expand respectively rather than toggle). When the state changes, /// rebuild the display list and keep the group's header selected so the /// cursor doesn't jump. Returns `true` if the state actually changed. pub fn set_group_collapsed(&mut self, group: GroupKind, collapsed: bool) -> bool { let changed = if collapsed { self.collapsed_groups.insert(group) } else { self.collapsed_groups.remove(&group) }; if changed { self.rebuild_entries(); if let Some(header) = self .entries .iter() .find(|e| matches!(e, TaskEntry::Header { group: g, .. } if *g == group)) { let id = header.stable_id(); self.list_state.select_by_id(id); } } changed } /// If the selected entry is a group header, return its group. pub fn selected_header_group(&self) -> Option { match self.selected_entry()? { TaskEntry::Header { group, .. } => Some(*group), _ => None, } } /// Number of running bg tasks + subagents + scheduled tasks. pub fn running_count( &self, bg_tasks: &std::collections::BTreeMap, subagents: &HashMap, scheduled: &HashMap, ) -> usize { bg_tasks .values() .filter(|t| t.status == BgTaskStatus::Running) .count() + subagents.values().filter(|s| s.is_running()).count() + scheduled.len() } // -- Visibility ---------------------------------------------------------- pub fn show_done(&self) -> bool { self.show_done } pub fn is_visible(&self) -> bool { self.overlay.visible } pub fn on_state_change(&mut self) { if !self.overlay.visible { self.list_state.close_input_bar(); } self.opened_by_auto = false; } pub fn desired_height(&self, view_height: u16) -> u16 { if !self.overlay.visible { return 0; } if view_height < 12 { return 0; } let count = self.entries.len(); if count == 0 { return 1; } let fraction_cap = (view_height as f32 * MAX_TASKS_FRACTION).floor() as u16; let max = MAX_TASKS_HEIGHT.min(fraction_cap).max(1); // Reserve one extra row for the search/filter input bar (or an // accepted matcher's status line) so it gets its own line instead of // displacing the last task/agent entry. `ListPane` carves the bar out // of the bottom of the area it's given, so without this the pane would // show one fewer entry the moment `/` (or `f`) is pressed. let bar = u16::from( self.list_state.input_mode().is_some() || self.list_state.matcher().is_some(), ); (count as u16).min(max).max(1) + bar } // -- Tick ---------------------------------------------------------------- pub fn tick(&mut self) -> bool { self.tick += 1; self.entries.iter().any(|e| e.is_running()) } pub fn tick_count(&self) -> u64 { self.tick } pub fn needs_tick(&self) -> bool { self.entries.iter().any(|e| e.is_running()) } // -- Input handling ------------------------------------------------------ pub fn handle_key(&mut self, key: &KeyEvent) -> bool { if crate::key!('h').matches(key) && self.list_state.input_mode().is_none() { self.show_done = !self.show_done; return true; } if self.entries.is_empty() { return false; } self.list_state.handle_key_event(key, &self.entries) } pub fn handle_paste(&mut self, text: &str) -> bool { self.list_state.handle_paste(text, &self.entries) } pub fn handle_scroll(&mut self, lines: i32, col: u16, row: u16) { let max = match self.list_state.viewport_height() { 0..=5 => 1, 6..=10 => 2, _ => lines.unsigned_abs() as i32, }; let capped = lines.signum() * lines.abs().min(max); self.list_state .handle_scroll_event(capped, col, row, &self.entries); } pub fn handle_mouse(&mut self, kind: MouseEventKind, col: u16, row: u16, area: Rect) -> bool { if self.entries.is_empty() { return false; } self.list_state .handle_mouse_event(kind, col, row, area, &self.entries) } /// Get the selected entry (if any). pub fn selected_entry(&self) -> Option<&TaskEntry> { let sel = self.list_state.selected_index()?; self.entries.get(sel) } /// Get the task_id if the selected entry is a BgTask. pub fn selected_task_id(&self) -> Option<&str> { match self.selected_entry()? { TaskEntry::BgTask { task_id, .. } => Some(task_id), _ => None, } } /// Get the subagent_id if the selected entry is an Agent. pub fn selected_subagent_id(&self) -> Option<&str> { match self.selected_entry()? { TaskEntry::Agent { subagent_id, .. } => Some(subagent_id), _ => None, } } /// Get the child_session_id if the selected entry is an Agent. pub fn selected_child_session_id(&self) -> Option<&str> { match self.selected_entry()? { TaskEntry::Agent { child_session_id, .. } => Some(child_session_id), _ => None, } } // -- Rendering ----------------------------------------------------------- fn content_area(area: Rect, layout_cfg: &LayoutConfig) -> Rect { let pad_left = HorizontalLayout::ACCENT + layout_cfg.block_pad_left; let pad_right = layout_cfg.block_pad_right; Rect { x: area.x + pad_left, y: area.y, width: area.width.saturating_sub(pad_left + pad_right), height: area.height, } } #[allow(clippy::too_many_arguments)] pub fn render( &mut self, area: Rect, buf: &mut Buffer, focused: bool, layout_cfg: &LayoutConfig, bg_tasks: &std::collections::BTreeMap, subagents: &HashMap, scheduled: &HashMap, ) { let inner = Self::content_area(area, layout_cfg); if self.entries.is_empty() { if inner.height > 0 && inner.width > 0 { let theme = Theme::current(); if self.show_done { let span = Span::styled( "No tasks or agents.", Style::default().fg(theme.gray_bright), ); buf.set_span(inner.x, inner.y, &span, inner.width); } else { let muted = Style::default().fg(theme.gray_bright); let key_style = Style::default() .fg(theme.text_primary) .add_modifier(Modifier::BOLD); let line = Line::from(vec![ Span::styled("No running tasks. Press ", muted), Span::styled("h", key_style), Span::styled(" to show all.", muted), ]); buf.set_line(inner.x, inner.y, &line, inner.width); } } return; } // Decide the reserved indicator rows from the CURRENT scroll offset // (NoWrap ⇒ one row per entry, so the total is the entry count), then // prepare the layout exactly once with the final viewport. Preparing // first with the full `inner` height would clamp the offset to that // larger viewport's max, leaving the offset short of the smaller // viewport's bottom — so ▼ could never turn off at the end. let total = self.entries.len(); let scrollable = total > inner.height as usize && inner.height >= 3; let scroll = self.list_state.scroll_offset(); // Reserve a row for a centered ▲ / ▼ indicator ONLY when that indicator // is actually shown — no blank reserved rows. The top row appears when // scrolled down; the bottom row appears when, after the top // reservation, content still extends past the viewport. let reserve_top = scrollable && scroll > 0; let top = u16::from(reserve_top); let rows_without_bottom = inner.height.saturating_sub(top) as usize; let reserve_bottom = scrollable && scroll + rows_without_bottom < total; let bottom = u16::from(reserve_bottom); let list_area = Rect { x: inner.x, y: inner.y + top, width: inner.width, height: inner.height - top - bottom, }; self.list_state .prepare_layout(&self.entries, list_area.width, list_area.height); // ListPane draws its scrollbar in the last column of the area it's // given. The overlay (right-aligned kill/view buttons) paints over // that same column, covering the scrollbar. Hand the list an area // extended one column into the right padding so the scrollbar lands // just past the overlay's right edge. // // Only widen when the list will *actually* draw a scrollbar there // (content overflows the viewport). When it won't, ListPane gives the // full area to content — so the extra column would be filled with // label text that bleeds one cell past the overlay's `[✗]` button // (the overlay only clears within `list_area`). Keeping `lp_area == // list_area` in that case lets the overlay truncate the label cleanly // before the button, with nothing rendered to its right. let needs_scrollbar = total > list_area.height as usize; let lp_area = if needs_scrollbar && list_area.right() < area.right() { Rect { width: list_area.width + 1, ..list_area } } else { list_area }; ListPane::new(&self.entries) .focused(focused) .style(self.list_style) .render(lp_area, buf, &mut self.list_state); // The right-corner indicators (▲/▼) are suppressed for this pane; // instead we draw the same glyphs, in the same color, centered on the // reserved row(s) so they're easier to see. let arrow_color = self.list_style.indicator_fg; if reserve_top { draw_centered_arrow(buf, inner, inner.y, "\u{25B2}", arrow_color); } if reserve_bottom { draw_centered_arrow( buf, inner, inner.y + inner.height - 1, "\u{25BC}", arrow_color, ); } // Overlay pass — positioned over the list area so the kill/view button // rows line up with the rows the list actually rendered. When the // search/filter input bar is open (or an accepted matcher's status is // shown), `ListPane` reserves the bottom row(s) of `list_area` for it, // so shrink the overlay area to match — otherwise the spinner icons // and kill/view buttons paint over the input bar (e.g. the `⸬` spinner // corrupting `search:` into `⸬earch:`). let bar_height = self.list_state.bottom_bar_height(list_area.height); let overlay_area = Rect { height: list_area.height.saturating_sub(bar_height), ..list_area }; self.render_overlay(overlay_area, buf, bg_tasks, subagents, scheduled); } fn render_overlay( &mut self, area: Rect, buf: &mut Buffer, bg_tasks: &std::collections::BTreeMap, subagents: &HashMap, _scheduled: &HashMap, ) { let theme = Theme::current(); let scroll_offset = self.list_state.scroll_offset(); self.kill_button_rects.clear(); self.view_button_rects.clear(); // Collect entry data first to avoid borrowing self.entries during mutation. let visible: Vec<(u16, OverlayEntryData)> = self .entries .iter() .skip(scroll_offset) .enumerate() .take_while(|(vis_row, _)| area.y + (*vis_row as u16) < area.y + area.height) .filter_map(|(vis_row, entry)| { let y = area.y + vis_row as u16; let data = match entry { TaskEntry::BgTask { task_id, .. } => OverlayEntryData::BgTask(task_id.clone()), TaskEntry::Agent { subagent_id, child_session_id, .. } => OverlayEntryData::Agent(subagent_id.clone(), child_session_id.clone()), TaskEntry::Scheduled { task_id, linked_subagent, .. } => OverlayEntryData::Scheduled(task_id.clone(), linked_subagent.clone()), // Group headers have no kill/view buttons; they still // occupy a row (vis_row is enumerated before this filter), // so the y offsets for following items stay correct. TaskEntry::Header { .. } => return None, }; Some((y, data)) }) .collect(); for (y, data) in visible { match data { OverlayEntryData::BgTask(ref task_id) => { let Some(task) = bg_tasks.get(task_id) else { continue; }; self.render_bg_task_overlay(area, buf, y, task_id, task, &theme); } OverlayEntryData::Agent(ref subagent_id, ref child_session_id) => { let Some(info) = subagents.get(child_session_id) else { continue; }; self.render_agent_overlay(area, buf, y, subagent_id, info, &theme); } OverlayEntryData::Scheduled(ref task_id, ref linked_subagent) => { self.render_scheduled_overlay( area, buf, y, task_id, linked_subagent.as_deref(), &theme, ); } } } } fn render_bg_task_overlay( &mut self, area: Rect, buf: &mut Buffer, y: u16, task_id: &str, task: &BgTaskState, theme: &Theme, ) { let (icon, icon_style, right_text, right_style) = if task.pending_kill { let frames = crate::glyphs::dot_spinner_frames(); let frame_idx = (self.tick / SPINNER_DIVISOR) as usize % frames.len(); ( frames[frame_idx], Style::default().fg(theme.accent_error), "killing\u{2026} ".to_string(), Style::default().fg(theme.accent_error), ) } else { match task.status { BgTaskStatus::Running => { let frames = crate::glyphs::dot_spinner_frames(); let frame_idx = (self.tick / SPINNER_DIVISOR) as usize % frames.len(); let elapsed = format_duration(task.elapsed()); ( frames[frame_idx], Style::default().fg(theme.accent_running), format!("{elapsed} "), Style::default().fg(theme.gray), ) } BgTaskStatus::Done => { let elapsed = format_duration(task.elapsed()); ( crate::glyphs::check_mark(), Style::default().fg(theme.accent_success), format!("{elapsed} "), Style::default().fg(theme.gray), ) } BgTaskStatus::Failed => { let elapsed = format_duration(task.elapsed()); ( crate::glyphs::ballot_x(), Style::default().fg(theme.accent_error), format!("{elapsed} "), Style::default().fg(theme.gray), ) } } }; buf.set_span(area.x, y, &Span::styled(icon, icon_style), 2); // Stdout line count, shown just to the left of the duration as // a compact `(N)` badge with SI scaling (`(1.2k)`, `(5.4M)`) so // huge outputs don't push the label off-screen. Hidden when // there's no captured output. Styled dim like the time text. // Reads the cached `stdout_line_count` (maintained by // `BgTaskState::set_stdout` / `append_stdout`) so the overlay // doesn't memchr-scan the full buffer per render frame. let badge = format_line_count_badge(task.stdout_line_count, task.truncated); let lines_text = if badge.is_empty() { String::new() } else { format!("{badge} ") }; let lines_w = lines_text.width() as u16; // Clear overlay area to prevent label text bleeding through. let right_text_w = right_text.width() as u16; let bg_kill_w: u16 = if task.status == BgTaskStatus::Running { 3 } else { 0 }; let bg_overlay_w = bg_kill_w + 3 + right_text_w + lines_w + 1; clear_overlay_area(buf, area, y, bg_overlay_w); let mut rx = area.x + area.width; // Kill button (visible even during pending_kill so the user can retry) if task.status == BgTaskStatus::Running { rx = rx.saturating_sub(3); let is_hovered = matches!( &self.hovered_kill, Some(TaskEntryId::BgTask(tid)) if tid == task_id ); let kill_style = if is_hovered { Style::default().fg(theme.accent_error) } else { Style::default().fg(theme.gray) }; buf.set_span( rx, y, &Span::styled(crate::glyphs::ballot_x_button(), kill_style), 3, ); self.kill_button_rects.push(( TaskEntryId::BgTask(task_id.to_string()), Rect::new(rx, y, 3, 1), )); } // View button rx = rx.saturating_sub(3); let is_view_hovered = matches!( &self.hovered_view, Some(TaskEntryId::BgTask(tid)) if tid == task_id ); let view_style = if is_view_hovered { Style::default().fg(theme.text_primary) } else { Style::default().fg(theme.gray) }; buf.set_span( rx, y, &Span::styled(crate::glyphs::enlarge_button(), view_style), 3, ); self.view_button_rects.push(( TaskEntryId::BgTask(task_id.to_string()), Rect::new(rx, y, 3, 1), )); // Time/status text let right_width = right_text.width() as u16; rx = rx.saturating_sub(right_width); buf.set_span(rx, y, &Span::styled(right_text, right_style), right_width); // Line count (just to the left of the duration). if lines_w > 0 { rx = rx.saturating_sub(lines_w); buf.set_span( rx, y, &Span::styled(lines_text, Style::default().fg(theme.gray)), lines_w, ); } if rx > area.x { buf.set_span(rx - 1, y, &Span::raw(" "), 1); } } fn render_agent_overlay( &mut self, area: Rect, buf: &mut Buffer, y: u16, subagent_id: &str, info: &SubagentInfo, theme: &Theme, ) { let (icon, icon_style, right_text, right_style) = if info.pending_kill { let frames = crate::glyphs::dot_spinner_frames(); let frame_idx = (self.tick / SPINNER_DIVISOR) as usize % frames.len(); ( frames[frame_idx], Style::default().fg(theme.accent_error), "killing\u{2026} ".to_string(), Style::default().fg(theme.accent_error), ) } else if info.is_running() { let frames = crate::glyphs::dot_spinner_frames(); let frame_idx = (self.tick / SPINNER_DIVISOR) as usize % frames.len(); let elapsed = format_duration(info.display_elapsed()); ( frames[frame_idx], Style::default().fg(theme.accent_running), format!("{elapsed} "), Style::default().fg(theme.gray), ) } else if info.status.as_deref() == Some("completed") { let elapsed = format_duration(info.display_elapsed()); ( crate::glyphs::check_mark(), Style::default().fg(theme.accent_success), format!("{elapsed} "), Style::default().fg(theme.gray), ) } else { let elapsed = format_duration(info.display_elapsed()); ( crate::glyphs::ballot_x(), Style::default().fg(theme.accent_error), format!("{elapsed} "), Style::default().fg(theme.gray), ) }; buf.set_span(area.x, y, &Span::styled(icon, icon_style), 2); // Clear overlay area to prevent label text bleeding through. let badge = format_context_badge(info); let model_text = info .model .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) .unwrap_or(""); let right_text_w = right_text.width() as u16; let kill_w: u16 = if info.is_running() { 3 } else { 0 }; let badge_w: u16 = if badge.is_empty() { 0 } else { badge.width() as u16 + 1 }; let model_w: u16 = if model_text.is_empty() { 0 } else { model_text.width() as u16 + 1 }; let overlay_w = kill_w + 3 + right_text_w + model_w + badge_w + 1; clear_overlay_area(buf, area, y, overlay_w); let mut rx = area.x + area.width; // Kill button (visible even during pending_kill so the user can retry) if info.is_running() { rx = rx.saturating_sub(3); let is_hovered = matches!( &self.hovered_kill, Some(TaskEntryId::Agent(sid)) if sid == subagent_id ); let kill_style = if is_hovered { Style::default().fg(theme.accent_error) } else { Style::default().fg(theme.gray) }; buf.set_span( rx, y, &Span::styled(crate::glyphs::ballot_x_button(), kill_style), 3, ); self.kill_button_rects.push(( TaskEntryId::Agent(subagent_id.to_string()), Rect::new(rx, y, 3, 1), )); } // View button rx = rx.saturating_sub(3); let is_view_hovered = matches!( &self.hovered_view, Some(TaskEntryId::Agent(sid)) if sid == subagent_id ); let view_style = if is_view_hovered { Style::default().fg(theme.text_primary) } else { Style::default().fg(theme.gray) }; buf.set_span( rx, y, &Span::styled(crate::glyphs::enlarge_button(), view_style), 3, ); self.view_button_rects.push(( TaskEntryId::Agent(subagent_id.to_string()), Rect::new(rx, y, 3, 1), )); // Time/status text let right_width = right_text.width() as u16; rx = rx.saturating_sub(right_width); buf.set_span(rx, y, &Span::styled(right_text, right_style), right_width); // Model (right-aligned, just to the left of elapsed). Pre-computed // above for overlay clearing. if !model_text.is_empty() { rx = rx.saturating_sub(model_w); let mstyle = Style::default().fg(theme.gray); buf.set_span( rx, y, &Span::styled(format!("{model_text} "), mstyle), model_w, ); } // Context badge (pre-computed above for overlay clearing) if !badge.is_empty() { rx = rx.saturating_sub(badge_w); let bstyle = Style::default().fg(theme.gray).add_modifier(Modifier::DIM); buf.set_span(rx, y, &Span::styled(format!("{badge} "), bstyle), badge_w); } if rx > area.x { buf.set_span(rx - 1, y, &Span::raw(" "), 1); } } fn render_scheduled_overlay( &mut self, area: Rect, buf: &mut Buffer, y: u16, task_id: &str, linked_subagent: Option<&str>, theme: &Theme, ) { let frames = crate::glyphs::dot_spinner_frames(); let frame_idx = (self.tick / SPINNER_DIVISOR) as usize % frames.len(); buf.set_span( area.x, y, &Span::styled(frames[frame_idx], Style::default().fg(theme.accent_running)), 2, ); let overlay_cols = if linked_subagent.is_some() { 7 } else { 4 }; clear_overlay_area(buf, area, y, overlay_cols); let mut rx = area.x + area.width; // Kill button [✗] rx = rx.saturating_sub(3); let is_hovered = matches!( &self.hovered_kill, Some(TaskEntryId::Scheduled(tid)) if tid == task_id ); let kill_style = if is_hovered { Style::default().fg(theme.accent_error) } else { Style::default().fg(theme.gray) }; buf.set_span( rx, y, &Span::styled(crate::glyphs::ballot_x_button(), kill_style), 3, ); self.kill_button_rects.push(( TaskEntryId::Scheduled(task_id.to_string()), Rect::new(rx, y, 3, 1), )); if linked_subagent.is_some() { rx = rx.saturating_sub(3); let is_view_hovered = matches!( &self.hovered_view, Some(TaskEntryId::Scheduled(tid)) if tid == task_id ); let view_style = if is_view_hovered { Style::default().fg(theme.text_primary) } else { Style::default().fg(theme.gray) }; buf.set_span( rx, y, &Span::styled(crate::glyphs::enlarge_button(), view_style), 3, ); self.view_button_rects.push(( TaskEntryId::Scheduled(task_id.to_string()), Rect::new(rx, y, 3, 1), )); } if rx > area.x { buf.set_span(rx - 1, y, &Span::raw(" "), 1); } } } #[cfg(test)] mod tests { use super::*; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; use std::time::Instant; fn make_info() -> SubagentInfo { SubagentInfo { subagent_id: Arc::from("sa-1"), child_session_id: Arc::from("cs-1"), description: Arc::from("Find API endpoints"), subagent_type: Arc::from("explore"), persona: None, role: None, model: None, context_source: None, resumed_from: None, capability_mode: None, context_normalized: false, parent_prompt_id: None, started_at: Instant::now(), last_progress_at: Instant::now(), finished: false, status: None, error: None, duration_ms: None, tool_calls: None, turns: None, turn_count: None, tool_call_count: None, tokens_used: None, context_window_tokens: None, context_usage_pct: None, tools_used: Vec::new(), error_count: None, activity_label: None, is_background: false, pending_kill: false, kill_requested_at: None, scrollback_entry_id: None, prompt: None, child_cwd: None, worktree_path: None, child_updates_replayed: false, } } fn make_bg_task(task_id: &str, command: &str, status: BgTaskStatus) -> BgTaskState { BgTaskState { task_id: task_id.into(), tool_call_id: String::new(), command: command.into(), description: None, cwd: String::new(), output_file: String::new(), status, start_time: SystemTime::now(), end_time: None, exit_code: None, signal: None, stdout: String::new(), stdout_line_count: 0, truncated: false, pending_kill: false, kill_requested_at: None, scrollback_entry_id: None, is_monitor: false, restored_from_replay: false, } } #[test] fn line_badge_empty_for_zero() { assert_eq!(format_line_count_badge(0, false), ""); // `truncated` doesn't conjure a badge out of an empty buffer. assert_eq!(format_line_count_badge(0, true), ""); } #[test] fn line_badge_raw_under_thousand() { assert_eq!(format_line_count_badge(1, false), "(1)"); assert_eq!(format_line_count_badge(42, false), "(42)"); assert_eq!(format_line_count_badge(999, false), "(999)"); } #[test] fn line_badge_decimal_thousands() { assert_eq!(format_line_count_badge(1_000, false), "(1.0k)"); assert_eq!(format_line_count_badge(1_234, false), "(1.2k)"); // Truncation, not rounding: 1999 stays at "1.9k". assert_eq!(format_line_count_badge(1_999, false), "(1.9k)"); assert_eq!(format_line_count_badge(9_999, false), "(9.9k)"); } #[test] fn line_badge_whole_thousands() { assert_eq!(format_line_count_badge(10_000, false), "(10k)"); assert_eq!(format_line_count_badge(99_999, false), "(99k)"); assert_eq!(format_line_count_badge(123_456, false), "(123k)"); assert_eq!(format_line_count_badge(999_999, false), "(999k)"); } #[test] fn line_badge_decimal_millions() { assert_eq!(format_line_count_badge(1_000_000, false), "(1.0M)"); assert_eq!(format_line_count_badge(1_234_567, false), "(1.2M)"); assert_eq!(format_line_count_badge(9_999_999, false), "(9.9M)"); } #[test] fn line_badge_whole_millions() { assert_eq!(format_line_count_badge(10_000_000, false), "(10M)"); assert_eq!(format_line_count_badge(123_456_789, false), "(123M)"); assert_eq!(format_line_count_badge(999_999_999, false), "(999M)"); } #[test] fn line_badge_truncated_appends_plus_suffix() { assert_eq!(format_line_count_badge(42, true), "(42+)"); assert_eq!(format_line_count_badge(1_234, true), "(1.2k+)"); assert_eq!(format_line_count_badge(123_456, true), "(123k+)"); assert_eq!(format_line_count_badge(1_234_567, true), "(1.2M+)"); assert_eq!(format_line_count_badge(123_456_789, true), "(123M+)"); } #[test] fn bg_task_label_single_line() { let task = make_bg_task("t1", "cargo test --release", BgTaskStatus::Running); let mut cache = HashMap::new(); let entry = TaskEntry::from_bg_task(&task, &mut cache); let label = match &entry { TaskEntry::BgTask { label, .. } => label.as_str(), _ => panic!("expected BgTask variant"), }; assert_eq!(label, "cargo test --release"); } #[test] fn bg_task_label_multiline_truncated() { let task = make_bg_task("t2", "echo hello\necho world", BgTaskStatus::Done); let mut cache = HashMap::new(); let entry = TaskEntry::from_bg_task(&task, &mut cache); let label = match &entry { TaskEntry::BgTask { label, .. } => label.as_str(), _ => panic!("expected BgTask variant"), }; assert!( label.ends_with('\u{2026}'), "multiline command should be truncated with ellipsis: {label}", ); assert!( !label.contains('\n'), "label should be single line: {label}", ); } #[test] fn bg_task_label_prefers_description_over_command() { let mut task = make_bg_task("t3", "cargo test --release", BgTaskStatus::Running); task.description = Some("Run release tests".into()); let mut cache = HashMap::new(); let entry = TaskEntry::from_bg_task(&task, &mut cache); let label = match &entry { TaskEntry::BgTask { label, .. } => label.as_str(), _ => panic!("expected BgTask variant"), }; // `Task ` prefix is included in the searchable label. assert_eq!(label, "Task Run release tests"); } #[test] fn bg_task_styled_prefix_uses_secondary_color() { let mut task = make_bg_task("t3a", "cargo test --release", BgTaskStatus::Running); task.description = Some("Run release tests".into()); let mut cache = HashMap::new(); let entry = TaskEntry::from_bg_task(&task, &mut cache); let styled = match &entry { TaskEntry::BgTask { styled, .. } => styled, _ => panic!("expected BgTask variant"), }; let theme = Theme::current(); assert_eq!(styled.spans.len(), 2); assert_eq!(styled.spans[0].content.as_ref(), "Task "); assert_eq!(styled.spans[0].style.fg, Some(theme.text_secondary)); assert_eq!(styled.spans[1].content.as_ref(), "Run release tests"); assert_eq!(styled.spans[1].style.fg, Some(theme.text_primary)); } #[test] fn monitor_task_styled_with_monitor_tag() { // Monitors render a blue "Monitor" tag + neutral description, // mirroring scheduled /loop rows — NOT the bash-highlighted command. let mut task = make_bg_task("mon1", "python -u counter.py", BgTaskStatus::Running); task.is_monitor = true; task.description = Some("incrementing event counter every 3s".into()); let mut cache = HashMap::new(); let entry = TaskEntry::from_bg_task(&task, &mut cache); let (label, styled) = match &entry { TaskEntry::BgTask { label, styled, .. } => (label.as_str(), styled), _ => panic!("expected BgTask variant"), }; let theme = Theme::current(); assert_eq!(label, "Monitor incrementing event counter every 3s"); assert_eq!(styled.spans.len(), 2); assert_eq!(styled.spans[0].content.as_ref(), "Monitor "); assert_eq!(styled.spans[0].style.fg, Some(theme.accent_system)); assert_eq!( styled.spans[1].content.as_ref(), "incrementing event counter every 3s" ); assert_eq!(styled.spans[1].style.fg, Some(theme.text_secondary)); } #[test] fn bg_task_no_prefix_when_no_description() { // Bare-command branch (no description) stays prefix-free — the // bash-highlighted command stands alone. let task = make_bg_task("t3b", "ls -la", BgTaskStatus::Running); let mut cache = HashMap::new(); let entry = TaskEntry::from_bg_task(&task, &mut cache); let (label, styled) = match &entry { TaskEntry::BgTask { label, styled, .. } => (label.as_str(), styled), _ => panic!("expected BgTask variant"), }; assert_eq!(label, "ls -la"); let joined: String = styled.spans.iter().map(|s| s.content.as_ref()).collect(); assert!( !joined.starts_with("Task "), "no description ⇒ no prefix, got: {joined:?}" ); } #[test] fn bg_task_label_falls_back_to_command_for_blank_description() { let mut task = make_bg_task("t4", "ls -la", BgTaskStatus::Running); task.description = Some(" ".into()); let mut cache = HashMap::new(); let entry = TaskEntry::from_bg_task(&task, &mut cache); let label = match &entry { TaskEntry::BgTask { label, .. } => label.as_str(), _ => panic!("expected BgTask variant"), }; assert_eq!(label, "ls -la"); } #[test] fn bg_task_label_collapses_description_newlines() { let mut task = make_bg_task("t5", "ls", BgTaskStatus::Running); task.description = Some("First line\nSecond line".into()); let mut cache = HashMap::new(); let entry = TaskEntry::from_bg_task(&task, &mut cache); let label = match &entry { TaskEntry::BgTask { label, .. } => label.as_str(), _ => panic!("expected BgTask variant"), }; assert_eq!(label, "Task First line Second line"); assert!(!label.contains('\n')); } #[test] fn bg_task_stable_id_deterministic() { let task = make_bg_task("t1", "ls", BgTaskStatus::Running); let mut cache = HashMap::new(); let e1 = TaskEntry::from_bg_task(&task, &mut cache); let e2 = TaskEntry::from_bg_task(&task, &mut cache); assert_eq!(e1.stable_id(), e2.stable_id()); } #[test] fn bg_task_and_agent_ids_differ() { let task = make_bg_task("shared-id", "ls", BgTaskStatus::Running); let mut cache = HashMap::new(); let bg = TaskEntry::from_bg_task(&task, &mut cache); let mut info = make_info(); info.child_session_id = "shared-id".into(); let agent = TaskEntry::from_subagent(&info); assert_ne!( bg.stable_id(), agent.stable_id(), "bg task and agent with same string id should have different stable_ids", ); } /// Render `pane` to a fresh buffer of the given size and return the /// concatenated character content for every row. Lets tests do a /// `joined.contains("(N)")`-style assertion without depending on cell /// styling details. fn render_pane_to_strings( pane: &mut TasksPane, bg_tasks: &std::collections::BTreeMap, width: u16, height: u16, ) -> Vec { let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); let layout = crate::appearance::LayoutConfig::default(); pane.render( area, &mut buf, false, &layout, bg_tasks, &HashMap::new(), &HashMap::new(), ); (0..height) .map(|y| { (0..width) .filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string())) .collect::() }) .collect() } #[test] fn render_shows_line_count_badge_for_bg_task_with_stdout() { let mut pane = TasksPane::new(); pane.overlay.show(); let mut task = make_bg_task("t1", "ls", BgTaskStatus::Running); // 42 newline-terminated rows ⇒ `(42)`. Use `set_stdout` so the // cached `stdout_line_count` is populated. task.set_stdout((0..42).map(|i| format!("line {i}\n")).collect::()); assert_eq!(task.stdout.lines().count(), 42); assert_eq!(task.stdout_line_count, 42); let mut bg_tasks = std::collections::BTreeMap::new(); bg_tasks.insert("t1".into(), task); pane.sync( &bg_tasks, &HashMap::new(), &HashMap::new(), None, &HashSet::new(), ); // 12+ rows so `desired_height` is non-zero; wide enough that the // overlay isn't clipped. let lines = render_pane_to_strings(&mut pane, &bg_tasks, 80, 16); let joined = lines.join("\n"); assert!( joined.contains("(42)"), "expected `(42)` badge in rendered buffer, got:\n{joined}", ); } /// Resume regression: bg tasks restored from a `session/load` /// replay are historical context — they must not auto-open the overlay /// (on cold resumes they die again within the same load; the open/close /// flash looked like tasks "loading then failing"). A genuinely new live /// task must still trigger the auto-show edge. #[test] fn restored_running_tasks_do_not_auto_show() { let mut pane = TasksPane::new(); let mut restored = make_bg_task("t-restored", "tail -f deploy.log", BgTaskStatus::Running); restored.restored_from_replay = true; let mut bg_tasks = std::collections::BTreeMap::new(); bg_tasks.insert("t-restored".to_string(), restored); pane.sync( &bg_tasks, &HashMap::new(), &HashMap::new(), None, &HashSet::new(), ); assert!( !pane.is_visible(), "replay-restored running tasks must not auto-open the tasks pane" ); // A live (non-restored) task still triggers the 0→N auto-show edge. bg_tasks.insert( "t-live".to_string(), make_bg_task("t-live", "cargo build", BgTaskStatus::Running), ); pane.sync( &bg_tasks, &HashMap::new(), &HashMap::new(), None, &HashSet::new(), ); assert!( pane.is_visible(), "a new live running task must still auto-open the tasks pane" ); } #[test] fn render_shows_plus_suffix_when_truncated() { let mut pane = TasksPane::new(); pane.overlay.show(); let mut task = make_bg_task("t1", "ls", BgTaskStatus::Running); task.set_stdout((0..42).map(|i| format!("line {i}\n")).collect::()); task.truncated = true; let mut bg_tasks = std::collections::BTreeMap::new(); bg_tasks.insert("t1".into(), task); pane.sync( &bg_tasks, &HashMap::new(), &HashMap::new(), None, &HashSet::new(), ); let lines = render_pane_to_strings(&mut pane, &bg_tasks, 80, 16); let joined = lines.join("\n"); assert!( joined.contains("(42+)"), "expected `(42+)` badge in rendered buffer, got:\n{joined}", ); } #[test] fn render_hides_badge_when_stdout_empty() { let mut pane = TasksPane::new(); pane.overlay.show(); let task = make_bg_task("t1", "ls", BgTaskStatus::Running); assert!(task.stdout.is_empty()); let mut bg_tasks = std::collections::BTreeMap::new(); bg_tasks.insert("t1".into(), task); pane.sync( &bg_tasks, &HashMap::new(), &HashMap::new(), None, &HashSet::new(), ); let lines = render_pane_to_strings(&mut pane, &bg_tasks, 80, 16); let joined = lines.join("\n"); assert!( !joined.contains("()"), "should not render empty `()` badge, got:\n{joined}", ); } #[test] fn search_bar_not_overwritten_by_task_overlay() { // Regression: while subagents/tasks are running, opening the search // bar (`/`) used to render broken UI — the overlay pass (spinner + // kill/view buttons) painted over the bottom input-bar row that // `ListPane` reserves, corrupting `search:` into `⸬earch:` (the `⸬` // spinner glyph clobbering the leading `s`). The overlay must stop one // row short of the input bar. let mut pane = TasksPane::new(); pane.overlay.show(); // Three running tasks ⇒ entries = [Tasks header, t0, t1, t2]. let mut bg_tasks = std::collections::BTreeMap::new(); for i in 0..3 { bg_tasks.insert( format!("t{i}"), make_bg_task( &format!("t{i}"), &format!("sleep {i}"), BgTaskStatus::Running, ), ); } pane.sync( &bg_tasks, &HashMap::new(), &HashMap::new(), None, &HashSet::new(), ); // Press `/` to open the search bar. assert!(pane.handle_key(&crate::key!('/').to_key_event())); assert!( pane.list_state.input_mode().is_some(), "`/` should open the search input bar", ); // Height 4: the header + 3 task rows exactly fill the area, so the // search bar steals the bottom row. Without the fix, the third task's // overlay lands on that same row and clobbers the `search:` prompt. let lines = render_pane_to_strings(&mut pane, &bg_tasks, 60, 4); let joined = lines.join("\n"); assert!( joined.contains("search:"), "search bar prompt must render intact (not clobbered by the task \ overlay spinner), got:\n{joined}", ); // The row carrying the prompt must start with `search:` (after the // pane's left padding) — not an overlay spinner/kill glyph. let bar_row = lines .iter() .find(|l| l.contains("search:")) .expect("a rendered row containing the search bar"); assert!( bar_row.trim_start().starts_with("search:"), "search bar row should begin with the prompt, not an overlay \ glyph: {bar_row:?}", ); } #[test] fn search_bar_adds_a_line_keeping_last_entry_visible() { // Opening the search bar should grow the pane by exactly one row so the // bar gets its own line — the last task/agent must stay visible rather // than being displaced by the bar. let mut pane = TasksPane::new(); pane.overlay.show(); let mut bg_tasks = std::collections::BTreeMap::new(); for i in 0..3 { bg_tasks.insert( format!("t{i}"), make_bg_task( &format!("t{i}"), &format!("sleep {i}"), BgTaskStatus::Running, ), ); } pane.sync( &bg_tasks, &HashMap::new(), &HashMap::new(), None, &HashSet::new(), ); // Tall enough that all entries fit without scrolling. let view_height = 40u16; let h_before = pane.desired_height(view_height); // Press `/` to open the search bar. assert!(pane.handle_key(&crate::key!('/').to_key_event())); let h_after = pane.desired_height(view_height); assert_eq!( h_after, h_before + 1, "opening search should add exactly one row for the bar", ); // Render at the grown height: all three tasks AND the search bar must // be visible together. let lines = render_pane_to_strings(&mut pane, &bg_tasks, 60, h_after); let joined = lines.join("\n"); assert!(joined.contains("sleep 0"), "first task visible:\n{joined}"); assert!(joined.contains("sleep 1"), "second task visible:\n{joined}"); assert!( joined.contains("sleep 2"), "last task must remain visible after opening search:\n{joined}", ); assert!(joined.contains("search:"), "search bar visible:\n{joined}"); } #[test] fn render_loop_row_truncates_before_kill_button() { // A non-scrollable loop row with a long prompt must truncate before // the `[✗]` kill button — nothing may render to its right. Regression: // the scrollbar-padding column used to be filled with label text when // the list wasn't scrollable, bleeding one cell past `[✗]`. let mut pane = TasksPane::new(); pane.overlay.show(); let mut scheduled = HashMap::new(); scheduled.insert( "l1".into(), make_scheduled_info( "l1", "every 1m", "do a very long thing that definitely overflows the row width several times", None, ), ); pane.sync( &BTreeMap::new(), &HashMap::new(), &scheduled, None, &HashSet::new(), ); // One header + one loop row in a tall pane ⇒ not scrollable. let area = Rect::new(0, 0, 40, 10); let mut buf = Buffer::empty(area); let layout = crate::appearance::LayoutConfig::default(); pane.render( area, &mut buf, false, &layout, &BTreeMap::new(), &HashMap::new(), &scheduled, ); // Locate the `✗` kill glyph; every cell past the closing `]` must be // blank (no scrollbar when not scrollable, no leaked label text). let mut found = false; for y in 0..area.height { let row: Vec = (0..area.width) .map(|x| { buf.cell((x, y)) .map(|c| c.symbol().to_string()) .unwrap_or_default() }) .collect(); if let Some(x) = row.iter().position(|s| s == "\u{2717}") { found = true; for cell in &row[x + 2..] { assert!( cell.trim().is_empty(), "non-blank cell after `[✗]` on loop row: {row:?}", ); } } } assert!(found, "expected a `✗` kill button on the loop row"); } #[test] fn render_shows_centered_down_arrow_when_overflowing() { let mut pane = TasksPane::new(); pane.overlay.show(); let mut bg_tasks = std::collections::BTreeMap::new(); for i in 0..20 { bg_tasks.insert( format!("t{i}"), make_bg_task(&format!("t{i}"), &format!("cmd {i}"), BgTaskStatus::Running), ); } pane.sync( &bg_tasks, &HashMap::new(), &HashMap::new(), None, &HashSet::new(), ); // A short panel forces the list to overflow; at the top of the list a // centered ▼ appears on the reserved bottom row. let lines = render_pane_to_strings(&mut pane, &bg_tasks, 40, 6); let joined = lines.join("\n"); assert!( joined.contains('\u{25BC}'), "expected centered ▼ when the list overflows, got:\n{joined}" ); } #[test] fn render_hides_down_arrow_at_bottom() { let mut pane = TasksPane::new(); pane.overlay.show(); let mut bg_tasks = std::collections::BTreeMap::new(); for i in 0..20 { bg_tasks.insert( format!("t{i}"), make_bg_task(&format!("t{i}"), &format!("cmd {i}"), BgTaskStatus::Running), ); } pane.sync( &bg_tasks, &HashMap::new(), &HashMap::new(), None, &HashSet::new(), ); // Establish the viewport, then scroll to the very bottom. let _ = render_pane_to_strings(&mut pane, &bg_tasks, 40, 6); pane.list_state.set_scroll_offset(1000); let lines = render_pane_to_strings(&mut pane, &bg_tasks, 40, 6); let joined = lines.join("\n"); // At the bottom: ▲ shows (content above), ▼ must NOT (nothing below). assert!( joined.contains('\u{25B2}'), "expected ▲ when scrolled down, got:\n{joined}" ); assert!( !joined.contains('\u{25BC}'), "▼ must hide at the bottom of the list, got:\n{joined}" ); } #[test] fn sync_sorts_running_before_done() { let mut pane = TasksPane::new(); pane.show_done = true; let mut bg_tasks = std::collections::BTreeMap::new(); bg_tasks.insert( "done".into(), make_bg_task("done", "echo done", BgTaskStatus::Done), ); bg_tasks.insert( "running".into(), make_bg_task("running", "sleep 99", BgTaskStatus::Running), ); pane.sync( &bg_tasks, &HashMap::new(), &HashMap::new(), None, &HashSet::new(), ); assert!(pane.items.len() >= 2); assert!(pane.items[0].is_running(), "first entry should be running",); assert!(!pane.items[1].is_running(), "second entry should be done",); } #[test] fn sync_groups_agents_before_bg_tasks() { let mut pane = TasksPane::new(); pane.show_done = true; let mut bg_tasks = std::collections::BTreeMap::new(); bg_tasks.insert("t1".into(), make_bg_task("t1", "ls", BgTaskStatus::Done)); let mut subagents = HashMap::new(); let mut info = make_info(); info.finished = true; info.status = Some("completed".into()); subagents.insert("cs-1".into(), info); pane.sync( &bg_tasks, &subagents, &HashMap::new(), None, &HashSet::new(), ); assert_eq!(pane.items.len(), 2); assert!( matches!(&pane.items[0], TaskEntry::Agent { .. }), "agents should sort before bg tasks", ); assert!( matches!(&pane.items[1], TaskEntry::BgTask { .. }), "bg tasks should sort after agents", ); } #[test] fn sync_groups_monitors_as_their_own_block() { // Monitors are BgTask entries but get their own contiguous group: // subagents → one-shot tasks → monitors → scheduled. let mut pane = TasksPane::new(); pane.show_done = true; let mut bg_tasks = std::collections::BTreeMap::new(); bg_tasks.insert("t1".into(), make_bg_task("t1", "ls", BgTaskStatus::Running)); let mut mon = make_bg_task("m1", "tail -f log", BgTaskStatus::Running); mon.is_monitor = true; bg_tasks.insert("m1".into(), mon); let mut subagents = HashMap::new(); subagents.insert("cs-1".into(), make_info()); // running subagent pane.sync( &bg_tasks, &subagents, &HashMap::new(), None, &HashSet::new(), ); assert_eq!(pane.items.len(), 3); assert!( matches!(&pane.items[0], TaskEntry::Agent { .. }), "subagent first", ); assert!( matches!( &pane.items[1], TaskEntry::BgTask { is_monitor: false, .. } ), "one-shot task second", ); assert!( matches!( &pane.items[2], TaskEntry::BgTask { is_monitor: true, .. } ), "monitor in its own group, after one-shot tasks", ); } #[test] fn monitors_and_loops_share_one_watchers_section() { // Monitor (BgTask) and loop (Scheduled) tasks render under a single // "Watchers" header, monitors sorted before loops. let mut pane = TasksPane::new(); pane.show_done = true; let mut bg_tasks = std::collections::BTreeMap::new(); let mut mon = make_bg_task("m1", "tail -f log", BgTaskStatus::Running); mon.is_monitor = true; bg_tasks.insert("m1".into(), mon); let mut scheduled = HashMap::new(); scheduled.insert( "l1".into(), make_scheduled_info("l1", "every 1m", "do x", None), ); pane.sync( &bg_tasks, &HashMap::new(), &scheduled, None, &HashSet::new(), ); // items: monitor first, then loop. assert_eq!(pane.items.len(), 2); assert!(matches!( &pane.items[0], TaskEntry::BgTask { is_monitor: true, .. } )); assert!(matches!(&pane.items[1], TaskEntry::Scheduled { .. })); // entries: ONE Watchers header (count 2), then monitor, then loop. assert_eq!(pane.entries.len(), 3); let header_text: String = match &pane.entries[0] { TaskEntry::Header { group: GroupKind::Watchers, styled, } => styled.spans.iter().map(|s| s.content.as_ref()).collect(), _ => panic!("expected Watchers header first"), }; assert!(header_text.contains("Watchers"), "got: {header_text}"); assert!(header_text.contains('2'), "combined count: {header_text}"); assert!(matches!( &pane.entries[1], TaskEntry::BgTask { is_monitor: true, .. } )); assert!(matches!(&pane.entries[2], TaskEntry::Scheduled { .. })); } #[test] fn sync_hides_done_by_default() { let mut pane = TasksPane::new(); let mut bg_tasks = std::collections::BTreeMap::new(); bg_tasks.insert( "done".into(), make_bg_task("done", "echo done", BgTaskStatus::Done), ); bg_tasks.insert( "running".into(), make_bg_task("running", "sleep 99", BgTaskStatus::Running), ); pane.sync( &bg_tasks, &HashMap::new(), &HashMap::new(), None, &HashSet::new(), ); assert_eq!(pane.items.len(), 1, "only running tasks shown by default"); assert!(pane.items[0].is_running()); } #[test] fn sync_inserts_group_headers_with_counts() { let mut pane = TasksPane::new(); let mut bg_tasks = std::collections::BTreeMap::new(); bg_tasks.insert("t1".into(), make_bg_task("t1", "ls", BgTaskStatus::Running)); let mut subagents = HashMap::new(); subagents.insert("cs-1".into(), make_info()); // running subagent pane.sync( &bg_tasks, &subagents, &HashMap::new(), None, &HashSet::new(), ); // Display list interleaves a header before each group's items: // [Header(Subagents), Agent, Header(Tasks), BgTask]. assert_eq!(pane.entries.len(), 4); assert!(matches!( &pane.entries[0], TaskEntry::Header { group: GroupKind::Subagents, .. } )); assert!(matches!(&pane.entries[1], TaskEntry::Agent { .. })); assert!(matches!( &pane.entries[2], TaskEntry::Header { group: GroupKind::Tasks, .. } )); assert!(matches!(&pane.entries[3], TaskEntry::BgTask { .. })); // The header text carries the label and the count. let header_text: String = match &pane.entries[0] { TaskEntry::Header { styled, .. } => { styled.spans.iter().map(|s| s.content.as_ref()).collect() } _ => unreachable!(), }; assert!(header_text.contains("Subagents"), "got: {header_text}"); assert!(header_text.contains('1'), "count in header: {header_text}"); } #[test] fn toggle_group_hides_and_shows_items() { let mut pane = TasksPane::new(); let mut subagents = HashMap::new(); subagents.insert("cs-1".into(), make_info()); pane.sync( &std::collections::BTreeMap::new(), &subagents, &HashMap::new(), None, &HashSet::new(), ); // Expanded: header + item. assert_eq!(pane.entries.len(), 2); assert!(matches!(&pane.entries[1], TaskEntry::Agent { .. })); // Collapse → only the header remains. pane.toggle_group(GroupKind::Subagents); assert_eq!(pane.entries.len(), 1); assert!(matches!( &pane.entries[0], TaskEntry::Header { group: GroupKind::Subagents, .. } )); // Expand → item returns. pane.toggle_group(GroupKind::Subagents); assert_eq!(pane.entries.len(), 2); assert!(matches!(&pane.entries[1], TaskEntry::Agent { .. })); } #[test] fn arrow_keys_expand_and_collapse_group() { // ← collapses, → expands (vs Enter / click which toggle). The arrow // handler calls `set_group_collapsed`; exercise it directly. let mut pane = TasksPane::new(); let mut subagents = HashMap::new(); subagents.insert("cs-1".into(), make_info()); pane.sync( &std::collections::BTreeMap::new(), &subagents, &HashMap::new(), None, &HashSet::new(), ); assert_eq!(pane.entries.len(), 2); // ← collapses the expanded group. assert!(pane.set_group_collapsed(GroupKind::Subagents, true)); assert_eq!(pane.entries.len(), 1); // ← again: already collapsed, no change. assert!(!pane.set_group_collapsed(GroupKind::Subagents, true)); assert_eq!(pane.entries.len(), 1); // → expands it again. assert!(pane.set_group_collapsed(GroupKind::Subagents, false)); assert_eq!(pane.entries.len(), 2); assert!(matches!(&pane.entries[1], TaskEntry::Agent { .. })); // → again: already expanded, no change. assert!(!pane.set_group_collapsed(GroupKind::Subagents, false)); assert_eq!(pane.entries.len(), 2); } #[test] fn emptied_group_forgets_collapse_state() { // Collapse a group, let it empty out, then repopulate it: the new // items must be visible (group auto-expands) rather than hidden under // a stale collapsed header. let mut pane = TasksPane::new(); let mut subagents = HashMap::new(); subagents.insert("cs-1".into(), make_info()); pane.sync( &std::collections::BTreeMap::new(), &subagents, &HashMap::new(), None, &HashSet::new(), ); pane.toggle_group(GroupKind::Subagents); assert!(pane.collapsed_groups.contains(&GroupKind::Subagents)); // Group empties (no subagents) → collapse state is forgotten. pane.sync( &std::collections::BTreeMap::new(), &HashMap::new(), &HashMap::new(), None, &HashSet::new(), ); assert!(!pane.collapsed_groups.contains(&GroupKind::Subagents)); // A new subagent arrives → shown expanded (header + item), not hidden. let mut next = make_info(); next.child_session_id = "cs-2".into(); next.subagent_id = "sa-2".into(); let mut subagents2 = HashMap::new(); subagents2.insert("cs-2".into(), next); pane.sync( &std::collections::BTreeMap::new(), &subagents2, &HashMap::new(), None, &HashSet::new(), ); assert_eq!(pane.entries.len(), 2); assert!(matches!(&pane.entries[1], TaskEntry::Agent { .. })); } #[test] fn subagents_ordered_by_agent_type() { let mut pane = TasksPane::new(); let mut subagents = HashMap::new(); // Two running subagents of different types; both running so the // running-first key ties and the type order decides. let mut plan = make_info(); plan.child_session_id = "cs-plan".into(); plan.subagent_type = "plan".into(); let mut explore = make_info(); explore.child_session_id = "cs-explore".into(); explore.subagent_type = "explore".into(); subagents.insert("cs-plan".into(), plan); subagents.insert("cs-explore".into(), explore); pane.sync( &std::collections::BTreeMap::new(), &subagents, &HashMap::new(), None, &HashSet::new(), ); // Ordered by agent type alphabetically: Explore before Plan. let types: Vec<&str> = pane .items .iter() .map(|e| match e { TaskEntry::Agent { type_label, .. } => type_label.as_str(), _ => panic!("expected Agent"), }) .collect(); assert_eq!(types, vec!["Explore", "Plan"]); } #[test] fn entry_label_includes_type_badge() { let info = make_info(); let entry = TaskEntry::from_subagent(&info); let label = match &entry { TaskEntry::Agent { label, .. } => label.as_str(), _ => panic!("expected Agent variant"), }; assert!( label.starts_with("Explore "), "label should start with capitalized type badge: {label}", ); } #[test] fn entry_label_includes_meta() { let mut info = make_info(); info.persona = Some("researcher".into()); info.model = Some("grok-3".into()); let entry = TaskEntry::from_subagent(&info); let label = match &entry { TaskEntry::Agent { label, .. } => label.as_str(), _ => panic!("expected Agent variant"), }; assert!( label.contains("Researcher"), "label should contain capitalized persona: {label}", ); assert!( label.contains("grok-3"), "label should contain model: {label}", ); } #[test] fn entry_label_no_meta_when_empty() { let info = make_info(); let entry = TaskEntry::from_subagent(&info); let label = match &entry { TaskEntry::Agent { label, .. } => label.as_str(), _ => panic!("expected Agent variant"), }; assert_eq!(label, "Explore Find API endpoints"); } #[test] fn subagent_activity_suffix_renders_while_running_only() { let mut info = make_info(); info.activity_label = Some("Running: cargo build".into()); let entry = TaskEntry::from_subagent(&info); let (label, styled) = match &entry { TaskEntry::Agent { label, styled, .. } => (label, styled), _ => panic!("expected Agent variant"), }; let suffix = styled.spans.last().unwrap(); assert_eq!(suffix.content.as_ref(), " \u{2014} Running: cargo build"); assert_eq!(suffix.style.fg, Some(Theme::current().gray)); assert!( !label.contains("cargo build"), "activity must stay out of the searchable label: {label}" ); // Finished rows drop the suffix even if a stale label lingers. info.finished = true; let entry = TaskEntry::from_subagent(&info); let styled = match &entry { TaskEntry::Agent { styled, .. } => styled, _ => panic!("expected Agent variant"), }; assert!( styled .spans .iter() .all(|s| !s.content.contains("cargo build")), "no activity suffix on finished rows: {styled:?}" ); } #[test] fn subagent_activity_suffix_caps_description() { let long_desc = "d".repeat(60); let mut info = make_info(); info.description = Arc::from(long_desc.as_str()); info.activity_label = Some("Thinking".into()); let entry = TaskEntry::from_subagent(&info); let (label, styled) = match &entry { TaskEntry::Agent { label, styled, .. } => (label, styled), _ => panic!("expected Agent variant"), }; let desc = styled.spans[1].content.as_ref(); assert!( desc.ends_with('\u{2026}') && desc.chars().count() <= 40, "description must be capped when an activity suffix renders: {desc}" ); assert!( label.contains(&long_desc), "the searchable label keeps the full description: {label}" ); // Without an activity suffix the description renders uncapped. info.activity_label = None; let entry = TaskEntry::from_subagent(&info); let styled = match &entry { TaskEntry::Agent { styled, .. } => styled, _ => panic!("expected Agent variant"), }; assert_eq!(styled.spans[1].content.as_ref(), long_desc); } fn make_scheduled_info( id: &str, schedule: &str, prompt: &str, next: Option<&str>, ) -> ScheduledTaskInfo { ScheduledTaskInfo { task_id: id.to_string(), prompt: prompt.to_string(), human_schedule: schedule.to_string(), created_at: std::time::Instant::now(), next_fire_at: next.map(|s| s.to_string()), tag: "loop".into(), last_subagent_id: None, } } #[test] fn scheduled_label_shows_next_in_countdown() { let mut pane = TasksPane::new(); let mut scheduled = HashMap::new(); // future next let next = (chrono::Utc::now() + chrono::Duration::seconds(125)).to_rfc3339(); scheduled.insert( "t1".into(), make_scheduled_info("t1", "every 1m", "do x", Some(&next)), ); pane.sync( &BTreeMap::new(), &HashMap::new(), &scheduled, None, &HashSet::new(), ); let label = match &pane.items[0] { TaskEntry::Scheduled { label, .. } => label, _ => panic!("expected Scheduled"), }; assert!( label.contains("next in 2m"), "expected countdown in label: {label}" ); } #[test] fn scheduled_label_shows_running_now_on_cron_match() { let mut pane = TasksPane::new(); let mut scheduled = HashMap::new(); scheduled.insert( "cron1".into(), make_scheduled_info("cron1", "every 5m", "loop", None), ); pane.sync( &std::collections::BTreeMap::new(), &HashMap::new(), &scheduled, Some("cron1"), &HashSet::new(), ); let label = match &pane.items[0] { TaskEntry::Scheduled { label, .. } => label, _ => panic!("expected Scheduled"), }; assert!( label.contains("(running)"), "expected (running) when cron matches: {label}" ); } #[test] fn scheduled_provisional_shows_starting() { let mut pane = TasksPane::new(); let mut scheduled = HashMap::new(); scheduled.insert( "provisional-abc".into(), make_scheduled_info("provisional-abc", "every 10s", "soon", None), ); pane.sync( &BTreeMap::new(), &HashMap::new(), &scheduled, None, &HashSet::new(), ); let label = match &pane.items[0] { TaskEntry::Scheduled { label, .. } => label, _ => panic!("expected Scheduled"), }; assert!( label.contains("(starting)"), "expected (starting) for provisional: {label}" ); } #[test] fn scheduled_queued_shows_queued() { let mut pane = TasksPane::new(); let mut scheduled = HashMap::new(); scheduled.insert( "q1".into(), make_scheduled_info("q1", "every 5m", "check", None), ); let mut queued = HashSet::new(); queued.insert("q1"); pane.sync(&BTreeMap::new(), &HashMap::new(), &scheduled, None, &queued); let label = match &pane.items[0] { TaskEntry::Scheduled { label, .. } => label, _ => panic!("expected Scheduled"), }; assert!( label.contains("(queued)"), "expected (queued) when task is in pending_prompts: {label}" ); } #[test] fn scheduled_past_shows_due_now() { let mut pane = TasksPane::new(); let mut scheduled = HashMap::new(); let past = (chrono::Utc::now() - chrono::Duration::seconds(10)).to_rfc3339(); scheduled.insert( "due".into(), make_scheduled_info("due", "every 1h", "past", Some(&past)), ); pane.sync( &BTreeMap::new(), &HashMap::new(), &scheduled, None, &HashSet::new(), ); let label = match &pane.items[0] { TaskEntry::Scheduled { label, .. } => label, _ => panic!("expected Scheduled"), }; assert!( label.contains("due now"), "expected due now for past: {label}" ); } #[test] fn scheduled_unicode_prompt_safe_no_panic() { let mut pane = TasksPane::new(); let mut scheduled = HashMap::new(); let unicode_prompt = "测试emoji🚀".repeat(20); // multi-byte >60 bytes scheduled.insert( "uni".into(), make_scheduled_info("uni", "every 1s", &unicode_prompt, None), ); pane.sync( &BTreeMap::new(), &HashMap::new(), &scheduled, None, &HashSet::new(), ); let entry = &pane.items[0]; let label = match entry { TaskEntry::Scheduled { label, .. } => label, _ => panic!("expected Scheduled"), }; assert!(label.contains("Loop"), "tag rendered in label"); assert!( label.contains("...") || label.len() > 10, "preview truncated or full" ); } #[test] fn scheduled_bad_next_fire_at_falls_back_to_approx() { let mut pane = TasksPane::new(); let mut scheduled = HashMap::new(); scheduled.insert( "bad".into(), make_scheduled_info("bad", "every 30s", "fallback", Some("not-a-date")), ); pane.sync( &BTreeMap::new(), &HashMap::new(), &scheduled, None, &HashSet::new(), ); let label = match &pane.items[0] { TaskEntry::Scheduled { label, .. } => label, _ => panic!("expected Scheduled"), }; assert!( label.contains("next in") || label.contains("due now"), "bad rfc should fallback to approx countdown: {label}" ); assert!( !label.contains("~soon"), "should not ~soon on rfc parse fail fallback: {label}" ); } #[test] fn scheduled_unknown_schedule_no_suffix() { let mut pane = TasksPane::new(); let mut scheduled = HashMap::new(); scheduled.insert( "unk".into(), make_scheduled_info("unk", "unknown schedule", "x", None), ); pane.sync( &BTreeMap::new(), &HashMap::new(), &scheduled, None, &HashSet::new(), ); let label = match &pane.items[0] { TaskEntry::Scheduled { label, .. } => label, _ => panic!("expected Scheduled"), }; assert!( !label.contains("(next") && !label.contains("(running") && !label.contains("(queued"), "unknown schedule should have no status suffix: {label}" ); } }