返回 DeepSeek-TUI-2026
agent_card.rs
根目录 / crates / tui / src / tui / widgets / agent_card.rs
1 //! In-transcript cards for sub-agent activity (issue #128).
2 //!
3 //! Two cards consume the #130 mailbox stream and render live in the chat
4 //! transcript:
5 //!
6 //! - [`DelegateCard`] — single `agent_spawn` invocation. Live tree of the
7 //! last 3 actions plus a header with status / glyph / role.
8 //! - [`FanoutCard`] — `rlm` fanout (or any future multi-child dispatch).
9 //! Dot-grid of worker slots (`●` filled, `○` pending) plus an aggregate
10 //! counts line.
11 //!
12 //! Both cards are state machines updated by [`apply_to_delegate`] /
13 //! [`apply_to_fanout`]. The sidebar (see `tui/sidebar.rs`) defers detail
14 //! to whichever card is active in the transcript, so these are the
15 //! primary status surface.
16
17 use ratatui::style::{Color, Modifier, Style};
18 use ratatui::text::{Line, Span};
19
20 use crate::palette;
21 use crate::tools::subagent::MailboxMessage;
22 use crate::tui::widgets::tool_card::{ToolFamily, family_glyph, family_label};
23
24 /// Maximum number of recent actions kept on a `DelegateCard`. Older entries
25 /// are dropped from the head; an ellipsis row signals truncation.
26 pub const DELEGATE_MAX_ACTIONS: usize = 3;
27
28 /// Lifecycle of a delegated / fanned-out agent.
29 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
30 pub enum AgentLifecycle {
31 Pending,
32 Running,
33 Completed,
34 Failed,
35 Cancelled,
36 }
37
38 impl AgentLifecycle {
39 fn is_terminal(self) -> bool {
40 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
41 }
42
43 fn label(self) -> &'static str {
44 match self {
45 Self::Pending => "pending",
46 Self::Running => "running",
47 Self::Completed => "done",
48 Self::Failed => "failed",
49 Self::Cancelled => "cancelled",
50 }
51 }
52
53 fn color(self) -> Color {
54 match self {
55 Self::Pending => palette::TEXT_MUTED,
56 Self::Running => palette::STATUS_WARNING,
57 Self::Completed => palette::STATUS_SUCCESS,
58 Self::Failed => palette::STATUS_ERROR,
59 Self::Cancelled => palette::TEXT_MUTED,
60 }
61 }
62 }
63
64 /// Card for a single delegated `agent_spawn` invocation.
65 ///
66 /// Stores the last [`DELEGATE_MAX_ACTIONS`] action lines; older entries are
67 /// truncated and a single ellipsis row is rendered above the visible tail.
68 #[derive(Debug, Clone)]
69 pub struct DelegateCard {
70 pub agent_id: String,
71 pub agent_type: String,
72 pub status: AgentLifecycle,
73 pub summary: Option<String>,
74 actions: Vec<String>,
75 truncated: bool,
76 }
77
78 impl DelegateCard {
79 #[must_use]
80 pub fn new(agent_id: impl Into<String>, agent_type: impl Into<String>) -> Self {
81 Self {
82 agent_id: agent_id.into(),
83 agent_type: agent_type.into(),
84 status: AgentLifecycle::Pending,
85 summary: None,
86 actions: Vec::new(),
87 truncated: false,
88 }
89 }
90
91 pub fn push_action(&mut self, action: impl Into<String>) {
92 self.actions.push(action.into());
93 if self.actions.len() > DELEGATE_MAX_ACTIONS {
94 // Drop one head entry per overflow so steady-state is exactly
95 // DELEGATE_MAX_ACTIONS lines; the ellipsis row signals the rest.
96 self.actions.remove(0);
97 self.truncated = true;
98 }
99 }
100
101 #[must_use]
102 pub fn render_lines(&self, _width: u16) -> Vec<Line<'static>> {
103 let mut lines = Vec::with_capacity(self.actions.len() + 3);
104 lines.push(card_header(
105 ToolFamily::Delegate,
106 self.status,
107 &self.agent_type,
108 &self.agent_id,
109 ));
110 if self.truncated {
111 lines.push(Line::from(Span::styled(
112 " \u{2026}".to_string(), // …
113 Style::default().fg(palette::TEXT_MUTED),
114 )));
115 }
116 for action in &self.actions {
117 lines.push(Line::from(vec![
118 Span::styled(" \u{2502} ", Style::default().fg(palette::TEXT_DIM)),
119 Span::styled(
120 truncate_action(action, 200),
121 Style::default().fg(palette::TEXT_TOOL_OUTPUT),
122 ),
123 ]));
124 }
125 if self.status.is_terminal()
126 && let Some(summary) = self.summary.as_ref()
127 {
128 lines.push(Line::from(vec![
129 Span::styled(" \u{2570} ", Style::default().fg(palette::TEXT_DIM)),
130 Span::styled(
131 truncate_action(summary, 200),
132 Style::default().fg(self.status.color()),
133 ),
134 ]));
135 }
136 lines
137 }
138
139 /// Number of actions held — exposed for tests; bounded at
140 /// `DELEGATE_MAX_ACTIONS`.
141 #[must_use]
142 #[cfg(test)]
143 pub fn action_count(&self) -> usize {
144 self.actions.len()
145 }
146
147 /// Whether the head was truncated (older actions dropped).
148 #[must_use]
149 #[cfg(test)]
150 pub fn truncated(&self) -> bool {
151 self.truncated
152 }
153 }
154
155 /// One worker slot in a fanout group.
156 #[derive(Debug, Clone)]
157 pub struct WorkerSlot {
158 /// Stable logical worker key. Stays tied to the worker slot even after a
159 /// concrete sub-agent id exists.
160 pub worker_id: String,
161 /// Concrete agent id once spawned; placeholders use the worker id.
162 pub agent_id: String,
163 pub status: AgentLifecycle,
164 }
165
166 impl WorkerSlot {
167 #[must_use]
168 pub fn new(worker_id: impl Into<String>, status: AgentLifecycle) -> Self {
169 let worker_id = worker_id.into();
170 Self {
171 agent_id: worker_id.clone(),
172 worker_id,
173 status,
174 }
175 }
176 }
177
178 /// Card for `rlm` (or any multi-child dispatch) fanout: dot-grid +
179 /// aggregate counts.
180 ///
181 /// Slots are added as `ChildSpawned` envelopes arrive (or pre-allocated by
182 /// the engine when the worker count is known up front); each slot
183 /// transitions independently as its `Completed` / `Failed` / `Cancelled`
184 /// envelope is observed.
185 #[derive(Debug, Clone)]
186 pub struct FanoutCard {
187 pub kind: String,
188 pub workers: Vec<WorkerSlot>,
189 }
190
191 impl FanoutCard {
192 #[must_use]
193 pub fn new(kind: impl Into<String>) -> Self {
194 Self {
195 kind: kind.into(),
196 workers: Vec::new(),
197 }
198 }
199
200 /// Pre-seed worker slots when the fanout size is known up front.
201 #[allow(dead_code)]
202 pub fn with_workers<I, S>(mut self, ids: I) -> Self
203 where
204 I: IntoIterator<Item = S>,
205 S: Into<String>,
206 {
207 for id in ids {
208 self.workers
209 .push(WorkerSlot::new(id.into(), AgentLifecycle::Pending));
210 }
211 self
212 }
213
214 /// Update or insert a worker by id.
215 pub fn upsert_worker(&mut self, agent_id: &str, status: AgentLifecycle) {
216 if let Some(slot) = self
217 .workers
218 .iter_mut()
219 .find(|s| s.agent_id == agent_id || s.worker_id == agent_id)
220 {
221 slot.agent_id = agent_id.to_string();
222 slot.status = status;
223 } else {
224 self.workers.push(WorkerSlot::new(agent_id, status));
225 }
226 }
227
228 /// Attach a real agent id to the first pending placeholder slot. Fanout
229 /// cards are seeded from task ids before child agents exist; when a child
230 /// starts, this keeps the dot count stable instead of appending a second
231 /// circle for the same unit of work.
232 pub fn claim_pending_worker(&mut self, agent_id: &str, status: AgentLifecycle) {
233 if let Some(slot) = self.workers.iter_mut().find(|s| s.agent_id == agent_id) {
234 slot.status = status;
235 return;
236 }
237 if let Some(slot) = self
238 .workers
239 .iter_mut()
240 .find(|s| matches!(s.status, AgentLifecycle::Pending))
241 {
242 slot.agent_id = agent_id.to_string();
243 slot.status = status;
244 return;
245 }
246 self.upsert_worker(agent_id, status);
247 }
248
249 fn counts(&self) -> (usize, usize, usize, usize) {
250 let mut done = 0usize;
251 let mut running = 0usize;
252 let mut failed = 0usize;
253 let mut pending = 0usize;
254 for slot in &self.workers {
255 match slot.status {
256 AgentLifecycle::Completed => done += 1,
257 AgentLifecycle::Running => running += 1,
258 AgentLifecycle::Failed | AgentLifecycle::Cancelled => failed += 1,
259 AgentLifecycle::Pending => pending += 1,
260 }
261 }
262 (done, running, failed, pending)
263 }
264
265 #[must_use]
266 pub fn dot_grid(&self) -> String {
267 let mut s = String::with_capacity(self.workers.len());
268 for slot in &self.workers {
269 let glyph = match slot.status {
270 AgentLifecycle::Completed => '\u{25CF}', // ●
271 AgentLifecycle::Running => '\u{25D0}', // ◐
272 AgentLifecycle::Failed => '\u{00D7}', // ×
273 AgentLifecycle::Cancelled => '\u{2298}', // ⊘
274 AgentLifecycle::Pending => '\u{25CB}', // ○
275 };
276 s.push(glyph);
277 }
278 s
279 }
280
281 #[must_use]
282 pub fn render_lines(&self, _width: u16) -> Vec<Line<'static>> {
283 let mut lines = Vec::with_capacity(3);
284 let header_status = self.aggregate_status();
285 let title = format!("{} ({} workers)", self.kind, self.workers.len());
286 let family = if self.kind == "rlm" {
287 ToolFamily::Rlm
288 } else {
289 ToolFamily::Fanout
290 };
291 lines.push(card_header(family, header_status, &self.kind, &title));
292 lines.push(Line::from(vec![
293 Span::styled(" ", Style::default()),
294 Span::styled(
295 self.dot_grid(),
296 Style::default()
297 .fg(palette::DEEPSEEK_SKY)
298 .add_modifier(Modifier::BOLD),
299 ),
300 ]));
301 let (done, running, failed, pending) = self.counts();
302 lines.push(Line::from(vec![
303 Span::styled(" ", Style::default()),
304 Span::styled(
305 format!(
306 "{done} done \u{00B7} {running} running \u{00B7} {failed} failed \u{00B7} {pending} pending"
307 ),
308 Style::default().fg(palette::TEXT_MUTED),
309 ),
310 ]));
311 lines
312 }
313
314 fn aggregate_status(&self) -> AgentLifecycle {
315 let (done, running, failed, pending) = self.counts();
316 if running > 0 || pending > 0 {
317 AgentLifecycle::Running
318 } else if failed > 0 && done == 0 {
319 AgentLifecycle::Failed
320 } else if done > 0 {
321 AgentLifecycle::Completed
322 } else {
323 AgentLifecycle::Pending
324 }
325 }
326
327 /// Worker count (slots seeded or observed via mailbox).
328 #[must_use]
329 pub fn worker_count(&self) -> usize {
330 self.workers.len()
331 }
332 }
333
334 fn card_header(
335 family: ToolFamily,
336 status: AgentLifecycle,
337 role: &str,
338 detail: &str,
339 ) -> Line<'static> {
340 let glyph = family_glyph(family);
341 let verb = family_label(family);
342 let header_color = status.color();
343 Line::from(vec![
344 Span::styled(
345 format!("{glyph} "),
346 Style::default()
347 .fg(header_color)
348 .add_modifier(Modifier::BOLD),
349 ),
350 Span::styled(
351 verb.to_string(),
352 Style::default()
353 .fg(header_color)
354 .add_modifier(Modifier::BOLD),
355 ),
356 Span::raw(" "),
357 Span::styled(role.to_string(), Style::default().fg(palette::TEXT_PRIMARY)),
358 Span::raw(" "),
359 Span::styled(
360 format!("[{}]", status.label()),
361 Style::default().fg(header_color),
362 ),
363 Span::raw(" "),
364 Span::styled(detail.to_string(), Style::default().fg(palette::TEXT_MUTED)),
365 ])
366 }
367
368 fn truncate_action(text: &str, max: usize) -> String {
369 let trimmed = text.trim();
370 if trimmed.chars().count() <= max {
371 trimmed.to_string()
372 } else {
373 let mut out: String = trimmed.chars().take(max.saturating_sub(1)).collect();
374 out.push('\u{2026}');
375 out
376 }
377 }
378
379 /// Apply a mailbox envelope to a `DelegateCard`. Returns `true` if the
380 /// state changed (UI may want to redraw); `false` if the envelope was for
381 /// a different `agent_id`.
382 pub fn apply_to_delegate(card: &mut DelegateCard, msg: &MailboxMessage) -> bool {
383 if msg.agent_id() != card.agent_id {
384 return false;
385 }
386 match msg {
387 MailboxMessage::Started { .. } => {
388 card.status = AgentLifecycle::Running;
389 }
390 MailboxMessage::Progress { status, .. } => {
391 card.status = AgentLifecycle::Running;
392 card.push_action(status);
393 }
394 MailboxMessage::ToolCallStarted {
395 tool_name, step, ..
396 } => {
397 card.push_action(format!("[{step}] {tool_name} started"));
398 }
399 MailboxMessage::ToolCallCompleted {
400 tool_name,
401 step,
402 ok,
403 ..
404 } => {
405 card.push_action(format!(
406 "[{step}] {tool_name} {}",
407 if *ok { "ok" } else { "failed" }
408 ));
409 }
410 MailboxMessage::Completed { summary, .. } => {
411 card.status = AgentLifecycle::Completed;
412 card.summary = Some(summary.clone());
413 }
414 MailboxMessage::Failed { error, .. } => {
415 card.status = AgentLifecycle::Failed;
416 card.summary = Some(error.clone());
417 }
418 MailboxMessage::Cancelled { .. } => {
419 card.status = AgentLifecycle::Cancelled;
420 }
421 MailboxMessage::ChildSpawned { .. } => {
422 // Delegate cards represent a single agent; child spawns belong
423 // to a sibling fanout card, not this one.
424 return false;
425 }
426 MailboxMessage::TokenUsage { .. } => {
427 // Cost accumulation happens in handle_subagent_mailbox (ui.rs)
428 // before this apply function is called; TokenUsage never reaches
429 // this arm in practice.
430 return false;
431 }
432 }
433 true
434 }
435
436 /// Apply a mailbox envelope to a `FanoutCard`. Updates per-worker state
437 /// based on which child the envelope is about. Returns `true` on change.
438 pub fn apply_to_fanout(card: &mut FanoutCard, msg: &MailboxMessage) -> bool {
439 let id = msg.agent_id();
440 match msg {
441 MailboxMessage::Started { .. } => {
442 card.claim_pending_worker(id, AgentLifecycle::Running);
443 true
444 }
445 MailboxMessage::Progress { .. } | MailboxMessage::ToolCallStarted { .. } => {
446 card.claim_pending_worker(id, AgentLifecycle::Running);
447 true
448 }
449 MailboxMessage::ToolCallCompleted { .. } => true,
450 MailboxMessage::Completed { .. } => {
451 card.upsert_worker(id, AgentLifecycle::Completed);
452 true
453 }
454 MailboxMessage::Failed { .. } => {
455 card.upsert_worker(id, AgentLifecycle::Failed);
456 true
457 }
458 MailboxMessage::Cancelled { .. } => {
459 card.upsert_worker(id, AgentLifecycle::Cancelled);
460 true
461 }
462 MailboxMessage::ChildSpawned { child_id, .. } => {
463 card.upsert_worker(child_id, AgentLifecycle::Pending);
464 true
465 }
466 MailboxMessage::TokenUsage { .. } => {
467 // Cost accumulation happens in handle_subagent_mailbox (ui.rs)
468 // before this apply function is called; TokenUsage never reaches
469 // this arm in practice.
470 true
471 }
472 }
473 }
474
475 #[cfg(test)]
476 mod tests {
477 use super::*;
478
479 fn render_to_strings(lines: &[Line<'static>]) -> Vec<String> {
480 lines
481 .iter()
482 .map(|line| {
483 line.spans
484 .iter()
485 .map(|span| span.content.as_ref())
486 .collect::<String>()
487 })
488 .collect()
489 }
490
491 #[test]
492 fn delegate_card_truncates_to_last_three_actions_with_ellipsis() {
493 let mut card = DelegateCard::new("agent_001", "general");
494 card.push_action("read README.md");
495 card.push_action("grep TODO");
496 card.push_action("edit src/lib.rs");
497 // Up to the limit — no truncation yet.
498 assert!(!card.truncated());
499 assert_eq!(card.action_count(), DELEGATE_MAX_ACTIONS);
500
501 card.push_action("write tests");
502 card.push_action("run cargo test");
503 assert!(card.truncated(), "truncation flag flips on overflow");
504 assert_eq!(
505 card.action_count(),
506 DELEGATE_MAX_ACTIONS,
507 "stable steady-state size"
508 );
509
510 let rendered = render_to_strings(&card.render_lines(80));
511 assert!(
512 rendered.iter().any(|line| line.contains('\u{2026}')),
513 "ellipsis indicator must render: got {rendered:?}"
514 );
515 // The oldest two actions ("read README.md", "grep TODO") were dropped.
516 assert!(
517 !rendered.iter().any(|line| line.contains("read README.md")),
518 "oldest action evicted: got {rendered:?}"
519 );
520 assert!(
521 rendered.iter().any(|line| line.contains("run cargo test")),
522 "newest action retained: got {rendered:?}"
523 );
524 assert!(
525 rendered.iter().any(|line| line.contains("write tests")),
526 "second-newest retained: got {rendered:?}"
527 );
528 assert!(
529 rendered.iter().any(|line| line.contains("edit src/lib.rs")),
530 "third-newest retained: got {rendered:?}"
531 );
532 }
533
534 #[test]
535 fn delegate_card_terminal_status_renders_summary_row() {
536 let mut card = DelegateCard::new("agent_002", "explore");
537 card.push_action("listing files");
538 let msg = MailboxMessage::Completed {
539 agent_id: "agent_002".into(),
540 summary: "scanned 42 files, no TODOs found".into(),
541 };
542 assert!(apply_to_delegate(&mut card, &msg));
543 assert_eq!(card.status, AgentLifecycle::Completed);
544 let rendered = render_to_strings(&card.render_lines(80));
545 assert!(
546 rendered
547 .iter()
548 .any(|line| line.contains("scanned 42 files")),
549 "summary row renders on terminal status: got {rendered:?}"
550 );
551 }
552
553 #[test]
554 fn delegate_card_ignores_envelopes_for_other_agents() {
555 let mut card = DelegateCard::new("agent_a", "general");
556 let other = MailboxMessage::progress("agent_b", "noise");
557 assert!(!apply_to_delegate(&mut card, &other));
558 assert_eq!(card.action_count(), 0);
559 }
560
561 #[test]
562 fn fanout_card_dot_grid_renders_stateful_worker_slots() {
563 let mut card = FanoutCard::new("fanout")
564 .with_workers(["w_1", "w_2", "w_3", "w_4", "w_5", "w_6", "w_7"]);
565 card.upsert_worker("w_1", AgentLifecycle::Completed);
566 card.upsert_worker("w_2", AgentLifecycle::Completed);
567 card.upsert_worker("w_3", AgentLifecycle::Running);
568 card.upsert_worker("w_4", AgentLifecycle::Failed);
569 // 5/6/7 stay Pending.
570
571 // Completed fills; running and failed are distinct; pending stays open.
572 assert_eq!(
573 card.dot_grid(),
574 "\u{25CF}\u{25CF}\u{25D0}\u{00D7}\u{25CB}\u{25CB}\u{25CB}"
575 );
576 }
577
578 #[test]
579 fn fanout_card_aggregate_counts_match_dot_grid() {
580 let mut card = FanoutCard::new("rlm").with_workers(["w_1", "w_2", "w_3", "w_4"]);
581 card.upsert_worker("w_1", AgentLifecycle::Completed);
582 card.upsert_worker("w_2", AgentLifecycle::Completed);
583 card.upsert_worker("w_3", AgentLifecycle::Completed);
584 card.upsert_worker("w_4", AgentLifecycle::Failed);
585 let rendered = render_to_strings(&card.render_lines(80));
586 // The stats row is the one carrying "running" too; the header may
587 // mention "done" alone via the lifecycle status badge.
588 let stats = rendered
589 .iter()
590 .find(|line| line.contains("running") && line.contains("pending"))
591 .expect("counts line present");
592 assert!(stats.contains("3 done"), "completed count: {stats}");
593 assert!(
594 stats.contains("1 failed"),
595 "failed/cancelled fold into the same bucket: {stats}"
596 );
597 assert!(stats.contains("0 running"), "no running: {stats}");
598 assert!(stats.contains("0 pending"), "no pending: {stats}");
599 }
600
601 #[test]
602 fn fanout_apply_inserts_unknown_worker_via_child_spawned() {
603 let mut card = FanoutCard::new("fanout");
604 let msg = MailboxMessage::ChildSpawned {
605 parent_id: "root".into(),
606 child_id: "agent_late".into(),
607 };
608 assert!(apply_to_fanout(&mut card, &msg));
609 assert_eq!(card.worker_count(), 1);
610 assert_eq!(card.workers[0].agent_id, "agent_late");
611 assert_eq!(card.workers[0].status, AgentLifecycle::Pending);
612 }
613
614 #[test]
615 fn fanout_started_claims_seeded_pending_slot_without_growing_grid() {
616 let mut card = FanoutCard::new("fanout").with_workers(["task:a", "task:b"]);
617 let started =
618 MailboxMessage::started("agent_live", crate::tools::subagent::SubAgentType::General);
619
620 assert!(apply_to_fanout(&mut card, &started));
621
622 assert_eq!(card.worker_count(), 2);
623 assert_eq!(card.workers[0].agent_id, "agent_live");
624 assert_eq!(card.workers[0].status, AgentLifecycle::Running);
625 assert_eq!(card.workers[1].agent_id, "task:b");
626 assert_eq!(card.workers[1].status, AgentLifecycle::Pending);
627 }
628
629 #[test]
630 fn fanout_apply_transitions_worker_through_lifecycle() {
631 let mut card = FanoutCard::new("fanout").with_workers(["w_1"]);
632 let started = MailboxMessage::started("w_1", crate::tools::subagent::SubAgentType::General);
633 apply_to_fanout(&mut card, &started);
634 assert_eq!(card.workers[0].status, AgentLifecycle::Running);
635
636 let done = MailboxMessage::Completed {
637 agent_id: "w_1".into(),
638 summary: "ok".into(),
639 };
640 apply_to_fanout(&mut card, &done);
641 assert_eq!(card.workers[0].status, AgentLifecycle::Completed);
642 }
643
644 #[test]
645 fn fanout_dot_grid_arithmetic_for_various_n() {
646 // Spot-check several fanout sizes with a mix of states; this is the
647 // arithmetic snapshot the issue acceptance calls out.
648 let cases: &[(usize, usize, &str)] = &[
649 (1, 0, "\u{25CB}"),
650 (1, 1, "\u{25CF}"),
651 (3, 2, "\u{25CF}\u{25CF}\u{25CB}"),
652 (
653 7,
654 3,
655 "\u{25CF}\u{25CF}\u{25CF}\u{25CB}\u{25CB}\u{25CB}\u{25CB}",
656 ),
657 ];
658 for (total, done, expected) in cases {
659 let ids: Vec<String> = (0..*total).map(|i| format!("w_{i}")).collect();
660 let mut card = FanoutCard::new("fanout").with_workers(ids.iter().cloned());
661 for id in ids.iter().take(*done) {
662 card.upsert_worker(id, AgentLifecycle::Completed);
663 }
664 assert_eq!(
665 card.dot_grid(),
666 *expected,
667 "fanout dot-grid for total={total} done={done}",
668 );
669 }
670 }
671 }
672
672 lines RUST