From c2e0889cf7a5e1e8c5f495ee5f4bf5cad24676ce Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 30 May 2026 19:31:25 -0300 Subject: [PATCH] feat(tui): add token activity command --- codex-rs/tui/src/app.rs | 6 +- codex-rs/tui/src/app/background_requests.rs | 35 + codex-rs/tui/src/app/event_dispatch.rs | 20 +- codex-rs/tui/src/app/history_ui.rs | 1 + codex-rs/tui/src/app/resize_reflow.rs | 46 +- codex-rs/tui/src/app/tests.rs | 26 + codex-rs/tui/src/app_backtrack.rs | 1 + codex-rs/tui/src/app_event.rs | 12 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 7 + .../bottom_pane/chat_composer/slash_input.rs | 1 + codex-rs/tui/src/bottom_pane/command_popup.rs | 6 + codex-rs/tui/src/bottom_pane/mod.rs | 5 + .../tui/src/bottom_pane/slash_commands.rs | 32 +- codex-rs/tui/src/chatwidget.rs | 3 + codex-rs/tui/src/chatwidget/constructor.rs | 5 + codex-rs/tui/src/chatwidget/settings.rs | 2 + codex-rs/tui/src/chatwidget/slash_dispatch.rs | 26 + .../src/chatwidget/tests/slash_commands.rs | 61 ++ codex-rs/tui/src/chatwidget/tokens.rs | 839 ++++++++++++++++++ codex-rs/tui/src/slash_command.rs | 5 + codex-rs/tui/src/transcript_reflow.rs | 23 + 21 files changed, 1154 insertions(+), 8 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/tokens.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e31b18044c..5a2e3578f1 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1231,7 +1231,9 @@ See the Codex keymap documentation for supported actions and examples." event: TuiEvent, ) -> Result { let terminal_resize_reflow_enabled = self.terminal_resize_reflow_enabled(); - if terminal_resize_reflow_enabled && matches!(event, TuiEvent::Draw | TuiEvent::Resize) { + if self.should_handle_draw_pre_render() + && matches!(event, TuiEvent::Draw | TuiEvent::Resize) + { self.handle_draw_pre_render(tui)?; } else if matches!(event, TuiEvent::Draw | TuiEvent::Resize) { let size = tui.terminal.size()?; @@ -1310,7 +1312,7 @@ See the Codex keymap documentation for supported actions and examples." self.disable_ambient_pet_before_shutdown(tui)?; self.chat_widget.show_shutdown_in_progress(); let terminal_resize_reflow_enabled = self.terminal_resize_reflow_enabled(); - if terminal_resize_reflow_enabled { + if self.should_handle_draw_pre_render() { self.handle_draw_pre_render(tui)?; } self.chat_widget.pre_draw_tick(); diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index d90f511d6f..c0ff236212 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -23,6 +23,9 @@ use crate::hooks_rpc::write_hook_trust; use crate::hooks_rpc::write_hook_trusts; use codex_utils_absolute_path::AbsolutePathBuf; +const TOKEN_ACTIVITY_FETCH_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(/*secs*/ 15); + impl App { pub(super) fn fetch_mcp_inventory( &mut self, @@ -77,6 +80,25 @@ impl App { }); } + pub(super) fn refresh_token_activity( + &mut self, + app_server: &AppServerSession, + request_id: u64, + ) { + 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( + TOKEN_ACTIVITY_FETCH_TIMEOUT, + fetch_account_token_activity(request_handle), + ) + .await + .map_err(|_| "account/tokenUsage/read timed out in TUI".to_string()) + .and_then(|result| result.map_err(|err| err.to_string())); + app_event_tx.send(AppEvent::TokenActivityLoaded { request_id, result }); + }); + } + pub(super) fn send_add_credits_nudge_email( &mut self, app_server: &AppServerSession, @@ -646,6 +668,19 @@ pub(super) async fn fetch_account_rate_limits( Ok(app_server_rate_limit_snapshots(response)) } +pub(super) async fn fetch_account_token_activity( + request_handle: AppServerRequestHandle, +) -> Result { + let request_id = RequestId::String(format!("account-token-usage-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::GetAccountTokenUsage { + request_id, + params: None, + }) + .await + .wrap_err("account/tokenUsage/read failed in TUI") +} + pub(super) async fn send_add_credits_nudge_email( request_handle: AppServerRequestHandle, credit_type: AddCreditsNudgeCreditType, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 2e1b97aace..44873ed22d 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -238,7 +238,9 @@ impl App { } AppEvent::ConsolidateProposedPlan(source) => { if !self.terminal_resize_reflow_enabled() { - self.transcript_reflow.clear(); + if !self.transcript_reflow.history_cell_refresh_requested() { + self.transcript_reflow.clear(); + } return Ok(AppRunControl::Continue); } let end = self.transcript_cells.len(); @@ -683,6 +685,9 @@ impl App { AppEvent::RefreshRateLimits { origin } => { self.refresh_rate_limits(app_server, origin); } + AppEvent::RefreshTokenActivity { request_id } => { + self.refresh_token_activity(app_server, request_id); + } AppEvent::OpenThreadGoalMenu { thread_id } => { self.open_thread_goal_menu(app_server, thread_id).await; } @@ -739,6 +744,17 @@ impl App { } } }, + AppEvent::TokenActivityLoaded { request_id, result } => { + if let Err(err) = &result { + tracing::warn!("account/tokenUsage/read failed during TUI refresh: {err}"); + } + if self + .chat_widget + .finish_token_activity_refresh(request_id, result) + { + self.refresh_updated_history_cell(tui)?; + } + } AppEvent::ConnectorsLoaded { result, is_final } => { self.chat_widget.on_connectors_loaded(result, is_final); } @@ -1991,6 +2007,7 @@ impl App { } self.sync_tui_theme_selection(name); self.refresh_status_line(); + tui.frame_requester().schedule_frame(); } Err(err) => { self.restore_runtime_theme_from_config(); @@ -2003,6 +2020,7 @@ impl App { } AppEvent::SyntaxThemePreviewed => { self.refresh_status_line(); + tui.frame_requester().schedule_frame(); } AppEvent::OpenKeymapActionMenu { context, action } => { self.chat_widget diff --git a/codex-rs/tui/src/app/history_ui.rs b/codex-rs/tui/src/app/history_ui.rs index e923db1770..0ed33dd7d4 100644 --- a/codex-rs/tui/src/app/history_ui.rs +++ b/codex-rs/tui/src/app/history_ui.rs @@ -94,6 +94,7 @@ impl App { self.deferred_history_lines.clear(); self.has_emitted_history_lines = false; self.transcript_reflow.clear(); + self.chat_widget.clear_pending_token_activity_refreshes(); self.initial_history_replay_buffer = None; self.backtrack = BacktrackState::default(); self.backtrack_render_pending = false; diff --git a/codex-rs/tui/src/app/resize_reflow.rs b/codex-rs/tui/src/app/resize_reflow.rs index 34e25697e2..6dfa0bc49a 100644 --- a/codex-rs/tui/src/app/resize_reflow.rs +++ b/codex-rs/tui/src/app/resize_reflow.rs @@ -264,6 +264,14 @@ impl App { /// transient stream rows. pub(super) fn maybe_finish_stream_reflow(&mut self, tui: &mut tui::Tui) -> Result<()> { if !self.terminal_resize_reflow_enabled() { + if self.transcript_reflow.take_stream_finish_reflow_needed() { + self.schedule_immediate_history_cell_refresh(tui); + self.maybe_run_resize_reflow(tui)?; + return Ok(()); + } + if self.transcript_reflow.history_cell_refresh_requested() { + return Ok(()); + } self.transcript_reflow.clear(); return Ok(()); } @@ -286,6 +294,31 @@ impl App { tui.frame_requester().schedule_frame(); } + /// Rebuild history after an existing cell's render output changes. + /// + /// Use the resize-reflow lifecycle so overlays defer the replay and active streams receive the + /// same post-consolidation repair as resize-triggered rebuilds. + pub(super) fn refresh_updated_history_cell(&mut self, tui: &mut tui::Tui) -> Result<()> { + self.schedule_immediate_history_cell_refresh(tui); + self.maybe_run_resize_reflow(tui) + } + + fn schedule_immediate_history_cell_refresh(&mut self, tui: &mut tui::Tui) { + self.transcript_reflow.schedule_history_cell_refresh(); + tui.frame_requester().schedule_frame(); + } + + pub(crate) fn retry_pending_history_cell_refresh(&self, tui: &mut tui::Tui) { + if self.transcript_reflow.history_cell_refresh_requested() { + tui.frame_requester().schedule_frame(); + } + } + + pub(super) fn should_handle_draw_pre_render(&self) -> bool { + self.terminal_resize_reflow_enabled() + || self.transcript_reflow.history_cell_refresh_requested() + } + /// Force stream-finalized output through the resize reflow path. /// /// Proposed plan consolidation uses this stricter path because a completed plan is inserted or @@ -293,7 +326,9 @@ impl App { /// resize, the visible scrollback can keep the pre-consolidation wrapping. pub(super) fn finish_required_stream_reflow(&mut self, tui: &mut tui::Tui) -> Result<()> { if !self.terminal_resize_reflow_enabled() { - self.transcript_reflow.clear(); + if !self.transcript_reflow.history_cell_refresh_requested() { + self.transcript_reflow.clear(); + } return Ok(()); } self.schedule_immediate_resize_reflow(tui); @@ -334,7 +369,10 @@ impl App { } else { frame_requester.schedule_frame_in(TRANSCRIPT_REFLOW_DEBOUNCE); } - } else if !self.terminal_resize_reflow_enabled() && width.changed { + } else if !self.terminal_resize_reflow_enabled() + && width.changed + && !self.transcript_reflow.history_cell_refresh_requested() + { self.transcript_reflow.clear(); } } @@ -388,7 +426,9 @@ impl App { /// reuse terminal-wrapped output here would preserve exactly the stale wrapping this feature is /// meant to remove. pub(super) fn maybe_run_resize_reflow(&mut self, tui: &mut tui::Tui) -> Result<()> { - if !self.terminal_resize_reflow_enabled() { + if !self.terminal_resize_reflow_enabled() + && !self.transcript_reflow.history_cell_refresh_requested() + { self.transcript_reflow.clear(); return Ok(()); } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 19da8ea77d..aecd9ec333 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4093,6 +4093,32 @@ async fn height_shrink_schedules_resize_reflow() { assert!(app.transcript_reflow.has_pending_reflow()); } +#[tokio::test] +async fn disabled_resize_reflow_preserves_pending_history_cell_refresh() { + let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; + let frame_requester = crate::tui::FrameRequester::test_dummy(); + app.config + .features + .set_enabled(Feature::TerminalResizeReflow, /*enabled*/ false) + .expect("feature should be configurable"); + assert!(!app.should_handle_draw_pre_render()); + app.transcript_reflow.schedule_history_cell_refresh(); + assert!(app.should_handle_draw_pre_render()); + + assert!(!app.handle_draw_size_change( + ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35), + ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35), + &frame_requester, + )); + assert!(app.handle_draw_size_change( + ratatui::layout::Size::new(/*width*/ 119, /*height*/ 35), + ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35), + &frame_requester, + )); + + assert!(app.transcript_reflow.history_cell_refresh_requested()); +} + fn test_turn(turn_id: &str, status: TurnStatus, items: Vec) -> Turn { Turn { id: turn_id.to_string(), diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 075a4a5ae2..64a4b82936 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -252,6 +252,7 @@ impl App { } self.overlay = None; self.backtrack.overlay_preview_active = false; + self.retry_pending_history_cell_refresh(tui); if was_backtrack { // Ensure backtrack state is fully reset when overlay closes (e.g. via 'q'). self.reset_backtrack_state(); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index f1e612dd58..3e3a506fb7 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -13,6 +13,7 @@ use std::path::PathBuf; use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::GetAccountTokenUsageResponse; use codex_app_server_protocol::MarketplaceAddResponse; use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::MarketplaceUpgradeResponse; @@ -292,6 +293,17 @@ pub(crate) enum AppEvent { result: Result, String>, }, + /// Fetch account-wide token activity for a `/tokens` history card. + RefreshTokenActivity { + request_id: u64, + }, + + /// Result of fetching account-wide token activity. + TokenActivityLoaded { + request_id: u64, + result: Result, + }, + /// Send a user-confirmed request to notify the workspace owner. SendAddCreditsNudgeEmail { credit_type: AddCreditsNudgeCreditType, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index ec3284866d..9e70e9c8a5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -368,6 +368,7 @@ pub(crate) struct ChatComposer { config: ChatComposerConfig, connectors_enabled: bool, plugins_command_enabled: bool, + token_activity_command_enabled: bool, service_tier_commands_enabled: bool, service_tier_commands: Vec, mentions_v2_enabled: bool, @@ -438,6 +439,7 @@ impl ChatComposer { collaboration_modes_enabled: self.collaboration_modes_enabled, connectors_enabled: self.connectors_enabled, plugins_command_enabled: self.plugins_command_enabled, + token_activity_command_enabled: self.token_activity_command_enabled, service_tier_commands_enabled: self.service_tier_commands_enabled, goal_command_enabled: self.goal_command_enabled, personality_command_enabled: self.personality_command_enabled, @@ -536,6 +538,7 @@ impl ChatComposer { config, connectors_enabled: false, plugins_command_enabled: false, + token_activity_command_enabled: false, service_tier_commands_enabled: false, service_tier_commands: Vec::new(), mentions_v2_enabled: false, @@ -587,6 +590,10 @@ impl ChatComposer { self.plugins_command_enabled = enabled; } + pub fn set_token_activity_command_enabled(&mut self, enabled: bool) { + self.token_activity_command_enabled = enabled; + } + pub fn set_mentions_v2_enabled(&mut self, enabled: bool) { self.mentions_v2_enabled = enabled; self.history.set_at_mention_restore_enabled(enabled); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs index bb9d2c6904..b821ca6b39 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs @@ -174,6 +174,7 @@ impl<'a> SlashInput<'a> { collaboration_modes_enabled: self.command_flags.collaboration_modes_enabled, connectors_enabled: self.command_flags.connectors_enabled, plugins_command_enabled: self.command_flags.plugins_command_enabled, + token_activity_command_enabled: self.command_flags.token_activity_command_enabled, service_tier_commands_enabled: self.command_flags.service_tier_commands_enabled, goal_command_enabled: self.command_flags.goal_command_enabled, personality_command_enabled: self.command_flags.personality_command_enabled, diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index f6e6b4929c..e1cdabf281 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -44,6 +44,7 @@ pub(crate) struct CommandPopupFlags { pub(crate) collaboration_modes_enabled: bool, pub(crate) connectors_enabled: bool, pub(crate) plugins_command_enabled: bool, + pub(crate) token_activity_command_enabled: bool, pub(crate) service_tier_commands_enabled: bool, pub(crate) goal_command_enabled: bool, pub(crate) personality_command_enabled: bool, @@ -59,6 +60,7 @@ impl From for BuiltinCommandFlags { collaboration_modes_enabled: value.collaboration_modes_enabled, connectors_enabled: value.connectors_enabled, plugins_command_enabled: value.plugins_command_enabled, + token_activity_command_enabled: value.token_activity_command_enabled, service_tier_commands_enabled: value.service_tier_commands_enabled, goal_command_enabled: value.goal_command_enabled, personality_command_enabled: value.personality_command_enabled, @@ -457,6 +459,7 @@ mod tests { collaboration_modes_enabled: true, connectors_enabled: false, plugins_command_enabled: false, + token_activity_command_enabled: false, service_tier_commands_enabled: false, goal_command_enabled: false, personality_command_enabled: true, @@ -485,6 +488,7 @@ mod tests { collaboration_modes_enabled: true, connectors_enabled: false, plugins_command_enabled: false, + token_activity_command_enabled: false, service_tier_commands_enabled: false, goal_command_enabled: false, personality_command_enabled: false, @@ -518,6 +522,7 @@ mod tests { collaboration_modes_enabled: true, connectors_enabled: false, plugins_command_enabled: false, + token_activity_command_enabled: false, service_tier_commands_enabled: false, goal_command_enabled: false, personality_command_enabled: true, @@ -546,6 +551,7 @@ mod tests { collaboration_modes_enabled: false, connectors_enabled: false, plugins_command_enabled: false, + token_activity_command_enabled: false, service_tier_commands_enabled: false, goal_command_enabled: false, personality_command_enabled: true, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index fa92914670..3b637abdb7 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -326,6 +326,11 @@ impl BottomPane { self.request_redraw(); } + pub fn set_token_activity_command_enabled(&mut self, enabled: bool) { + self.composer.set_token_activity_command_enabled(enabled); + self.request_redraw(); + } + pub fn set_mentions_v2_enabled(&mut self, enabled: bool) { self.composer.set_mentions_v2_enabled(enabled); self.request_redraw(); diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index e907b6c5e5..a4aba21c79 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -58,6 +58,7 @@ pub(crate) struct BuiltinCommandFlags { pub(crate) collaboration_modes_enabled: bool, pub(crate) connectors_enabled: bool, pub(crate) plugins_command_enabled: bool, + pub(crate) token_activity_command_enabled: bool, pub(crate) service_tier_commands_enabled: bool, pub(crate) goal_command_enabled: bool, pub(crate) personality_command_enabled: bool, @@ -75,6 +76,7 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st .filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Plan) .filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps) .filter(|(_, cmd)| flags.plugins_command_enabled || *cmd != SlashCommand::Plugins) + .filter(|(_, cmd)| flags.token_activity_command_enabled || *cmd != SlashCommand::Tokens) .filter(|(_, cmd)| flags.goal_command_enabled || *cmd != SlashCommand::Goal) .filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality) .filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime) @@ -108,11 +110,13 @@ pub(crate) fn commands_for_input( /// Find a single built-in command by exact name, after applying feature gating. /// -/// Side-conversation gating is intentionally enforced by dispatch rather than exact lookup so a -/// typed command can produce a side-specific unavailable message while the popup still hides it. +/// Side-conversation and token-activity gating are intentionally enforced by dispatch rather than +/// exact lookup so a typed command can produce a specific unavailable message while the popup still +/// hides it. pub(crate) fn find_builtin_command(name: &str, flags: BuiltinCommandFlags) -> Option { let cmd = SlashCommand::from_str(name).ok()?; builtins_for_input(BuiltinCommandFlags { + token_activity_command_enabled: true, side_conversation_active: false, ..flags }) @@ -163,6 +167,7 @@ mod tests { collaboration_modes_enabled: true, connectors_enabled: true, plugins_command_enabled: true, + token_activity_command_enabled: true, service_tier_commands_enabled: true, goal_command_enabled: true, personality_command_enabled: true, @@ -256,6 +261,28 @@ mod tests { assert_eq!(find_builtin_command("goal", flags), None); } + #[test] + fn tokens_command_is_hidden_from_input_when_account_token_activity_is_disabled() { + let mut flags = all_enabled_flags(); + flags.token_activity_command_enabled = false; + assert_eq!( + builtins_for_input(flags) + .into_iter() + .find(|(_, command)| *command == SlashCommand::Tokens), + None + ); + } + + #[test] + fn tokens_command_exact_lookup_still_resolves_when_account_token_activity_is_disabled() { + let mut flags = all_enabled_flags(); + flags.token_activity_command_enabled = false; + assert_eq!( + find_builtin_command("tokens", flags), + Some(SlashCommand::Tokens) + ); + } + #[test] fn realtime_command_is_hidden_when_realtime_is_disabled() { let mut flags = all_enabled_flags(); @@ -297,6 +324,7 @@ mod tests { SlashCommand::Diff, SlashCommand::Mention, SlashCommand::Status, + SlashCommand::Tokens, ] ); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c24d5dfcec..46b3a5746d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -411,6 +411,7 @@ mod status_controls; mod status_surfaces; mod streaming; use self::status_surfaces::CachedProjectRootName; +mod tokens; mod tool_lifecycle; mod tool_requests; mod transcript; @@ -549,6 +550,8 @@ pub(crate) struct ChatWidget { rate_limit_snapshots_by_limit_id: BTreeMap, refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>, next_status_refresh_request_id: u64, + refreshing_token_activity_outputs: Vec<(u64, tokens::TokenActivityHandle)>, + next_token_activity_request_id: u64, plan_type: Option, codex_rate_limit_reached_type: Option, rate_limit_warnings: RateLimitWarningState, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 623016f59f..f8bfd0778a 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -124,6 +124,8 @@ impl ChatWidget { rate_limit_snapshots_by_limit_id: BTreeMap::new(), refreshing_status_outputs: Vec::new(), next_status_refresh_request_id: 0, + refreshing_token_activity_outputs: Vec::new(), + next_token_activity_request_id: 0, plan_type: initial_plan_type, codex_rate_limit_reached_type: None, rate_limit_warnings: RateLimitWarningState::default(), @@ -262,6 +264,9 @@ impl ChatWidget { widget .bottom_pane .set_connectors_enabled(widget.connectors_enabled()); + widget + .bottom_pane + .set_token_activity_command_enabled(widget.has_chatgpt_account); widget.refresh_status_surfaces(); widget diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 45dbc362e9..21648803a5 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -233,6 +233,8 @@ impl ChatWidget { self.has_chatgpt_account = has_chatgpt_account; self.bottom_pane .set_connectors_enabled(self.connectors_enabled()); + self.bottom_pane + .set_token_activity_command_enabled(has_chatgpt_account); } pub(crate) fn set_realtime_audio_device( diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 99833da8a8..356cee103e 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -35,6 +35,7 @@ const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str = const GOAL_USAGE: &str = "Usage: /goal "; const GOAL_USAGE_HINT: &str = "Example: /goal improve benchmark coverage"; const RAW_USAGE: &str = "Usage: /raw [on|off]"; +const TOKENS_CHATGPT_LOGIN_REQUIRED: &str = "Sign in with ChatGPT to use /tokens."; impl ChatWidget { /// Dispatch a bare slash command and record its staged local-history entry. @@ -414,6 +415,11 @@ impl ChatWidget { ); } } + SlashCommand::Tokens => { + if self.ensure_token_activity_command_available() { + self.add_token_activity_output(tokens::TokenActivityView::Daily); + } + } SlashCommand::Ide => { self.handle_ide_command(); } @@ -614,6 +620,16 @@ impl ChatWidget { } = prepared; let trimmed = args.trim(); match cmd { + SlashCommand::Tokens => { + if self.ensure_token_activity_command_available() { + match tokens::TokenActivityView::parse(trimmed) { + Some(view) => self.add_token_activity_output(view), + None => self.add_error_message( + "Usage: /tokens [daily|weekly|cumulative]".to_string(), + ), + } + } + } SlashCommand::Ide => { self.handle_ide_command_args(trimmed); } @@ -944,6 +960,7 @@ impl ChatWidget { collaboration_modes_enabled: self.collaboration_modes_enabled(), connectors_enabled: self.connectors_enabled(), plugins_command_enabled: self.config.features.enabled(Feature::Plugins), + token_activity_command_enabled: self.has_chatgpt_account, goal_command_enabled: self.config.features.enabled(Feature::Goals), service_tier_commands_enabled: self.fast_mode_enabled(), personality_command_enabled: self.config.features.enabled(Feature::Personality), @@ -954,6 +971,14 @@ impl ChatWidget { } } + fn ensure_token_activity_command_available(&mut self) -> bool { + if self.has_chatgpt_account { + return true; + } + self.add_error_message(TOKENS_CHATGPT_LOGIN_REQUIRED.to_string()); + false + } + fn queued_command_drain_result(&self, cmd: SlashCommand) -> QueueDrain { if self.is_user_turn_pending_or_running() || !self.bottom_pane.no_modal_or_popup_active() { return QueueDrain::Stop; @@ -961,6 +986,7 @@ impl ChatWidget { match cmd { SlashCommand::Ide | SlashCommand::Status + | SlashCommand::Tokens | SlashCommand::DebugConfig | SlashCommand::Ps | SlashCommand::Stop diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 4f425c25c9..0389c82f19 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1136,6 +1136,67 @@ async fn usage_error_slash_command_is_available_from_local_recall() { assert_eq!(recall_latest_after_clearing(&mut chat), "/raw maybe"); } +#[tokio::test] +async fn signed_out_tokens_command_reports_chatgpt_login_requirement() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + submit_composer_text(&mut chat, "/tokens"); + + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|cell| lines_to_single_string(cell)) + .collect::>() + .join("\n"); + assert!( + rendered.contains("Sign in with ChatGPT to use /tokens."), + "expected ChatGPT login requirement, got: {rendered:?}" + ); + assert_eq!(recall_latest_after_clearing(&mut chat), "/tokens"); +} + +#[tokio::test] +async fn signed_out_tokens_command_with_args_reports_chatgpt_login_requirement() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + submit_composer_text(&mut chat, "/tokens weekly"); + + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|cell| lines_to_single_string(cell)) + .collect::>() + .join("\n"); + assert!( + rendered.contains("Sign in with ChatGPT to use /tokens."), + "expected ChatGPT login requirement, got: {rendered:?}" + ); + assert_eq!(recall_latest_after_clearing(&mut chat), "/tokens weekly"); +} + +#[tokio::test] +async fn clearing_pending_token_activity_refreshes_discards_late_result() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + + chat.dispatch_command(SlashCommand::Tokens); + + assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_))); + let request_id = match rx.try_recv() { + Ok(AppEvent::RefreshTokenActivity { request_id }) => request_id, + other => panic!("expected token activity refresh request, got {other:?}"), + }; + + chat.clear_pending_token_activity_refreshes(); + + assert!( + !chat.finish_token_activity_refresh( + request_id, + Err("stale token activity result".to_string()), + ) + ); +} + #[tokio::test] async fn unrecognized_slash_command_is_not_added_to_local_recall() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tokens.rs b/codex-rs/tui/src/chatwidget/tokens.rs new file mode 100644 index 0000000000..ad65c0bdcc --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tokens.rs @@ -0,0 +1,839 @@ +use std::collections::BTreeMap; +use std::sync::Arc; +use std::sync::RwLock; + +use chrono::Datelike; +use chrono::Duration; +use chrono::NaiveDate; +use chrono::Utc; +use codex_app_server_protocol::GetAccountTokenUsageResponse; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; + +use super::ChatWidget; +use crate::app_event::AppEvent; +use crate::color::blend; +use crate::history_cell::CompositeHistoryCell; +use crate::history_cell::HistoryCell; +use crate::history_cell::PlainHistoryCell; +use crate::history_cell::plain_lines; +use crate::render::highlight::foreground_style_for_scopes; +use crate::status::format_tokens_compact; +use crate::terminal_palette::StdoutColorLevel; +use crate::terminal_palette::best_color; +use crate::terminal_palette::default_bg; +use crate::terminal_palette::default_fg; +use crate::terminal_palette::stdout_color_level; + +// In low-color terminals we distinguish empty vs active cells by glyph (a +// width-matched filled/hollow pair). In truecolor terminals the grid uses a +// single glyph and lets color carry the intensity (GitHub-style), which keeps +// the grid perfectly aligned and free of texture noise. +const EMPTY_CELL_GLYPH: &str = "□"; +const ACTIVE_CELL_GLYPH: &str = "■"; +const WEEK_COUNT: usize = 52; +const DAY_COUNT: usize = 7; +const CELL_COUNT: usize = WEEK_COUNT * DAY_COUNT; +const CHART_LEFT_WIDTH: usize = 4; +const SUMMARY_INDENT: &str = " "; +const SUMMARY_INDENT_WIDTH: u16 = 1; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum TokenActivityView { + Daily, + Weekly, + Cumulative, +} + +impl TokenActivityView { + pub(super) fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "" | "day" | "daily" => Some(Self::Daily), + "week" | "weekly" => Some(Self::Weekly), + "cumulative" => Some(Self::Cumulative), + _ => None, + } + } + + fn label(self) -> &'static str { + match self { + Self::Daily => "Daily", + Self::Weekly => "Weekly", + Self::Cumulative => "Cumulative", + } + } +} + +#[derive(Debug)] +enum TokenActivityState { + Loading, + Loaded(GetAccountTokenUsageResponse), + Error, +} + +#[derive(Clone, Debug)] +pub(super) struct TokenActivityHandle { + state: Arc>, +} + +impl TokenActivityHandle { + pub(super) fn finish(&self, result: Result) { + let state = match result { + Ok(response) => TokenActivityState::Loaded(response), + Err(_) => TokenActivityState::Error, + }; + #[expect(clippy::expect_used)] + let mut current = self.state.write().expect("token activity state poisoned"); + *current = state; + } +} + +#[derive(Debug)] +struct TokenActivityHistoryCell { + view: TokenActivityView, + state: Arc>, +} + +pub(super) fn new_token_activity_output( + view: TokenActivityView, +) -> (CompositeHistoryCell, TokenActivityHandle) { + let command = PlainHistoryCell::new(vec![ + format!("/tokens {}", view.label().to_lowercase()) + .magenta() + .into(), + ]); + let state = Arc::new(RwLock::new(TokenActivityState::Loading)); + let handle = TokenActivityHandle { + state: Arc::clone(&state), + }; + let card = TokenActivityHistoryCell { view, state }; + ( + CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]), + handle, + ) +} + +impl HistoryCell for TokenActivityHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + #[expect(clippy::expect_used)] + let state = self.state.read().expect("token activity state poisoned"); + match &*state { + TokenActivityState::Loading => { + vec![ + " Token activity".bold().into(), + " Loading...".dim().into(), + ] + } + TokenActivityState::Error => vec![ + " Token activity".bold().into(), + " Token activity unavailable".dim().into(), + ], + TokenActivityState::Loaded(response) => self.loaded_lines(response, width), + } + } + + fn raw_lines(&self) -> Vec> { + plain_lines(self.display_lines(u16::MAX)) + } +} + +impl TokenActivityHistoryCell { + fn loaded_lines( + &self, + response: &GetAccountTokenUsageResponse, + width: u16, + ) -> Vec> { + let mut lines = vec![ + vec![ + Span::from(" Token activity").bold(), + Span::styled(" last 12 months", label_style()), + ] + .into(), + ]; + lines.extend(summary_lines(response, graph_width(width))); + // Separate the headline numbers from the calendar below. + lines.push(Line::default()); + let Some(buckets) = response.daily_usage_buckets.as_ref() else { + lines.push(" Token activity history unavailable".dim().into()); + return lines; + }; + + lines.extend(self.chart_lines(buckets, Utc::now().date_naive(), width)); + lines + } + + fn chart_lines( + &self, + buckets: &[codex_app_server_protocol::AccountTokenUsageDailyBucket], + today: NaiveDate, + width: u16, + ) -> Vec> { + let mut lines = Vec::new(); + let values = daily_values(buckets, today); + let shown_columns = shown_columns(width); + if shown_columns == 0 { + lines.push(" Widen terminal to show activity graph".dim().into()); + return lines; + } + + let palette = TokenActivityPalette::current(); + let levels = levels_for_view(&values, self.view); + let first_column = WEEK_COUNT - shown_columns; + lines.push(month_labels(today, first_column, shown_columns)); + for row in 0..DAY_COUNT { + let mut spans = vec![weekday_label(self.view, row)]; + for column in first_column..WEEK_COUNT { + if column > first_column { + spans.push(" ".into()); + } + let index = column * DAY_COUNT + row; + if self.view == TokenActivityView::Daily + && cell_date(today, index).is_some_and(|date| date > today) + { + spans.push(" ".into()); + } else { + let style = if self.view == TokenActivityView::Daily { + palette.for_level(levels[index]) + } else { + palette.for_bar_level(levels[index]) + }; + spans.push(Span::styled(palette.glyph(levels[index]), style)); + } + } + lines.push(spans.into()); + } + // Separate the calendar from the legend/footer below. + lines.push(Line::default()); + match self.view { + TokenActivityView::Daily => lines.push(legend_line(&palette)), + TokenActivityView::Weekly | TokenActivityView::Cumulative => { + lines.push(bar_caption(self.view, &values)) + } + } + lines.push(view_footer(self.view)); + lines + } +} + +fn shown_columns(width: u16) -> usize { + (usize::from(width) + .saturating_sub(CHART_LEFT_WIDTH) + .saturating_add(/*rhs*/ 1) + / 2) + .min(WEEK_COUNT) +} + +fn graph_width(width: u16) -> u16 { + if width == u16::MAX { + return width; + } + (CHART_LEFT_WIDTH + shown_columns(width) * 2 - 1) as u16 +} + +fn summary_lines(response: &GetAccountTokenUsageResponse, width: u16) -> Vec> { + let summary = &response.summary; + let fields = [ + ("Lifetime", format_optional_tokens(summary.lifetime_tokens)), + ("Peak", format_optional_tokens(summary.peak_daily_tokens)), + ( + "Streak", + format_streak(summary.current_streak_days, summary.longest_streak_days), + ), + ( + "Longest task", + format_optional_duration(summary.longest_running_turn_sec), + ), + ]; + pack_fields(&fields, width) + .into_iter() + .map(|group| align_summary_line(summary_line(&fields, &group), width)) + .collect() +} + +/// Greedily pack summary fields into as few lines as fit `width`, +/// keeping field order. `u16::MAX` (raw/copy mode) always yields one line. +fn pack_fields(fields: &[(&str, String)], width: u16) -> Vec> { + if width == u16::MAX { + return vec![(0..fields.len()).collect()]; + } + let max = usize::from(width.saturating_sub(SUMMARY_INDENT_WIDTH)); + let mut groups: Vec> = Vec::new(); + let mut current: Vec = Vec::new(); + for index in 0..fields.len() { + let mut candidate = current.clone(); + candidate.push(index); + if !current.is_empty() && summary_line(fields, &candidate).width() > max { + groups.push(std::mem::take(&mut current)); + current.push(index); + } else { + current = candidate; + } + } + if !current.is_empty() { + groups.push(current); + } + groups +} + +fn summary_line(fields: &[(&str, String)], indexes: &[usize]) -> Line<'static> { + let mut spans = Vec::new(); + for (index, field_index) in indexes.iter().enumerate() { + if index > 0 { + spans.push(Span::styled(" · ", label_style())); + } + let (label, value) = &fields[*field_index]; + spans.push(Span::styled(format!("{label} "), label_style())); + spans.push(Span::styled(value.clone(), numeric_style())); + } + spans.into() +} + +fn align_summary_line(mut line: Line<'static>, width: u16) -> Line<'static> { + if width == u16::MAX { + return line; + } + line.spans.insert(/*index*/ 0, SUMMARY_INDENT.into()); + line +} + +fn format_optional_tokens(value: Option) -> String { + value + .map(format_tokens_compact) + .unwrap_or_else(|| "-".to_string()) +} + +/// Combine the current and longest streak into one field: a bare `54d` when +/// they match, otherwise `12d (best 54d)`. +fn format_streak(current: Option, longest: Option) -> String { + match (current, longest) { + (Some(current), Some(longest)) if current == longest => format!("{current}d"), + (Some(current), Some(longest)) => format!("{current}d (best {longest}d)"), + (Some(current), None) => format!("{current}d"), + (None, Some(longest)) => format!("- (best {longest}d)"), + (None, None) => "-".to_string(), + } +} + +fn format_optional_duration(value: Option) -> String { + value.map_or_else( + || "-".to_string(), + |seconds| { + let seconds = seconds.max(/*other*/ 0); + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + match (hours, minutes) { + (0, 0) => format!("{seconds}s"), + (0, minutes) => format!("{minutes}m"), + (hours, 0) => format!("{hours}h"), + (hours, minutes) => format!("{hours}h {minutes}m"), + } + }, + ) +} + +fn numeric_style() -> Style { + foreground_style_for_scopes(&["constant.numeric", "constant"]) + .unwrap_or_else(|| Style::default().green()) +} + +fn label_style() -> Style { + foreground_style_for_scopes(&["comment"]).unwrap_or_else(|| Style::default().dim()) +} + +fn weekday_label(view: TokenActivityView, row: usize) -> Span<'static> { + if view != TokenActivityView::Daily { + // Bar views fill from the bottom (row 6) upward, so the gutter doubles + // as a coarse Y-axis: peak at the top, baseline at the bottom. + return Span::styled( + match row { + 0 => "max ", + 6 => " 0 ", + _ => " ", + }, + label_style(), + ); + } + Span::styled( + match row { + 0 => " Su ", + 1 => " Mo ", + 2 => " Tu ", + 3 => " We ", + 4 => " Th ", + 5 => " Fr ", + 6 => " Sa ", + _ => " ", + }, + label_style(), + ) +} + +fn legend_line(palette: &TokenActivityPalette) -> Line<'static> { + let mut spans = vec![Span::styled(" Less ", label_style())]; + for level in 0..=4 { + if level > 0 { + spans.push(" ".into()); + } + spans.push(Span::styled(palette.glyph(level), palette.for_level(level))); + } + spans.push(Span::styled(" More", label_style())); + spans.into() +} + +/// Caption for the bar-chart views, where the 5-step daily legend would be +/// misleading. States what each bar represents and the peak it is scaled to. +fn bar_caption(view: TokenActivityView, values: &[i64]) -> Line<'static> { + let weeks = weekly_totals(values); + let (lead, peak) = match view { + TokenActivityView::Weekly => ( + "Each column = 1 week · tallest ", + weeks.iter().copied().max().unwrap_or(/*default*/ 0), + ), + TokenActivityView::Cumulative => ("Running total · top ", weeks.iter().sum::()), + TokenActivityView::Daily => ("", 0), + }; + if peak <= 0 { + return Span::styled(" No token activity in the last 12 months", label_style()).into(); + } + vec![ + Span::styled(format!(" {lead}"), label_style()), + Span::styled(format_tokens_compact(peak), numeric_style()), + ] + .into() +} + +/// Dim footer that surfaces the other `/tokens` views and emphasizes the +/// active one, so the weekly/cumulative modes are discoverable from the card. +fn view_footer(active: TokenActivityView) -> Line<'static> { + let mut spans = vec![Span::styled(" ", label_style())]; + let views = [ + (TokenActivityView::Daily, "daily"), + (TokenActivityView::Weekly, "weekly"), + (TokenActivityView::Cumulative, "cumulative"), + ]; + for (index, (view, name)) in views.into_iter().enumerate() { + if index > 0 { + spans.push(Span::styled(" · ", label_style())); + } + let style = if view == active { + numeric_style().bold() + } else { + label_style() + }; + spans.push(Span::styled(name, style)); + } + spans.into() +} + +fn month_labels(today: NaiveDate, first_column: usize, shown_columns: usize) -> Line<'static> { + let mut cells = vec![' '; shown_columns * 2 - 1]; + let start = chart_start(today); + let mut last_end = 0; + for column in first_column..WEEK_COUNT { + let date = start + Duration::days((column * DAY_COUNT) as i64); + if date.day() > 7 { + continue; + } + let label = date.format("%b").to_string(); + let offset = (column - first_column) * 2; + if offset < last_end || offset + label.len() > cells.len() { + continue; + } + for (index, ch) in label.chars().enumerate() { + cells[offset + index] = ch; + } + last_end = offset + label.len() + 1; + } + vec![ + " ".into(), + Span::styled(cells.into_iter().collect::(), label_style()), + ] + .into() +} + +fn daily_values( + buckets: &[codex_app_server_protocol::AccountTokenUsageDailyBucket], + today: NaiveDate, +) -> Vec { + let start = chart_start(today); + let end = start + Duration::days(CELL_COUNT as i64); + let mut by_date = BTreeMap::new(); + for bucket in buckets { + let Ok(date) = NaiveDate::parse_from_str(&bucket.start_date, "%Y-%m-%d") else { + continue; + }; + if date < start || date >= end || date > today { + continue; + } + *by_date.entry(date).or_insert(/*default*/ 0) += bucket.tokens.max(/*other*/ 0); + } + (0..CELL_COUNT) + .map(|offset| { + by_date + .get(&(start + Duration::days(offset as i64))) + .copied() + .unwrap_or(/*default*/ 0) + }) + .collect() +} + +fn levels_for_view(values: &[i64], view: TokenActivityView) -> Vec { + match view { + TokenActivityView::Daily => graded_levels(values), + TokenActivityView::Weekly => bar_levels(&weekly_totals(values)), + TokenActivityView::Cumulative => { + let cumulative = weekly_totals(values) + .into_iter() + .scan(/*initial_state*/ 0, |sum, value| { + *sum += value; + Some(*sum) + }) + .collect::>(); + bar_levels(&cumulative) + } + } +} + +fn graded_levels(values: &[i64]) -> Vec { + let max = values.iter().copied().max().unwrap_or(/*default*/ 0); + values + .iter() + .map(|value| match (*value, max) { + (0, _) | (_, 0) => 0, + (value, max) if value * 4 > max * 3 => 4, + (value, max) if value * 2 > max => 3, + (value, max) if value * 4 > max => 2, + _ => 1, + }) + .collect() +} + +fn weekly_totals(values: &[i64]) -> Vec { + values + .chunks(DAY_COUNT) + .map(|week| week.iter().sum()) + .collect() +} + +fn bar_levels(totals: &[i64]) -> Vec { + let max = totals.iter().copied().max().unwrap_or(/*default*/ 0); + totals + .iter() + .flat_map(|value| { + let height = if *value <= 0 || max <= 0 { + 0 + } else { + ((*value * DAY_COUNT as i64 + max - 1) / max) as usize + }; + (0..DAY_COUNT).map(move |row| if DAY_COUNT - row <= height { 4 } else { 0 }) + }) + .collect() +} + +fn chart_start(today: NaiveDate) -> NaiveDate { + let week_start = today - Duration::days(i64::from(today.weekday().num_days_from_sunday())); + week_start - Duration::weeks((WEEK_COUNT - 1) as i64) +} + +fn cell_date(today: NaiveDate, index: usize) -> Option { + chart_start(today).checked_add_signed(Duration::days(index as i64)) +} + +struct TokenActivityPalette { + styles: [Style; 5], + bar_style: Style, + /// True when the terminal supports a truecolor gradient, so the grid can + /// encode intensity purely by color and render every cell with a single + /// glyph. False on low-color terminals, where we fall back to a + /// filled/hollow glyph pair so empty and active cells stay distinguishable. + uses_color: bool, +} + +impl TokenActivityPalette { + fn current() -> Self { + let fallback = [ + Style::default().dim(), + Style::default().green().dim(), + Style::default().green(), + Style::default().light_green(), + Style::default().light_green().bold(), + ]; + let fallback_bar_style = Style::default().light_green(); + let fallback_palette = || Self { + styles: fallback, + bar_style: fallback_bar_style, + uses_color: false, + }; + let (Some(fg), Some(bg), Some(anchor)) = (default_fg(), default_bg(), theme_anchor_rgb()) + else { + return fallback_palette(); + }; + if matches!( + stdout_color_level(), + StdoutColorLevel::Ansi16 | StdoutColorLevel::Unknown + ) { + return fallback_palette(); + } + let empty_alpha = if crate::color::is_light(bg) { + 0.18 + } else { + 0.14 + }; + let alphas = [empty_alpha, 0.22, 0.42, 0.68, 1.00]; + let styles = std::array::from_fn(|index| { + let color = if index == 0 { + blend(fg, bg, alphas[index]) + } else { + blend(anchor, bg, alphas[index]) + }; + Style::default().fg(best_color(color)) + }); + let bar_style = Style::default().fg(best_color(blend(anchor, bg, /*alpha*/ 0.78))); + Self { + styles, + bar_style, + uses_color: true, + } + } + + fn for_level(&self, level: usize) -> Style { + self.styles[level.min(/*other*/ 4)] + } + + fn for_bar_level(&self, level: usize) -> Style { + if level == 0 { + self.for_level(/*level*/ 0) + } else { + self.bar_style + } + } + + /// The glyph for a cell at `level`. In truecolor we always use the filled + /// glyph and let color carry the intensity; in low-color we use the hollow + /// glyph for empty cells so they remain visible without a color gradient. + fn glyph(&self, level: usize) -> &'static str { + if self.uses_color || level > 0 { + ACTIVE_CELL_GLYPH + } else { + EMPTY_CELL_GLYPH + } + } +} + +fn theme_anchor_rgb() -> Option<(u8, u8, u8)> { + match numeric_style().fg? { + Color::Rgb(r, g, b) => Some((r, g, b)), + _ => None, + } +} + +impl ChatWidget { + pub(super) fn add_token_activity_output(&mut self, view: TokenActivityView) { + let request_id = self.next_token_activity_request_id; + self.next_token_activity_request_id = + self.next_token_activity_request_id.wrapping_add(/*rhs*/ 1); + let (cell, handle) = new_token_activity_output(view); + self.refreshing_token_activity_outputs + .push((request_id, handle)); + self.add_to_history(cell); + self.app_event_tx + .send(AppEvent::RefreshTokenActivity { request_id }); + } + + pub(crate) fn finish_token_activity_refresh( + &mut self, + request_id: u64, + result: Result, + ) -> bool { + let mut remaining = Vec::with_capacity(self.refreshing_token_activity_outputs.len()); + let mut result = Some(result); + let mut updated_any = false; + for (pending_request_id, handle) in self.refreshing_token_activity_outputs.drain(..) { + if pending_request_id == request_id { + #[expect(clippy::expect_used)] + handle.finish(result.take().expect("token activity result consumed once")); + updated_any = true; + } else { + remaining.push((pending_request_id, handle)); + } + } + self.refreshing_token_activity_outputs = remaining; + if updated_any { + self.request_redraw(); + } + updated_any + } + + pub(crate) fn clear_pending_token_activity_refreshes(&mut self) { + self.refreshing_token_activity_outputs.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::AccountTokenUsageDailyBucket; + use codex_app_server_protocol::AccountTokenUsageSummary; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + + #[test] + fn duplicate_dates_sum_and_negative_values_clamp() { + let today = + NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date"); + let buckets = vec![ + AccountTokenUsageDailyBucket { + start_date: "2026-05-29".to_string(), + tokens: 10, + }, + AccountTokenUsageDailyBucket { + start_date: "2026-05-29".to_string(), + tokens: 5, + }, + AccountTokenUsageDailyBucket { + start_date: "2026-05-28".to_string(), + tokens: -4, + }, + ]; + + let values = daily_values(&buckets, today); + + assert_eq!(values.iter().sum::(), 15); + } + + #[test] + fn bar_levels_fill_from_bottom() { + let levels = bar_levels(&[0, 10]); + + assert_eq!(&levels[..DAY_COUNT], &[0; DAY_COUNT]); + assert_eq!(&levels[DAY_COUNT..], &[4; DAY_COUNT]); + } + + #[test] + fn token_activity_view_aliases_parse() { + assert_eq!(TokenActivityView::parse(""), Some(TokenActivityView::Daily)); + assert_eq!( + TokenActivityView::parse("day"), + Some(TokenActivityView::Daily) + ); + assert_eq!( + TokenActivityView::parse("week"), + Some(TokenActivityView::Weekly) + ); + assert_eq!( + TokenActivityView::parse("cumulative"), + Some(TokenActivityView::Cumulative) + ); + assert_eq!(TokenActivityView::parse("year"), None); + } + + #[test] + fn daily_graph_snapshot_uses_distinct_empty_and_active_cells() { + let today = + NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date"); + let buckets = vec![ + AccountTokenUsageDailyBucket { + start_date: "2026-05-25".to_string(), + tokens: 1, + }, + AccountTokenUsageDailyBucket { + start_date: "2026-05-29".to_string(), + tokens: 4, + }, + ]; + let cell = TokenActivityHistoryCell { + view: TokenActivityView::Daily, + state: Arc::new(RwLock::new(TokenActivityState::Loading)), + }; + + let rendered = cell + .chart_lines(&buckets, today, /*width*/ 22) + .into_iter() + .map(|line| line.to_string().trim_end().to_string()) + .collect::>() + .join("\n"); + + assert_snapshot!(rendered, @r" + Apr May + Su □ □ □ □ □ □ □ □ □ + Mo □ □ □ □ □ □ □ □ ■ + Tu □ □ □ □ □ □ □ □ □ + We □ □ □ □ □ □ □ □ □ + Th □ □ □ □ □ □ □ □ □ + Fr □ □ □ □ □ □ □ □ ■ + Sa □ □ □ □ □ □ □ □ + + Less □ ■ ■ ■ ■ More + daily · weekly · cumulative + "); + } + + #[test] + fn daily_graph_snapshot_stays_left_aligned_in_wide_terminal() { + assert_eq!(graph_width(/*width*/ 160), 107); + assert_eq!(graph_width(/*width*/ u16::MAX), u16::MAX); + + let today = + NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date"); + let cell = TokenActivityHistoryCell { + view: TokenActivityView::Daily, + state: Arc::new(RwLock::new(TokenActivityState::Loading)), + }; + let lines = cell.chart_lines(&[], today, /*width*/ 160); + let rendered = [&lines[0], &lines[1], lines.last().expect("legend line")] + .into_iter() + .map(|line| line.to_string().trim_end().to_string()) + .collect::>() + .join("\n"); + + assert_snapshot!(rendered, @" + Jun Jul Aug Sep Oct Nov Dec Jan Feb Mar Apr May + Su □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ + daily · weekly · cumulative + "); + } + + #[test] + fn summary_snapshot_left_aligns_and_splits_when_needed() { + let response = GetAccountTokenUsageResponse { + summary: AccountTokenUsageSummary { + lifetime_tokens: Some(21_400_000_000), + peak_daily_tokens: Some(835_000_000), + longest_running_turn_sec: Some(13_920), + current_streak_days: Some(54), + longest_streak_days: Some(54), + }, + daily_usage_buckets: None, + }; + let rendered = |width| { + summary_lines(&response, graph_width(width)) + .into_iter() + .map(|line| line.to_string().trim_end().to_string()) + .collect::>() + .join("\n") + }; + + assert_snapshot!( + format!( + "wide:\n{}\n\nnarrow:\n{}\n\ntight:\n{}", + rendered(/*width*/ 120), + rendered(/*width*/ 80), + rendered(/*width*/ 62) + ), + @" + wide: + Lifetime 21.4B · Peak 835M · Streak 54d · Longest task 3h 52m + + narrow: + Lifetime 21.4B · Peak 835M · Streak 54d · Longest task 3h 52m + + tight: + Lifetime 21.4B · Peak 835M · Streak 54d + Longest task 3h 52m + " + ); + } +} diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 3fccdf5967..94bd176e2f 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -45,6 +45,7 @@ pub enum SlashCommand { Diff, Mention, Status, + Tokens, DebugConfig, Title, Statusline, @@ -98,6 +99,7 @@ impl SlashCommand { SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", SlashCommand::Hooks => "view and manage lifecycle hooks", SlashCommand::Status => "show current session configuration and token usage", + SlashCommand::Tokens => "show account token activity", SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", SlashCommand::Title => "configure which items appear in the terminal title", SlashCommand::Statusline => "configure which items appear in the status line", @@ -157,6 +159,7 @@ impl SlashCommand { | SlashCommand::Keymap | SlashCommand::Mcp | SlashCommand::Raw + | SlashCommand::Tokens | SlashCommand::Pets | SlashCommand::Side | SlashCommand::Btw @@ -174,6 +177,7 @@ impl SlashCommand { | SlashCommand::Diff | SlashCommand::Mention | SlashCommand::Status + | SlashCommand::Tokens | SlashCommand::Ide ) } @@ -210,6 +214,7 @@ impl SlashCommand { | SlashCommand::Skills | SlashCommand::Hooks | SlashCommand::Status + | SlashCommand::Tokens | SlashCommand::DebugConfig | SlashCommand::Ps | SlashCommand::Stop diff --git a/codex-rs/tui/src/transcript_reflow.rs b/codex-rs/tui/src/transcript_reflow.rs index 33318a4d0f..56e76a686e 100644 --- a/codex-rs/tui/src/transcript_reflow.rs +++ b/codex-rs/tui/src/transcript_reflow.rs @@ -29,6 +29,7 @@ pub(crate) struct TranscriptReflowState { last_reflow_width: Option, pending_reflow_width: Option, pending_until: Option, + history_cell_refresh_requested: bool, ran_during_stream: bool, resize_requested_during_stream: bool, } @@ -93,6 +94,12 @@ impl TranscriptReflowState { self.pending_until = Some(Instant::now()); } + /// Schedule an immediate rebuild because an existing history cell changed its rendered output. + pub(crate) fn schedule_history_cell_refresh(&mut self) { + self.history_cell_refresh_requested = true; + self.schedule_immediate(); + } + #[cfg(test)] pub(crate) fn set_due_for_test(&mut self) { self.pending_until = Some(Instant::now() - Duration::from_millis(1)); @@ -110,9 +117,14 @@ impl TranscriptReflowState { self.pending_until.is_some() } + pub(crate) fn history_cell_refresh_requested(&self) -> bool { + self.history_cell_refresh_requested + } + pub(crate) fn clear_pending_reflow(&mut self) { self.pending_until = None; self.pending_reflow_width = None; + self.history_cell_refresh_requested = false; } /// Remember the terminal width that actually rebuilt transcript scrollback. @@ -262,6 +274,17 @@ mod tests { assert!(state.reflow_needed_for_width(/*width*/ 100)); } + #[test] + fn clear_pending_reflow_clears_history_cell_refresh_request() { + let mut state = TranscriptReflowState::default(); + state.schedule_history_cell_refresh(); + + assert!(state.history_cell_refresh_requested()); + state.clear_pending_reflow(); + + assert!(!state.history_cell_refresh_requested()); + } + #[test] fn mark_reflowed_width_reports_unchanged_width() { let mut state = TranscriptReflowState::default();