diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index aadfaf95fc..8d5240926f 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1211,6 +1211,7 @@ async fn active_thread_drain_yields_after_frame_deadline_without_dropping_events crate::token_usage::TokenUsage { input_tokens: 4, cached_input_tokens: 1, + cache_write_input_tokens: 0, output_tokens: 5, reasoning_output_tokens: 0, total_tokens: 10, @@ -1239,6 +1240,7 @@ async fn active_thread_drain_yields_after_frame_deadline_without_dropping_events crate::token_usage::TokenUsage { input_tokens: 4, cached_input_tokens: 1, + cache_write_input_tokens: 0, output_tokens: 10, reasoning_output_tokens: 0, total_tokens: 15, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c1167400c9..ba0ed1b247 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -953,20 +953,8 @@ fn request_permissions_from_params( fn token_usage_info_from_app_server(token_usage: ThreadTokenUsage) -> TokenUsageInfo { TokenUsageInfo { - total_token_usage: TokenUsage { - total_tokens: token_usage.total.total_tokens, - input_tokens: token_usage.total.input_tokens, - cached_input_tokens: token_usage.total.cached_input_tokens, - output_tokens: token_usage.total.output_tokens, - reasoning_output_tokens: token_usage.total.reasoning_output_tokens, - }, - last_token_usage: TokenUsage { - total_tokens: token_usage.last.total_tokens, - input_tokens: token_usage.last.input_tokens, - cached_input_tokens: token_usage.last.cached_input_tokens, - output_tokens: token_usage.last.output_tokens, - reasoning_output_tokens: token_usage.last.reasoning_output_tokens, - }, + total_token_usage: token_usage.total.into(), + last_token_usage: token_usage.last.into(), model_context_window: token_usage.model_context_window, } } diff --git a/codex-rs/tui/src/chatwidget/statusline_state.rs b/codex-rs/tui/src/chatwidget/statusline_state.rs index 852bf73c07..91708c30e9 100644 --- a/codex-rs/tui/src/chatwidget/statusline_state.rs +++ b/codex-rs/tui/src/chatwidget/statusline_state.rs @@ -1,5 +1,7 @@ //! Projects the displayed conversation into the provider-facing statusline snapshot. +mod usage; + use super::ChatWidget; use super::normalize_thread_name; use super::status_state::TerminalTitleStatusKind as TerminalTitleStatus; @@ -44,6 +46,7 @@ struct StatuslineStateThread { agent: StatuslineStateAgent, model: StatuslineStateModel, workspace: StatuslineStateWorkspace, + usage: usage::Usage, #[serde(skip_serializing_if = "Option::is_none")] approval: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -248,6 +251,7 @@ impl ChatWidget { name, agent, model, + usage: self.into(), workspace: StatuslineStateWorkspace { cwd: codex_utils_path_uri::LegacyAppPathString::from_path( self.status_line_workspace_cwd(), diff --git a/codex-rs/tui/src/chatwidget/statusline_state/usage.rs b/codex-rs/tui/src/chatwidget/statusline_state/usage.rs new file mode 100644 index 0000000000..b6e6e6bed4 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/statusline_state/usage.rs @@ -0,0 +1,62 @@ +//! Provider-facing usage comes from the same token report as the built-in footer. + +use super::ChatWidget; +use crate::token_usage::TokenUsage; +use serde::Serialize; + +#[derive(Serialize)] +pub(super) struct Usage { + #[serde(skip_serializing_if = "Option::is_none")] + total: Option, +} + +#[derive(Serialize)] +struct Counts { + total_input_tokens: i64, + cache_read_input_tokens: i64, + cache_write_input_tokens: i64, + other_input_tokens: i64, + output_tokens: i64, +} + +impl Counts { + fn reported(usage: &TokenUsage) -> Option { + let other_input_tokens = usage + .input_tokens + .checked_sub(usage.cached_input_tokens)? + .checked_sub(usage.cache_write_input_tokens)?; + if [ + other_input_tokens, + usage.cached_input_tokens, + usage.cache_write_input_tokens, + usage.output_tokens, + ] + .into_iter() + .any(|count| count < 0) + { + return None; + } + // A synthetic context fill reports its size without a token breakdown. + if usage.total_tokens != 0 && usage.input_tokens == 0 && usage.output_tokens == 0 { + return None; + } + Some(Self { + total_input_tokens: usage.input_tokens, + cache_read_input_tokens: usage.cached_input_tokens, + cache_write_input_tokens: usage.cache_write_input_tokens, + other_input_tokens, + output_tokens: usage.output_tokens, + }) + } +} + +impl From<&ChatWidget> for Usage { + fn from(chat: &ChatWidget) -> Self { + Self { + total: chat + .token_info + .as_ref() + .and_then(|info| Counts::reported(&info.total_token_usage)), + } + } +} diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 8a6096966e..8a85a3dd74 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -515,7 +515,7 @@ fn token_usage_breakdown(usage: TokenUsage) -> codex_app_server_protocol::TokenU total_tokens: usage.total_tokens, input_tokens: usage.input_tokens, cached_input_tokens: usage.cached_input_tokens, - cache_write_input_tokens: 0, + cache_write_input_tokens: usage.cache_write_input_tokens, output_tokens: usage.output_tokens, reasoning_output_tokens: usage.reasoning_output_tokens, } diff --git a/codex-rs/tui/src/chatwidget/tests/statusline_state_tests.rs b/codex-rs/tui/src/chatwidget/tests/statusline_state_tests.rs index f38a2aac36..4c6657cfbe 100644 --- a/codex-rs/tui/src/chatwidget/tests/statusline_state_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/statusline_state_tests.rs @@ -34,6 +34,7 @@ async fn statusline_state_projects_the_displayed_settings() { "config": {"statusline_items": ["run-state"]}, "thread": { "workspace": {"cwd": expected_cwd}, + "usage": {}, "id": id, "name": "Saved carts", "agent": {"state": "ready"}, "model": {"id": "gpt-5.5", "display_name": "Test model", "provider": "openai", "reasoning_level": "none", "service_tier": "acme-priority", "service_tier_display_name": "Turbo"}, @@ -47,3 +48,47 @@ async fn statusline_state_projects_the_displayed_settings() { let state = serde_json::to_value(chat.statusline_state()).unwrap(); assert_eq!(state["thread"]["model"]["reasoning_level"], "default"); } + +#[tokio::test] +async fn statusline_totals_use_the_reported_token_breakdown() { + let (mut chat, _events, _ops) = make_chatwidget_manual(Some("gpt-5.2")).await; + chat.handle_thread_session(configured_thread_session(ThreadId::new())); + let mut info = + helpers::make_token_info(/*total_tokens*/ 15, /*context_window*/ 100_000); + info.total_token_usage = TokenUsage { + input_tokens: 110, + cached_input_tokens: 60, + cache_write_input_tokens: 10, + output_tokens: 20, + total_tokens: 130, + ..Default::default() + }; + helpers::handle_token_count(&mut chat, Some(info.clone())); + let state = serde_json::to_value(chat.statusline_state()).unwrap(); + assert_eq!( + state["thread"]["usage"]["total"], + json!({ + "total_input_tokens": 110, "cache_read_input_tokens": 60, + "cache_write_input_tokens": 10, "other_input_tokens": 40, "output_tokens": 20 + }) + ); + + info.total_token_usage.cache_write_input_tokens = 51; + for (report, has_total) in [ + (info, false), // The cache counts exceed total input. + ( + helpers::make_token_info( + /*total_tokens*/ 100_000, /*context_window*/ 100_000, + ), + false, + ), // Synthetic context fill. + ( + helpers::make_token_info(/*total_tokens*/ 0, /*context_window*/ 100_000), + true, + ), // Measured zero. + ] { + helpers::handle_token_count(&mut chat, Some(report)); + let state = serde_json::to_value(chat.statusline_state()).unwrap(); + assert_eq!(state["thread"]["usage"].get("total").is_some(), has_total); + } +} diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index c51f429dfa..1301926691 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -283,6 +283,7 @@ async fn status_snapshot_includes_reasoning_details() { let usage = TokenUsage { input_tokens: 1_200, cached_input_tokens: 200, + cache_write_input_tokens: 0, output_tokens: 900, reasoning_output_tokens: 150, total_tokens: 2_250, @@ -981,6 +982,7 @@ async fn status_snapshot_includes_forked_from() { let usage = TokenUsage { input_tokens: 800, cached_input_tokens: 0, + cache_write_input_tokens: 0, output_tokens: 400, reasoning_output_tokens: 0, total_tokens: 1_200, @@ -1035,6 +1037,7 @@ async fn status_snapshot_includes_monthly_limit() { let usage = TokenUsage { input_tokens: 800, cached_input_tokens: 0, + cache_write_input_tokens: 0, output_tokens: 400, reasoning_output_tokens: 0, total_tokens: 1_200, @@ -1101,6 +1104,7 @@ async fn status_snapshot_includes_enterprise_monthly_credit_limit() { let usage = TokenUsage { input_tokens: 800, cached_input_tokens: 0, + cache_write_input_tokens: 0, output_tokens: 400, reasoning_output_tokens: 0, total_tokens: 1_200, @@ -1179,6 +1183,7 @@ async fn status_snapshot_uses_generic_limit_labels_for_unsupported_windows() { let usage = TokenUsage { input_tokens: 800, cached_input_tokens: 0, + cache_write_input_tokens: 0, output_tokens: 400, reasoning_output_tokens: 0, total_tokens: 1_200, @@ -1468,6 +1473,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { let usage = TokenUsage { input_tokens: 1_200, cached_input_tokens: 200, + cache_write_input_tokens: 0, output_tokens: 900, reasoning_output_tokens: 0, total_tokens: 2_100, @@ -1570,6 +1576,7 @@ async fn status_snapshot_wraps_in_narrow_terminal() { let usage = TokenUsage { input_tokens: 1_200, cached_input_tokens: 200, + cache_write_input_tokens: 0, output_tokens: 900, reasoning_output_tokens: 150, total_tokens: 2_250, @@ -1677,6 +1684,7 @@ async fn status_snapshot_shows_missing_limits_message() { let usage = TokenUsage { input_tokens: 500, cached_input_tokens: 0, + cache_write_input_tokens: 0, output_tokens: 250, reasoning_output_tokens: 0, total_tokens: 750, @@ -1725,6 +1733,7 @@ async fn status_snapshot_uses_default_reasoning_when_config_empty() { let usage = TokenUsage { input_tokens: 500, cached_input_tokens: 0, + cache_write_input_tokens: 0, output_tokens: 250, reasoning_output_tokens: 0, total_tokens: 750, @@ -1793,6 +1802,7 @@ async fn status_snapshot_shows_refreshing_limits_notice() { let usage = TokenUsage { input_tokens: 500, cached_input_tokens: 0, + cache_write_input_tokens: 0, output_tokens: 250, reasoning_output_tokens: 0, total_tokens: 750, @@ -1951,6 +1961,7 @@ async fn status_snapshot_includes_credits_and_limits() { let usage = TokenUsage { input_tokens: 1_500, cached_input_tokens: 100, + cache_write_input_tokens: 0, output_tokens: 600, reasoning_output_tokens: 0, total_tokens: 2_200, @@ -2024,6 +2035,7 @@ async fn status_snapshot_shows_unavailable_limits_message() { let usage = TokenUsage { input_tokens: 500, cached_input_tokens: 0, + cache_write_input_tokens: 0, output_tokens: 250, reasoning_output_tokens: 0, total_tokens: 750, @@ -2084,6 +2096,7 @@ async fn status_snapshot_treats_refreshing_empty_limits_as_unavailable() { let usage = TokenUsage { input_tokens: 500, cached_input_tokens: 0, + cache_write_input_tokens: 0, output_tokens: 250, reasoning_output_tokens: 0, total_tokens: 750, @@ -2146,6 +2159,7 @@ async fn status_snapshot_shows_stale_limits_message() { let usage = TokenUsage { input_tokens: 1_200, cached_input_tokens: 200, + cache_write_input_tokens: 0, output_tokens: 900, reasoning_output_tokens: 150, total_tokens: 2_250, @@ -2216,6 +2230,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { let usage = TokenUsage { input_tokens: 900, cached_input_tokens: 200, + cache_write_input_tokens: 0, output_tokens: 350, reasoning_output_tokens: 0, total_tokens: 1_450, @@ -2289,6 +2304,7 @@ async fn status_context_window_uses_last_usage() { let total_usage = TokenUsage { input_tokens: 12_800, cached_input_tokens: 0, + cache_write_input_tokens: 0, output_tokens: 879, reasoning_output_tokens: 0, total_tokens: 102_000, @@ -2296,6 +2312,7 @@ async fn status_context_window_uses_last_usage() { let last_usage = TokenUsage { input_tokens: 12_800, cached_input_tokens: 0, + cache_write_input_tokens: 0, output_tokens: 879, reasoning_output_tokens: 0, total_tokens: 13_679, diff --git a/codex-rs/tui/src/token_usage.rs b/codex-rs/tui/src/token_usage.rs index ac9a20be9a..07ae276bf1 100644 --- a/codex-rs/tui/src/token_usage.rs +++ b/codex-rs/tui/src/token_usage.rs @@ -12,11 +12,26 @@ const BASELINE_TOKENS: i64 = 12000; pub struct TokenUsage { pub input_tokens: i64, pub cached_input_tokens: i64, + #[serde(default)] + pub cache_write_input_tokens: i64, pub output_tokens: i64, pub reasoning_output_tokens: i64, pub total_tokens: i64, } +impl From for TokenUsage { + fn from(usage: codex_app_server_protocol::TokenUsageBreakdown) -> Self { + Self { + input_tokens: usage.input_tokens, + cached_input_tokens: usage.cached_input_tokens, + cache_write_input_tokens: usage.cache_write_input_tokens, + output_tokens: usage.output_tokens, + reasoning_output_tokens: usage.reasoning_output_tokens, + total_tokens: usage.total_tokens, + } + } +} + impl TokenUsage { pub fn is_zero(&self) -> bool { self.total_tokens == 0