| 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 |