返回 CodeWhale
agent_roster.rs
根目录 / crates / tui / src / tui / agent_roster.rs
1 //! Receipts-only projection of every agent that ran this session (#5479).
2 //!
3 //! Terminal and transcript rendering for the agent roster. Data structures
4 //! and projections are in [`crate::agent_roster`].
5
6 use std::collections::BTreeMap;
7
8 use crate::agent_roster::{
9 AgentRosterRow, all_rows_have_usage, format_duration, format_tokens, roster_totals,
10 };
11
12 /// Absent receipts render as `—`. See the truth rule in the module docs.
13 fn or_dash(value: Option<String>) -> String {
14 value.unwrap_or_else(|| "—".to_string())
15 }
16
17 /// Render the roster as transcript text.
18 ///
19 /// The parent session is row zero (`● main`); workflow parents collapse their
20 /// children into `n/m done` and the children are indented beneath them.
21 #[must_use]
22 pub fn render_agent_roster(rows: &[AgentRosterRow], parent_label: &str) -> String {
23 if rows.is_empty() {
24 return format!(
25 "● {parent_label}\n\nNo agents have run in this session yet. \
26 Spawn one with the `agent` tool, or `/fleet` to set up roles."
27 );
28 }
29
30 let mut children_by_parent: BTreeMap<&str, Vec<&AgentRosterRow>> = BTreeMap::new();
31 for row in rows {
32 if let Some(parent) = row.parent_run_id.as_deref() {
33 children_by_parent.entry(parent).or_default().push(row);
34 }
35 }
36
37 let mut out = format!("● {parent_label}\n");
38 for row in rows {
39 // A child is printed under its parent, not again at the top level.
40 if row
41 .parent_run_id
42 .as_deref()
43 .is_some_and(|parent| rows.iter().any(|candidate| candidate.run_id == parent))
44 {
45 continue;
46 }
47 out.push_str(&render_row(row, rows, 1));
48 append_descendants(&mut out, row.run_id.as_str(), &children_by_parent, rows, 2);
49 }
50 out.push_str(&render_totals(rows));
51 out
52 }
53
54 /// Footer totals, labelled honestly.
55 ///
56 /// When only some rows carry a usage receipt the line says so, because a bare
57 /// total silently implies it covers every agent listed above it.
58 fn render_totals(rows: &[AgentRosterRow]) -> String {
59 let (input, output) = roster_totals(rows);
60 if input.is_none() && output.is_none() {
61 return format!(
62 "\n{} agent{} · no usage receipts recorded\n",
63 rows.len(),
64 if rows.len() == 1 { "" } else { "s" }
65 );
66 }
67 let coverage = if all_rows_have_usage(rows) {
68 String::new()
69 } else {
70 let reported = rows
71 .iter()
72 .filter(|row| row.input_tokens.is_some() || row.output_tokens.is_some())
73 .count();
74 format!(" (receipts from {reported} of {} agents)", rows.len())
75 };
76 format!(
77 "\n{} agent{} · {} · {}{coverage}\n",
78 rows.len(),
79 if rows.len() == 1 { "" } else { "s" },
80 or_dash(input.map(|t| format!("↓ {}", format_tokens(t)))),
81 or_dash(output.map(|t| format!("↑ {}", format_tokens(t)))),
82 )
83 }
84
85 fn append_descendants(
86 out: &mut String,
87 parent_run_id: &str,
88 children_by_parent: &BTreeMap<&str, Vec<&AgentRosterRow>>,
89 all: &[AgentRosterRow],
90 depth: usize,
91 ) {
92 for child in children_by_parent.get(parent_run_id).into_iter().flatten() {
93 out.push_str(&render_row(child, all, depth));
94 append_descendants(
95 out,
96 child.run_id.as_str(),
97 children_by_parent,
98 all,
99 depth + 1,
100 );
101 }
102 }
103
104 fn render_row(row: &AgentRosterRow, all: &[AgentRosterRow], depth: usize) -> String {
105 let indent = " ".repeat(depth);
106 let elapsed = or_dash(row.millis.map(format_duration));
107 let input = or_dash(row.input_tokens.map(|t| format!("↓ {}", format_tokens(t))));
108 let output = or_dash(row.output_tokens.map(|t| format!("↑ {}", format_tokens(t))));
109 let activity = match row.workflow_progress(all) {
110 Some((done, total)) => format!("{done}/{total} agents done"),
111 None => or_dash(row.activity.clone()),
112 };
113 format!(
114 "{indent}{glyph} {name} {activity} {elapsed} · {input} · {output}\n",
115 glyph = row.state.glyph(),
116 name = row.display_name,
117 )
118 }
119
120 #[cfg(test)]
121 mod tests;
122
122 lines RUST