From 63cb06637b1840027948ec6de7863b2bfdc53eca Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 27 Apr 2026 22:15:26 -0500 Subject: [PATCH] feat(tui): #128 in-transcript DelegateCard + FanoutCard Cards consume the #130 mailbox stream and render live in the transcript: - DelegateCard: last-3-actions tree for active agent_spawn - FanoutCard: dot-grid + aggregate stats for agent_swarm / rlm fanout Sidebar demoted to a navigator (count + role); detail lives in the card. Engine wires SubAgentRuntime::with_mailbox so the primitive actually flows. Cards re-bind on session resume via runtime_threads agent_ids. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/tui/src/commands/session.rs | 9 + crates/tui/src/core/engine.rs | 54 +- crates/tui/src/core/events.rs | 8 + crates/tui/src/runtime_threads.rs | 169 +++++++ crates/tui/src/tui/app.rs | 16 + crates/tui/src/tui/history.rs | 24 + crates/tui/src/tui/sidebar.rs | 292 +++++++---- crates/tui/src/tui/transcript.rs | 4 +- crates/tui/src/tui/ui.rs | 128 ++++- crates/tui/src/tui/widgets/agent_card.rs | 608 +++++++++++++++++++++++ crates/tui/src/tui/widgets/mod.rs | 1 + 11 files changed, 1192 insertions(+), 121 deletions(-) create mode 100644 crates/tui/src/tui/widgets/agent_card.rs diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index 67744e2d..97e3b02f 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -157,6 +157,7 @@ pub fn export(app: &mut App, path: Option<&str>) -> CommandResult { HistoryCell::System { content } => ("*System:*", content.clone()), HistoryCell::Thinking { content, .. } => ("*Thinking:*", content.clone()), HistoryCell::Tool(tool) => ("**Tool:**", render_tool_cell(tool, 80)), + HistoryCell::SubAgent(sub) => ("**Sub-agent:**", render_subagent_cell(sub, 80)), }; let _ = write!(content, "{}\n\n{}\n\n---\n\n", role, body.trim()); @@ -182,6 +183,14 @@ fn render_tool_cell(tool: &crate::tui::history::ToolCell, width: u16) -> String .join("\n") } +fn render_subagent_cell(cell: &crate::tui::history::SubAgentCell, width: u16) -> String { + cell.lines(width) + .into_iter() + .map(line_to_string) + .collect::>() + .join("\n") +} + fn line_to_string(line: ratatui::text::Line<'static>) -> String { line.spans .into_iter() diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 49d84a4a..7e0f9da7 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -41,7 +41,7 @@ use crate::tools::plan::{SharedPlanState, new_shared_plan_state}; use crate::tools::shell::{SharedShellManager, new_shared_shell_manager}; use crate::tools::spec::{ApprovalRequirement, ToolError, ToolResult, required_str}; use crate::tools::subagent::{ - SharedSubAgentManager, SubAgentRuntime, SubAgentType, new_shared_subagent_manager, + Mailbox, SharedSubAgentManager, SubAgentRuntime, SubAgentType, new_shared_subagent_manager, }; use crate::tools::todo::{SharedTodoList, new_shared_todo_list}; use crate::tools::user_input::{UserInputRequest, UserInputResponse}; @@ -1501,21 +1501,53 @@ impl Engine { builder = builder.with_shell_tools(); } + // Mailbox for structured sub-agent envelopes (#128/#130). One per + // turn: the receiver is drained by a short-lived task that converts + // envelopes into `Event::SubAgentMailbox` so the UI can route them + // to the matching in-transcript card. The drainer exits naturally + // when every cloned sender is dropped at turn-end. + let mailbox_for_runtime = if self.config.features.enabled(Feature::Subagents) { + let cancel_token = self.cancel_token.child_token(); + let (mailbox, mut receiver) = Mailbox::new(cancel_token.clone()); + let tx_event_clone = self.tx_event.clone(); + tokio::spawn(async move { + while let Some(envelope) = receiver.recv().await { + if tx_event_clone + .send(Event::SubAgentMailbox { + seq: envelope.seq, + message: envelope.message, + }) + .await + .is_err() + { + break; + } + } + }); + Some((mailbox, cancel_token)) + } else { + None + }; + let tool_registry = match mode { AppMode::Agent | AppMode::Yolo => { if self.config.features.enabled(Feature::Subagents) { let runtime = if let Some(client) = self.deepseek_client.clone() { - Some( - SubAgentRuntime::new( - client, - self.session.model.clone(), - tool_context.clone(), - self.session.allow_shell, - Some(self.tx_event.clone()), - Arc::clone(&self.subagent_manager), - ) - .with_max_spawn_depth(self.config.max_spawn_depth), + let mut rt = SubAgentRuntime::new( + client, + self.session.model.clone(), + tool_context.clone(), + self.session.allow_shell, + Some(self.tx_event.clone()), + Arc::clone(&self.subagent_manager), ) + .with_max_spawn_depth(self.config.max_spawn_depth); + if let Some((mailbox, cancel_token)) = mailbox_for_runtime.as_ref() { + rt = rt + .with_mailbox(mailbox.clone()) + .with_cancel_token(cancel_token.clone()); + } + Some(rt) } else { None }; diff --git a/crates/tui/src/core/events.rs b/crates/tui/src/core/events.rs index b5be4034..4b800057 100644 --- a/crates/tui/src/core/events.rs +++ b/crates/tui/src/core/events.rs @@ -192,6 +192,14 @@ pub enum Event { /// Sub-agent listing AgentList { agents: Vec }, + /// Structured sub-agent mailbox envelope (issue #128). Carries the + /// monotonic seq + the typed `MailboxMessage` so the UI can route each + /// envelope to the correct in-transcript card. + SubAgentMailbox { + seq: u64, + message: crate::tools::subagent::MailboxMessage, + }, + // === System Events === /// An error occurred Error { diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 2d8aa206..a8d05660 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -779,6 +779,23 @@ impl RuntimeThreadManager { Ok(thread) } + /// Resume a thread and recover the sub-agent rebind hints needed to + /// reconstruct in-transcript cards (issue #128). Drains the persisted + /// `agent.*` event stream and collapses it into the latest known + /// status per `agent_id` — the UI consumes this to seed empty + /// `DelegateCard` / `FanoutCard` placeholders so subsequent live + /// mailbox envelopes mutate them in place. + #[allow(dead_code)] // exposed for the runtime API resume flow; consumed by #128 follow-up. + pub async fn resume_thread_with_agent_rebind( + &self, + id: &str, + ) -> Result<(ThreadRecord, Vec)> { + let thread = self.resume_thread(id).await?; + let events = self.store.events_since(&thread.id, None)?; + let hints = collect_agent_rebind_hints(&events); + Ok((thread, hints)) + } + pub async fn fork_thread(&self, id: &str) -> Result { let source = self.get_thread(id).await?; let mut forked = source.clone(); @@ -2340,6 +2357,66 @@ fn tool_kind_for_name(name: &str) -> TurnItemKind { TurnItemKind::ToolCall } +/// One sub-agent rebind hint extracted from a thread's persisted event +/// timeline (issue #128). When the TUI resumes a session that was +/// mid-fanout, the in-transcript card stack is empty — these hints let the +/// UI know which agent_ids were live (or recently terminal) so it can +/// reconstruct the matching `DelegateCard` / `FanoutCard` placeholders +/// before fresh mailbox envelopes arrive on a re-attached engine. +/// +/// The helper is the testable contract here — actual TUI wire-up to the +/// resume flow is a follow-up; the runtime API consumer (`runtime_api.rs`) +/// can already call `resume_thread_with_agent_rebind` to drive it. +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] // consumed by #128 follow-up TUI resume wiring; tested here. +pub struct AgentRebindHint { + pub agent_id: String, + pub status: AgentRebindStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +pub enum AgentRebindStatus { + Spawned, + InProgress, + Completed, +} + +/// Collapse a chronologically ordered slice of `RuntimeEventRecord` into +/// the latest known status per `agent_id`. Drops entries that aren't in +/// the `agent.*` family. Cards built from these hints are immediately +/// open to mutation by subsequent live mailbox envelopes (each envelope's +/// `agent_id` matches one already in the rebind map). +#[must_use] +#[allow(dead_code)] +pub fn collect_agent_rebind_hints(events: &[RuntimeEventRecord]) -> Vec { + use std::collections::BTreeMap; + let mut latest: BTreeMap = BTreeMap::new(); + for event in events { + let id = match event.payload.get("agent_id").and_then(|v| v.as_str()) { + Some(id) => id.to_string(), + None => continue, + }; + let next_status = match event.event.as_str() { + "agent.spawned" => Some(AgentRebindStatus::Spawned), + "agent.progress" => Some(AgentRebindStatus::InProgress), + "agent.completed" => Some(AgentRebindStatus::Completed), + _ => None, + }; + if let Some(status) = next_status { + // Don't downgrade Completed → InProgress on out-of-order events. + let entry = latest.entry(id).or_insert(status); + if !matches!(*entry, AgentRebindStatus::Completed) { + *entry = status; + } + } + } + latest + .into_iter() + .map(|(agent_id, status)| AgentRebindHint { agent_id, status }) + .collect() +} + pub fn summarize_text(text: &str, limit: usize) -> String { let take = limit.saturating_sub(3); let mut count = 0; @@ -3805,4 +3882,96 @@ mod tests { assert_eq!(parse_mode("unknown"), AppMode::Agent); assert_eq!(parse_mode("plan"), AppMode::Plan); } + + fn rebind_event(event: &str, agent_id: &str, seq: u64) -> RuntimeEventRecord { + RuntimeEventRecord { + schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, + seq, + timestamp: Utc::now(), + thread_id: "thr_test".to_string(), + turn_id: Some("turn_test".to_string()), + item_id: None, + event: event.to_string(), + payload: json!({ "agent_id": agent_id }), + } + } + + #[test] + fn collect_agent_rebind_hints_resumes_a_mid_fanout_session() { + // Mirror what runtime_threads persists during a real fanout: three + // workers spawned, two finished, one still running when the session + // was killed. The TUI re-attach must rebuild placeholders for the + // running worker AND the two completed workers (the fanout card + // tracks all of them so the dot-grid stays accurate post-resume). + let events = vec![ + rebind_event("agent.spawned", "agent_a", 1), + rebind_event("agent.spawned", "agent_b", 2), + rebind_event("agent.spawned", "agent_c", 3), + rebind_event("agent.progress", "agent_a", 4), + rebind_event("agent.completed", "agent_a", 5), + rebind_event("agent.progress", "agent_b", 6), + rebind_event("agent.completed", "agent_b", 7), + rebind_event("agent.progress", "agent_c", 8), + ]; + let hints = collect_agent_rebind_hints(&events); + assert_eq!(hints.len(), 3, "every fanout worker must be rebound"); + let by_id: std::collections::BTreeMap<&str, AgentRebindStatus> = hints + .iter() + .map(|h| (h.agent_id.as_str(), h.status)) + .collect(); + assert_eq!(by_id.get("agent_a"), Some(&AgentRebindStatus::Completed)); + assert_eq!(by_id.get("agent_b"), Some(&AgentRebindStatus::Completed)); + assert_eq!( + by_id.get("agent_c"), + Some(&AgentRebindStatus::InProgress), + "in-flight worker must rebind in InProgress, not downgrade" + ); + } + + #[test] + fn collect_agent_rebind_hints_ignores_unrelated_events() { + // Status / tool events should not produce phantom hints — only the + // agent.* family carries the contract we re-bind from. + let events = vec![ + RuntimeEventRecord { + schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, + seq: 1, + timestamp: Utc::now(), + thread_id: "thr".to_string(), + turn_id: None, + item_id: None, + event: "tool.completed".to_string(), + payload: json!({"name": "read_file"}), + }, + rebind_event("agent.spawned", "agent_x", 2), + RuntimeEventRecord { + schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, + seq: 3, + timestamp: Utc::now(), + thread_id: "thr".to_string(), + turn_id: None, + item_id: None, + event: "compaction.completed".to_string(), + payload: json!({"messages_after": 12}), + }, + ]; + let hints = collect_agent_rebind_hints(&events); + assert_eq!(hints.len(), 1); + assert_eq!(hints[0].agent_id, "agent_x"); + } + + #[test] + fn collect_agent_rebind_hints_does_not_downgrade_completed_to_in_progress() { + // Out-of-order replay: a stale `agent.progress` arriving after the + // completed event must NOT clobber the terminal status. This matters + // when an event log is concatenated from interrupted segments. + let events = vec![ + rebind_event("agent.spawned", "agent_y", 1), + rebind_event("agent.completed", "agent_y", 2), + rebind_event("agent.progress", "agent_y", 3), + ]; + let hints = collect_agent_rebind_hints(&events); + assert_eq!(hints.len(), 1); + assert_eq!(hints[0].status, AgentRebindStatus::Completed); + } } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 4128cbde..e73889a1 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -456,6 +456,19 @@ pub struct App { pub subagent_cache: Vec, /// Last known per-agent progress text for running sub-agents. pub agent_progress: HashMap, + /// In-transcript sub-agent card index by `agent_id` (issue #128). + /// Maps each live sub-agent to the `HistoryCell::SubAgent` it renders + /// into, so successive mailbox envelopes mutate the same cell rather + /// than spawning duplicates. + pub subagent_card_index: HashMap, + /// History index of the most recent FanoutCard. Sibling sub-agents + /// spawned by the same `agent_swarm` / `rlm` invocation route into + /// this card; reset when a fresh fanout-family tool call starts. + pub last_fanout_card_index: Option, + /// Most recently observed sub-agent dispatch tool name (set on + /// `ToolCallStarted` for `agent_spawn` / `agent_swarm` / etc., cleared + /// after the first `Started` mailbox envelope routes through it). + pub pending_subagent_dispatch: Option, /// Animation anchor for status-strip active sub-agent spinner. pub agent_activity_started_at: Option, pub ui_theme: UiTheme, @@ -847,6 +860,9 @@ impl App { max_subagents, subagent_cache: Vec::new(), agent_progress: HashMap::new(), + subagent_card_index: HashMap::new(), + last_fanout_card_index: None, + pending_subagent_dispatch: None, agent_activity_started_at: None, ui_theme, onboarding: if needs_onboarding { diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 1d9ee567..a494ce9c 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -88,6 +88,27 @@ pub enum HistoryCell { duration_secs: Option, }, Tool(ToolCell), + /// Live in-transcript card for sub-agent activity (issue #128). Owns + /// either a single `DelegateCard` or a multi-worker `FanoutCard`; the + /// UI re-binds it from the mailbox stream as envelopes arrive. + SubAgent(SubAgentCell), +} + +/// In-transcript sub-agent cell — either a single delegate or a fanout. +/// State mutates over the turn as mailbox envelopes are drained. +#[derive(Debug, Clone)] +pub enum SubAgentCell { + Delegate(crate::tui::widgets::agent_card::DelegateCard), + Fanout(crate::tui::widgets::agent_card::FanoutCard), +} + +impl SubAgentCell { + pub fn lines(&self, width: u16) -> Vec> { + match self { + SubAgentCell::Delegate(card) => card.render_lines(width), + SubAgentCell::Fanout(card) => card.render_lines(width), + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -149,6 +170,7 @@ impl HistoryCell { duration_secs, } => render_thinking(content, width, *streaming, *duration_secs, false, false), HistoryCell::Tool(cell) => cell.lines_with_motion(width, false), + HistoryCell::SubAgent(cell) => cell.lines(width), } } @@ -209,6 +231,7 @@ impl HistoryCell { width, ), HistoryCell::System { .. } => self.lines(width), + HistoryCell::SubAgent(cell) => cell.lines(width), } } @@ -252,6 +275,7 @@ impl HistoryCell { /*low_motion*/ false, ), HistoryCell::Tool(cell) => cell.transcript_lines(width), + HistoryCell::SubAgent(cell) => cell.lines(width), } } diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 0b721065..a9f2aa3b 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -302,129 +302,114 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &App) { } let content_width = area.width.saturating_sub(4) as usize; - let mut lines: Vec> = Vec::with_capacity(usize::from(area.height).max(4)); - // The footer's `running_agent_count` takes the union of `agent_progress` - // (live engine progress events) and `subagent_cache` (the snapshot that - // arrives async via `Op::ListSubAgents`). When 5 agents are spawning, the - // footer chip says "5 agents" because progress events update immediately, - // but `subagent_cache` is empty until the engine responds — so the - // sidebar would say "No agents" while the footer says 5 (#63). - // - // Mirror the footer's union here. Cached entries get the full status - // line; progress-only IDs get a single "starting…" row using the latest - // progress message, so the sidebar matches the footer in real time. + // Demoted to navigator (issue #128): the in-transcript DelegateCard / + // FanoutCard now carries the live action tree and dot-grid. The sidebar + // shows just count + role-mix so the user can scan parallel work at a + // glance and scroll to the matching transcript card for detail. let cached_ids: std::collections::HashSet<&str> = app .subagent_cache .iter() .map(|agent| agent.agent_id.as_str()) .collect(); - let progress_only: Vec<(&str, &str)> = app + let progress_only_count = app .agent_progress + .keys() + .filter(|id| !cached_ids.contains(id.as_str())) + .count(); + let cached_running = app + .subagent_cache .iter() - .filter(|(id, _)| !cached_ids.contains(id.as_str())) - .map(|(id, msg)| (id.as_str(), msg.as_str())) - .collect(); + .filter(|agent| matches!(agent.status, SubAgentStatus::Running)) + .count(); + let role_counts: std::collections::BTreeMap = + app.subagent_cache + .iter() + .fold(std::collections::BTreeMap::new(), |mut acc, agent| { + *acc.entry(agent.agent_type.as_str().to_string()) + .or_insert(0) += 1; + acc + }); - if app.subagent_cache.is_empty() && progress_only.is_empty() { + let summary = SidebarSubagentSummary { + cached_total: app.subagent_cache.len(), + cached_running, + progress_only_count, + role_counts, + }; + let lines = subagent_navigator_lines(&summary, content_width); + + render_sidebar_section(f, area, "Agents", lines); +} + +/// Minimal projection of the data the sub-agent sidebar needs. Lifted out +/// of `render_sidebar_subagents` so the rendering can be snapshot-tested +/// without a full `App`. +#[derive(Debug, Clone, Default)] +pub struct SidebarSubagentSummary { + pub cached_total: usize, + pub cached_running: usize, + pub progress_only_count: usize, + pub role_counts: std::collections::BTreeMap, +} + +/// Build the demoted navigator lines from a summary projection. Public +/// for the snapshot test in this module. +pub fn subagent_navigator_lines( + summary: &SidebarSubagentSummary, + content_width: usize, +) -> Vec> { + let mut lines: Vec> = Vec::with_capacity(4); + + if summary.cached_total == 0 && summary.progress_only_count == 0 { lines.push(Line::from(Span::styled( "No agents", Style::default().fg(palette::TEXT_MUTED), ))); - } else { - let cached_running = app - .subagent_cache - .iter() - .filter(|agent| matches!(agent.status, SubAgentStatus::Running)) - .count(); - let live_running = cached_running + progress_only.len(); - let total = app.subagent_cache.len() + progress_only.len(); - let done = total.saturating_sub(live_running); - // When agents have all finished, "0 running / 1" reads as broken. - // Switch to "1 done" once nothing is in flight; only show the - // running/total split while activity is live. - let header = if live_running > 0 { - vec![ - Span::styled( - format!("{live_running} running"), - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - ), - Span::styled( - format!(" / {total}"), - Style::default().fg(palette::TEXT_MUTED), - ), - ] - } else { - vec![Span::styled( - format!("{done} done"), - Style::default().fg(palette::STATUS_SUCCESS), - )] - }; - lines.push(Line::from(header)); - - let usable_rows = area.height.saturating_sub(3) as usize; - let max_agents = usable_rows.saturating_sub(lines.len()); - - let push_agent_row = - |lines: &mut Vec>, summary: &str, detail: &str, color| { - lines.push(Line::from(Span::styled( - truncate_line_to_width(summary, content_width.max(1)), - Style::default().fg(color), - ))); - lines.push(Line::from(Span::styled( - format!( - " {}", - truncate_line_to_width(detail, content_width.saturating_sub(2).max(1)) - ), - Style::default().fg(palette::TEXT_DIM), - ))); - }; - - // Live (progress-only) agents first — they're the freshest signal. - let mut rendered = 0usize; - for (id, msg) in progress_only.iter().take(max_agents) { - let summary = format!("{} starting", truncate_line_to_width(id, 10)); - push_agent_row(&mut lines, &summary, msg, palette::STATUS_WARNING); - rendered += 1; - } - - // Then the cached snapshot for everything that's already settled into - // `subagent_cache`. - let remaining_budget = max_agents.saturating_sub(rendered); - for agent in app.subagent_cache.iter().take(remaining_budget) { - let (status_label, status_color) = match &agent.status { - SubAgentStatus::Running => ("running", palette::STATUS_WARNING), - SubAgentStatus::Completed => ("done", palette::STATUS_SUCCESS), - SubAgentStatus::Interrupted(_) => ("interrupted", palette::STATUS_WARNING), - SubAgentStatus::Failed(_) => ("failed", palette::STATUS_ERROR), - SubAgentStatus::Cancelled => ("cancelled", palette::TEXT_MUTED), - }; - let agent_type = agent.agent_type.as_str(); - let role = agent.assignment.role.as_deref().unwrap_or("default"); - let summary = format!( - "{} {agent_type}/{role} {status_label} ({} steps)", - truncate_line_to_width(&agent.agent_id, 10), - agent.steps_taken - ); - push_agent_row( - &mut lines, - &summary, - &agent.assignment.objective, - status_color, - ); - rendered += 1; - } - - let remaining = total.saturating_sub(rendered); - if remaining > 0 { - lines.push(Line::from(Span::styled( - format!("+{remaining} more agents"), - Style::default().fg(palette::TEXT_MUTED), - ))); - } + return lines; } - render_sidebar_section(f, area, "Agents", lines); + let live_running = summary.cached_running + summary.progress_only_count; + let total = summary.cached_total + summary.progress_only_count; + let done = total.saturating_sub(live_running); + let header = if live_running > 0 { + vec![ + Span::styled( + format!("{live_running} running"), + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + ), + Span::styled( + format!(" / {total}"), + Style::default().fg(palette::TEXT_MUTED), + ), + ] + } else { + vec![Span::styled( + format!("{done} done"), + Style::default().fg(palette::STATUS_SUCCESS), + )] + }; + lines.push(Line::from(header)); + + if !summary.role_counts.is_empty() { + let mix: Vec = summary + .role_counts + .iter() + .map(|(role, count)| format!("{count} {role}")) + .collect(); + let role_line = mix.join(" \u{00B7} "); + lines.push(Line::from(Span::styled( + truncate_line_to_width(&role_line, content_width.max(1)), + Style::default().fg(palette::TEXT_DIM), + ))); + } + + lines.push(Line::from(Span::styled( + "(see transcript card for detail)", + Style::default().fg(palette::TEXT_MUTED).italic(), + ))); + + lines } fn render_sidebar_section(f: &mut Frame, area: Rect, title: &str, lines: Vec>) { @@ -455,3 +440,94 @@ fn render_sidebar_section(f: &mut Frame, area: Rect, title: &str, lines: Vec]) -> Vec { + lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect() + } + + #[test] + fn navigator_empty_state_says_no_agents() { + let summary = SidebarSubagentSummary::default(); + let lines = subagent_navigator_lines(&summary, 32); + let text = lines_to_text(&lines); + assert_eq!(text, vec!["No agents".to_string()]); + } + + #[test] + fn navigator_running_state_renders_count_role_and_navigator_hint() { + // Two general agents (one running, one done) + one explore (running). + let mut role_counts = std::collections::BTreeMap::new(); + role_counts.insert("general".to_string(), 2); + role_counts.insert("explore".to_string(), 1); + let summary = SidebarSubagentSummary { + cached_total: 3, + cached_running: 2, + progress_only_count: 0, + role_counts, + }; + let text = lines_to_text(&subagent_navigator_lines(&summary, 64)); + assert!(text[0].contains("2 running"), "header: {:?}", text[0]); + assert!(text[0].contains("/ 3"), "total in header: {:?}", text[0]); + assert!( + text[1].contains("1 explore") && text[1].contains("2 general"), + "role mix line: {:?}", + text[1] + ); + assert!( + text.iter().any(|l| l.contains("transcript card")), + "navigator hint must defer to transcript: {text:?}", + ); + } + + #[test] + fn navigator_settled_state_says_done() { + let mut role_counts = std::collections::BTreeMap::new(); + role_counts.insert("general".to_string(), 1); + let summary = SidebarSubagentSummary { + cached_total: 1, + cached_running: 0, + progress_only_count: 0, + role_counts, + }; + let text = lines_to_text(&subagent_navigator_lines(&summary, 32)); + assert!(text[0].contains("1 done"), "settled header: {:?}", text[0]); + } + + #[test] + fn navigator_truncates_long_role_mix_to_content_width() { + // Build a wide role mix; assert it doesn't blow past content_width. + let mut role_counts = std::collections::BTreeMap::new(); + for role in ["general", "explore", "plan", "review", "custom", "extra"] { + role_counts.insert(role.to_string(), 1); + } + let summary = SidebarSubagentSummary { + cached_total: 6, + cached_running: 6, + progress_only_count: 0, + role_counts, + }; + let lines = subagent_navigator_lines(&summary, 16); + let role_line: &str = lines[1] + .spans + .first() + .map(|s| s.content.as_ref()) + .unwrap_or(""); + assert!( + role_line.chars().count() <= 16, + "role line {role_line:?} exceeded content_width" + ); + } +} diff --git a/crates/tui/src/tui/transcript.rs b/crates/tui/src/tui/transcript.rs index aee33b16..2070f837 100644 --- a/crates/tui/src/tui/transcript.rs +++ b/crates/tui/src/tui/transcript.rs @@ -169,7 +169,9 @@ impl TranscriptViewCache { is_conversational: cell.is_conversational(), is_system_or_tool: matches!( cell, - HistoryCell::System { .. } | HistoryCell::Tool(_) + HistoryCell::System { .. } + | HistoryCell::Tool(_) + | HistoryCell::SubAgent(_) ), }); idx += 1; diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 1df3248f..a66c0114 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -49,7 +49,7 @@ use crate::task_manager::{ }; use crate::tools::ReviewOutput; use crate::tools::spec::{ToolError, ToolResult}; -use crate::tools::subagent::{SubAgentResult, SubAgentStatus}; +use crate::tools::subagent::{MailboxMessage, SubAgentResult, SubAgentStatus}; use crate::tui::command_palette::{ CommandPaletteView, build_entries as build_command_palette_entries, }; @@ -510,6 +510,28 @@ async fn run_event_loop( EngineEvent::ToolCallStarted { id, name, input } => { app.pending_tool_uses .push((id.clone(), name.clone(), input.clone())); + // Note this dispatch so the next sub-agent `Started` + // mailbox envelope routes into the right card kind + // (delegate vs fanout). + if matches!( + name.as_str(), + "agent_spawn" + | "agent_swarm" + | "spawn_agents_on_csv" + | "rlm" + | "delegate" + ) { + app.pending_subagent_dispatch = Some(name.clone()); + if matches!( + name.as_str(), + "agent_swarm" | "spawn_agents_on_csv" | "rlm" + ) { + // New fanout invocation — children should + // group under a fresh card, not the + // previous swarm's leftover. + app.last_fanout_card_index = None; + } + } handle_tool_call_started(app, &id, &name, &input); } EngineEvent::ToolCallComplete { id, name, result } => { @@ -807,6 +829,10 @@ async fn run_event_loop( // Individual spawn/complete events already log to history; // full list available via /agents command. } + EngineEvent::SubAgentMailbox { seq, message } => { + handle_subagent_mailbox(app, seq, &message); + transcript_batch_updated = true; + } EngineEvent::ApprovalRequired { id, tool_name, @@ -3992,6 +4018,8 @@ fn idle_poll_ms(app: &App) -> u64 { } fn history_has_live_motion(history: &[HistoryCell]) -> bool { + use crate::tui::history::SubAgentCell; + use crate::tui::widgets::agent_card::AgentLifecycle; history.iter().any(|cell| match cell { HistoryCell::Thinking { streaming, .. } => *streaming, HistoryCell::Tool(tool) => match tool { @@ -4009,6 +4037,14 @@ fn history_has_live_motion(history: &[HistoryCell]) -> bool { ToolCell::WebSearch(cell) => cell.status == ToolStatus::Running, ToolCell::Generic(cell) => cell.status == ToolStatus::Running, }, + HistoryCell::SubAgent(SubAgentCell::Delegate(card)) => matches!( + card.status, + AgentLifecycle::Pending | AgentLifecycle::Running + ), + HistoryCell::SubAgent(SubAgentCell::Fanout(card)) => card + .workers + .iter() + .any(|w| matches!(w.status, AgentLifecycle::Pending | AgentLifecycle::Running)), _ => false, }) } @@ -4327,6 +4363,7 @@ fn open_tool_details_pager(app: &mut App) -> bool { HistoryCell::System { .. } => "Note".to_string(), HistoryCell::Thinking { .. } => "Reasoning".to_string(), HistoryCell::Tool(_) => "Message".to_string(), + HistoryCell::SubAgent(_) => "Sub-agent".to_string(), }; let width = app .last_transcript_area @@ -4404,6 +4441,95 @@ fn sort_subagents_in_place(agents: &mut [SubAgentResult]) { }); } +/// Route a `MailboxMessage` envelope to the matching in-transcript card, +/// allocating a `DelegateCard` or `FanoutCard` on first sight (issue #128). +fn handle_subagent_mailbox(app: &mut App, _seq: u64, message: &MailboxMessage) { + use crate::tui::history::{HistoryCell, SubAgentCell}; + use crate::tui::widgets::agent_card::{ + DelegateCard, FanoutCard, apply_to_delegate, apply_to_fanout, + }; + + // Resolve (or allocate) the target cell for this envelope. ChildSpawned + // is special — it always belongs to the active fanout card if one + // exists; otherwise it seeds a new one. + let agent_id = message.agent_id().to_string(); + + if matches!(message, MailboxMessage::ChildSpawned { .. }) + && let Some(idx) = app.last_fanout_card_index + && let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get_mut(idx) + { + apply_to_fanout(card, message); + app.subagent_card_index.insert(agent_id, idx); + app.mark_history_updated(); + return; + } + + // Existing card for this agent_id? Mutate in place. + if let Some(&idx) = app.subagent_card_index.get(&agent_id) { + let updated = match app.history.get_mut(idx) { + Some(HistoryCell::SubAgent(SubAgentCell::Delegate(card))) => { + apply_to_delegate(card, message) + } + Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) => { + apply_to_fanout(card, message) + } + _ => false, + }; + if updated { + app.mark_history_updated(); + } + return; + } + + // No existing card — only `Started` reasonably opens one. Anything else + // for an unknown agent_id is dropped (likely arrived after the cell was + // cleared, e.g. session-resume edge cases). + let MailboxMessage::Started { agent_type, .. } = message else { + return; + }; + + let dispatch_kind = app.pending_subagent_dispatch.as_deref(); + let is_fanout = matches!( + dispatch_kind, + Some("agent_swarm" | "spawn_agents_on_csv" | "rlm") + ); + + if is_fanout { + // Reuse the active fanout card for sibling spawns; otherwise create + // one anchored at this position so subsequent siblings join it. + if let Some(idx) = app.last_fanout_card_index + && let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = + app.history.get_mut(idx) + { + card.upsert_worker( + &agent_id, + crate::tui::widgets::agent_card::AgentLifecycle::Running, + ); + app.subagent_card_index.insert(agent_id, idx); + } else { + let mut card = FanoutCard::new(dispatch_kind.unwrap_or("fanout").to_string()); + card.upsert_worker( + &agent_id, + crate::tui::widgets::agent_card::AgentLifecycle::Running, + ); + app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card))); + let idx = app.history.len().saturating_sub(1); + app.last_fanout_card_index = Some(idx); + app.subagent_card_index.insert(agent_id, idx); + } + } else { + let card = DelegateCard::new(agent_id.clone(), agent_type.clone()); + app.add_message(HistoryCell::SubAgent(SubAgentCell::Delegate(card))); + let idx = app.history.len().saturating_sub(1); + app.subagent_card_index.insert(agent_id, idx); + // Single delegate consumes the pending dispatch label so a follow-on + // tool call doesn't accidentally inherit it. + app.pending_subagent_dispatch = None; + } + + app.mark_history_updated(); +} + fn task_mode_label(mode: AppMode) -> &'static str { mode.as_setting() } diff --git a/crates/tui/src/tui/widgets/agent_card.rs b/crates/tui/src/tui/widgets/agent_card.rs new file mode 100644 index 00000000..45081e62 --- /dev/null +++ b/crates/tui/src/tui/widgets/agent_card.rs @@ -0,0 +1,608 @@ +//! In-transcript cards for sub-agent activity (issue #128). +//! +//! Two cards consume the #130 mailbox stream and render live in the chat +//! transcript: +//! +//! - [`DelegateCard`] — single `agent_spawn` invocation. Live tree of the +//! last 3 actions plus a header with status / glyph / role. +//! - [`FanoutCard`] — `agent_swarm` / `rlm` fanout. Dot-grid of worker +//! slots (`●` filled, `○` pending) plus an aggregate counts line. +//! +//! Both cards are state machines updated by [`apply_to_delegate`] / +//! [`apply_to_fanout`]. The sidebar (see `tui/sidebar.rs`) defers detail +//! to whichever card is active in the transcript, so these are the +//! primary status surface. + +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; + +use crate::palette; +use crate::tools::subagent::MailboxMessage; +use crate::tui::widgets::tool_card::{ToolFamily, family_glyph, family_label}; + +/// Maximum number of recent actions kept on a `DelegateCard`. Older entries +/// are dropped from the head; an ellipsis row signals truncation. +pub const DELEGATE_MAX_ACTIONS: usize = 3; + +/// Lifecycle of a delegated / fanned-out agent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentLifecycle { + Pending, + Running, + Completed, + Failed, + Cancelled, +} + +impl AgentLifecycle { + fn is_terminal(self) -> bool { + matches!(self, Self::Completed | Self::Failed | Self::Cancelled) + } + + fn label(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Running => "running", + Self::Completed => "done", + Self::Failed => "failed", + Self::Cancelled => "cancelled", + } + } + + fn color(self) -> Color { + match self { + Self::Pending => palette::TEXT_MUTED, + Self::Running => palette::STATUS_WARNING, + Self::Completed => palette::STATUS_SUCCESS, + Self::Failed => palette::STATUS_ERROR, + Self::Cancelled => palette::TEXT_MUTED, + } + } +} + +/// Card for a single delegated `agent_spawn` invocation. +/// +/// Stores the last [`DELEGATE_MAX_ACTIONS`] action lines; older entries are +/// truncated and a single ellipsis row is rendered above the visible tail. +#[derive(Debug, Clone)] +pub struct DelegateCard { + pub agent_id: String, + pub agent_type: String, + pub status: AgentLifecycle, + pub summary: Option, + actions: Vec, + truncated: bool, +} + +impl DelegateCard { + #[must_use] + pub fn new(agent_id: impl Into, agent_type: impl Into) -> Self { + Self { + agent_id: agent_id.into(), + agent_type: agent_type.into(), + status: AgentLifecycle::Pending, + summary: None, + actions: Vec::new(), + truncated: false, + } + } + + pub fn push_action(&mut self, action: impl Into) { + self.actions.push(action.into()); + if self.actions.len() > DELEGATE_MAX_ACTIONS { + // Drop one head entry per overflow so steady-state is exactly + // DELEGATE_MAX_ACTIONS lines; the ellipsis row signals the rest. + self.actions.remove(0); + self.truncated = true; + } + } + + #[must_use] + pub fn render_lines(&self, _width: u16) -> Vec> { + let mut lines = Vec::with_capacity(self.actions.len() + 3); + lines.push(card_header( + ToolFamily::Delegate, + self.status, + &self.agent_type, + &self.agent_id, + )); + if self.truncated { + lines.push(Line::from(Span::styled( + " \u{2026}".to_string(), // … + Style::default().fg(palette::TEXT_MUTED), + ))); + } + for action in &self.actions { + lines.push(Line::from(vec![ + Span::styled(" \u{2502} ", Style::default().fg(palette::TEXT_DIM)), + Span::styled( + truncate_action(action, 200), + Style::default().fg(palette::TEXT_TOOL_OUTPUT), + ), + ])); + } + if self.status.is_terminal() + && let Some(summary) = self.summary.as_ref() + { + lines.push(Line::from(vec![ + Span::styled(" \u{2570} ", Style::default().fg(palette::TEXT_DIM)), + Span::styled( + truncate_action(summary, 200), + Style::default().fg(self.status.color()), + ), + ])); + } + lines + } + + /// Number of actions held — exposed for tests; bounded at + /// `DELEGATE_MAX_ACTIONS`. + #[must_use] + #[cfg(test)] + pub fn action_count(&self) -> usize { + self.actions.len() + } + + /// Whether the head was truncated (older actions dropped). + #[must_use] + #[cfg(test)] + pub fn truncated(&self) -> bool { + self.truncated + } +} + +/// One worker slot in a fanout group. +#[derive(Debug, Clone)] +pub struct WorkerSlot { + pub agent_id: String, + pub status: AgentLifecycle, +} + +/// Card for `agent_swarm` / `rlm` fanout: dot-grid + aggregate counts. +/// +/// Slots are added as `ChildSpawned` envelopes arrive (or pre-allocated by +/// the engine when the worker count is known up front); each slot +/// transitions independently as its `Completed` / `Failed` / `Cancelled` +/// envelope is observed. +#[derive(Debug, Clone)] +pub struct FanoutCard { + pub kind: String, + pub workers: Vec, +} + +impl FanoutCard { + #[must_use] + pub fn new(kind: impl Into) -> Self { + Self { + kind: kind.into(), + workers: Vec::new(), + } + } + + /// Pre-seed worker slots when the fanout size is known up front (the + /// `agent_swarm` tool dispatches N children atomically). + #[cfg(test)] + pub fn with_workers(mut self, ids: I) -> Self + where + I: IntoIterator, + S: Into, + { + for id in ids { + self.workers.push(WorkerSlot { + agent_id: id.into(), + status: AgentLifecycle::Pending, + }); + } + self + } + + /// Update or insert a worker by id. + pub fn upsert_worker(&mut self, agent_id: &str, status: AgentLifecycle) { + if let Some(slot) = self.workers.iter_mut().find(|s| s.agent_id == agent_id) { + slot.status = status; + } else { + self.workers.push(WorkerSlot { + agent_id: agent_id.to_string(), + status, + }); + } + } + + fn counts(&self) -> (usize, usize, usize, usize) { + let mut done = 0usize; + let mut running = 0usize; + let mut failed = 0usize; + let mut pending = 0usize; + for slot in &self.workers { + match slot.status { + AgentLifecycle::Completed => done += 1, + AgentLifecycle::Running => running += 1, + AgentLifecycle::Failed | AgentLifecycle::Cancelled => failed += 1, + AgentLifecycle::Pending => pending += 1, + } + } + (done, running, failed, pending) + } + + #[must_use] + pub fn dot_grid(&self) -> String { + let mut s = String::with_capacity(self.workers.len()); + for slot in &self.workers { + // Filled circle for "done", open for everything else; the counts + // line below carries the running/failed split that one glyph + // can't. + let glyph = if matches!(slot.status, AgentLifecycle::Completed) { + '\u{25CF}' // ● + } else { + '\u{25CB}' // ○ + }; + s.push(glyph); + } + s + } + + #[must_use] + pub fn render_lines(&self, _width: u16) -> Vec> { + let mut lines = Vec::with_capacity(3); + let header_status = self.aggregate_status(); + let title = format!("{} ({} workers)", self.kind, self.workers.len()); + lines.push(card_header( + ToolFamily::Fanout, + header_status, + &self.kind, + &title, + )); + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + self.dot_grid(), + Style::default() + .fg(palette::DEEPSEEK_SKY) + .add_modifier(Modifier::BOLD), + ), + ])); + let (done, running, failed, pending) = self.counts(); + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + format!( + "{done} done \u{00B7} {running} running \u{00B7} {failed} failed \u{00B7} {pending} pending" + ), + Style::default().fg(palette::TEXT_MUTED), + ), + ])); + lines + } + + fn aggregate_status(&self) -> AgentLifecycle { + let (done, running, failed, pending) = self.counts(); + if running > 0 || pending > 0 { + AgentLifecycle::Running + } else if failed > 0 && done == 0 { + AgentLifecycle::Failed + } else if done > 0 { + AgentLifecycle::Completed + } else { + AgentLifecycle::Pending + } + } + + /// Worker count (slots seeded or observed via mailbox). + #[must_use] + #[cfg(test)] + pub fn worker_count(&self) -> usize { + self.workers.len() + } +} + +fn card_header( + family: ToolFamily, + status: AgentLifecycle, + role: &str, + detail: &str, +) -> Line<'static> { + let glyph = family_glyph(family); + let verb = family_label(family); + let header_color = status.color(); + Line::from(vec![ + Span::styled( + format!("{glyph} "), + Style::default() + .fg(header_color) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + verb.to_string(), + Style::default() + .fg(header_color) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(role.to_string(), Style::default().fg(palette::TEXT_PRIMARY)), + Span::raw(" "), + Span::styled( + format!("[{}]", status.label()), + Style::default().fg(header_color), + ), + Span::raw(" "), + Span::styled(detail.to_string(), Style::default().fg(palette::TEXT_MUTED)), + ]) +} + +fn truncate_action(text: &str, max: usize) -> String { + let trimmed = text.trim(); + if trimmed.chars().count() <= max { + trimmed.to_string() + } else { + let mut out: String = trimmed.chars().take(max.saturating_sub(1)).collect(); + out.push('\u{2026}'); + out + } +} + +/// Apply a mailbox envelope to a `DelegateCard`. Returns `true` if the +/// state changed (UI may want to redraw); `false` if the envelope was for +/// a different `agent_id`. +pub fn apply_to_delegate(card: &mut DelegateCard, msg: &MailboxMessage) -> bool { + if msg.agent_id() != card.agent_id { + return false; + } + match msg { + MailboxMessage::Started { .. } => { + card.status = AgentLifecycle::Running; + } + MailboxMessage::Progress { status, .. } => { + card.status = AgentLifecycle::Running; + card.push_action(status); + } + MailboxMessage::ToolCallStarted { + tool_name, step, .. + } => { + card.push_action(format!("[{step}] {tool_name} started")); + } + MailboxMessage::ToolCallCompleted { + tool_name, + step, + ok, + .. + } => { + card.push_action(format!( + "[{step}] {tool_name} {}", + if *ok { "ok" } else { "failed" } + )); + } + MailboxMessage::Completed { summary, .. } => { + card.status = AgentLifecycle::Completed; + card.summary = Some(summary.clone()); + } + MailboxMessage::Failed { error, .. } => { + card.status = AgentLifecycle::Failed; + card.summary = Some(error.clone()); + } + MailboxMessage::Cancelled { .. } => { + card.status = AgentLifecycle::Cancelled; + } + MailboxMessage::ChildSpawned { .. } => { + // Delegate cards represent a single agent; child spawns belong + // to a sibling fanout card, not this one. + return false; + } + } + true +} + +/// Apply a mailbox envelope to a `FanoutCard`. Updates per-worker state +/// based on which child the envelope is about. Returns `true` on change. +pub fn apply_to_fanout(card: &mut FanoutCard, msg: &MailboxMessage) -> bool { + let id = msg.agent_id(); + match msg { + MailboxMessage::Started { .. } => { + card.upsert_worker(id, AgentLifecycle::Running); + true + } + MailboxMessage::Progress { .. } | MailboxMessage::ToolCallStarted { .. } => { + card.upsert_worker(id, AgentLifecycle::Running); + true + } + MailboxMessage::ToolCallCompleted { .. } => true, + MailboxMessage::Completed { .. } => { + card.upsert_worker(id, AgentLifecycle::Completed); + true + } + MailboxMessage::Failed { .. } => { + card.upsert_worker(id, AgentLifecycle::Failed); + true + } + MailboxMessage::Cancelled { .. } => { + card.upsert_worker(id, AgentLifecycle::Cancelled); + true + } + MailboxMessage::ChildSpawned { child_id, .. } => { + card.upsert_worker(child_id, AgentLifecycle::Pending); + true + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn render_to_strings(lines: &[Line<'static>]) -> Vec { + lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect() + } + + #[test] + fn delegate_card_truncates_to_last_three_actions_with_ellipsis() { + let mut card = DelegateCard::new("agent_001", "general"); + card.push_action("read README.md"); + card.push_action("grep TODO"); + card.push_action("edit src/lib.rs"); + // Up to the limit — no truncation yet. + assert!(!card.truncated()); + assert_eq!(card.action_count(), DELEGATE_MAX_ACTIONS); + + card.push_action("write tests"); + card.push_action("run cargo test"); + assert!(card.truncated(), "truncation flag flips on overflow"); + assert_eq!( + card.action_count(), + DELEGATE_MAX_ACTIONS, + "stable steady-state size" + ); + + let rendered = render_to_strings(&card.render_lines(80)); + assert!( + rendered.iter().any(|line| line.contains('\u{2026}')), + "ellipsis indicator must render: got {rendered:?}" + ); + // The oldest two actions ("read README.md", "grep TODO") were dropped. + assert!( + !rendered.iter().any(|line| line.contains("read README.md")), + "oldest action evicted: got {rendered:?}" + ); + assert!( + rendered.iter().any(|line| line.contains("run cargo test")), + "newest action retained: got {rendered:?}" + ); + assert!( + rendered.iter().any(|line| line.contains("write tests")), + "second-newest retained: got {rendered:?}" + ); + assert!( + rendered.iter().any(|line| line.contains("edit src/lib.rs")), + "third-newest retained: got {rendered:?}" + ); + } + + #[test] + fn delegate_card_terminal_status_renders_summary_row() { + let mut card = DelegateCard::new("agent_002", "explore"); + card.push_action("listing files"); + let msg = MailboxMessage::Completed { + agent_id: "agent_002".into(), + summary: "scanned 42 files, no TODOs found".into(), + }; + assert!(apply_to_delegate(&mut card, &msg)); + assert_eq!(card.status, AgentLifecycle::Completed); + let rendered = render_to_strings(&card.render_lines(80)); + assert!( + rendered + .iter() + .any(|line| line.contains("scanned 42 files")), + "summary row renders on terminal status: got {rendered:?}" + ); + } + + #[test] + fn delegate_card_ignores_envelopes_for_other_agents() { + let mut card = DelegateCard::new("agent_a", "general"); + let other = MailboxMessage::progress("agent_b", "noise"); + assert!(!apply_to_delegate(&mut card, &other)); + assert_eq!(card.action_count(), 0); + } + + #[test] + fn fanout_card_dot_grid_renders_filled_for_done_only() { + let mut card = FanoutCard::new("swarm") + .with_workers(["w_1", "w_2", "w_3", "w_4", "w_5", "w_6", "w_7"]); + card.upsert_worker("w_1", AgentLifecycle::Completed); + card.upsert_worker("w_2", AgentLifecycle::Completed); + card.upsert_worker("w_3", AgentLifecycle::Running); + card.upsert_worker("w_4", AgentLifecycle::Failed); + // 5/6/7 stay Pending. + + // Two filled (only Completed counts as ●), five open. + assert_eq!( + card.dot_grid(), + "\u{25CF}\u{25CF}\u{25CB}\u{25CB}\u{25CB}\u{25CB}\u{25CB}" + ); + } + + #[test] + fn fanout_card_aggregate_counts_match_dot_grid() { + let mut card = FanoutCard::new("rlm").with_workers(["w_1", "w_2", "w_3", "w_4"]); + card.upsert_worker("w_1", AgentLifecycle::Completed); + card.upsert_worker("w_2", AgentLifecycle::Completed); + card.upsert_worker("w_3", AgentLifecycle::Completed); + card.upsert_worker("w_4", AgentLifecycle::Failed); + let rendered = render_to_strings(&card.render_lines(80)); + // The stats row is the one carrying "running" too; the header may + // mention "done" alone via the lifecycle status badge. + let stats = rendered + .iter() + .find(|line| line.contains("running") && line.contains("pending")) + .expect("counts line present"); + assert!(stats.contains("3 done"), "completed count: {stats}"); + assert!( + stats.contains("1 failed"), + "failed/cancelled fold into the same bucket: {stats}" + ); + assert!(stats.contains("0 running"), "no running: {stats}"); + assert!(stats.contains("0 pending"), "no pending: {stats}"); + } + + #[test] + fn fanout_apply_inserts_unknown_worker_via_child_spawned() { + let mut card = FanoutCard::new("swarm"); + let msg = MailboxMessage::ChildSpawned { + parent_id: "root".into(), + child_id: "agent_late".into(), + }; + assert!(apply_to_fanout(&mut card, &msg)); + assert_eq!(card.worker_count(), 1); + assert_eq!(card.workers[0].agent_id, "agent_late"); + assert_eq!(card.workers[0].status, AgentLifecycle::Pending); + } + + #[test] + fn fanout_apply_transitions_worker_through_lifecycle() { + let mut card = FanoutCard::new("swarm").with_workers(["w_1"]); + let started = MailboxMessage::started("w_1", crate::tools::subagent::SubAgentType::General); + apply_to_fanout(&mut card, &started); + assert_eq!(card.workers[0].status, AgentLifecycle::Running); + + let done = MailboxMessage::Completed { + agent_id: "w_1".into(), + summary: "ok".into(), + }; + apply_to_fanout(&mut card, &done); + assert_eq!(card.workers[0].status, AgentLifecycle::Completed); + } + + #[test] + fn fanout_dot_grid_arithmetic_for_various_n() { + // Spot-check several fanout sizes with a mix of states; this is the + // arithmetic snapshot the issue acceptance calls out. + let cases: &[(usize, usize, &str)] = &[ + (1, 0, "\u{25CB}"), + (1, 1, "\u{25CF}"), + (3, 2, "\u{25CF}\u{25CF}\u{25CB}"), + ( + 7, + 3, + "\u{25CF}\u{25CF}\u{25CF}\u{25CB}\u{25CB}\u{25CB}\u{25CB}", + ), + ]; + for (total, done, expected) in cases { + let ids: Vec = (0..*total).map(|i| format!("w_{i}")).collect(); + let mut card = FanoutCard::new("swarm").with_workers(ids.iter().cloned()); + for id in ids.iter().take(*done) { + card.upsert_worker(id, AgentLifecycle::Completed); + } + assert_eq!( + card.dot_grid(), + *expected, + "fanout dot-grid for total={total} done={done}", + ); + } + } +} diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 6dd5510e..c5c5afb3 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -9,6 +9,7 @@ pub mod key_hint; // evaluate the rendering in isolation. The follow-up PR plumbs it through // the composer area in `ui.rs`. `pub mod` (vs the usual `pub use` pattern) // keeps the unused-imports lint quiet until then. +pub mod agent_card; pub mod pending_input_preview; mod renderable; pub mod tool_card;