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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user