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