diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4d8131171..8575cdfce 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -59,8 +59,10 @@ use crate::model_catalog::ModelCatalog; use crate::model_migration::ModelMigrationOutcome; use crate::model_migration::migration_copy_for_models; use crate::model_migration::run_model_migration_prompt; +use crate::multi_agents::agent_picker_row_status; use crate::multi_agents::agent_picker_status_dot_spans; use crate::multi_agents::format_agent_picker_entry_name; +use crate::multi_agents::format_agent_picker_metrics; use crate::multi_agents::next_agent_shortcut_matches; use crate::multi_agents::previous_agent_shortcut_matches; use crate::pager_overlay::Overlay; diff --git a/codex-rs/tui/src/app/agent_navigation.rs b/codex-rs/tui/src/app/agent_navigation.rs index 25dbfd1f4..0caaed56e 100644 --- a/codex-rs/tui/src/app/agent_navigation.rs +++ b/codex-rs/tui/src/app/agent_navigation.rs @@ -23,9 +23,11 @@ use crate::multi_agents::format_agent_picker_entry_name; use crate::multi_agents::format_agent_picker_item_name; use crate::multi_agents::next_agent_shortcut; use crate::multi_agents::previous_agent_shortcut; +use codex_app_server_protocol::CollabAgentStatus; use codex_protocol::ThreadId; use ratatui::text::Span; use std::collections::HashMap; +use std::time::Instant; /// Small state container for multi-agent picker ordering and labeling. /// @@ -50,6 +52,41 @@ pub(crate) struct AgentNavigationState { /// Authoritative absolute agent paths (for example `/root/backend_audit/db_check`) captured /// from the server-composed `AgentPath`, keyed by thread id. paths: HashMap, + /// Live per-thread runtime telemetry (status, elapsed, tokens, task) rendered by the enriched + /// agent picker. Kept in its own map so it survives `upsert` metadata refreshes and thread + /// switches; only `clear` and `remove` drop it. + live_metrics: HashMap, +} + +/// Live runtime telemetry for a tracked agent thread. +/// +/// These fields are folded from the app-server event stream and rendered in the enriched agent +/// picker rows (status dot, task, elapsed timer, token total). They are intentionally kept here in +/// [`AgentNavigationState`] rather than on the `ChatWidget`: switching into an agent's window +/// rebuilds the `ChatWidget` from scratch, which would otherwise reset the elapsed timer and token +/// total every time the user glanced at a different agent. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AgentLiveMetrics { + /// Latest lifecycle status folded from turn/thread notifications. + pub(crate) status: CollabAgentStatus, + /// When the most recent turn started, used to render a live elapsed timer. `None` until the + /// thread emits its first `TurnStarted`. + pub(crate) started_at: Option, + /// Short description of the current task, derived from the latest turn's first user input. + pub(crate) last_task_message: Option, + /// Cumulative total token usage most recently reported for the thread. + pub(crate) token_total: i64, +} + +impl Default for AgentLiveMetrics { + fn default() -> Self { + Self { + status: CollabAgentStatus::PendingInit, + started_at: None, + last_task_message: None, + token_total: 0, + } + } } /// Direction of keyboard traversal through the stable picker order. @@ -150,6 +187,56 @@ impl AgentNavigationState { self.threads.contains_key(thread_id) } + /// Returns the live runtime telemetry captured for a thread, if any turn/thread event has been + /// folded for it yet. + pub(crate) fn metrics(&self, thread_id: &ThreadId) -> Option<&AgentLiveMetrics> { + self.live_metrics.get(thread_id) + } + + /// Returns a mutable telemetry record for a thread, creating a default one on first sight. + /// + /// The default seeds `PendingInit`/no-elapsed/zero-tokens so a thread that has only just been + /// observed renders as a hollow pending dot until the first real event arrives. + fn metrics_mut(&mut self, thread_id: ThreadId) -> &mut AgentLiveMetrics { + self.live_metrics.entry(thread_id).or_default() + } + + /// Folds a `TurnStarted` event: the thread is now running and its elapsed timer restarts. + /// + /// A non-empty `task_message` (the turn's first user input) is captured so the picker row can + /// describe what the agent is working on; an empty message leaves the previous task in place. + pub(crate) fn note_turn_started(&mut self, thread_id: ThreadId, task_message: Option) { + let task_message = task_message + .map(|task| task.split_whitespace().collect::>().join(" ")) + .filter(|task| !task.is_empty()); + let metrics = self.metrics_mut(thread_id); + metrics.status = CollabAgentStatus::Running; + metrics.started_at = Some(Instant::now()); + if task_message.is_some() { + metrics.last_task_message = task_message; + } + } + + /// Folds a `TurnCompleted` event, honoring the terminal turn status mapped by the caller. + pub(crate) fn note_turn_completed(&mut self, thread_id: ThreadId, status: CollabAgentStatus) { + self.metrics_mut(thread_id).status = status; + } + + /// Folds a non-retrying `Error` event: the thread is now errored. + pub(crate) fn note_error(&mut self, thread_id: ThreadId) { + self.metrics_mut(thread_id).status = CollabAgentStatus::Errored; + } + + /// Records a status transition folded from a thread-lifecycle event (close / status change). + pub(crate) fn note_status(&mut self, thread_id: ThreadId, status: CollabAgentStatus) { + self.metrics_mut(thread_id).status = status; + } + + /// Records the cumulative total token usage most recently reported for a thread. + pub(crate) fn note_token_usage(&mut self, thread_id: ThreadId, token_total: i64) { + self.metrics_mut(thread_id).token_total = token_total.max(0); + } + /// Marks a thread as closed without removing it from the traversal cache. /// /// Closed threads stay in the picker and in spawn order so users can still review them and so @@ -176,6 +263,7 @@ impl AgentNavigationState { self.order.clear(); self.parents.clear(); self.paths.clear(); + self.live_metrics.clear(); } /// Removes a tracked thread entirely from picker metadata and traversal order. @@ -186,6 +274,7 @@ impl AgentNavigationState { pub(crate) fn remove(&mut self, thread_id: ThreadId) { self.threads.remove(&thread_id); self.order.retain(|candidate| *candidate != thread_id); + self.live_metrics.remove(&thread_id); } /// Returns whether there is at least one tracked thread other than the primary one. @@ -289,7 +378,7 @@ impl AgentNavigationState { let previous: Span<'static> = previous_agent_shortcut().into(); let next: Span<'static> = next_agent_shortcut().into(); format!( - "Select an agent to watch. {} previous, {} next.", + "Switch into an agent's live window. {} previous, {} next.", previous.content, next.content ) } @@ -416,4 +505,114 @@ mod tests { Some("Main [default]".to_string()) ); } + + #[test] + fn note_turn_started_marks_running_and_records_task_and_start() { + let (mut state, _, first_agent_id, _) = populated_state(); + assert_eq!(state.metrics(&first_agent_id), None); + + state.note_turn_started( + first_agent_id, + Some(" audit the backend ".to_string()), + ); + + let metrics = state.metrics(&first_agent_id).expect("metrics recorded"); + assert_eq!(metrics.status, CollabAgentStatus::Running); + assert!(metrics.started_at.is_some()); + // Whitespace is collapsed so the picker row stays on a single tidy line. + assert_eq!( + metrics.last_task_message.as_deref(), + Some("audit the backend") + ); + } + + #[test] + fn note_turn_started_keeps_previous_task_when_message_is_blank() { + let (mut state, _, first_agent_id, _) = populated_state(); + state.note_turn_started(first_agent_id, Some("first task".to_string())); + state.note_turn_started(first_agent_id, Some(" ".to_string())); + + assert_eq!( + state + .metrics(&first_agent_id) + .and_then(|metrics| metrics.last_task_message.as_deref()), + Some("first task") + ); + } + + #[test] + fn note_turn_completed_and_error_transition_status() { + let (mut state, _, first_agent_id, second_agent_id) = populated_state(); + + state.note_turn_started(first_agent_id, None); + state.note_turn_completed(first_agent_id, CollabAgentStatus::Completed); + assert_eq!( + state + .metrics(&first_agent_id) + .map(|metrics| metrics.status.clone()), + Some(CollabAgentStatus::Completed) + ); + + state.note_error(second_agent_id); + assert_eq!( + state + .metrics(&second_agent_id) + .map(|metrics| metrics.status.clone()), + Some(CollabAgentStatus::Errored) + ); + } + + #[test] + fn note_status_and_token_usage_are_recorded() { + let (mut state, _, first_agent_id, second_agent_id) = populated_state(); + + state.note_status(first_agent_id, CollabAgentStatus::Shutdown); + state.note_token_usage(first_agent_id, 69_742); + // Negative totals are clamped so a bad report never renders a negative token count. + state.note_token_usage(second_agent_id, -5); + + let metrics = state.metrics(&first_agent_id).expect("metrics recorded"); + assert_eq!(metrics.status, CollabAgentStatus::Shutdown); + assert_eq!(metrics.token_total, 69_742); + assert_eq!( + state + .metrics(&second_agent_id) + .map(|metrics| metrics.token_total), + Some(0) + ); + } + + #[test] + fn metrics_survive_metadata_upsert() { + let (mut state, _, first_agent_id, _) = populated_state(); + state.note_turn_started(first_agent_id, Some("task".to_string())); + state.note_token_usage(first_agent_id, 1_234); + + // A picker-liveness refresh re-`upsert`s metadata; live telemetry must not be reset. + state.upsert( + first_agent_id, + Some("Robie".to_string()), + Some("worker".to_string()), + /*is_closed*/ true, + ); + + let metrics = state.metrics(&first_agent_id).expect("metrics preserved"); + assert_eq!(metrics.status, CollabAgentStatus::Running); + assert_eq!(metrics.token_total, 1_234); + assert_eq!(metrics.last_task_message.as_deref(), Some("task")); + } + + #[test] + fn remove_and_clear_drop_metrics() { + let (mut state, main_thread_id, first_agent_id, _) = populated_state(); + state.note_token_usage(first_agent_id, 10); + state.note_token_usage(main_thread_id, 20); + + state.remove(first_agent_id); + assert_eq!(state.metrics(&first_agent_id), None); + assert!(state.metrics(&main_thread_id).is_some()); + + state.clear(); + assert_eq!(state.metrics(&main_thread_id), None); + } } diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index ae41933b1..55690ff9e 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -11,9 +11,15 @@ use crate::app_server_session::AppServerSession; use crate::app_server_session::status_account_display_from_auth_mode; use codex_app_server_client::AppServerEvent; use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::CollabAgentStatus; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput; +use codex_protocol::ThreadId; impl App { pub(super) fn refresh_mcp_startup_expected_servers_from_config(&mut self) { @@ -129,6 +135,11 @@ impl App { self.note_session_recap_turn_completed(thread_id, ¬ification.turn); } + // Fold this update into the live agent-metrics cache before routing so that main + // and every sub-agent picker row stay in sync regardless of which channel the + // notification is ultimately enqueued on. + self.note_agent_metrics_from_notification(thread_id, ¬ification); + let result = if self.primary_thread_id == Some(thread_id) { self.enqueue_thread_notification(thread_id, notification) .await @@ -174,6 +185,49 @@ impl App { .handle_server_notification(notification, /*replay_kind*/ None); } + /// Folds a per-thread app-server notification into the live agent-metrics cache so the enriched + /// agent picker can render status, elapsed time, task, and token totals for main and every + /// sub-agent uniformly. + /// + /// This mirrors `codex_core`'s `agent_status_from_event`, but operates on the v2 app-server + /// notification stream the TUI actually receives rather than on core `EventMsg`s. + fn note_agent_metrics_from_notification( + &mut self, + thread_id: ThreadId, + notification: &ServerNotification, + ) { + match notification { + ServerNotification::TurnStarted(started) => { + let task = first_user_input_text(&started.turn); + self.agent_navigation.note_turn_started(thread_id, task); + } + ServerNotification::TurnCompleted(completed) => { + self.agent_navigation.note_turn_completed( + thread_id, + collab_status_from_turn_status(&completed.turn.status), + ); + } + // A retrying error does not interrupt the turn, so the thread stays running. + ServerNotification::Error(error) if !error.will_retry => { + self.agent_navigation.note_error(thread_id); + } + ServerNotification::ThreadClosed(_) => { + self.agent_navigation + .note_status(thread_id, CollabAgentStatus::Shutdown); + } + ServerNotification::ThreadStatusChanged(changed) => { + if let Some(status) = collab_status_from_thread_status(&changed.status) { + self.agent_navigation.note_status(thread_id, status); + } + } + ServerNotification::ThreadTokenUsageUpdated(usage) => { + self.agent_navigation + .note_token_usage(thread_id, usage.token_usage.total.total_tokens); + } + _ => {} + } + } + async fn handle_server_request_event( &mut self, app_server_client: &mut AppServerSession, @@ -233,3 +287,40 @@ impl App { } } } + +/// Maps a completed turn's status onto the picker's `CollabAgentStatus` vocabulary. +fn collab_status_from_turn_status(status: &TurnStatus) -> CollabAgentStatus { + match status { + TurnStatus::Completed => CollabAgentStatus::Completed, + TurnStatus::Interrupted => CollabAgentStatus::Interrupted, + TurnStatus::Failed => CollabAgentStatus::Errored, + TurnStatus::InProgress => CollabAgentStatus::Running, + } +} + +/// Maps a thread-lifecycle status change onto the picker's `CollabAgentStatus` vocabulary. +/// +/// `Idle` returns `None` so a completed agent keeps its terminal glyph between turns instead of +/// flipping back to a neutral running/pending state. +fn collab_status_from_thread_status(status: &ThreadStatus) -> Option { + match status { + ThreadStatus::NotLoaded => Some(CollabAgentStatus::Shutdown), + ThreadStatus::SystemError => Some(CollabAgentStatus::Errored), + ThreadStatus::Active { .. } => Some(CollabAgentStatus::Running), + ThreadStatus::Idle => None, + } +} + +/// Extracts a short task description from a turn's first textual user input, if present. +fn first_user_input_text(turn: &Turn) -> Option { + turn.items.iter().find_map(|item| match item { + ThreadItem::UserMessage { content, .. } => content.iter().find_map(|input| match input { + UserInput::Text { text, .. } => { + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + _ => None, + }), + _ => None, + }) +} diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 86c73c457..cd845d0c2 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -138,6 +138,22 @@ impl App { } return; } + // At an empty composer with no overlay/modal, a bare Down or Left arrow opens the agent + // picker (the switchable "FleetView" agent list). Up/Right are intentionally left to the + // composer for history recall and cursor motion. We only intercept when there is actually + // somewhere to switch to (or a new agent can be created) so a stray arrow in a plain + // single-agent session never pops the picker or the "enable subagents" prompt. + if self.overlay.is_none() + && self.chat_widget.no_modal_or_popup_active() + && self.chat_widget.composer_text_with_pending().is_empty() + && matches!(key_event.kind, KeyEventKind::Press) + && key_event.modifiers.is_empty() + && matches!(key_event.code, KeyCode::Down | KeyCode::Left) + && self.agent_switcher_available() + { + self.app_event_tx.send(AppEvent::OpenAgentPicker); + return; + } if side_return_shortcut_matches(key_event) && self.maybe_return_from_side(tui, app_server).await { @@ -280,6 +296,17 @@ impl App { self.overlay.is_none() && self.chat_widget.no_modal_or_popup_active() } + /// Whether opening the agent picker from an empty composer would show anything useful: either + /// collaboration is enabled (so a new agent can be spawned) or at least one non-primary agent + /// thread already exists to switch into. Gating the arrow-open on this mirrors the guard in + /// `open_agent_picker` and keeps the picker from surfacing on a plain single-agent session. + fn agent_switcher_available(&self) -> bool { + self.config.features.enabled(Feature::Collab) + || self + .agent_navigation + .has_non_primary_thread(self.primary_thread_id) + } + pub(super) fn refresh_status_line(&mut self) { self.chat_widget.refresh_status_line(); } diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index ec044968c..ffeb7a3ae 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -15,7 +15,7 @@ fn agent_picker_hint_line(can_create_agent: bool) -> Line<'static> { Line::from(vec![ "Press ".into(), crate::key_hint::plain(KeyCode::Enter).into(), - " to select, ".into(), + " to switch, ".into(), crate::key_hint::plain(KeyCode::Char('n')).into(), " for new, ".into(), crate::key_hint::plain(KeyCode::Char('r')).into(), @@ -27,7 +27,7 @@ fn agent_picker_hint_line(can_create_agent: bool) -> Line<'static> { Line::from(vec![ "Press ".into(), crate::key_hint::plain(KeyCode::Enter).into(), - " to watch, ".into(), + " to switch, ".into(), crate::key_hint::plain(KeyCode::Char('r')).into(), " to rename, or ".into(), crate::key_hint::plain(KeyCode::Esc).into(), @@ -58,10 +58,24 @@ impl App { }); } - for (thread_id, entry) in self.agent_navigation.ordered_threads() { + // Always render the primary ("Main") thread first, then the rest in stable spawn order. + // Spawn order already puts main first in the common case, but resume/backfill races can + // register a subagent before the primary lands, so hoist it explicitly. + let mut ordered_threads = self.agent_navigation.ordered_threads(); + if let Some(primary_thread_id) = self.primary_thread_id + && let Some(primary_pos) = ordered_threads + .iter() + .position(|(thread_id, _)| *thread_id == primary_thread_id) + { + let primary_entry = ordered_threads.remove(primary_pos); + ordered_threads.insert(0, primary_entry); + } + + for (thread_id, entry) in ordered_threads { let item_idx = items.len(); first_agent_idx.get_or_insert(item_idx); - if self.active_thread_id == Some(thread_id) { + let is_active = self.active_thread_id == Some(thread_id); + if is_active { initial_selected_idx = Some(item_idx); } let is_primary = self.primary_thread_id == Some(thread_id); @@ -69,6 +83,21 @@ impl App { let current_name = entry.thread_name.clone(); let uuid = thread_id.to_string(); let rename_label = name.clone(); + + let metrics = self.agent_navigation.metrics(&thread_id); + let status = agent_picker_row_status( + entry.is_closed, + metrics.map(|metrics| metrics.status.clone()), + ); + let elapsed_secs = metrics + .and_then(|metrics| metrics.started_at) + .map(|started_at| started_at.elapsed().as_secs()); + let token_total = metrics.map(|metrics| metrics.token_total).unwrap_or(0); + let description = format_agent_picker_metrics( + metrics.and_then(|metrics| metrics.last_task_message.as_deref()), + elapsed_secs, + token_total, + ); let mut shortcut_actions = Vec::new(); if can_create_agent { shortcut_actions.push(SelectionShortcutAction { @@ -92,14 +121,16 @@ impl App { }); items.push(SelectionItem { name: name.clone(), - name_prefix_spans: agent_picker_status_dot_spans(entry.is_closed), - description: Some(uuid.clone()), - is_current: self.active_thread_id == Some(thread_id), + name_prefix_spans: agent_picker_status_dot_spans(status, is_active), + description, + is_current: is_active, actions: vec![Box::new(move |tx| { tx.send(AppEvent::SelectAgentThread(thread_id)); })], shortcut_actions, dismiss_on_select: true, + // Keep the raw thread id searchable even though the visible description now shows + // live metrics instead of the UUID. search_value: Some(format!("{name} {uuid}")), ..Default::default() }); diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index 9983efe30..5a94603df 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -6,6 +6,8 @@ use crate::history_cell::PlainHistoryCell; use crate::render::line_utils::prefix_lines; +use crate::status::format_tokens_compact; +use crate::status_indicator_widget::fmt_elapsed_compact; use crate::style::accent_color; use crate::text_formatting::truncate_text; use codex_app_server_protocol::CollabAgentState; @@ -29,6 +31,8 @@ use std::collections::HashSet; const COLLAB_PROMPT_PREVIEW_GRAPHEMES: usize = 160; const COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES: usize = 160; const COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES: usize = 240; +/// Max graphemes of the current-task preview shown in the agent picker description column. +const COLLAB_PICKER_TASK_PREVIEW_GRAPHEMES: usize = 48; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct AgentPickerThreadEntry { @@ -75,13 +79,92 @@ pub(crate) struct SpawnRequestSummary { pub(crate) reasoning_effort: ReasoningEffortConfig, } -pub(crate) fn agent_picker_status_dot_spans(is_closed: bool) -> Vec> { - let dot = if is_closed { - "•".into() - } else { - "•".green() +/// Resolves the lifecycle status to render for an agent picker row. +/// +/// Live telemetry (folded from the event stream) is authoritative when present, except that a +/// picker entry the app has since marked closed downgrades a still-`Running`/`PendingInit` thread to +/// `Shutdown` so a stale "running" dot never lingers on a thread the backend reports as gone. +/// Threads with no telemetry yet render as `PendingInit` (a hollow, dim dot), or `Shutdown` when +/// already closed. +pub(crate) fn agent_picker_row_status( + is_closed: bool, + live_status: Option, +) -> CollabAgentStatus { + match live_status { + Some(status) if is_terminal_collab_status(&status) => status, + Some(_) if is_closed => CollabAgentStatus::Shutdown, + Some(status) => status, + None if is_closed => CollabAgentStatus::Shutdown, + None => CollabAgentStatus::PendingInit, + } +} + +fn is_terminal_collab_status(status: &CollabAgentStatus) -> bool { + matches!( + status, + CollabAgentStatus::Completed + | CollabAgentStatus::Interrupted + | CollabAgentStatus::Errored + | CollabAgentStatus::Shutdown + | CollabAgentStatus::NotFound + ) +} + +/// Builds the leading status glyph for an agent picker row. +/// +/// The glyph encodes lifecycle status by both shape and color, reusing the `CollabAgentStatus` +/// vocabulary: a running or pending thread shows a dot that is filled (`●`) when it is the currently +/// watched thread and hollow (`○`) otherwise, so the active agent stands out; terminal states show a +/// distinct glyph instead — a green check when completed, a yellow check when interrupted ("wrapped +/// up"), a red cross when errored, and a dim square when stopped. +pub(crate) fn agent_picker_status_dot_spans( + status: CollabAgentStatus, + is_active: bool, +) -> Vec> { + let glyph: Span<'static> = match status { + CollabAgentStatus::Completed => "✓".green(), + // Allow `.yellow()` + #[allow(clippy::disallowed_methods)] + CollabAgentStatus::Interrupted => "✓".yellow(), + CollabAgentStatus::Errored | CollabAgentStatus::NotFound => "✗".red(), + CollabAgentStatus::Shutdown => "■".dim(), + CollabAgentStatus::Running if is_active => "●".green(), + CollabAgentStatus::Running => "○".green(), + CollabAgentStatus::PendingInit if is_active => "●".dim(), + CollabAgentStatus::PendingInit => "○".dim(), }; - vec![dot, " ".into()] + vec![glyph, " ".into()] +} + +/// Formats the enriched agent picker description column: ` · ↓ tokens`. +/// +/// Every segment is optional: a thread with no captured task, no started turn, or no reported token +/// usage simply omits that part. Returns `None` when there is nothing at all to show so the row can +/// fall back to a bare agent name. +pub(crate) fn format_agent_picker_metrics( + task: Option<&str>, + elapsed_secs: Option, + token_total: i64, +) -> Option { + let mut stats: Vec = Vec::new(); + if let Some(elapsed_secs) = elapsed_secs { + stats.push(fmt_elapsed_compact(elapsed_secs)); + } + if token_total > 0 { + stats.push(format!("↓ {} tokens", format_tokens_compact(token_total))); + } + + let task = task + .map(str::trim) + .filter(|task| !task.is_empty()) + .map(|task| truncate_text(task, COLLAB_PICKER_TASK_PREVIEW_GRAPHEMES)); + + match (task, stats.is_empty()) { + (Some(task), true) => Some(task), + (Some(task), false) => Some(format!("{task} {}", stats.join(" · "))), + (None, true) => None, + (None, false) => Some(stats.join(" · ")), + } } pub(crate) fn format_agent_picker_item_name( @@ -1106,4 +1189,121 @@ mod tests { .collect::>() .join("") } + + #[test] + fn agent_picker_row_status_prefers_live_status_but_respects_closed() { + // No telemetry yet: pending when open, stopped when the picker marked it closed. + assert_eq!( + agent_picker_row_status(/*is_closed*/ false, None), + CollabAgentStatus::PendingInit + ); + assert_eq!( + agent_picker_row_status(/*is_closed*/ true, None), + CollabAgentStatus::Shutdown + ); + // A live running thread the picker has since marked closed downgrades to stopped. + assert_eq!( + agent_picker_row_status(/*is_closed*/ true, Some(CollabAgentStatus::Running)), + CollabAgentStatus::Shutdown + ); + // Terminal telemetry is authoritative even when the entry is closed. + assert_eq!( + agent_picker_row_status(/*is_closed*/ true, Some(CollabAgentStatus::Completed)), + CollabAgentStatus::Completed + ); + assert_eq!( + agent_picker_row_status(/*is_closed*/ false, Some(CollabAgentStatus::Running)), + CollabAgentStatus::Running + ); + } + + #[test] + fn agent_picker_status_dot_spans_encode_status_shape_and_color() { + let running_active = agent_picker_status_dot_spans(CollabAgentStatus::Running, true); + assert_eq!(running_active[0].content.as_ref(), "●"); + assert_eq!(running_active[0].style.fg, Some(Color::Green)); + + let running_idle = agent_picker_status_dot_spans(CollabAgentStatus::Running, false); + assert_eq!(running_idle[0].content.as_ref(), "○"); + assert_eq!(running_idle[0].style.fg, Some(Color::Green)); + + let completed = agent_picker_status_dot_spans(CollabAgentStatus::Completed, false); + assert_eq!(completed[0].content.as_ref(), "✓"); + assert_eq!(completed[0].style.fg, Some(Color::Green)); + + let interrupted = agent_picker_status_dot_spans(CollabAgentStatus::Interrupted, false); + assert_eq!(interrupted[0].content.as_ref(), "✓"); + assert_eq!(interrupted[0].style.fg, Some(Color::Yellow)); + + let errored = agent_picker_status_dot_spans(CollabAgentStatus::Errored, false); + assert_eq!(errored[0].content.as_ref(), "✗"); + assert_eq!(errored[0].style.fg, Some(Color::Red)); + + let shutdown = agent_picker_status_dot_spans(CollabAgentStatus::Shutdown, false); + assert_eq!(shutdown[0].content.as_ref(), "■"); + assert!(shutdown[0].style.add_modifier.contains(Modifier::DIM)); + } + + #[test] + fn format_agent_picker_metrics_composes_task_elapsed_and_tokens() { + assert_eq!( + format_agent_picker_metrics(Some("audit the backend"), Some(125), 69_742), + Some("audit the backend 2m 05s · ↓ 69.7K tokens".to_string()) + ); + assert_eq!( + format_agent_picker_metrics(None, Some(5), 0), + Some("5s".to_string()) + ); + assert_eq!( + format_agent_picker_metrics(Some("just a task"), None, 0), + Some("just a task".to_string()) + ); + assert_eq!( + format_agent_picker_metrics(None, None, 0), + None, + "an empty row should have no description" + ); + } + + #[test] + fn agent_picker_rows_snapshot() { + // Deterministic stand-in for the live picker rows: ` · tokens` + // with Main first, one running/active row, one completed row, and one errored row. + let rows = [ + ( + CollabAgentStatus::Running, + /*is_active*/ true, + "Main [default]", + format_agent_picker_metrics(Some("triage the failing suite"), Some(125), 69_742), + ), + ( + CollabAgentStatus::Completed, + /*is_active*/ false, + "Robie [explorer]", + format_agent_picker_metrics(Some("map the crate graph"), Some(42), 12_800), + ), + ( + CollabAgentStatus::Errored, + /*is_active*/ false, + "Bob [worker]", + format_agent_picker_metrics(Some("run migrations"), Some(9), 1_024), + ), + ]; + + let snapshot = rows + .iter() + .map(|(status, is_active, name, description)| { + let mut spans = agent_picker_status_dot_spans(status.clone(), *is_active); + spans.push(Span::from((*name).to_string())); + if let Some(description) = description { + spans.push(Span::from(" ")); + spans.push(Span::from(description.clone())); + } + line_to_text(&Line::from(spans)) + }) + .collect::>() + .join("\n"); + + assert_snapshot!("agent_picker_rows", snapshot); + } } diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__agent_picker_selection_items_with_new_agent.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__agent_picker_selection_items_with_new_agent.snap index 80c3a8647..ef385b459 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__agent_picker_selection_items_with_new_agent.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__agent_picker_selection_items_with_new_agent.snap @@ -4,5 +4,5 @@ expression: "lines.join(\"\\n\")" --- initial_selected_idx=Some(1) 0: New agent | Start a new child agent thread from the current thread. | current=false -1: • Main [default] | 00000000-0000-0000-0000-000000000001 | current=true -2: • Scout [worker] | 00000000-0000-0000-0000-000000000002 | current=false +1: ● Main [default] | | current=true +2: ○ Scout [worker] | | current=false diff --git a/codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__agent_picker_rows.snap b/codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__agent_picker_rows.snap new file mode 100644 index 000000000..3bfa7b950 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__agent_picker_rows.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/multi_agents.rs +expression: snapshot +--- +● Main [default] triage the failing suite 2m 05s · ↓ 69.7K tokens +✓ Robie [explorer] map the crate graph 42s · ↓ 12.8K tokens +✗ Bob [worker] run migrations 9s · ↓ 1.02K tokens