feat(tui): show sub-agents as type, objective, elapsed and tokens

The work surface listed sub-agents as a numbered role plus a run-on facts
string. It answered "which agent is this" and never answered the two
questions anyone actually asks of a running fleet: how long has it been
going, and what has it cost.

Rebuild the row as columns, keeping the placement, the selection, and the
click-to-open behaviour exactly as they were:

    > general-purpose  Streaming dead-code removal   12m 33s - 111.9k tokens

- Identity column is the agent's type, with `(+N)` when that agent has
  spawned children that are themselves on the surface. The sequential
  number and the whale nickname come off the strip; the nickname still
  lives in Agent Details, and no raw agent id renders (#36 holds).
- Per-agent token spend had no path to the renderer at all. `AgentRunUsage`
  lives on the manager behind an async lock, so accumulate the child's own
  `output_tokens` onto `AgentProgressMeta` from the `TokenUsage` mailbox
  envelope that already arrives synchronously. The down-arrow is received
  tokens, and the field stays `None` until a real envelope lands: an agent
  whose spend is unknown shows no figure rather than a fabricated `0`. A
  *reported* zero still renders, because that is a fact.
- Elapsed freezes. The manager recomputes `duration_ms` as
  `started_at.elapsed()` on every snapshot, so a finished agent's row ticked
  forever; latch the first terminal reading instead. Formatting goes through
  `crate::elapsed::format_elapsed_secs`, the existing convention.
- Narrow surfaces degrade in a settled order - tokens, then elapsed, then
  the type column - so the objective is the last thing to go. Everything
  truncates; nothing wraps. The type column is never truncated, only
  dropped, because a clipped `general-purpo...` misnames roles that share a
  prefix.
- A height-capped list ends in a `N more` line. The scrollbar showed
  position but never amount.

Three colour roles and no more: the objective is normal text, every
secondary figure is muted, and accent_primary keeps meaning "selected".
Status stays in the glyph rather than being spent as colour.

Verified with `cargo clippy -p codewhale-tui --all-targets -- -Dwarnings`
(clean) and `cargo test -p codewhale-tui --bin codewhale-tui --
work_surface:: sidebar:: rail_` (141 passed).
This commit is contained in:
Hmbown
2026-08-04 02:09:44 -07:00
parent 9573bfa0ff
commit 7b20ef513a
5 changed files with 717 additions and 84 deletions
+5
View File
@@ -441,6 +441,11 @@ pub struct AgentProgressMeta {
/// These stay absent until the provider actually reports usage.
pub resolved_provider: Option<String>,
pub resolved_model: Option<String>,
/// Tokens this child has *received* from its provider, accumulated across
/// its own usage envelopes. `None` until the provider actually reports
/// usage: a sub-agent whose spend is unknown renders no token figure at
/// all rather than a fabricated `0`.
pub received_tokens: Option<u64>,
}
/// Per-turn LSP repair-loop summary for the Turn Inspector (#4107).
+10 -1
View File
@@ -610,7 +610,16 @@ fn bounded_mailbox_message(message: &MailboxMessage) -> MailboxMessage {
fn record_agent_current_activity(app: &mut App, message: &MailboxMessage) {
let agent_id = message.agent_id().to_string();
let meta = app.agent_progress_meta.entry(agent_id).or_default();
if let MailboxMessage::TokenUsage { route, .. } = message {
if let MailboxMessage::TokenUsage { route, usage, .. } = message {
// The child's own received-token tally. `output_tokens` is what came
// back down from the provider, which is what the work-surface `↓`
// figure claims to be. Absent until a real envelope lands, so an
// agent with no reported usage shows no number instead of a zero.
meta.received_tokens = Some(
meta.received_tokens
.unwrap_or(0)
.saturating_add(u64::from(usage.output_tokens)),
);
meta.resolved_provider = Some(route.provider.as_str().to_string());
meta.resolved_model = Some(bound_agent_activity_text(
&crate::cost_status::sanitize_persisted_route_label(&route.model),
+283 -10
View File
@@ -972,8 +972,14 @@ mod tests {
.iter()
.find(|row| row.id.0 == "worker:agent_worker")
.expect("agent work row");
// #36: number + fleet role + short name — never the raw agent id.
assert_eq!(row.label, "1 worker · Blue Whale");
// The identity column is the agent's type. It is never the raw agent
// id (#36), and it carries no `(+N)` while the agent is childless.
assert_eq!(row.label, "worker");
let facts = row.agent.as_ref().expect("agent row facts");
assert_eq!(facts.objective, "Wire settled file activity");
assert_eq!(facts.elapsed_secs, Some(0));
// No usage envelope has been seen, so there is no token figure at all.
assert_eq!(facts.tokens, None);
assert!(row.detail.contains("Wire settled file activity"));
assert!(row.detail.contains("using File.apply_patch"));
assert!(row.detail.contains("step 2"));
@@ -1024,8 +1030,8 @@ mod tests {
}
#[test]
fn agent_rows_number_by_fleet_role_and_never_leak_raw_ids() {
// #36: the strip shows sequential number + fleet role; the raw agent
fn agent_rows_identify_by_fleet_role_and_never_leak_raw_ids() {
// #36: the strip identifies an agent by its fleet role; the raw agent
// id hash is noise and must never render as the "name". Flat fan-outs
// carry no nesting chrome.
let mut app = app();
@@ -1054,8 +1060,8 @@ mod tests {
.iter()
.find(|row| row.id.0 == "worker:agent_99aa77bb")
.expect("second agent row");
assert_eq!(first.label, "1 builder");
assert_eq!(second.label, "2 scout");
assert_eq!(first.label, "builder");
assert_eq!(second.label, "scout");
assert!(first.detail.starts_with("running"), "{}", first.detail);
for row in rows.iter().filter(|row| row.id.0.starts_with("worker:")) {
assert!(!row.label.contains("agent_e0b2dcf1"), "{}", row.label);
@@ -1071,7 +1077,8 @@ mod tests {
#[test]
fn agent_rows_order_and_indent_nested_spawns_under_their_parent() {
// #36: nesting is visible only when actually present — the child
// renders directly under its parent with a `↳` indent.
// renders directly under its parent with a `↳` indent, and the parent
// advertises the child it spawned as `(+1)`.
let mut app = app();
app.current_session_id = Some(SESSION.to_string());
app.subagent_cache.push(cached_worker(
@@ -1097,11 +1104,11 @@ mod tests {
.collect::<Vec<_>>();
let parent_pos = worker_labels
.iter()
.position(|label| *label == "1 builder")
.expect("parent row label");
.position(|label| *label == "builder (+1)")
.expect("parent row label with child count");
let child_pos = worker_labels
.iter()
.position(|label| *label == " 2 scout")
.position(|label| *label == "↳ scout")
.expect("indented child row label");
assert!(
child_pos == parent_pos + 1,
@@ -1154,6 +1161,272 @@ mod tests {
assert!(!row.detail.contains("files changed"), "{}", row.detail);
}
// ---- Fleet row layout -------------------------------------------------
/// Painted lines, one per terminal row, trailing padding removed.
fn render_rows(app: &mut App, width: u16, height: u16) -> Vec<String> {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|frame| super::render(frame, frame.area(), app))
.expect("draw");
let buffer = terminal.backend().buffer().clone();
(0..height)
.map(|y| {
(0..width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
.trim_end()
.to_string()
})
.collect()
}
fn fleet_row(rows: &[String]) -> String {
rows.iter()
.find(|line| line.contains("Streaming"))
.cloned()
.unwrap_or_else(|| panic!("no fleet row in {rows:?}"))
}
fn fleet_worker(
id: &str,
role: &str,
objective: &str,
duration_ms: u64,
status: SubAgentStatus,
) -> SubAgentResult {
let mut agent = cached_worker(id, role, None, None, status);
agent.assignment.objective = objective.to_string();
agent.duration_ms = duration_ms;
agent
}
/// Seed a live fleet of one, with a reported token spend.
fn fleet_app(tokens: Option<u64>) -> App {
let mut app = app();
app.current_session_id = Some(SESSION.to_string());
app.subagent_cache.push(fleet_worker(
"agent_stream",
"general-purpose",
"Streaming dead-code removal",
753_000,
SubAgentStatus::Running,
));
app.agent_progress_meta.insert(
"agent_stream".to_string(),
crate::tui::app::AgentProgressMeta {
received_tokens: tokens,
..crate::tui::app::AgentProgressMeta::default()
},
);
app
}
#[test]
fn fleet_row_lays_out_type_objective_and_a_right_aligned_receipt() {
let mut app = fleet_app(Some(111_900));
let rows = render_rows(&mut app, 100, 4);
assert_eq!(
fleet_row(&rows),
" ▸ general-purpose Streaming dead-code removal \
12m 33s · ↓ 111.9k tokens"
);
// The group header the strip already had stays put.
assert!(
rows.iter().any(|line| line.contains("Subagents 1")),
"{rows:?}"
);
}
#[test]
fn fleet_row_drops_tokens_then_elapsed_then_type_as_the_surface_narrows() {
// Settled degradation order. The objective is the last thing to go and
// every column truncates rather than wrapping.
let mut app = fleet_app(Some(111_900));
let medium = fleet_row(&render_rows(&mut app, 62, 4));
assert!(medium.contains("12m 33s"), "{medium}");
assert!(!medium.contains("tokens"), "{medium}");
assert!(medium.contains("general-purpose"), "{medium}");
let narrow = fleet_row(&render_rows(&mut app, 44, 4));
assert!(!narrow.contains("tokens"), "{narrow}");
assert!(!narrow.contains("12m 33s"), "{narrow}");
assert!(narrow.contains("general-purpose"), "{narrow}");
let tight = fleet_row(&render_rows(&mut app, 28, 4));
assert!(!tight.contains("general-purpose"), "{tight}");
assert!(tight.contains("Streaming"), "{tight}");
for line in [&medium, &narrow, &tight] {
assert!(line.chars().all(|ch| ch != '\n'), "{line}");
}
}
#[test]
fn fleet_row_elapsed_freezes_once_the_agent_is_finished() {
// The manager recomputes `duration_ms` as `started_at.elapsed()` on
// every snapshot, so a finished agent's raw duration keeps growing.
// The row must latch the first terminal reading instead.
let mut app = fleet_app(None);
app.subagent_cache[0].status = SubAgentStatus::Completed;
app.subagent_cache[0].duration_ms = 753_000;
let first = super::model::project(&mut app);
let finished = first
.iter()
.find(|row| row.id.0 == "worker:agent_stream")
.and_then(|row| row.agent.as_ref())
.expect("finished agent facts");
assert_eq!(finished.elapsed_secs, Some(753));
// A later snapshot reports a larger duration for the same dead agent.
app.subagent_cache[0].duration_ms = 999_000;
let second = super::model::project(&mut app);
let still = second
.iter()
.find(|row| row.id.0 == "worker:agent_stream")
.and_then(|row| row.agent.as_ref())
.expect("finished agent facts");
assert_eq!(
still.elapsed_secs,
Some(753),
"finished elapsed must freeze"
);
}
#[test]
fn fleet_row_elapsed_still_advances_while_the_agent_runs() {
let mut app = fleet_app(None);
app.subagent_cache[0].duration_ms = 10_000;
let early = super::model::project(&mut app);
assert_eq!(
early
.iter()
.find(|row| row.id.0 == "worker:agent_stream")
.and_then(|row| row.agent.as_ref())
.expect("running agent facts")
.elapsed_secs,
Some(10)
);
app.subagent_cache[0].duration_ms = 40_000;
let later = super::model::project(&mut app);
assert_eq!(
later
.iter()
.find(|row| row.id.0 == "worker:agent_stream")
.and_then(|row| row.agent.as_ref())
.expect("running agent facts")
.elapsed_secs,
Some(40)
);
}
#[test]
fn fleet_row_with_no_reported_usage_shows_no_token_figure_at_all() {
// An unknown number is rendered as nothing. Never `0`, which would
// claim the agent spent nothing.
let mut app = fleet_app(None);
let row = fleet_row(&render_rows(&mut app, 100, 4));
assert!(!row.contains("tokens"), "{row}");
assert!(!row.contains('↓'), "{row}");
assert!(row.contains("12m 33s"), "{row}");
let mut spent = fleet_app(Some(0));
let zero = fleet_row(&render_rows(&mut spent, 100, 4));
// A *reported* zero is a fact and does render.
assert!(zero.contains("↓ 0 tokens"), "{zero}");
}
#[test]
fn fleet_row_child_badge_counts_children_that_are_on_the_surface() {
let mut app = app();
app.current_session_id = Some(SESSION.to_string());
app.subagent_cache.push(cached_worker(
"agent_lead",
"general-purpose",
None,
None,
SubAgentStatus::Running,
));
for child in ["agent_c1", "agent_c2", "agent_c3"] {
app.subagent_cache.push(cached_worker(
child,
"scout",
None,
Some("agent_lead"),
SubAgentStatus::Running,
));
}
// A child whose parent is not on the surface must not be counted for
// anyone, and must not inflate the lead's badge.
app.subagent_cache.push(cached_worker(
"agent_orphan",
"scout",
None,
Some("agent_missing"),
SubAgentStatus::Running,
));
let rows = super::model::project(&mut app);
let label = |id: &str| {
rows.iter()
.find(|row| row.id.0 == format!("worker:{id}"))
.map(|row| row.label.clone())
.unwrap_or_else(|| panic!("row for {id}"))
};
assert_eq!(label("agent_lead"), "general-purpose (+3)");
assert_eq!(label("agent_c1"), "↳ scout");
assert_eq!(label("agent_orphan"), "scout");
}
#[test]
fn a_capped_fleet_list_announces_how_many_rows_it_is_hiding() {
let mut app = app();
app.current_session_id = Some(SESSION.to_string());
for index in 0..8 {
app.subagent_cache.push(cached_worker(
&format!("agent_{index}"),
"general-purpose",
None,
None,
SubAgentStatus::Running,
));
}
// Four content rows for nine projected rows (header + eight workers).
let rows = render_rows(&mut app, 100, 5);
let more = rows
.iter()
.find(|line| line.contains("more"))
.unwrap_or_else(|| panic!("no overflow line in {rows:?}"));
// Nine projected rows (header + eight workers); three fit, six do not.
assert!(more.contains("↓ 6 more"), "{more}");
// Right-aligned against the content column, not the left margin.
assert!(more.starts_with(" "), "{more}");
}
#[test]
fn fleet_rows_render_in_top_left_and_right_placements() {
for placement in [
super::WorkSurfacePlacement::Top,
super::WorkSurfacePlacement::Left,
super::WorkSurfacePlacement::Right,
] {
let mut app = fleet_app(Some(111_900));
app.work_surface.placement = placement;
app.work_surface.effective_placement = placement;
let rows = render_rows(&mut app, 40, 8);
let row = fleet_row(&rows);
assert!(
row.contains("Streaming"),
"{placement:?} lost the objective: {rows:?}"
);
}
}
#[test]
fn progress_only_work_rows_use_typed_activity_not_display_substrings() {
let mut app = app();
+154 -63
View File
@@ -7,8 +7,8 @@ use ratatui::layout::Rect;
use crate::settings::InlineDiffMode;
use crate::tools::canonical_action::canonical_action_alias;
use crate::tools::subagent::{AgentWorkerStatus, SubAgentStatus};
use crate::tui::app::{AgentCurrentActivityStatus, App, SidebarRowAction};
use crate::tools::subagent::{AgentWorkerStatus, SubAgentResult, SubAgentStatus};
use crate::tui::app::{AgentCurrentActivityStatus, AgentProgressMeta, App, SidebarRowAction};
use crate::tui::history::{
FileActivityKind, FileActivitySummary, FileMutationReceipt, HistoryCell, ToolCell,
};
@@ -121,6 +121,27 @@ pub(super) struct WorkRow {
pub tone: WorkTone,
pub selectable: bool,
pub primary_action: Option<SidebarRowAction>,
/// Present only on sub-agent rows. Carries the fields the fleet row paints
/// beyond `label`, so the renderer can drop them one at a time as the
/// surface narrows instead of truncating one pre-joined string.
pub agent: Option<AgentRowFacts>,
}
/// The parts of a sub-agent row that are laid out as their own columns.
///
/// `label` already carries the identity column (nesting indent, agent type,
/// `(+N)` child count). This carries the rest: what the agent is doing, and
/// the right-aligned receipt.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(super) struct AgentRowFacts {
/// What the agent was sent to do.
pub objective: String,
/// Wall-clock seconds, frozen once the agent is observed terminal so a
/// finished agent stops ticking. `None` when no duration is known.
pub elapsed_secs: Option<u64>,
/// Tokens received from the provider. `None` means *genuinely unknown* —
/// the row then renders no token figure rather than claiming zero.
pub tokens: Option<u64>,
}
#[derive(Debug, Clone)]
@@ -221,6 +242,12 @@ pub struct WorkSurfaceState {
/// Bumped on accepted user turns / newly started operations.
user_turn_epoch: u64,
last_handled_user_turn_epoch: u64,
/// Elapsed wall-clock, in ms, captured the first frame each sub-agent was
/// observed in a terminal state. The manager's `duration_ms` is
/// `started_at.elapsed()` recomputed per snapshot, so it keeps growing
/// after an agent finishes; latching the first terminal reading is what
/// makes a completed row stop ticking.
pub(super) frozen_agent_elapsed_ms: std::collections::HashMap<String, u64>,
/// Session-instance ownership of the restored session record (#4416).
pub(crate) session_instance: Option<SessionInstanceScope>,
/// Test override for the sessions directory the ownership probe reads;
@@ -287,6 +314,7 @@ impl WorkSurfaceState {
activity_suppressed: false,
user_turn_epoch: 0,
last_handled_user_turn_epoch: 0,
frozen_agent_elapsed_ms: std::collections::HashMap::new(),
session_instance: None,
session_owner_probe_dir: None,
}
@@ -419,6 +447,7 @@ impl WorkSurfaceState {
pub(super) fn project(app: &mut App) -> Vec<WorkRow> {
let active_session = app.current_session_id.is_some();
freeze_terminal_agent_elapsed(app);
let agents = agent_rows(app);
let coordination = coordination_row(app);
let activity = settled_file_activity(app);
@@ -961,6 +990,7 @@ fn coordination_row(app: &App) -> Option<RankedWorkRow> {
body: crate::tui::coordination_detail::format(app.ui_locale, projection),
stop_action: None,
}),
agent: None,
},
})
}
@@ -998,7 +1028,6 @@ struct AgentRowSeed {
agent_id: String,
parent_run_id: Option<String>,
role: String,
name: Option<String>,
ranked: RankedWorkRow,
}
@@ -1012,16 +1041,18 @@ fn agent_nesting_indent(depth: usize) -> String {
}
}
/// Compose the condensed strip label: sequential number + fleet role, plus a
/// short name only when a real one exists (nickname or stable label). Raw
/// agent-id hashes are never a name — when no name is known the label simply
/// omits it rather than fabricating one (#36).
fn agent_strip_label(indent: &str, number: usize, role: &str, name: Option<&str>) -> String {
let base = match name {
Some(name) => format!("{number} {role} · {name}"),
None => format!("{number} {role}"),
};
format!("{indent}{base}")
/// Compose the sub-agent identity column: nesting indent, the agent's
/// type/role, and `(+N)` when that agent has spawned children of its own.
///
/// The raw agent-id hash is never a name and is never rendered (#36); the
/// nickname lives in Agent Details, not in this column, so the type stays
/// scannable down the left edge the way a fleet listing should read.
fn agent_strip_label(indent: &str, role: &str, children: usize) -> String {
if children == 0 {
format!("{indent}{role}")
} else {
format!("{indent}{role} (+{children})")
}
}
/// Order worker rows so nested spawns sit directly under their parent, then
@@ -1072,6 +1103,18 @@ fn order_agent_seeds(seeds: Vec<AgentRowSeed>) -> Vec<RankedWorkRow> {
push_tree(idx, 0, &seeds, &children, &mut seen, &mut order);
}
// `(+N)` counts children that are actually on this surface: the same map
// the tree walk used, so the badge can never promise a child the list does
// not show. Snapshot it before `seeds` is consumed — `children` borrows it.
let child_counts: Vec<usize> = seeds
.iter()
.map(|seed| {
children
.get(seed.agent_id.as_str())
.map_or(0, |indices| indices.len())
})
.collect();
let mut slots: Vec<Option<AgentRowSeed>> = seeds.into_iter().map(Some).collect();
order
.into_iter()
@@ -1080,12 +1123,7 @@ fn order_agent_seeds(seeds: Vec<AgentRowSeed>) -> Vec<RankedWorkRow> {
let seed = slots[idx].take().expect("each row emitted exactly once");
let mut ranked = seed.ranked;
let indent = agent_nesting_indent(depth.min(3));
ranked.row.label = agent_strip_label(
&indent,
position.saturating_add(1),
&seed.role,
seed.name.as_deref(),
);
ranked.row.label = agent_strip_label(&indent, &seed.role, child_counts[idx]);
// `ordered_rows` re-sorts within status buckets by `order`; stamp
// the tree position so a child sorts directly under its parent
// whenever they share a bucket.
@@ -1125,46 +1163,9 @@ fn agent_rows(app: &App) -> Vec<RankedWorkRow> {
.filter(|role| !role.trim().is_empty())
.unwrap_or_else(|| agent.agent_type.as_str())
.to_string();
// A name is a nickname or stable label — never `agent.name`,
// which is the raw session id hash (#36).
let name = agent
.nickname
.clone()
.filter(|name| !name.trim().is_empty() && name != &agent.agent_id)
.or_else(|| app.agent_label_map.get(&agent.agent_id).cloned());
let terminal = current_activity
.map(|activity| {
matches!(
activity.status,
AgentCurrentActivityStatus::Done
| AgentCurrentActivityStatus::Canceled
| AgentCurrentActivityStatus::Failed
| AgentCurrentActivityStatus::Interrupted
)
})
.or_else(|| {
agent.worker_status.map(|worker_status| {
matches!(
worker_status,
AgentWorkerStatus::Completed
| AgentWorkerStatus::Cancelled
| AgentWorkerStatus::Failed
| AgentWorkerStatus::Interrupted
)
})
})
.unwrap_or(matches!(
agent.status,
SubAgentStatus::Completed
| SubAgentStatus::Cancelled
| SubAgentStatus::Failed(_)
| SubAgentStatus::Interrupted(_)
| SubAgentStatus::BudgetExhausted
));
let mut facts = vec![
status.to_string(),
summarize_assignment(&agent.assignment.objective),
];
let terminal = agent_is_terminal(agent, meta);
let objective = summarize_assignment(&agent.assignment.objective);
let mut facts = vec![status.to_string(), objective.clone()];
// Quiet completion (#36): a finished agent keeps its one-line
// status and objective; in-flight metadata (current tool, step
// counters, file tallies) is working state, not a receipt, and
@@ -1194,7 +1195,6 @@ fn agent_rows(app: &App) -> Vec<RankedWorkRow> {
agent_id: agent.agent_id.clone(),
parent_run_id: agent.parent_run_id.clone(),
role,
name,
ranked: RankedWorkRow {
bucket,
order,
@@ -1203,7 +1203,7 @@ fn agent_rows(app: &App) -> Vec<RankedWorkRow> {
id: WorkRowId(format!("worker:{}", agent.agent_id)),
mark: agent_mark(bucket),
// Stamped by `order_agent_seeds` once the display
// order (and therefore the number) is known.
// depth (and therefore the indent) is known.
label: String::new(),
detail: facts.join(" · "),
tone: bucket_tone(bucket),
@@ -1211,6 +1211,13 @@ fn agent_rows(app: &App) -> Vec<RankedWorkRow> {
primary_action: Some(SidebarRowAction::OpenAgentDetail {
agent_id: agent.agent_id.clone(),
}),
agent: Some(AgentRowFacts {
objective,
elapsed_secs: Some(
agent_elapsed_ms(app, &agent.agent_id, agent.duration_ms) / 1_000,
),
tokens: meta.and_then(|meta| meta.received_tokens),
}),
},
},
}
@@ -1236,7 +1243,6 @@ fn agent_rows(app: &App) -> Vec<RankedWorkRow> {
let bucket = current_activity
.map(|activity| current_activity_status_bucket(activity.status))
.unwrap_or(WorkBucket::Active);
let name = app.agent_label_map.get(id).cloned();
let mut facts = vec![status.to_string()];
if let Some(detail) =
current_activity.and_then(|activity| activity.detail.as_deref())
@@ -1263,7 +1269,6 @@ fn agent_rows(app: &App) -> Vec<RankedWorkRow> {
// Role is unknown until the manager snapshot arrives;
// "agent" is the honest fallback, not a fabrication.
role: "agent".to_string(),
name,
ranked: RankedWorkRow {
bucket,
order: 5_000usize.saturating_add(order),
@@ -1278,6 +1283,17 @@ fn agent_rows(app: &App) -> Vec<RankedWorkRow> {
primary_action: Some(SidebarRowAction::OpenAgentDetail {
agent_id: id.clone(),
}),
agent: Some(AgentRowFacts {
// No manager snapshot yet, so there is no
// assignment to quote: the live activity line
// is the honest answer to "what is it doing".
objective: facts.join(" · "),
// Neither a duration nor a usage envelope has
// been seen for this id. Both render as
// nothing rather than as `0s` / `0 tokens`.
elapsed_secs: None,
tokens: meta.and_then(|meta| meta.received_tokens),
}),
},
},
}
@@ -1290,6 +1306,78 @@ fn summarize_assignment(value: &str) -> String {
crate::tui::history::summarize_tool_output(value)
}
/// Has this agent stopped working? Typed live activity wins over the worker
/// status, which in turn wins over the coarse manager status — the same
/// precedence the row's status label and bucket already use.
fn agent_is_terminal(agent: &SubAgentResult, meta: Option<&AgentProgressMeta>) -> bool {
meta.and_then(|meta| meta.current_activity.as_ref())
.map(|activity| {
matches!(
activity.status,
AgentCurrentActivityStatus::Done
| AgentCurrentActivityStatus::Canceled
| AgentCurrentActivityStatus::Failed
| AgentCurrentActivityStatus::Interrupted
)
})
.or_else(|| {
agent.worker_status.map(|worker_status| {
matches!(
worker_status,
AgentWorkerStatus::Completed
| AgentWorkerStatus::Cancelled
| AgentWorkerStatus::Failed
| AgentWorkerStatus::Interrupted
)
})
})
.unwrap_or(matches!(
agent.status,
SubAgentStatus::Completed
| SubAgentStatus::Cancelled
| SubAgentStatus::Failed(_)
| SubAgentStatus::Interrupted(_)
| SubAgentStatus::BudgetExhausted
))
}
/// Latch each finished agent's elapsed time the first frame it is observed
/// terminal, and forget agents that have left the cache.
///
/// The manager recomputes `SubAgentResult::duration_ms` as
/// `started_at.elapsed()` on every snapshot, so a completed agent's duration
/// keeps growing for as long as it stays listed. Without this pass a finished
/// row would tick forever, which is exactly the thing a receipt must not do.
fn freeze_terminal_agent_elapsed(app: &mut App) {
let live: HashSet<&str> = app
.subagent_cache
.iter()
.map(|agent| agent.agent_id.as_str())
.collect();
app.work_surface
.frozen_agent_elapsed_ms
.retain(|id, _| live.contains(id.as_str()));
for agent in &app.subagent_cache {
if !agent_is_terminal(agent, app.agent_progress_meta.get(&agent.agent_id)) {
continue;
}
app.work_surface
.frozen_agent_elapsed_ms
.entry(agent.agent_id.clone())
.or_insert(agent.duration_ms);
}
}
/// Frozen elapsed for a finished agent, live elapsed for a running one.
fn agent_elapsed_ms(app: &App, agent_id: &str, duration_ms: u64) -> u64 {
app.work_surface
.frozen_agent_elapsed_ms
.get(agent_id)
.copied()
.unwrap_or(duration_ms)
}
fn current_activity_status_bucket(status: AgentCurrentActivityStatus) -> WorkBucket {
match status {
AgentCurrentActivityStatus::Waiting
@@ -1515,6 +1603,7 @@ fn aggregate_activity_row(activity: &SettledFileActivity) -> Option<RankedWorkRo
body: body_parts.join("\n\n"),
stop_action: None,
}),
agent: None,
},
})
}
@@ -1646,6 +1735,7 @@ fn section_heading(id: &str, label: &str, detail: &str) -> WorkRow {
tone: WorkTone::Heading,
selectable: false,
primary_action: None,
agent: None,
}
}
@@ -1703,6 +1793,7 @@ fn graph_node_row(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> WorkRow {
body: inspector_text(snapshot, node),
stop_action: stop_action.map(Box::new),
}),
agent: None,
}
}
+265 -10
View File
@@ -15,7 +15,7 @@ use crate::tui::app::{App, SidebarHoverRow, SidebarHoverSection};
use crate::tui::ui_text::truncate_line_to_width;
use super::model::{
RailPanel, WorkHitbox, WorkRow, WorkSurfacePlacement, WorkTone, project_visible,
AgentRowFacts, RailPanel, WorkHitbox, WorkRow, WorkSurfacePlacement, WorkTone, project_visible,
};
const SIDE_RAIL_MIN_HOST_WIDTH: u16 = 72;
@@ -337,6 +337,15 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
let list_height = body_area.height.saturating_sub(header_height);
let body_height = usize::from(list_height);
let overflow = rows.len() > body_height;
// A capped list owes the reader the size of what it is hiding, so the
// last painted row becomes `↓ N more`. The scrollbar shows position; only
// this shows how much work is off-screen.
let more_row = overflow && body_height >= 2;
let list_rows = if more_row {
body_height.saturating_sub(1)
} else {
body_height
};
let inset = u16::from(body_area.width >= 60);
let rail_width = u16::from(overflow);
let content_area = Rect {
@@ -349,13 +358,13 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
height: list_height,
};
app.work_surface.visible_rows = body_height;
app.work_surface.visible_rows = list_rows;
app.work_surface.total_rows = rows.len();
// A redraw may clamp an obsolete offset, but it must not reveal the
// remembered keyboard selection: doing so undoes mouse-wheel scrolling
// whenever that selection is above the viewport (#4594).
app.work_surface.clamp_viewport(&rows);
let max_offset = rows.len().saturating_sub(body_height.max(1));
let max_offset = rows.len().saturating_sub(list_rows.max(1));
app.work_surface.scroll_offset = app.work_surface.scroll_offset.min(max_offset);
Block::default()
@@ -418,12 +427,9 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
}
let start = app.work_surface.scroll_offset;
let visible = rows
.iter()
.skip(start)
.take(body_height)
.collect::<Vec<_>>();
let mut lines = Vec::with_capacity(visible.len());
let visible = rows.iter().skip(start).take(list_rows).collect::<Vec<_>>();
let role_column = agent_role_column(&visible);
let mut lines = Vec::with_capacity(visible.len().saturating_add(1));
let mut hover_rows = Vec::new();
let mut hitboxes = Vec::new();
for (visible_index, row) in visible.iter().enumerate() {
@@ -451,6 +457,65 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
} else {
format!("{compact_owner}{mark} ")
};
// Sub-agent rows own their own column layout: glyph, agent type,
// objective, right-aligned elapsed and tokens. They stay ordinary
// rows in every other respect — same hitbox, same selection, same
// primary action.
if let Some(facts) = row.agent.as_ref() {
let laid_out = layout_agent_row(
usize::from(content_area.width),
UnicodeWidthStr::width(prefix.as_str()),
&row.label,
role_column,
facts,
);
let (normal, muted) = agent_row_styles(app, selected, hovered, opened);
let display = format!(
"{prefix}{}{}{}{}{}",
laid_out.role,
if laid_out.role.is_empty() {
String::new()
} else {
" ".repeat(AGENT_ROLE_GUTTER)
},
laid_out.objective,
" ".repeat(laid_out.gap),
laid_out.receipt,
);
let mut spans = vec![Span::styled(prefix.clone(), normal)];
if !laid_out.role.is_empty() {
spans.push(Span::styled(
format!("{}{}", laid_out.role, " ".repeat(AGENT_ROLE_GUTTER)),
muted,
));
}
spans.push(Span::styled(laid_out.objective.clone(), normal));
spans.push(Span::styled(
format!("{}{}", " ".repeat(laid_out.gap), laid_out.receipt),
muted,
));
lines.push(Line::from(spans));
hitboxes.push(WorkHitbox {
id: row.id.clone(),
row_y,
});
hover_rows.push(SidebarHoverRow {
row_y,
display_text: display,
full_text: format!("{} · {}", row.label, row.detail),
detail: Some(row.detail.clone()),
is_truncated: laid_out.objective != facts.objective
|| laid_out.receipt != agent_receipt(facts, AgentRowTier::Full),
click_action: row.primary_action.clone(),
stop_action: None,
stop_zone_start_col: None,
stop_zone_end_col: None,
});
continue;
}
let detail_candidate = if row.tone != WorkTone::Heading && content_area.width >= 44 {
format!(" {}", row.detail)
} else {
@@ -493,6 +558,30 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
}
}
if more_row {
// Right-aligned under the receipt column, muted like every other
// secondary figure. Scrolled to the bottom there is nothing below, so
// the reserved row stays blank rather than claiming a count of zero.
let remaining = rows
.len()
.saturating_sub(start.saturating_add(visible.len()));
let text = if remaining == 0 {
String::new()
} else {
truncate_line_to_width(
&format!("{remaining} more"),
usize::from(content_area.width),
)
};
let pad = usize::from(content_area.width).saturating_sub(UnicodeWidthStr::width(&*text));
lines.push(Line::from(Span::styled(
format!("{}{text}", " ".repeat(pad)),
Style::default()
.fg(app.ui_theme.text_muted)
.bg(app.ui_theme.surface_bg),
)));
}
Paragraph::new(lines).render(content_area, frame.buffer_mut());
render_divider(frame, area, placement, app);
if overflow {
@@ -505,7 +594,7 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
height: content_area.height,
},
app.work_surface.scroll_offset,
body_height,
list_rows,
rows.len(),
app,
);
@@ -701,6 +790,172 @@ fn top_todo_progress(app: &App, rows: &[WorkRow]) -> Option<String> {
)
}
/// Gap between the agent-type column and the objective.
const AGENT_ROLE_GUTTER: usize = 2;
/// Minimum gap between the objective and the right-aligned receipt.
const AGENT_RECEIPT_GUTTER: usize = 2;
/// Columns the objective must keep before an optional column may stay. Below
/// this the objective is a shrug — "Streaming d…" answers nothing — so the
/// optional column loses instead.
const AGENT_OBJECTIVE_MIN: usize = 24;
/// How much of a sub-agent row survives at the current width.
///
/// Degradation order, widest to narrowest: the token figure goes first, then
/// the elapsed time, then the agent-type column. The objective is the last
/// thing to go — a fleet row that cannot say what the agent is doing has
/// stopped being worth a row.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AgentRowTier {
/// Type, objective, elapsed, tokens.
Full,
/// Type, objective, elapsed.
NoTokens,
/// Type, objective.
NoReceipt,
/// Objective only.
ObjectiveOnly,
}
const AGENT_ROW_TIERS: [AgentRowTier; 4] = [
AgentRowTier::Full,
AgentRowTier::NoTokens,
AgentRowTier::NoReceipt,
AgentRowTier::ObjectiveOnly,
];
/// A sub-agent row resolved to painted columns.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct AgentRowText {
/// Agent-type column, padded to the shared width. Empty once dropped.
role: String,
objective: String,
/// `12m 33s · ↓ 111.9k tokens`. Empty once dropped.
receipt: String,
/// Spaces separating the objective from the receipt.
gap: usize,
}
/// The right-aligned receipt at a given tier. A figure the runtime never
/// reported is absent, never zero: an agent with no usage envelope shows no
/// token count at all.
fn agent_receipt(facts: &AgentRowFacts, tier: AgentRowTier) -> String {
let elapsed = facts
.elapsed_secs
.filter(|_| matches!(tier, AgentRowTier::Full | AgentRowTier::NoTokens))
.map(crate::elapsed::format_elapsed_secs);
let tokens = facts
.tokens
.filter(|_| tier == AgentRowTier::Full)
.map(|tokens| {
format!(
"↓ {} tokens",
crate::tui::footer_ui::format_token_count_compact(tokens)
)
});
match (elapsed, tokens) {
(Some(elapsed), Some(tokens)) => format!("{elapsed} · {tokens}"),
(Some(only), None) | (None, Some(only)) => only,
(None, None) => String::new(),
}
}
/// Shared width of the agent-type column across the rows painted this frame,
/// so the objectives line up the way a fleet listing should read.
///
/// Deliberately uncapped: an agent type is either shown whole or dropped by
/// the tier machinery. A truncated `general-purpo…` is a worse answer than no
/// type column at all, and it would misname roles that share a prefix.
fn agent_role_column(rows: &[&WorkRow]) -> usize {
rows.iter()
.filter(|row| row.agent.is_some())
.map(|row| UnicodeWidthStr::width(row.label.as_str()))
.max()
.unwrap_or(0)
}
/// Fit one sub-agent row into `width`, dropping optional columns in
/// [`AGENT_ROW_TIERS`] order until the objective has room to say something.
/// Every column truncates; nothing ever wraps.
fn layout_agent_row(
width: usize,
prefix_width: usize,
label: &str,
role_column: usize,
facts: &AgentRowFacts,
) -> AgentRowText {
for tier in AGENT_ROW_TIERS {
let receipt = agent_receipt(facts, tier);
let role = if tier == AgentRowTier::ObjectiveOnly || role_column == 0 {
String::new()
} else {
let pad = role_column.saturating_sub(UnicodeWidthStr::width(label));
format!("{label}{}", " ".repeat(pad))
};
let role_cost = if role.is_empty() {
0
} else {
UnicodeWidthStr::width(role.as_str()).saturating_add(AGENT_ROLE_GUTTER)
};
let receipt_cost = if receipt.is_empty() {
0
} else {
UnicodeWidthStr::width(receipt.as_str()).saturating_add(AGENT_RECEIPT_GUTTER)
};
let budget = width
.saturating_sub(prefix_width)
.saturating_sub(role_cost)
.saturating_sub(receipt_cost);
if budget < AGENT_OBJECTIVE_MIN && tier != AgentRowTier::ObjectiveOnly {
continue;
}
let objective = truncate_line_to_width(&facts.objective, budget);
let gap = width
.saturating_sub(prefix_width)
.saturating_sub(role_cost)
.saturating_sub(UnicodeWidthStr::width(objective.as_str()))
.saturating_sub(UnicodeWidthStr::width(receipt.as_str()));
return AgentRowText {
role,
objective,
receipt,
gap,
};
}
AgentRowText::default()
}
/// Normal-text and muted styles for one sub-agent row.
///
/// Three colour roles and no more: the objective is normal text, every
/// secondary figure (type, `(+N)`, elapsed, tokens) is muted, and
/// `accent_primary` means "this is the row you have selected" and nothing
/// else. Status is carried by the glyph, never by colour.
fn agent_row_styles(app: &App, selected: bool, hovered: bool, opened: bool) -> (Style, Style) {
let bg = if selected {
app.ui_theme.selection_bg
} else if hovered {
app.ui_theme.elevated_bg
} else {
app.ui_theme.surface_bg
};
let mut normal = Style::default().fg(app.ui_theme.text_body).bg(bg);
let mut muted = Style::default().fg(app.ui_theme.text_muted).bg(bg);
if selected || opened {
normal = normal.fg(app.ui_theme.accent_primary);
muted = muted.fg(app.ui_theme.accent_primary);
}
if selected {
normal = normal.add_modifier(Modifier::BOLD);
muted = muted.add_modifier(Modifier::BOLD);
}
if opened {
normal = normal.add_modifier(Modifier::UNDERLINED);
muted = muted.add_modifier(Modifier::UNDERLINED);
}
(normal, muted)
}
fn row_style(app: &App, row: &WorkRow, selected: bool, hovered: bool, opened: bool) -> Style {
// Headings (group headers like `▾ Subagents 2`) are muted structure, not
// interaction — accent_primary is reserved for selection/focus. GrokBuild