From aee8a55ab6010f1d53e741edec74dbcffa07bcfe Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 12 Sep 2026 04:17:56 +0000 Subject: [PATCH] Show task tokens and usage estimates in the agent command center (#44970) ## What changed - Display input/output token counts and estimated credits and USD cost in task details. Prefer live token totals, falling back to complete totals from usage breakdowns. - Fetch estimates for the selected task on a one-minute cadence for supported Business and Enterprise plans. Cache results, preserve prior nonzero estimates when a response reports zero, and stop fetching when the capability is unavailable. - Clear cached usage and discard pending results on account changes, reconnects, or missed server events. Prioritize activity and usage over the original prompt when space is limited. ## Testing Add a rendering snapshot and tests covering caching, stale results, unavailable usage, token resets, and incomplete usage breakdowns. GitOrigin-RevId: b72c96053557389fdc723a70fab0dab841a52e49 --- codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app/agents_overview.rs | 7 + .../tui/src/app/agents_overview_actions.rs | 1 + .../tui/src/app/agents_overview_details.rs | 6 + codex-rs/tui/src/app/agents_overview_tests.rs | 3 + .../tui/src/app/agents_overview_threads.rs | 23 +- codex-rs/tui/src/app/agents_overview_usage.rs | 199 +++++++++++++++ .../src/app/agents_overview_usage_tests.rs | 236 ++++++++++++++++++ codex-rs/tui/src/app/agents_overview_view.rs | 8 + codex-rs/tui/src/app/app_server_events.rs | 7 + codex-rs/tui/src/app/background_requests.rs | 4 +- codex-rs/tui/src/app/event_dispatch.rs | 3 + codex-rs/tui/src/app/reconnect.rs | 3 + ...__tests__usage__agents_overview_usage.snap | 28 +++ codex-rs/tui/src/app_event.rs | 7 + 15 files changed, 533 insertions(+), 4 deletions(-) create mode 100644 codex-rs/tui/src/app/agents_overview_usage.rs create mode 100644 codex-rs/tui/src/app/agents_overview_usage_tests.rs create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__usage__agents_overview_usage.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index edc16444a7..b86b59aa5d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -204,6 +204,7 @@ mod agents_overview; mod agents_overview_actions; mod agents_overview_details; mod agents_overview_threads; +mod agents_overview_usage; mod agents_overview_view; pub(crate) use agents_overview::AGENTS_OVERVIEW_VIEW_ID; mod app_server_event_targets; @@ -953,6 +954,7 @@ impl App { // Allow widgets to process any pending timers before rendering. let had_active_view = self.chat_widget.has_active_view(); self.chat_widget.pre_draw_tick(); + self.refresh_agents_overview_usage(app_server, tui.frame_requester()); let rendered_area = self.render_chat_widget_frame(tui, screen_size)?; if !had_active_view && self.chat_widget.has_active_view() diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index 3cb0ae2832..f75d360299 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -40,6 +40,9 @@ pub(super) struct AgentsOverviewState { /// Local visibility only; activity and metadata refreshes never reveal hidden roots. pub(super) hidden_threads: HashSet, pub(super) last_messages: HashMap, + pub(super) usage: HashMap, + pub(super) pending_usage: Option<(ThreadId, Uuid)>, + pub(super) usage_disabled: bool, pub(super) activity: HashMap, pub(super) initialized: bool, pub(super) unsent_prompt: Option, @@ -175,6 +178,7 @@ impl App { self.agents_overview.threads.remove(&thread_id); self.agents_overview.last_messages.remove(&thread_id); self.agents_overview.activity.remove(&thread_id); + self.agents_overview.usage.remove(&thread_id); continue; } thread.turns.clear(); @@ -197,6 +201,9 @@ impl App { { // Discard stale read results without clearing activity received after the revert. self.agents_overview.last_messages.remove(&thread_id); + if let Some(usage) = self.agents_overview.usage.get_mut(&thread_id) { + usage.tokens = None; + } continue; } self.track_agents_overview_notification(¬ification); diff --git a/codex-rs/tui/src/app/agents_overview_actions.rs b/codex-rs/tui/src/app/agents_overview_actions.rs index 7e3ddff863..7af12e85cf 100644 --- a/codex-rs/tui/src/app/agents_overview_actions.rs +++ b/codex-rs/tui/src/app/agents_overview_actions.rs @@ -266,6 +266,7 @@ impl App { self.agents_overview.threads.remove(&removed_id); self.agents_overview.activity.remove(&removed_id); self.agents_overview.last_messages.remove(&removed_id); + self.agents_overview.usage.remove(&removed_id); self.agents_overview.refresh_thread_ids.remove(&removed_id); self.agents_overview.input_states.remove(&removed_id); self.agents_overview.dispatched_requests.remove(&removed_id); diff --git a/codex-rs/tui/src/app/agents_overview_details.rs b/codex-rs/tui/src/app/agents_overview_details.rs index 5b42329340..bc1249270e 100644 --- a/codex-rs/tui/src/app/agents_overview_details.rs +++ b/codex-rs/tui/src/app/agents_overview_details.rs @@ -36,6 +36,7 @@ pub(super) fn preview_markdown(text: &str) -> String { #[derive(Clone, Default)] pub(super) struct AgentsOverviewDetails { pub(super) lines: Vec>, + pub(super) usage_lines: Vec>, pub(super) last_message: Option<(String, AbsolutePathBuf)>, } @@ -216,6 +217,11 @@ impl App { .map(|message| (message.clone(), source.cwd.clone())); AgentsOverviewDetails { lines, + usage_lines: ThreadId::from_string(&root.id) + .ok() + .and_then(|id| self.agents_overview.usage.get(&id)) + .map(super::agents_overview_usage::usage_lines) + .unwrap_or_default(), last_message, } } diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 41c7ff054f..30018a0db3 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -2995,3 +2995,6 @@ fn trust_fixture_folders(app: &mut App) { toml::Value::try_from(projects).expect("trust fixture"), )); } + +#[path = "agents_overview_usage_tests.rs"] +mod usage; diff --git a/codex-rs/tui/src/app/agents_overview_threads.rs b/codex-rs/tui/src/app/agents_overview_threads.rs index 78cf416443..c2c4d9496c 100644 --- a/codex-rs/tui/src/app/agents_overview_threads.rs +++ b/codex-rs/tui/src/app/agents_overview_threads.rs @@ -48,6 +48,16 @@ impl App { .get_mut(&thread_id) .and_then(Option::as_mut); match notification { + ServerNotification::ThreadTokenUsageUpdated(usage) => { + if self.agents_overview.threads.contains_key(&thread_id) { + self.agents_overview + .usage + .entry(thread_id) + .or_default() + .tokens = Some(usage.token_usage.total.clone()); + self.repaint_agents_overview(); + } + } ServerNotification::ThreadStarted(started) => { if started.thread.ephemeral { return; @@ -59,11 +69,15 @@ impl App { ServerNotification::ThreadArchived(_) | ServerNotification::ThreadDeleted(_) => { self.agents_overview.activity.remove(&thread_id); self.agents_overview.last_messages.remove(&thread_id); + self.agents_overview.usage.remove(&thread_id); self.agents_overview.threads.remove(&thread_id); self.agents_overview.refresh_thread_ids.remove(&thread_id); } ServerNotification::ThreadClosed(_) => { self.agents_overview.activity.remove(&thread_id); + if let Some(usage) = self.agents_overview.usage.get_mut(&thread_id) { + usage.tokens = None; + } if let Some(thread) = thread { thread.status = ThreadStatus::NotLoaded; } @@ -71,6 +85,9 @@ impl App { ServerNotification::ThreadReverted(_) => { self.agents_overview.activity.remove(&thread_id); self.agents_overview.last_messages.remove(&thread_id); + if let Some(usage) = self.agents_overview.usage.get_mut(&thread_id) { + usage.tokens = None; + } self.repaint_agents_overview(); } ServerNotification::ThreadStatusChanged(status) => { @@ -94,8 +111,10 @@ impl App { } _ => return, } - if !matches!(notification, ServerNotification::ThreadReverted(_)) - && self.agents_overview.threads.contains_key(&thread_id) + if !matches!( + notification, + ServerNotification::ThreadReverted(_) | ServerNotification::ThreadTokenUsageUpdated(_) + ) && self.agents_overview.threads.contains_key(&thread_id) { self.agents_overview.refresh_thread_ids.insert(thread_id); } diff --git a/codex-rs/tui/src/app/agents_overview_usage.rs b/codex-rs/tui/src/app/agents_overview_usage.rs new file mode 100644 index 0000000000..1c0f41bca9 --- /dev/null +++ b/codex-rs/tui/src/app/agents_overview_usage.rs @@ -0,0 +1,199 @@ +//! Demand-driven dashboard usage. Only the selected task is fetched on a one-minute +//! refresh cadence, including after failures. Account changes and reconnects invalidate +//! pending billing results and disabled capability state. + +use super::App; +use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID; +use super::background_requests::THREAD_USAGE_FETCH_TIMEOUT; +use super::background_requests::fetch_thread_usage; +use crate::app_event::AppEvent; +use crate::app_server_session::AppServerSession; +use crate::chatwidget::ThreadUsageOutcome; +use crate::status::format_credit_micros; +use crate::status::format_estimated_usd_micros; +use crate::status::format_tokens_compact; +use crate::tui::FrameRequester; +use codex_app_server_protocol::ThreadUsage; +use codex_app_server_protocol::ThreadUsageBreakdownGroup; +use codex_app_server_protocol::TokenUsageBreakdown; +use codex_protocol::ThreadId; +use codex_protocol::account::PlanType; +use ratatui::style::Stylize; +use ratatui::text::Line; +use std::time::Duration; +use std::time::Instant; +use uuid::Uuid; + +const REFRESH_INTERVAL: Duration = Duration::from_secs(/*secs*/ 60); + +#[derive(Default)] +pub(super) struct AgentsOverviewUsage { + pub(super) tokens: Option, + pub(super) estimate: Option, + fetched_at: Option, +} + +impl App { + pub(super) fn refresh_agents_overview_usage( + &mut self, + app_server: &AppServerSession, + frame_requester: FrameRequester, + ) { + if self.reconnect.offline + || self.agents_overview.usage_disabled + || self.agents_overview.pending_usage.is_some() + || !self.chat_widget.has_codex_backend_auth() + || !matches!( + self.chat_widget.current_plan_type(), + Some( + PlanType::Business + | PlanType::EnterpriseCbpUsageBased + | PlanType::EnterpriseCbpAutomation + ) + ) + { + return; + } + let Some(thread_id) = self + .chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) + .and_then(|index| self.agents_overview.visible_thread_ids.get(index)) + .copied() + else { + return; + }; + if let Some(age) = self + .agents_overview + .usage + .get(&thread_id) + .and_then(|usage| usage.fetched_at) + .map(|fetched| fetched.elapsed()) + && age < REFRESH_INTERVAL + { + frame_requester.schedule_frame_in(REFRESH_INTERVAL - age); + return; + } + let request_id = Uuid::new_v4(); + self.agents_overview.pending_usage = Some((thread_id, request_id)); + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = tokio::time::timeout( + THREAD_USAGE_FETCH_TIMEOUT, + fetch_thread_usage(request_handle, thread_id), + ) + .await + .map_err(|_| "dashboard usage request timed out".to_string()) + .and_then(|result| result.map_err(|error| error.to_string())); + app_event_tx.send(AppEvent::AgentsOverviewUsageLoaded { + thread_id, + request_id, + result, + }); + }); + } + + pub(super) fn finish_agents_overview_usage( + &mut self, + thread_id: ThreadId, + request_id: Uuid, + result: Result, + ) { + if self.agents_overview.pending_usage != Some((thread_id, request_id)) { + return; + } + self.agents_overview.pending_usage = None; + if self.agents_overview.threads.contains_key(&thread_id) { + let usage = self.agents_overview.usage.entry(thread_id).or_default(); + usage.fetched_at = Some(Instant::now()); + match result { + Ok(ThreadUsageOutcome::Available(mut estimate)) + if estimate.thread_id == thread_id.to_string() => + { + if let Some(previous) = &usage.estimate { + let zero_credits = estimate.estimated_usage_credits_micros == 0 + && previous.estimated_usage_credits_micros > 0; + let zero_cost = estimate.estimated_usage_usd_micros == Some(0) + && previous + .estimated_usage_usd_micros + .is_some_and(|cost| cost > 0); + if zero_credits { + estimate.estimated_usage_credits_micros = + previous.estimated_usage_credits_micros; + } + if zero_cost { + estimate.estimated_usage_usd_micros = + previous.estimated_usage_usd_micros; + } + if (zero_credits || zero_cost) && estimate.groups.is_empty() { + estimate.groups.clone_from(&previous.groups); + } + } + usage.estimate = Some(estimate); + } + Ok(ThreadUsageOutcome::Disabled) => { + self.agents_overview.usage_disabled = true; + for usage in self.agents_overview.usage.values_mut() { + usage.estimate = None; + } + } + Ok(ThreadUsageOutcome::Available(_)) | Err(_) => {} + } + } + self.repaint_agents_overview(); + } +} + +pub(super) fn usage_lines(usage: &AgentsOverviewUsage) -> Vec> { + let sum = |count: fn(&ThreadUsageBreakdownGroup) -> Option| { + let groups = &usage.estimate.as_ref()?.groups; + if groups.is_empty() { + return None; + } + groups.iter().try_fold(/*init*/ 0_i64, |total, group| { + count(group) + .filter(|tokens| *tokens >= 0) + .map(|tokens| total.saturating_add(tokens)) + }) + }; + let input = usage + .tokens + .as_ref() + .map(|tokens| tokens.input_tokens) + .or_else(|| sum(|group| group.input_tokens)); + let output = usage + .tokens + .as_ref() + .map(|tokens| tokens.output_tokens) + .or_else(|| sum(|group| group.output_tokens)); + let mut lines = Vec::new(); + let mut tokens = Vec::new(); + if let Some(input) = input { + tokens.push(format!("{} in", format_tokens_compact(input))); + } + if let Some(output) = output { + tokens.push(format!("{} out", format_tokens_compact(output))); + } + if !tokens.is_empty() { + lines.push(vec!["Tokens: ".dim(), tokens.join(" · ").into()].into()); + } + if let Some(estimate) = &usage.estimate { + let mut values = Vec::new(); + if estimate.estimated_usage_credits_micros >= 0 { + values.push(format!( + "{} credits", + format_credit_micros(estimate.estimated_usage_credits_micros) + )); + } + if let Some(cost) = estimate + .estimated_usage_usd_micros + .and_then(format_estimated_usd_micros) + { + values.push(cost); + } + if !values.is_empty() { + lines.push(vec!["Est. usage: ".dim(), values.join(" · ").into()].into()); + } + } + lines +} diff --git a/codex-rs/tui/src/app/agents_overview_usage_tests.rs b/codex-rs/tui/src/app/agents_overview_usage_tests.rs new file mode 100644 index 0000000000..467c3df01b --- /dev/null +++ b/codex-rs/tui/src/app/agents_overview_usage_tests.rs @@ -0,0 +1,236 @@ +use super::*; +use crate::app::agents_overview_usage::AgentsOverviewUsage; +use crate::app::agents_overview_usage::usage_lines; +use crate::chatwidget::ThreadUsageOutcome; +use codex_app_server_protocol::AccountUpdatedNotification; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::ThreadUsage; +use codex_app_server_protocol::TokenUsageBreakdown; +use codex_protocol::account::PlanType; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn selected_usage_is_cached_and_account_changes_discard_old_results() -> Result<()> { + let mut app = make_test_app().await; + let app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + app.chat_widget.update_account_state( + /*status_account_display*/ None, + Some(PlanType::Business), + /*has_chatgpt_account*/ false, + /*has_codex_backend_auth*/ true, + ); + let selected = ThreadId::from_u128(/*value*/ 1); + let other = ThreadId::new(); + let threads = [(selected, "Review parser"), (other, "Other task")].map(|(id, name)| { + overview_thread(id, /*parent_thread_id*/ None, name, ThreadStatus::Idle) + }); + app.agents_overview.threads = threads + .iter() + .cloned() + .map(|thread| (ThreadId::from_string(&thread.id).unwrap(), Some(thread))) + .collect(); + let view = app.agents_overview_view(threads.to_vec(), Some(selected)); + app.agents_overview.visible_thread_ids = view.thread_ids(); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let thread_id = selected; + let request_id = Uuid::new_v4(); + let result = ThreadUsageOutcome::Available(serde_json::from_value(serde_json::json!({ + "threadId": selected.to_string(), + "estimatedUsageCreditsMicros": 3_400_000, + "estimatedUsageUsdMicros": 140_000, + "groups": [{"estimatedUsageCreditsMicros": 3_400_000, "inputTokens": 12_000, "outputTokens": 3_000}] + })).unwrap()); + let tui = crate::tui::test_support::make_test_tui()?; + app.agents_overview.pending_usage = Some((thread_id, request_id)); + app.refresh_agents_overview_usage(&app_server, tui.frame_requester()); + assert_eq!( + app.agents_overview.pending_usage, + Some((thread_id, request_id)) + ); + app.finish_agents_overview_usage(thread_id, request_id, Ok(result.clone())); + app.refresh_agents_overview_usage(&app_server, tui.frame_requester()); + assert_eq!(app.agents_overview.pending_usage, None); + + let ThreadUsageOutcome::Available(mut expected) = result.clone() else { + panic!("expected usage estimate") + }; + for (credits, cost, expected_credits) in [(0, 200_000, 3_400_000), (5_000_000, 0, 5_000_000)] { + let mut settlement = expected.clone(); + settlement.estimated_usage_credits_micros = credits; + settlement.estimated_usage_usd_micros = Some(cost); + settlement.groups.clear(); + expected.estimated_usage_credits_micros = expected_credits; + expected.estimated_usage_usd_micros = Some(200_000); + app.agents_overview.pending_usage = Some((thread_id, request_id)); + app.finish_agents_overview_usage( + thread_id, + request_id, + Ok(ThreadUsageOutcome::Available(settlement)), + ); + assert_eq!( + app.agents_overview.usage[&selected].estimate, + Some(expected.clone()) + ); + } + + let tokens = TokenUsageBreakdown { + total_tokens: 17_000, + input_tokens: 13_000, + cached_input_tokens: 1_000, + cache_write_input_tokens: 0, + output_tokens: 4_000, + reasoning_output_tokens: 0, + }; + app.agents_overview.threads.remove(&selected); + app.agents_overview.request_id = Some(request_id); + app.track_agents_overview_notification(&ServerNotification::ThreadTokenUsageUpdated( + ThreadTokenUsageUpdatedNotification { + thread_id: selected.to_string(), + turn_id: "turn".into(), + token_usage: ThreadTokenUsage { + total: tokens.clone(), + last: tokens.clone(), + model_context_window: None, + }, + }, + )); + app.apply_agents_overview_thread_refresh( + &app_server, + request_id, + Ok(AgentsOverviewThreadRefresh { + threads: HashMap::from([(selected, Some(threads[0].clone()))]), + last_messages: HashMap::new(), + recent_seed_complete: true, + }), + ); + assert_eq!( + app.agents_overview.usage[&selected].tokens, + Some(tokens.clone()) + ); + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 96).contains("Tokens: 13K in · 4K out") + ); + let mut view = app.agents_overview_view(threads.to_vec(), Some(selected)); + let row = view + .rows + .iter_mut() + .find(|row| row.thread_id == selected) + .unwrap(); + row.group = AgentsOverviewGroup::NeedsYou; + row.details.lines = vec![ + Line::default(), + "Needs attention".into(), + "Which dependency version should I use?".into(), + "Open task to review.".into(), + ]; + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let project = test_path_display("/tmp/project"); + let group = format!( + "/tmp/project 2{}", + " ".repeat(project.len().saturating_sub("/tmp/project".len())) + ); + insta::assert_snapshot!( + "agents_overview_usage", + render_bottom_popup(&app.chat_widget, /*width*/ 96) + .replace(&format!("{project} 2"), &group) + .replace(&project, "/tmp/project") + .replace("fwd del", "del") + ); + for notification in [ + ServerNotification::ThreadReverted(codex_app_server_protocol::ThreadRevertedNotification { + thread_id: selected.to_string(), + }), + ServerNotification::ThreadClosed(ThreadClosedNotification { + thread_id: selected.to_string(), + }), + ] { + app.agents_overview.usage.get_mut(&selected).unwrap().tokens = Some(tokens.clone()); + app.track_agents_overview_notification(¬ification); + assert_eq!(app.agents_overview.usage[&selected].tokens, None); + } + assert_eq!( + app.agents_overview.usage[&selected].estimate, + Some(expected.clone()) + ); + let other_usage = app.agents_overview.usage.entry(other).or_default(); + other_usage.estimate = Some(expected.clone()); + other_usage.tokens = Some(tokens.clone()); + app.agents_overview.pending_usage = Some((thread_id, request_id)); + app.finish_agents_overview_usage(thread_id, request_id, Ok(ThreadUsageOutcome::Disabled)); + assert!( + app.agents_overview + .usage + .values() + .all(|usage| usage.estimate.is_none()) + ); + assert_eq!( + app.agents_overview.usage[&other].tokens, + Some(tokens.clone()) + ); + // A different selection must not retry an unavailable account-wide capability. + let view = app.agents_overview_view(threads.to_vec(), Some(other)); + app.agents_overview.visible_thread_ids = view.thread_ids(); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + app.refresh_agents_overview_usage(&app_server, tui.frame_requester()); + assert!(app.agents_overview.usage_disabled); + assert_eq!(app.agents_overview.pending_usage, None); + for event in [ + AppServerEvent::Lagged { skipped: 1 }, + AppServerEvent::ServerNotification(Box::new(ServerNotification::AccountUpdated( + AccountUpdatedNotification { + auth_mode: None, + plan_type: None, + }, + ))), + ] { + app.agents_overview + .usage + .entry(selected) + .or_default() + .tokens = Some(tokens.clone()); + app.agents_overview.pending_usage = Some((thread_id, request_id)); + app.agents_overview.usage_disabled = true; + app.handle_app_server_event(&app_server, event).await; + app.finish_agents_overview_usage(thread_id, request_id, Ok(result.clone())); + assert!(app.agents_overview.usage.is_empty()); + assert_eq!(app.agents_overview.pending_usage, None); + assert!(!app.agents_overview.usage_disabled); + } + app.agents_overview.usage_disabled = true; + app.agents_overview.pending_usage = Some((thread_id, request_id)); + app.app_server_target = AppServerTarget::LocalDaemon { + endpoint: crate::RemoteAppServerEndpoint::UnixSocket { + socket_path: test_path_buf("/tmp/test.sock").abs(), + }, + }; + assert!(app.begin_reconnect()); + app.finish_agents_overview_usage( + thread_id, + request_id, + Ok(ThreadUsageOutcome::Available(expected)), + ); + assert!(app.agents_overview.usage.is_empty()); + assert_eq!(app.agents_overview.pending_usage, None); + assert!(!app.agents_overview.usage_disabled); + Ok(()) +} + +#[test] +fn incomplete_billing_groups_do_not_display_partial_token_totals() { + let estimate: ThreadUsage = serde_json::from_value(serde_json::json!({ + "threadId": "thread", "estimatedUsageCreditsMicros": 0, "estimatedUsageUsdMicros": null, + "groups": [ + {"estimatedUsageCreditsMicros": 0, "inputTokens": 100, "outputTokens": 10}, + {"estimatedUsageCreditsMicros": 0, "inputTokens": null, "outputTokens": 20} + ] + })) + .unwrap(); + let mut usage = AgentsOverviewUsage::default(); + usage.estimate = Some(estimate); + let text = usage_lines(&usage) + .into_iter() + .map(|line| line.to_string()) + .collect::>(); + assert_eq!(text, vec!["Tokens: 30 out", "Est. usage: 0 credits"]); +} diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index c4f3f64d1d..13bbf5cf84 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -480,6 +480,7 @@ impl AgentsOverviewView { model_name(&row.thread).to_string().into(), ]), ]; + lines.extend(row.details.usage_lines.clone()); if let Some(branch) = row .thread .git_info @@ -491,6 +492,7 @@ impl AgentsOverviewView { lines.push(branch.clone().into()); } let preview = super::agents_overview_details::preview_markdown(&row.thread.preview); + let prompt_start = crate::wrapping::word_wrap_lines(lines.clone(), width).len(); lines.extend([Line::default(), Line::from("Prompt".dim())]); let prompt = crate::markdown_render::render_markdown_text_with_width_and_cwd( match preview.as_str() { @@ -521,6 +523,12 @@ impl AgentsOverviewView { ); } let mut details = crate::wrapping::word_wrap_lines(details, width); + if !row.details.usage_lines.is_empty() + && details.len() > usize::from(area.height).saturating_sub(lines.len()) + { + // Activity and usage take precedence over repeating the original prompt. + lines.truncate(prompt_start); + } let available = usize::from(area.height).saturating_sub(lines.len()); if details.len() > available { details.truncate(available); diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 8844588457..e3d54b8e75 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -78,6 +78,9 @@ impl App { self.agents_overview.refresh_notifications.clear(); self.agents_overview.activity.clear(); self.agents_overview.last_messages.clear(); + self.agents_overview.usage.clear(); + self.agents_overview.pending_usage = None; + self.agents_overview.usage_disabled = false; self.repaint_agents_overview(); self.refresh_agents_overview_threads(app_server_client); } @@ -227,6 +230,10 @@ impl App { return; } ServerNotification::AccountUpdated(notification) => { + self.agents_overview.usage.clear(); + self.agents_overview.pending_usage = None; + self.agents_overview.usage_disabled = false; + self.repaint_agents_overview(); self.chat_widget.cyber_policy_notice = Default::default(); self.rate_limit_hard_stop_generation = self.rate_limit_hard_stop_generation.wrapping_add(1); diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 311c8482b1..19f3308d47 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -32,7 +32,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; const TOKEN_ACTIVITY_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(/*secs*/ 15); -const THREAD_USAGE_FETCH_TIMEOUT: std::time::Duration = +pub(super) const THREAD_USAGE_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(/*secs*/ 65); const RATE_LIMIT_RESET_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(/*secs*/ 15); @@ -844,7 +844,7 @@ pub(super) async fn fetch_account_token_activity( .wrap_err("account/usage/read failed in TUI") } -async fn fetch_thread_usage( +pub(super) async fn fetch_thread_usage( request_handle: AppServerRequestHandle, thread_id: ThreadId, ) -> Result { diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 6bcadcf892..1d04512a2e 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1699,6 +1699,9 @@ impl App { } => { self.finish_thread_usage_refresh(tui, thread_id, request_id, result)?; } + AppEvent::AgentsOverviewUsageLoaded { thread_id, request_id, result } => { + self.finish_agents_overview_usage(thread_id, request_id, result); + } AppEvent::CommitPendingUsageOutput => { self.insert_pending_usage_output_if_ready(tui); } diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index 8f5e5295f2..24ce138de4 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -200,6 +200,9 @@ impl App { self.agents_overview.request_id = None; self.agents_overview.refresh_pending = false; self.agents_overview.refresh_notifications.clear(); + self.agents_overview.pending_usage = None; + self.agents_overview.usage_disabled = false; + self.agents_overview.usage.clear(); self.agents_overview.activity.clear(); self.agents_overview.last_messages.clear(); self.reconnect.presentation = if self diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__usage__agents_overview_usage.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__usage__agents_overview_usage.snap new file mode 100644 index 0000000000..853fb96813 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__usage__agents_overview_usage.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/agents_overview_usage_tests.rs +expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{project} 2\"),\n&group).replace(&project, \"/tmp/project\").replace(\"fwd del\", \"del\")" +--- + Agent command center + 1 need input 0 working 1 ready + ──────────────────────────────────────────────────────────────────────────────────────────── + /tmp/project 2 │ Task details + › ● Review parser Needs input │ + ○ Other task Ready │ Review parser + │ ● Needs input + │ + │ Needs attention + │ Which dependency version should I use? + │ Open task to review. + │ + │ Project + │ /tmp/project + │ Model: Unknown + │ Tokens: 13K in · 4K out + │ Est. usage: 5 credits · ~$0.20 + │ + │ + New task + +› Describe a new task + + enter create task ctrl+j newline esc tasks → open task diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index b1e802455f..00023b84f5 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -726,6 +726,13 @@ pub(crate) enum AppEvent { result: Result, }, + /// Result of fetching usage for the selected dashboard task. + AgentsOverviewUsageLoaded { + thread_id: ThreadId, + request_id: Uuid, + result: Result, + }, + /// Fetch workspace messages for the status-line headline item. RefreshStatusLineWorkspaceHeadline { request_id: u64,