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) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-04-27 22:15:26 -05:00
parent 32750cb52d
commit 63cb06637b
11 changed files with 1192 additions and 121 deletions
+9
View File
@@ -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::<Vec<_>>()
.join("\n")
}
fn line_to_string(line: ratatui::text::Line<'static>) -> String {
line.spans
.into_iter()
+43 -11
View File
@@ -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
};
+8
View File
@@ -192,6 +192,14 @@ pub enum Event {
/// Sub-agent listing
AgentList { agents: Vec<SubAgentResult> },
/// 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 {
+169
View File
@@ -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<AgentRebindHint>)> {
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<ThreadRecord> {
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<AgentRebindHint> {
use std::collections::BTreeMap;
let mut latest: BTreeMap<String, AgentRebindStatus> = 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);
}
}
+16
View File
@@ -456,6 +456,19 @@ pub struct App {
pub subagent_cache: Vec<SubAgentResult>,
/// Last known per-agent progress text for running sub-agents.
pub agent_progress: HashMap<String, String>,
/// 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<String, usize>,
/// 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<usize>,
/// 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<String>,
/// Animation anchor for status-strip active sub-agent spinner.
pub agent_activity_started_at: Option<Instant>,
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 {
+24
View File
@@ -88,6 +88,27 @@ pub enum HistoryCell {
duration_secs: Option<f32>,
},
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<Line<'static>> {
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),
}
}
+184 -108
View File
@@ -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<Line<'static>> = 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<String, usize> =
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<String, usize>,
}
/// 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<Line<'static>> {
let mut lines: Vec<Line<'static>> = 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<Line<'static>>, 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<String> = 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<Line<'static>>) {
@@ -455,3 +440,94 @@ fn render_sidebar_section(f: &mut Frame, area: Rect, title: &str, lines: Vec<Lin
f.render_widget(section, area);
}
#[cfg(test)]
mod tests {
use super::{SidebarSubagentSummary, subagent_navigator_lines};
use ratatui::text::Line;
fn lines_to_text(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.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"
);
}
}
+3 -1
View File
@@ -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;
+127 -1
View File
@@ -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()
}
+608
View File
@@ -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<String>,
actions: Vec<String>,
truncated: bool,
}
impl DelegateCard {
#[must_use]
pub fn new(agent_id: impl Into<String>, agent_type: impl Into<String>) -> 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<String>) {
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<Line<'static>> {
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<WorkerSlot>,
}
impl FanoutCard {
#[must_use]
pub fn new(kind: impl Into<String>) -> 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<I, S>(mut self, ids: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
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<Line<'static>> {
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<String> {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.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<String> = (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}",
);
}
}
}
+1
View File
@@ -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;