From c884536d848524d5f481eed6f8c02312de106a7a Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 12 Jun 2026 17:33:43 -0700 Subject: [PATCH] feat(tui): reland token activity command (#27925) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why [#25345](https://github.com/openai/codex/pull/25345) was approved, green, and squash-merged into its stacked base branch, `fcoury/tokenmaxxing-api`. Four minutes later, that base branch was force-pushed back to an API-only rebased head while preparing [#25344](https://github.com/openai/codex/pull/25344) for `main`. As a result, the squash commit from #25345 was orphaned and the TUI command never reached `main` or a release. This PR relands the orphaned TUI change from [`411410b8`](https://github.com/openai/codex/commit/411410b85c2d8eb050d441f17396c5c4048d866f) on current `main`. ## What changed - Add `/usage`, `/usage daily`, `/usage weekly`, and `/usage cumulative` for account token activity. - Fetch account usage asynchronously through the existing `account/usage/read` app-server RPC. - Render daily, weekly, and cumulative activity with theme-aware terminal palettes and bounded transient cards. - Preserve transcript ordering while assistant streams, history consolidations, active cells, and hooks complete. - Hide `/usage` from completion when backend auth is unavailable while keeping typed-command guidance. - Carry current-main behavior forward for cwd-aware Markdown parsing, Windows Terminal color detection, and personal access token auth. - Clear pending usage cards on thread rollback and delay completed cards until live hook output is committed. - Add focused regression and snapshot coverage for loading, auth errors, invalid views, rollback, hook ordering, layout, and charts. ## Prior review The original implementation was approved by Eric Traut in #25345 after testing multiple themes and light/dark terminals. This PR preserves that reviewed implementation while adapting it to current `main` and adding regression coverage for newer rollback and hook lifecycle behavior. ## Validation - `just test -p codex-tui token_activity palette renderable usage_command` — 37 passed. - Focused rollback, hook-ordering, and error snapshot tests — 4 passed. - `just fix -p codex-tui` — passed. - `UV_CACHE_DIR=/private/tmp/codex-uv-cache just fmt` — passed. - `cargo insta pending-snapshots` — no pending snapshots. - `just test -p codex-tui` — 2,870 passed; two unrelated guardian feature-flag tests failed because their expected `OverrideTurnContext` event was absent: - `update_feature_flags_disabling_guardian_clears_manual_review_policy_without_history` - `update_feature_flags_disabling_guardian_clears_review_policy_and_restores_default` - `just argument-comment-lint` could not complete because the local Bazel LLVM `compiler-rt` repository is missing `include/sanitizer/*.h`. The touched Rust diff was manually inspected and no missing opaque-literal argument comments were found. --- codex-rs/tui/src/app.rs | 12 +- codex-rs/tui/src/app/app_server_events.rs | 10 + codex-rs/tui/src/app/background_requests.rs | 35 ++ codex-rs/tui/src/app/event_dispatch.rs | 56 ++- codex-rs/tui/src/app/history_ui.rs | 57 +++ codex-rs/tui/src/app/resize_reflow.rs | 37 +- codex-rs/tui/src/app/tests.rs | 50 ++ codex-rs/tui/src/app_backtrack.rs | 3 + codex-rs/tui/src/app_event.rs | 15 + 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 | 5 + codex-rs/tui/src/bottom_pane/mod.rs | 5 + .../tui/src/bottom_pane/slash_commands.rs | 32 +- codex-rs/tui/src/chatwidget.rs | 27 +- codex-rs/tui/src/chatwidget/constructor.rs | 9 + codex-rs/tui/src/chatwidget/hook_lifecycle.rs | 4 + codex-rs/tui/src/chatwidget/rendering.rs | 10 + codex-rs/tui/src/chatwidget/settings.rs | 14 + codex-rs/tui/src/chatwidget/slash_dispatch.rs | 26 + ...fresh_renders_above_composer_snapshot.snap | 14 + ...and_reports_chatgpt_login_requirement.snap | 5 + ...mmand_with_invalid_view_reports_usage.snap | 5 + codex-rs/tui/src/chatwidget/streaming.rs | 7 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 21 +- .../tui/src/chatwidget/tests/plan_mode.rs | 1 + .../chatwidget/tests/popups_and_settings.rs | 1 + .../src/chatwidget/tests/slash_commands.rs | 406 +++++++++++++++ .../src/chatwidget/tests/status_and_layout.rs | 1 + codex-rs/tui/src/chatwidget/tokens.rs | 282 +++++++++++ codex-rs/tui/src/chatwidget/tokens/chart.rs | 474 ++++++++++++++++++ .../src/chatwidget/tokens/chart/palette.rs | 152 ++++++ .../chatwidget/tokens/chart/palette_tests.rs | 115 +++++ .../tui/src/chatwidget/tokens/chart_tests.rs | 243 +++++++++ codex-rs/tui/src/chatwidget/tokens_tests.rs | 38 ++ codex-rs/tui/src/chatwidget/turn_runtime.rs | 7 +- codex-rs/tui/src/render/renderable.rs | 59 ++- codex-rs/tui/src/render/renderable_tests.rs | 72 +++ codex-rs/tui/src/slash_command.rs | 5 + codex-rs/tui/src/status/account.rs | 2 +- codex-rs/tui/src/terminal_palette.rs | 5 + codex-rs/tui/src/transcript_reflow.rs | 23 + 42 files changed, 2296 insertions(+), 57 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__pending_token_activity_refresh_renders_above_composer_snapshot.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__signed_out_usage_command_reports_chatgpt_login_requirement.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_with_invalid_view_reports_usage.snap create mode 100644 codex-rs/tui/src/chatwidget/tokens.rs create mode 100644 codex-rs/tui/src/chatwidget/tokens/chart.rs create mode 100644 codex-rs/tui/src/chatwidget/tokens/chart/palette.rs create mode 100644 codex-rs/tui/src/chatwidget/tokens/chart/palette_tests.rs create mode 100644 codex-rs/tui/src/chatwidget/tokens/chart_tests.rs create mode 100644 codex-rs/tui/src/chatwidget/tokens_tests.rs create mode 100644 codex-rs/tui/src/render/renderable_tests.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 876022815..abc86ca67 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -143,6 +143,7 @@ use codex_model_provider_info::ModelProviderInfo; use codex_models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; use codex_otel::SessionTelemetry; +use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; use codex_protocol::config_types::Personality; #[cfg(target_os = "windows")] @@ -734,6 +735,7 @@ impl App { initial_user_message, enhanced_keys_supported: self.enhanced_keys_supported, has_chatgpt_account: self.chat_widget.has_chatgpt_account(), + has_codex_backend_auth: self.chat_widget.has_codex_backend_auth(), model_catalog: self.model_catalog.clone(), feedback: self.feedback.clone(), is_first_run: false, @@ -822,6 +824,7 @@ impl App { let feedback_audience = bootstrap.feedback_audience; let auth_mode = bootstrap.auth_mode; let has_chatgpt_account = bootstrap.has_chatgpt_account; + let has_codex_backend_auth = matches!(auth_mode, Some(TelemetryAuthMode::Chatgpt)); let requires_openai_auth = bootstrap.requires_openai_auth; let status_account_display = bootstrap.status_account_display.clone(); let initial_plan_type = bootstrap.plan_type; @@ -890,6 +893,7 @@ impl App { ), enhanced_keys_supported, has_chatgpt_account, + has_codex_backend_auth, model_catalog: model_catalog.clone(), feedback: feedback.clone(), is_first_run, @@ -925,6 +929,7 @@ impl App { ), enhanced_keys_supported, has_chatgpt_account, + has_codex_backend_auth, model_catalog: model_catalog.clone(), feedback: feedback.clone(), is_first_run, @@ -963,6 +968,7 @@ impl App { ), enhanced_keys_supported, has_chatgpt_account, + has_codex_backend_auth, model_catalog: model_catalog.clone(), feedback: feedback.clone(), is_first_run, @@ -1243,7 +1249,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()?; @@ -1322,7 +1330,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/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 6f6940c06..567a4b26a 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -80,6 +80,15 @@ impl App { return; } ServerNotification::AccountUpdated(notification) => { + let has_codex_backend_auth = matches!( + notification.auth_mode, + Some( + AuthMode::Chatgpt + | AuthMode::ChatgptAuthTokens + | AuthMode::AgentIdentity + | AuthMode::PersonalAccessToken + ) + ); self.chat_widget.update_account_state( status_account_display_from_auth_mode( notification.auth_mode, @@ -89,6 +98,7 @@ impl App { notification .auth_mode .is_some_and(AuthMode::has_chatgpt_account), + has_codex_backend_auth, ); return; } diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index a69d98744..a560cc703 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -24,6 +24,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, @@ -78,6 +81,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/usage/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, @@ -652,6 +674,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/usage/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 afb77df5b..6c6a31ee6 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -227,27 +227,7 @@ impl App { self.begin_thread_switch_history_replay_buffer(); } AppEvent::InsertHistoryCell(cell) => { - let cell: Arc = cell.into(); - if let Some(Overlay::Transcript(t)) = &mut self.overlay { - t.insert_cell(cell.clone()); - tui.frame_requester().schedule_frame(); - } - self.transcript_cells.push(cell.clone()); - if self.initial_history_replay_buffer.as_ref().is_some() { - self.insert_history_cell_lines_with_initial_replay_buffer( - tui, - cell.as_ref(), - self.chat_widget - .history_wrap_width(tui.terminal.last_known_screen_size.width), - ); - } else { - self.insert_history_cell_lines( - tui, - cell.as_ref(), - self.chat_widget - .history_wrap_width(tui.terminal.last_known_screen_size.width), - ); - } + self.insert_history_cell(tui, cell); } AppEvent::EndInitialHistoryReplayBuffer => { self.finish_initial_history_replay_buffer(tui); @@ -265,10 +245,16 @@ impl App { scrollback_reflow, deferred_history_cell, )?; + self.chat_widget.note_stream_consolidation_completed(); + self.insert_completed_token_activity_output_after_stream_shutdown(tui); } 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(); + } + self.chat_widget.note_stream_consolidation_completed(); + self.insert_completed_token_activity_output_after_stream_shutdown(tui); return Ok(AppRunControl::Continue); } let end = self.transcript_cells.len(); @@ -303,6 +289,8 @@ impl App { self.maybe_finish_stream_reflow(tui)?; } + self.chat_widget.note_stream_consolidation_completed(); + self.insert_completed_token_activity_output_after_stream_shutdown(tui); } AppEvent::ApplyThreadRollback { num_turns } => { if self.apply_non_pending_thread_rollback(num_turns) { @@ -722,6 +710,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; } @@ -778,6 +769,25 @@ impl App { } } }, + AppEvent::TokenActivityLoaded { request_id, result } => { + if let Err(err) = &result { + tracing::warn!("account/usage/read failed during TUI refresh: {err}"); + } + if self + .chat_widget + .finish_token_activity_refresh(request_id, result) + { + // Commit synchronously so an already queued /clear cannot overtake this card. + // Do not route through ChatWidget::add_to_history: /usage may complete during + // active work, and flushing an in-progress tool cell would corrupt its lifecycle. + // If an answer stream is active, keep the settled card transient until its + // provisional transcript cells have been consolidated. + self.insert_completed_token_activity_output_if_ready(tui); + } + } + AppEvent::CommitCompletedTokenActivityOutput => { + self.insert_completed_token_activity_output_after_stream_shutdown(tui); + } AppEvent::ConnectorsLoaded { result, is_final } => { self.chat_widget.on_connectors_loaded(result, is_final); } @@ -1966,6 +1976,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(); @@ -1978,6 +1989,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 0aff920e6..59ec0d3dc 100644 --- a/codex-rs/tui/src/app/history_ui.rs +++ b/codex-rs/tui/src/app/history_ui.rs @@ -8,6 +8,62 @@ use super::*; const DESKTOP_THREAD_OPENED_MESSAGE: &str = "Opened this session in Codex Desktop."; impl App { + pub(super) fn insert_history_cell(&mut self, tui: &mut tui::Tui, cell: Box) { + let cell: Arc = cell.into(); + if let Some(Overlay::Transcript(t)) = &mut self.overlay { + t.insert_cell(cell.clone()); + tui.frame_requester().schedule_frame(); + } + self.transcript_cells.push(cell.clone()); + if self.initial_history_replay_buffer.as_ref().is_some() { + self.insert_history_cell_lines_with_initial_replay_buffer( + tui, + cell.as_ref(), + self.chat_widget + .history_wrap_width(tui.terminal.last_known_screen_size.width), + ); + } else { + self.insert_history_cell_lines( + tui, + cell.as_ref(), + self.chat_widget + .history_wrap_width(tui.terminal.last_known_screen_size.width), + ); + } + // A committed cell can unblock a settled /usage card that was waiting + // behind a transient active cell or a provisional stream tail. + self.chat_widget + .request_completed_token_activity_output_insertion(); + } + + pub(super) fn insert_completed_token_activity_output_if_ready(&mut self, tui: &mut tui::Tui) { + if self.chat_widget.token_activity_history_insertion_blocked() + || self.transcript_cells.last().is_some_and(|cell| { + cell.as_any().is::() + || cell.as_any().is::() + }) + { + return; + } + self.insert_completed_token_activity_output(tui); + } + + pub(super) fn insert_completed_token_activity_output(&mut self, tui: &mut tui::Tui) { + if let Some(cell) = self.chat_widget.take_completed_token_activity_output() { + self.insert_history_cell(tui, Box::new(cell)); + } + } + + pub(super) fn insert_completed_token_activity_output_after_stream_shutdown( + &mut self, + tui: &mut tui::Tui, + ) { + if self.chat_widget.token_activity_history_insertion_blocked() { + return; + } + self.insert_completed_token_activity_output(tui); + } + pub(super) fn open_url_in_browser(&mut self, url: String) { if let Err(err) = webbrowser::open(&url) { self.chat_widget @@ -110,6 +166,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 7e3607e2d..948c1bbfa 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,22 @@ impl App { tui.frame_requester().schedule_frame(); } + 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 +317,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 +360,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 +417,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 36be8accb..ea6b49104 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -326,6 +326,7 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm ), enhanced_keys_supported: false, has_chatgpt_account: false, + has_codex_backend_auth: false, model_catalog: app.model_catalog.clone(), feedback: codex_feedback::CodexFeedback::new(), is_first_run: false, @@ -4594,6 +4595,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(), @@ -5379,6 +5406,7 @@ async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() { initial_user_message: None, enhanced_keys_supported: app.enhanced_keys_supported, has_chatgpt_account: app.chat_widget.has_chatgpt_account(), + has_codex_backend_auth: app.chat_widget.has_codex_backend_auth(), model_catalog: app.model_catalog.clone(), feedback: app.feedback.clone(), is_first_run: false, @@ -5536,12 +5564,34 @@ async fn queued_rollback_syncs_overlay_and_clears_deferred_history() { app.deferred_history_lines = vec![Line::from("stale buffered line").into()]; app.backtrack.overlay_preview_active = true; app.backtrack.nth_user_message = 1; + app.chat_widget.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true, + ); + app.chat_widget + .set_composer_text("/usage".to_string(), Vec::new(), Vec::new()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let pending_usage = app + .chat_widget + .active_cell_transcript_lines(/*width*/ 80) + .expect("pending usage transcript"); + assert!(lines_to_single_string(&pending_usage).contains("Token activity\n Loading...")); let changed = app.apply_non_pending_thread_rollback(/*num_turns*/ 1); assert!(changed); assert!(app.backtrack_render_pending); assert!(app.deferred_history_lines.is_empty()); + assert!( + app.chat_widget + .active_cell_transcript_lines(/*width*/ 80) + .is_none_or(|lines| !lines_to_single_string(&lines).contains("Token activity")) + ); assert_eq!(app.backtrack.nth_user_message, 0); let user_messages: Vec = app .transcript_cells diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index b03d4fdba..b285d8b7c 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -294,6 +294,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(); @@ -539,6 +540,7 @@ impl App { if !trim_transcript_cells_drop_last_n_user_turns(&mut self.transcript_cells, num_turns) { return false; } + self.chat_widget.clear_pending_token_activity_refreshes(); self.chat_widget .truncate_agent_copy_history_to_user_turn_count(user_count(&self.transcript_cells)); self.sync_overlay_after_transcript_trim(); @@ -562,6 +564,7 @@ impl App { &mut self.transcript_cells, pending.selection.nth_user_message, ) { + self.chat_widget.clear_pending_token_activity_refreshes(); self.chat_widget .truncate_agent_copy_history_to_user_turn_count(user_count(&self.transcript_cells)); self.sync_overlay_after_transcript_trim(); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 6898289e9..39e0b9503 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; @@ -293,6 +294,20 @@ pub(crate) enum AppEvent { result: Result, String>, }, + /// Fetch account-wide token activity for a `/usage` history card. + RefreshTokenActivity { + request_id: u64, + }, + + /// Result of fetching account-wide token activity. + TokenActivityLoaded { + request_id: u64, + result: Result, + }, + + /// Commit a settled token activity card after a stream shutdown barrier. + CommitCompletedTokenActivityOutput, + /// 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 0f4f89d5a..2423647b3 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -373,6 +373,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, @@ -441,6 +442,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, @@ -535,6 +537,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, @@ -577,6 +580,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 a268aeec7..721b92acc 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 2fd3cc1be..f613e5ee3 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, @@ -57,6 +58,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, @@ -529,6 +531,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, @@ -555,6 +558,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, @@ -586,6 +590,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, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index e75c666a5..a932c6c5c 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 9e1db7571..e011eb838 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, @@ -73,6 +74,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::Usage) .filter(|(_, cmd)| flags.goal_command_enabled || *cmd != SlashCommand::Goal) .filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality) .filter(|(_, cmd)| !flags.side_conversation_active || cmd.available_in_side_conversation()) @@ -104,8 +106,9 @@ pub(crate) fn commands_for_input( /// Find a single built-in command by a recognized name or alias, after applying feature gating. /// -/// Side-conversation gating is intentionally enforced by dispatch rather than command 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 +/// command 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().or_else(|| { let repeated_os = name.strip_prefix('g')?.strip_suffix("al")?; @@ -113,6 +116,7 @@ pub(crate) fn find_builtin_command(name: &str, flags: BuiltinCommandFlags) -> Op .then_some(SlashCommand::Goal) })?; 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, @@ -262,6 +267,28 @@ mod tests { assert_eq!(find_builtin_command("goal", flags), None); } + #[test] + fn usage_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::Usage), + None + ); + } + + #[test] + fn usage_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("usage", flags), + Some(SlashCommand::Usage) + ); + } + #[test] fn side_conversation_hides_commands_without_side_flag() { let commands = builtins_for_input(BuiltinCommandFlags { @@ -281,6 +308,7 @@ mod tests { SlashCommand::Diff, SlashCommand::Mention, SlashCommand::Status, + SlashCommand::Usage, ] ); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index efe7acf16..fb89fda2e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -407,6 +407,7 @@ mod status_controls; mod status_surfaces; mod streaming; use self::status_surfaces::CachedProjectRootName; +mod tokens; mod tool_lifecycle; mod tool_requests; mod transcript; @@ -483,6 +484,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) initial_user_message: Option, pub(crate) enhanced_keys_supported: bool, pub(crate) has_chatgpt_account: bool, + pub(crate) has_codex_backend_auth: bool, pub(crate) model_catalog: Arc, pub(crate) feedback: codex_feedback::CodexFeedback, pub(crate) is_first_run: bool, @@ -534,6 +536,7 @@ pub(crate) struct ChatWidget { /// The currently active collaboration mask, if any. active_collaboration_mask: Option, has_chatgpt_account: bool, + has_codex_backend_auth: bool, model_catalog: Arc, session_telemetry: SessionTelemetry, session_header: SessionHeader, @@ -545,6 +548,9 @@ 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_output: Option, + completed_token_activity_output: Option, + next_token_activity_request_id: u64, plan_type: Option, codex_rate_limit_reached_type: Option, rate_limit_warnings: RateLimitWarningState, @@ -556,6 +562,7 @@ pub(crate) struct ChatWidget { stream_controller: Option, // Stream lifecycle controller for proposed plan output. plan_stream_controller: Option, + pending_stream_consolidations: usize, /// Holds the platform clipboard lease so copied text remains available while supported. clipboard_lease: Option, copy_last_response_binding: Vec, @@ -1168,6 +1175,7 @@ impl ChatWidget { if let Some(active) = self.transcript.active_cell.take() { self.transcript.needs_final_message_separator = true; self.app_event_tx.send(AppEvent::InsertHistoryCell(active)); + self.request_completed_token_activity_output_insertion(); } } @@ -1382,6 +1390,7 @@ impl ChatWidget { tool.mark_failed(); } self.add_boxed_history(cell); + self.request_completed_token_activity_output_insertion(); } } @@ -1870,12 +1879,12 @@ impl ChatWidget { self.current_rollout_path.clone() } - /// Returns a cache key describing the current in-flight active cell for the transcript overlay. + /// Returns a cache key describing the current in-flight cells for the transcript overlay. /// /// `Ctrl+T` renders committed transcript cells plus a render-only live tail derived from the - /// current active cell, and the overlay caches that tail; this key is what it uses to decide - /// whether it must recompute. When there is no active cell, this returns `None` so the overlay - /// can drop the tail entirely. + /// current active, hook, and token activity cells, and the overlay caches that tail; this key is + /// what it uses to decide whether it must recompute. When there are no live cells, this returns + /// `None` so the overlay can drop the tail entirely. /// /// If callers mutate the active cell's transcript output without bumping the revision (or /// providing an appropriate animation tick), the overlay will keep showing a stale tail while @@ -1883,7 +1892,8 @@ impl ChatWidget { pub(crate) fn active_cell_transcript_key(&self) -> Option { let cell = self.transcript.active_cell.as_ref(); let hook_cell = self.active_hook_cell.as_ref(); - if cell.is_none() && hook_cell.is_none() { + let token_activity_cell = self.pending_token_activity_output(); + if cell.is_none() && hook_cell.is_none() && token_activity_cell.is_none() { return None; } Some(ActiveCellTranscriptKey { @@ -1921,6 +1931,13 @@ impl ChatWidget { } lines.extend(hook_lines); } + if let Some(token_activity_cell) = self.pending_token_activity_output() { + let token_activity_lines = token_activity_cell.transcript_hyperlink_lines(width); + if !token_activity_lines.is_empty() && !lines.is_empty() { + lines.push(HyperlinkLine::from("")); + } + lines.extend(token_activity_lines); + } (!lines.is_empty()).then_some(lines) } diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index d37ed06da..a6ceef558 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -19,6 +19,7 @@ impl ChatWidget { initial_user_message, enhanced_keys_supported, has_chatgpt_account, + has_codex_backend_auth, model_catalog, feedback, is_first_run, @@ -113,6 +114,7 @@ impl ChatWidget { current_collaboration_mode, active_collaboration_mask, has_chatgpt_account, + has_codex_backend_auth, model_catalog, session_telemetry, session_header: SessionHeader::new(header_model), @@ -124,6 +126,9 @@ impl ChatWidget { rate_limit_snapshots_by_limit_id: BTreeMap::new(), refreshing_status_outputs: Vec::new(), next_status_refresh_request_id: 0, + refreshing_token_activity_output: None, + completed_token_activity_output: None, + next_token_activity_request_id: 0, plan_type: initial_plan_type, codex_rate_limit_reached_type: None, rate_limit_warnings: RateLimitWarningState::default(), @@ -133,6 +138,7 @@ impl ChatWidget { adaptive_chunking: AdaptiveChunkingPolicy::default(), stream_controller: None, plan_stream_controller: None, + pending_stream_consolidations: 0, clipboard_lease: None, copy_last_response_binding, running_commands: HashMap::new(), @@ -255,6 +261,9 @@ impl ChatWidget { widget .bottom_pane .set_connectors_enabled(widget.connectors_enabled()); + widget + .bottom_pane + .set_token_activity_command_enabled(widget.has_codex_backend_auth); widget.refresh_status_surfaces(); widget diff --git a/codex-rs/tui/src/chatwidget/hook_lifecycle.rs b/codex-rs/tui/src/chatwidget/hook_lifecycle.rs index 04fa87f7d..129e6381f 100644 --- a/codex-rs/tui/src/chatwidget/hook_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/hook_lifecycle.rs @@ -10,6 +10,7 @@ impl ChatWidget { pub(super) fn clear_active_hook_cell(&mut self) { if self.active_hook_cell.take().is_some() { self.bump_active_cell_revision(); + self.request_completed_token_activity_output_insertion(); } } @@ -84,6 +85,7 @@ impl ChatWidget { self.transcript.needs_final_message_separator = true; self.app_event_tx .send(AppEvent::InsertHistoryCell(Box::new(completed_cell))); + self.request_completed_token_activity_output_insertion(); } pub(super) fn finish_active_hook_cell_if_idle(&mut self) { @@ -93,6 +95,7 @@ impl ChatWidget { if cell.is_empty() { self.active_hook_cell = None; self.bump_active_cell_revision(); + self.request_completed_token_activity_output_insertion(); return; } if cell.should_flush() @@ -102,6 +105,7 @@ impl ChatWidget { self.transcript.needs_final_message_separator = true; self.app_event_tx .send(AppEvent::InsertHistoryCell(Box::new(cell))); + self.request_completed_token_activity_output_insertion(); } } diff --git a/codex-rs/tui/src/chatwidget/rendering.rs b/codex-rs/tui/src/chatwidget/rendering.rs index 31c2d1dbf..37af21a01 100644 --- a/codex-rs/tui/src/chatwidget/rendering.rs +++ b/codex-rs/tui/src/chatwidget/rendering.rs @@ -26,6 +26,16 @@ impl ChatWidget { let mut flex = FlexRenderable::new(); flex.push(/*flex*/ 1, active_cell_renderable); flex.push(/*flex*/ 0, active_hook_cell_renderable); + if let Some(cell) = self.pending_token_activity_output() { + flex.push( + /*flex*/ 1, + RenderableItem::Owned(Box::new(TranscriptAreaRenderable { + child: cell, + top: 1, + right: active_cell_right_reserve, + })), + ); + } flex.push( /*flex*/ 0, RenderableItem::Owned(Box::new(BottomPaneComposerReserveRenderable { diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 28e16ff0f..5df31139e 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -206,17 +206,31 @@ impl ChatWidget { self.has_chatgpt_account } + pub(crate) fn has_codex_backend_auth(&self) -> bool { + self.has_codex_backend_auth + } + pub(crate) fn update_account_state( &mut self, status_account_display: Option, plan_type: Option, has_chatgpt_account: bool, + has_codex_backend_auth: bool, ) { + let account_state_changed = self.status_account_display != status_account_display + || self.has_chatgpt_account != has_chatgpt_account + || self.has_codex_backend_auth != has_codex_backend_auth; + if account_state_changed { + self.clear_pending_token_activity_refreshes(); + } self.status_account_display = status_account_display; self.plan_type = plan_type; self.has_chatgpt_account = has_chatgpt_account; + self.has_codex_backend_auth = has_codex_backend_auth; self.bottom_pane .set_connectors_enabled(self.connectors_enabled()); + self.bottom_pane + .set_token_activity_command_enabled(has_codex_backend_auth); } /// Set the syntax theme override in the widget's config copy. diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index dc2b43a4f..32ab70ed3 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -36,6 +36,7 @@ const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str = "Press Ctrl+C to return to the main thread first."; const GOAL_USAGE_HINT: &str = "Example: /goal improve benchmark coverage"; const RAW_USAGE: &str = "Usage: /raw [on|off]"; +const USAGE_CHATGPT_LOGIN_REQUIRED: &str = "Sign in with ChatGPT to use /usage."; impl ChatWidget { /// Dispatch a bare slash command and record its staged local-history entry. @@ -432,6 +433,11 @@ impl ChatWidget { ); } } + SlashCommand::Usage => { + if self.ensure_token_activity_command_available() { + self.add_token_activity_output(tokens::TokenActivityView::Daily); + } + } SlashCommand::Ide => { self.handle_ide_command(); } @@ -651,6 +657,16 @@ impl ChatWidget { } = prepared; let trimmed = args.trim(); match cmd { + SlashCommand::Usage => { + 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: /usage [daily|weekly|cumulative]".to_string(), + ), + } + } + } SlashCommand::Ide => { self.handle_ide_command_args(trimmed); } @@ -992,6 +1008,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_codex_backend_auth, 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), @@ -1000,6 +1017,14 @@ impl ChatWidget { } } + fn ensure_token_activity_command_available(&mut self) -> bool { + if self.has_codex_backend_auth { + return true; + } + self.add_error_message(USAGE_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; @@ -1007,6 +1032,7 @@ impl ChatWidget { match cmd { SlashCommand::Ide | SlashCommand::Status + | SlashCommand::Usage | SlashCommand::DebugConfig | SlashCommand::Ps | SlashCommand::Stop diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__pending_token_activity_refresh_renders_above_composer_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__pending_token_activity_refresh_renders_above_composer_snapshot.snap new file mode 100644 index 000000000..964f801c9 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__pending_token_activity_refresh_renders_above_composer_snapshot.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) +--- + +/usage daily + + Token activity + Loading... + + +› Ask Codex to do anything + + gpt-5.5 default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__signed_out_usage_command_reports_chatgpt_login_requirement.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__signed_out_usage_command_reports_chatgpt_login_requirement.snap new file mode 100644 index 000000000..b777fe517 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__signed_out_usage_command_reports_chatgpt_login_requirement.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +expression: rendered +--- +■ Sign in with ChatGPT to use /usage. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_with_invalid_view_reports_usage.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_with_invalid_view_reports_usage.snap new file mode 100644 index 000000000..bd4615eb8 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_with_invalid_view_reports_usage.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +expression: rendered +--- +■ Usage: /usage [daily|weekly|cumulative] diff --git a/codex-rs/tui/src/chatwidget/streaming.rs b/codex-rs/tui/src/chatwidget/streaming.rs index 848b0c2ef..f6eaa8099 100644 --- a/codex-rs/tui/src/chatwidget/streaming.rs +++ b/codex-rs/tui/src/chatwidget/streaming.rs @@ -40,6 +40,7 @@ impl ChatWidget { if let Some(source) = source { let source = parse_assistant_markdown(&source, self.config.cwd.as_path()).visible_markdown; + self.note_stream_consolidation_queued(); self.app_event_tx.send(AppEvent::ConsolidateAgentMessage { source, cwd: self.config.cwd.to_path_buf(), @@ -52,6 +53,9 @@ impl ChatWidget { if had_stream_controller && self.stream_controllers_idle() { self.app_event_tx.send(AppEvent::StopCommitAnimation); } + if had_stream_controller { + self.request_completed_token_activity_output_insertion(); + } } pub(super) fn stream_controllers_idle(&self) -> bool { @@ -175,18 +179,21 @@ impl ChatWidget { // TODO: Replace streamed output with the final plan item text if plan streaming is // removed or if we need to reconcile mismatches between streamed and final content. if let Some(source) = consolidated_plan_source { + self.note_stream_consolidation_queued(); self.app_event_tx .send(AppEvent::ConsolidateProposedPlan(source)); } } else if !plan_text.is_empty() { self.add_to_history(history_cell::new_proposed_plan(plan_text, &self.config.cwd)); } else if let Some(source) = consolidated_plan_source { + self.note_stream_consolidation_queued(); self.app_event_tx .send(AppEvent::ConsolidateProposedPlan(source)); } if should_restore_after_stream { self.status_state.pending_status_indicator_restore = true; self.maybe_restore_status_indicator_after_stream_idle(); + self.request_completed_token_activity_output_insertion(); } } diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 6741d1bc9..dc32e8c7b 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -147,6 +147,23 @@ pub(super) async fn make_chatwidget_manual( ChatWidget, tokio::sync::mpsc::UnboundedReceiver, tokio::sync::mpsc::UnboundedReceiver, +) { + make_chatwidget_manual_with_auth( + model_override, + /*has_chatgpt_account*/ false, + /*has_codex_backend_auth*/ false, + ) + .await +} + +pub(super) async fn make_chatwidget_manual_with_auth( + model_override: Option<&str>, + has_chatgpt_account: bool, + has_codex_backend_auth: bool, +) -> ( + ChatWidget, + tokio::sync::mpsc::UnboundedReceiver, + tokio::sync::mpsc::UnboundedReceiver, ) { let (tx_raw, rx) = unbounded_channel::(); let app_event_tx = AppEventSender::new(tx_raw); @@ -167,7 +184,8 @@ pub(super) async fn make_chatwidget_manual( workspace_command_runner: None, initial_user_message: None, enhanced_keys_supported: false, - has_chatgpt_account: false, + has_chatgpt_account, + has_codex_backend_auth, model_catalog, feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, @@ -228,6 +246,7 @@ pub(super) fn assert_no_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiv pub(crate) fn set_chatgpt_auth(chat: &mut ChatWidget) { chat.has_chatgpt_account = true; + chat.has_codex_backend_auth = true; chat.model_catalog = test_model_catalog(&chat.config); } diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 830f4259b..fd2b2314e 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1494,6 +1494,7 @@ async fn make_startup_chat_with_cli_overrides( initial_user_message: None, enhanced_keys_supported: false, has_chatgpt_account: false, + has_codex_backend_auth: false, model_catalog: test_model_catalog(&cfg), feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 729b5faca..4c225b331 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -38,6 +38,7 @@ async fn experimental_mode_plan_is_ignored_on_startup() { initial_user_message: None, enhanced_keys_supported: false, has_chatgpt_account: false, + has_codex_backend_auth: false, model_catalog: test_model_catalog(&cfg), feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index a565cc51e..37f20ad97 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -79,6 +79,21 @@ fn recall_latest_after_clearing(chat: &mut ChatWidget) -> String { chat.bottom_pane.composer_text() } +fn dispatch_usage_and_expect_refresh( + chat: &mut ChatWidget, + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> u64 { + chat.dispatch_command(SlashCommand::Usage); + expect_token_activity_refresh(rx) +} + +fn expect_token_activity_refresh(rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> u64 { + match rx.try_recv() { + Ok(AppEvent::RefreshTokenActivity { request_id }) => request_id, + other => panic!("expected token activity refresh request, got {other:?}"), + } +} + fn next_add_to_history_event(rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> String { loop { match rx.try_recv() { @@ -1168,6 +1183,397 @@ 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_usage_command_reports_chatgpt_login_requirement() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + submit_composer_text(&mut chat, "/usage"); + + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|cell| lines_to_single_string(cell)) + .collect::>() + .join("\n"); + assert_chatwidget_snapshot!( + "signed_out_usage_command_reports_chatgpt_login_requirement", + rendered + ); + assert_eq!(recall_latest_after_clearing(&mut chat), "/usage"); +} + +#[tokio::test] +async fn signed_out_usage_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, "/usage 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 /usage."), + "expected ChatGPT login requirement, got: {rendered:?}" + ); + assert_eq!(recall_latest_after_clearing(&mut chat), "/usage weekly"); +} + +#[tokio::test] +async fn usage_command_with_invalid_view_reports_usage_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + + submit_composer_text(&mut chat, "/usage monthly"); + + let rendered = drain_insert_history(&mut rx) + .iter() + .map(|cell| lines_to_single_string(cell)) + .collect::>() + .join("\n"); + assert_chatwidget_snapshot!("usage_command_with_invalid_view_reports_usage", rendered); + assert_eq!(recall_latest_after_clearing(&mut chat), "/usage monthly"); +} + +#[tokio::test] +async fn usage_command_runs_with_backend_auth_without_chatgpt_account_flag() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true, + ); + + chat.dispatch_command(SlashCommand::Usage); + + assert_matches!(rx.try_recv(), Ok(AppEvent::RefreshTokenActivity { .. })); + assert!(!chat.has_chatgpt_account()); +} + +#[tokio::test] +async fn usage_command_runs_with_backend_auth_from_widget_init() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual_with_auth( + /*model_override*/ None, /*has_chatgpt_account*/ false, + /*has_codex_backend_auth*/ true, + ) + .await; + + chat.dispatch_command(SlashCommand::Usage); + + assert_matches!(rx.try_recv(), Ok(AppEvent::RefreshTokenActivity { .. })); + assert!(!chat.has_chatgpt_account()); + assert!(chat.has_codex_backend_auth()); +} + +#[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); + + let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx); + assert_eq!( + chat.pending_token_activity_output() + .map(|cell| lines_to_single_string(&cell.display_lines(u16::MAX))), + Some("/usage daily\n\n Token activity\n Loading...\n".to_string()), + ); + assert_eq!( + chat.active_cell_transcript_lines(u16::MAX) + .map(|lines| lines_to_single_string(&lines)), + Some("/usage daily\n\n Token activity\n Loading...\n".to_string()), + ); + + 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 account_state_change_discards_pending_token_activity_refresh() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + + let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx); + assert!(chat.pending_token_activity_output().is_some()); + + chat.update_account_state( + Some(crate::status::StatusAccountDisplay::ChatGpt { + email: Some("new-account@example.com".to_string()), + plan: None, + }), + /*plan_type*/ None, + /*has_chatgpt_account*/ true, + /*has_codex_backend_auth*/ true, + ); + + assert!(chat.pending_token_activity_output().is_none()); + assert!( + !chat.finish_token_activity_refresh( + request_id, + Err("stale token activity result".to_string()), + ) + ); +} + +#[tokio::test] +async fn pending_token_activity_refresh_renders_above_composer_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + + dispatch_usage_and_expect_refresh(&mut chat, &mut rx); + + let width: u16 = 80; + let height = chat.desired_height(width); + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + term.set_viewport_area(Rect::new(/*x*/ 0, /*y*/ 0, width, height)); + term.draw(|f| { + chat.render(f.area(), f.buffer_mut()); + }) + .unwrap(); + assert_chatwidget_snapshot!( + "pending_token_activity_refresh_renders_above_composer_snapshot", + normalize_snapshot_paths(term.backend().vt100().screen().contents()) + ); +} + +#[tokio::test] +async fn completed_token_activity_refresh_returns_one_history_cell() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + + let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx); + assert!(chat.pending_token_activity_output().is_some()); + + assert!( + chat.finish_token_activity_refresh( + request_id, + Err("token activity unavailable".to_string()), + ) + ); + assert!(chat.pending_token_activity_output().is_some()); + let cell = chat + .take_completed_token_activity_output() + .expect("completed token activity cell"); + + assert!(chat.pending_token_activity_output().is_none()); + assert_eq!( + lines_to_single_string(&cell.display_lines(u16::MAX)), + "/usage daily\n\n Token activity\n Token activity unavailable\n", + ); +} + +#[tokio::test] +async fn completed_token_activity_refresh_waits_for_active_stream() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + + let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx); + chat.on_agent_message_delta("partial response".to_string()); + assert!(chat.token_activity_history_insertion_blocked()); + + assert!( + chat.finish_token_activity_refresh( + request_id, + Err("token activity unavailable".to_string()), + ) + ); + assert_eq!( + chat.pending_token_activity_output() + .map(|cell| lines_to_single_string(&cell.display_lines(u16::MAX))), + Some("/usage daily\n\n Token activity\n Token activity unavailable\n".to_string()), + ); + + chat.finalize_turn(); + assert!(!chat.token_activity_history_insertion_blocked()); + assert!( + std::iter::from_fn(|| rx.try_recv().ok()) + .any(|event| matches!(event, AppEvent::CommitCompletedTokenActivityOutput)) + ); + assert!(chat.take_completed_token_activity_output().is_some()); +} + +#[tokio::test] +async fn completed_token_activity_refresh_waits_for_queued_stream_consolidation() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + + let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx); + chat.on_agent_message_delta("partial response".to_string()); + chat.finalize_completed_assistant_message(/*message*/ None); + assert!(chat.pending_stream_consolidations > 0); + + assert!( + chat.finish_token_activity_refresh( + request_id, + Err("token activity unavailable".to_string()), + ) + ); + assert!(chat.token_activity_history_insertion_blocked()); + + chat.note_stream_consolidation_completed(); + + assert!(!chat.token_activity_history_insertion_blocked()); +} + +#[tokio::test] +async fn completed_token_activity_refresh_waits_for_active_history_cell() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + + let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx); + chat.transcript.active_cell = Some(Box::new(PlainHistoryCell::new(vec![Line::from( + "active tool", + )]))); + assert!( + chat.finish_token_activity_refresh( + request_id, + Err("token activity unavailable".to_string()), + ) + ); + assert!(chat.token_activity_history_insertion_blocked()); + + chat.flush_active_cell(); + + assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_))); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::CommitCompletedTokenActivityOutput) + ); +} + +#[tokio::test] +async fn completed_token_activity_refresh_waits_for_active_hook() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + handle_hook_started( + &mut chat, + hook_run( + "post-tool-use:0:/tmp/hooks.json", + codex_app_server_protocol::HookEventName::PostToolUse, + codex_app_server_protocol::HookRunStatus::Running, + "checking output policy", + Vec::new(), + ), + ); + + let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx); + assert!( + chat.finish_token_activity_refresh( + request_id, + Err("token activity unavailable".to_string()), + ) + ); + assert!(chat.token_activity_history_insertion_blocked()); + + handle_hook_completed( + &mut chat, + hook_run( + "post-tool-use:0:/tmp/hooks.json", + codex_app_server_protocol::HookEventName::PostToolUse, + codex_app_server_protocol::HookRunStatus::Completed, + "checking output policy", + vec![codex_app_server_protocol::HookOutputEntry { + kind: codex_app_server_protocol::HookOutputEntryKind::Context, + text: "hook context".to_string(), + }], + ), + ); + + assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_))); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::CommitCompletedTokenActivityOutput) + ); +} + +#[tokio::test] +async fn completed_token_activity_refresh_retries_after_plan_item_completion() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + + let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx); + let mut controller = crate::streaming::controller::PlanStreamController::new( + /*width*/ None, + &chat.config.cwd, + chat.history_render_mode(), + ); + controller.push("Plan details"); + chat.plan_stream_controller = Some(controller); + assert!( + chat.finish_token_activity_refresh( + request_id, + Err("token activity unavailable".to_string()), + ) + ); + + chat.on_plan_item_completed("Plan details".to_string()); + + assert!( + std::iter::from_fn(|| rx.try_recv().ok()) + .any(|event| matches!(event, AppEvent::CommitCompletedTokenActivityOutput)) + ); +} + +#[tokio::test] +async fn pending_token_activity_refresh_keeps_composer_visible_in_short_viewport() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + chat.transcript.active_cell = Some(Box::new(PlainHistoryCell::new( + std::iter::repeat_n(Line::from("active output"), /*n*/ 20).collect(), + ))); + + dispatch_usage_and_expect_refresh(&mut chat, &mut rx); + + let width: u16 = 80; + let height: u16 = 8; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + term.set_viewport_area(Rect::new(/*x*/ 0, /*y*/ 0, width, height)); + term.draw(|f| { + chat.render(f.area(), f.buffer_mut()); + }) + .unwrap(); + + assert!( + term.backend() + .vt100() + .screen() + .contents() + .contains("Ask Codex to do anything") + ); +} + +#[tokio::test] +async fn repeated_token_activity_refreshes_keep_only_latest_card() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + + let first_request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx); + + chat.dispatch_command_with_args(SlashCommand::Usage, "weekly".to_string(), Vec::new()); + let second_request_id = expect_token_activity_refresh(&mut rx); + + assert_eq!( + chat.pending_token_activity_output() + .map(|cell| lines_to_single_string(&cell.display_lines(u16::MAX))), + Some("/usage weekly\n\n Token activity\n Loading...\n".to_string()), + ); + assert!(!chat.finish_token_activity_refresh( + first_request_id, + Err("stale token activity result".to_string()), + )); + assert!(chat.finish_token_activity_refresh( + second_request_id, + Err("token activity unavailable".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/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 62aede458..f748d94e8 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -421,6 +421,7 @@ async fn configured_pet_load_is_deferred_until_after_construction() { initial_user_message: None, enhanced_keys_supported: false, has_chatgpt_account: false, + has_codex_backend_auth: false, model_catalog: test_model_catalog(&cfg), feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, diff --git a/codex-rs/tui/src/chatwidget/tokens.rs b/codex-rs/tui/src/chatwidget/tokens.rs new file mode 100644 index 000000000..85475899b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tokens.rs @@ -0,0 +1,282 @@ +//! Coordinates asynchronous `/usage` cards in the chat widget. +//! +//! The slash command builds a composite history cell immediately, but the widget +//! keeps that cell transient while the account request runs. The transient card is +//! rendered above the composer through [`ChatWidget::pending_token_activity_output`] +//! so loading never requires clearing or rewriting transcript history. When the +//! matching response arrives, [`TokenActivityHandle`] updates the shared card state +//! and [`ChatWidget::finish_token_activity_refresh`] moves the cell into a completed +//! slot. Event dispatch commits that completed cell into history only after active +//! output and stream consolidation no longer block insertion. +//! +//! Pure chart rendering and date bucketing live in [`chart`]. This module owns +//! request correlation, transient/completed card state, and integration with +//! `ChatWidget` history insertion. + +mod chart; + +use std::sync::Arc; +use std::sync::RwLock; + +use chrono::NaiveDate; +use chrono::Utc; +use codex_app_server_protocol::GetAccountTokenUsageResponse; +use ratatui::style::Stylize; +use ratatui::text::Line; + +use super::ChatWidget; +use crate::app_event::AppEvent; +use crate::history_cell::CompositeHistoryCell; +use crate::history_cell::HistoryCell; +use crate::history_cell::PlainHistoryCell; +use crate::history_cell::plain_lines; + +pub(super) use chart::TokenActivityView; + +/// Tracks the renderable lifecycle of one token activity history cell. +#[derive(Debug)] +enum TokenActivityState { + Loading, + Loaded { + response: GetAccountTokenUsageResponse, + today: NaiveDate, + }, + Error, +} + +/// Completes an asynchronously rendered token activity history cell. +/// +/// Clones share the same card state, allowing the background request path to +/// update a cell still owned by the widget's transient-output state. The widget +/// remains responsible for request-ID matching, redraws, and history insertion. +#[derive(Clone, Debug)] +pub(super) struct TokenActivityHandle { + state: Arc>, +} + +/// Holds the one transient token activity card waiting on its background response. +/// +/// The request ID prevents late results from mutating a newer `/usage` card. The +/// cell stays out of transcript history until the matching response completes and +/// the widget confirms that active output no longer blocks insertion. +pub(super) struct PendingTokenActivityOutput { + request_id: u64, + cell: CompositeHistoryCell, + handle: TokenActivityHandle, +} + +impl TokenActivityHandle { + /// Replaces the loading state with either fetched activity or an unavailable state. + /// + /// This method intentionally discards the error string because the TUI exposes + /// one stable unavailable message. Calling it more than once replaces the prior + /// terminal state, so request-ID matching should happen before completion. + pub(super) fn finish(&self, result: Result) { + self.finish_with_today(result, Utc::now().date_naive()); + } + + fn finish_with_today( + &self, + result: Result, + today: NaiveDate, + ) { + let state = match result { + Ok(response) => TokenActivityState::Loaded { response, today }, + Err(_) => TokenActivityState::Error, + }; + #[expect(clippy::expect_used)] + let mut current = self.state.write().expect("token activity state poisoned"); + *current = state; + } +} + +/// Renders one `/usage` card from shared asynchronous state. +#[derive(Debug)] +struct TokenActivityHistoryCell { + view: TokenActivityView, + state: Arc>, +} + +/// Creates the card contents and completion handle for one `/usage` invocation. +/// +/// The composite cell includes the echoed slash command and a loading card from +/// the start. Callers must retain the returned handle and complete it when the +/// matching background response arrives; otherwise the transient card stays loading. +pub(super) fn new_token_activity_output( + view: TokenActivityView, +) -> (CompositeHistoryCell, TokenActivityHandle) { + let command = PlainHistoryCell::new(vec![ + format!("/usage {}", 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, today } => { + chart::loaded_lines(self.view, response, *today, width) + } + } + } + + fn raw_lines(&self) -> Vec> { + plain_lines(self.display_lines(u16::MAX)) + } +} + +impl ChatWidget { + /// Starts a token activity refresh and replaces the current transient card. + /// + /// Each invocation receives a request ID so background responses update only + /// their own card. The card remains outside transcript history until completion, + /// which keeps loading visible without disturbing existing transcript content. + 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.completed_token_activity_output = None; + self.refreshing_token_activity_output = Some(PendingTokenActivityOutput { + request_id, + cell, + handle, + }); + self.bump_active_cell_revision(); + self.request_redraw(); + self.app_event_tx + .send(AppEvent::RefreshTokenActivity { request_id }); + } + + /// Returns the transient token activity card that should render above the composer. + /// + /// A loading card takes precedence over a completed card waiting for history + /// insertion. Callers should render the returned cell but leave ownership with + /// the widget so completion and insertion can update it safely. + pub(super) fn pending_token_activity_output(&self) -> Option<&dyn HistoryCell> { + self.refreshing_token_activity_output + .as_ref() + .map(|output| &output.cell as &dyn HistoryCell) + .or_else(|| { + self.completed_token_activity_output + .as_ref() + .map(|cell| cell as &dyn HistoryCell) + }) + } + + /// Applies a background token activity result to its matching transient card. + /// + /// Returns `true` when the pending request matched and moved into the completed + /// slot. Late responses return `false`, including responses for cards replaced + /// by a newer `/usage` invocation or cleared during transcript changes. + pub(crate) fn finish_token_activity_refresh( + &mut self, + request_id: u64, + result: Result, + ) -> bool { + let Some(output) = self.refreshing_token_activity_output.take() else { + return false; + }; + if output.request_id != request_id { + self.refreshing_token_activity_output = Some(output); + return false; + } + output.handle.finish(result); + self.completed_token_activity_output = Some(output.cell); + self.bump_active_cell_revision(); + self.request_redraw(); + true + } + + /// Reports whether a completed token activity card must wait before insertion. + /// + /// Inserting while a stream, queued consolidation, or active transcript cell is + /// present can reorder the card relative to visible output, so callers retry once + /// these barriers clear. + pub(crate) fn token_activity_history_insertion_blocked(&self) -> bool { + self.stream_controller.is_some() + || self.plan_stream_controller.is_some() + || self.pending_stream_consolidations > 0 + || self.transcript.active_cell.is_some() + || self.active_hook_cell.is_some() + } + + /// Records a stream consolidation barrier that delays token card insertion. + /// + /// Each queued consolidation should eventually call + /// [`ChatWidget::note_stream_consolidation_completed`]. + pub(crate) fn note_stream_consolidation_queued(&mut self) { + self.pending_stream_consolidations = + self.pending_stream_consolidations.saturating_add(/*rhs*/ 1); + } + + /// Releases one queued stream consolidation barrier. + /// + /// The counter saturates at zero so an unmatched completion does not underflow, + /// but paired queue/completion calls are still the intended contract. + pub(crate) fn note_stream_consolidation_completed(&mut self) { + self.pending_stream_consolidations = + self.pending_stream_consolidations.saturating_sub(/*rhs*/ 1); + } + + /// Transfers the completed token activity card into the history insertion path. + /// + /// Callers should use this only after + /// [`ChatWidget::token_activity_history_insertion_blocked`] returns `false`; + /// taking the card removes it from the transient render area. + pub(crate) fn take_completed_token_activity_output(&mut self) -> Option { + let output = self.completed_token_activity_output.take()?; + self.bump_active_cell_revision(); + Some(output) + } + + /// Requests another insertion attempt when a completed card is waiting. + /// + /// This is used after stream or history lifecycle events that may have cleared + /// the insertion barriers without directly owning the completed card. + pub(crate) fn request_completed_token_activity_output_insertion(&self) { + if self.completed_token_activity_output.is_some() { + self.app_event_tx + .send(AppEvent::CommitCompletedTokenActivityOutput); + } + } + + /// Drops transient and completed token cards that must no longer update. + /// + /// Late background responses cannot mutate cards after a transcript reset, + /// backtrack, or replacement flow clears this widget-owned state. + pub(crate) fn clear_pending_token_activity_refreshes(&mut self) { + let cleared_refresh = self.refreshing_token_activity_output.take().is_some(); + let cleared_completed = self.completed_token_activity_output.take().is_some(); + if cleared_refresh || cleared_completed { + self.bump_active_cell_revision(); + self.request_redraw(); + } + } +} + +#[cfg(test)] +#[path = "tokens_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/chatwidget/tokens/chart.rs b/codex-rs/tui/src/chatwidget/tokens/chart.rs new file mode 100644 index 000000000..58249c6b7 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tokens/chart.rs @@ -0,0 +1,474 @@ +//! Renders account token usage summaries and activity charts for `/usage`. +//! +//! This module owns the chart data bucketing and ratatui line construction. The +//! async card lifecycle stays in the parent `tokens` module so chart rendering +//! remains a pure transformation from a loaded usage response. + +mod palette; + +use std::collections::BTreeMap; + +use chrono::Datelike; +use chrono::Duration; +use chrono::NaiveDate; +use codex_app_server_protocol::GetAccountTokenUsageResponse; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; + +use crate::render::highlight::foreground_style_for_scopes; +use crate::status::format_tokens_compact; +use palette::TokenActivityPalette; + +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; + +/// Selects the aggregation represented by the token activity chart. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::chatwidget) enum TokenActivityView { + Daily, + Weekly, + Cumulative, +} + +impl TokenActivityView { + /// Parses the optional `/usage` argument into a supported chart view. + /// + /// An empty argument selects the daily view so `/usage` and `/usage daily` + /// behave identically. Returning `None` lets the slash-command dispatcher + /// report unsupported arguments instead of silently choosing a view. + pub(in crate::chatwidget) 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, + } + } + + pub(super) fn label(self) -> &'static str { + match self { + Self::Daily => "Daily", + Self::Weekly => "Weekly", + Self::Cumulative => "Cumulative", + } + } +} + +pub(super) fn loaded_lines( + view: TokenActivityView, + response: &GetAccountTokenUsageResponse, + today: NaiveDate, + 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(chart_lines(view, buckets, today, width)); + lines +} + +fn chart_lines( + view: TokenActivityView, + 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, 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(view, row)]; + for column in first_column..WEEK_COUNT { + if column > first_column { + spans.push(" ".into()); + } + let index = column * DAY_COUNT + row; + if view == TokenActivityView::Daily + && cell_date(today, index).is_some_and(|date| date > today) + { + spans.push(" ".into()); + } else { + let style = if view == TokenActivityView::Daily { + palette.for_level(levels[index]) + } else { + palette.for_bar_level(levels[index]) + }; + spans.push(Span::styled(palette.glyph(view, levels[index]), style)); + } + } + lines.push(spans.into()); + } + // Separate the calendar from the legend/footer below. + lines.push(Line::default()); + match view { + TokenActivityView::Daily => lines.push(legend_line(&palette)), + TokenActivityView::Weekly | TokenActivityView::Cumulative => { + lines.push(bar_caption(view, &values)) + } + } + lines.push(view_footer(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(TokenActivityView::Daily, 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 `/usage` 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() +} + +/// Normalizes backend daily buckets into the fixed 52-week display window. +/// +/// The returned vector is ordered by chart cell, starting with the oldest Sunday. +/// Invalid, out-of-window, and future dates are ignored. Duplicate dates are +/// accumulated and negative token values do not reduce activity. +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)) +} + +#[cfg(test)] +#[path = "chart_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/chatwidget/tokens/chart/palette.rs b/codex-rs/tui/src/chatwidget/tokens/chart/palette.rs new file mode 100644 index 000000000..0f45e9a0d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tokens/chart/palette.rs @@ -0,0 +1,152 @@ +//! Builds terminal-aware styles and glyph choices for token activity charts. +//! +//! The palette adapts theme colors to the active terminal color level while +//! keeping chart-specific glyph policy local to the token activity renderer. + +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::style::Stylize; + +use super::TokenActivityView; +use crate::color::blend; +use crate::render::highlight::foreground_style_for_scopes; +use crate::style::accent_style; +use crate::terminal_palette::StdoutColorLevel; +use crate::terminal_palette::best_color_for_level; +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 BAR_CELL_GLYPH: &str = "█"; + +/// Stores the terminal-specific styles and glyph strategy for token activity cells. +pub(super) 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 { + pub(super) fn current() -> Self { + Self::from_parts( + default_fg(), + default_bg(), + stdout_color_level(), + theme_activity_style(), + ) + } + + fn from_parts( + default_fg: Option<(u8, u8, u8)>, + default_bg: Option<(u8, u8, u8)>, + color_level: StdoutColorLevel, + active_style: Style, + ) -> Self { + let fallback_palette = || Self::fallback(active_style); + let (Some(fg), Some(bg), Some(anchor)) = + (default_fg, default_bg, activity_anchor_rgb(active_style)) + else { + return fallback_palette(); + }; + if matches!( + 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_for_level(color, color_level)) + }); + let bar_style = Style::default().fg(best_color_for_level( + blend(anchor, bg, /*alpha*/ 0.78), + color_level, + )); + Self { + styles, + bar_style, + uses_color: true, + } + } + + fn fallback(active_style: Style) -> Self { + let empty_style = Style::default().dim(); + Self { + styles: [ + empty_style, + active_style, + active_style, + active_style, + active_style, + ], + bar_style: active_style, + uses_color: false, + } + } + + pub(super) fn for_level(&self, level: usize) -> Style { + self.styles[level.min(/*other*/ 4)] + } + + pub(super) 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`. Daily truecolor renders every visible + /// cell with the same square glyph and lets color carry the intensity; in + /// low-color we use the hollow glyph for empty cells so they remain visible + /// without a color gradient. Bar views use full blocks for filled height and + /// spaces for empty height so the silhouette reads as a column chart. + pub(super) fn glyph(&self, view: TokenActivityView, level: usize) -> &'static str { + if view != TokenActivityView::Daily { + return if level == 0 { " " } else { BAR_CELL_GLYPH }; + } + if self.uses_color || level > 0 { + ACTIVE_CELL_GLYPH + } else { + EMPTY_CELL_GLYPH + } + } +} + +fn theme_activity_style() -> Style { + foreground_style_for_scopes(&["entity.name.type", "support.type", "variable"]) + .unwrap_or_else(accent_style) + .bold() +} + +fn activity_anchor_rgb(style: Style) -> Option<(u8, u8, u8)> { + match style.fg? { + Color::Rgb(r, g, b) => Some((r, g, b)), + _ => None, + } +} + +#[cfg(test)] +#[path = "palette_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/chatwidget/tokens/chart/palette_tests.rs b/codex-rs/tui/src/chatwidget/tokens/chart/palette_tests.rs new file mode 100644 index 000000000..458f950f3 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tokens/chart/palette_tests.rs @@ -0,0 +1,115 @@ +use super::*; +use crate::terminal_palette::rgb_color; +use pretty_assertions::assert_eq; +use ratatui::style::Modifier; + +#[test] +fn truecolor_palette_blends_theme_accent_against_dark_background() { + let default_fg = Some((240, 240, 240)); + let default_bg = Some((0, 0, 0)); + let active_style = Style::default().fg(rgb_color((100, 200, 50))).bold(); + let palette = TokenActivityPalette::from_parts( + default_fg, + default_bg, + StdoutColorLevel::TrueColor, + active_style, + ); + + assert_eq!( + palette.for_level(/*level*/ 0).fg, + Some(rgb_color((33, 33, 33))) + ); + assert_eq!( + palette.for_level(/*level*/ 1).fg, + Some(rgb_color((22, 44, 11))) + ); + assert_eq!( + palette.for_level(/*level*/ 4).fg, + Some(rgb_color((100, 200, 50))) + ); + assert_eq!( + palette.for_bar_level(/*level*/ 4).fg, + Some(rgb_color((78, 156, 39))) + ); + assert!(palette.uses_color); +} + +#[test] +fn truecolor_palette_blends_empty_cell_for_light_background() { + let default_fg = Some((0, 0, 0)); + let default_bg = Some((255, 255, 255)); + let active_style = Style::default().fg(rgb_color((0, 95, 135))).bold(); + let palette = TokenActivityPalette::from_parts( + default_fg, + default_bg, + StdoutColorLevel::TrueColor, + active_style, + ); + + assert_eq!( + palette.for_level(/*level*/ 0).fg, + Some(rgb_color((209, 209, 209))) + ); + assert_eq!( + palette.for_level(/*level*/ 4).fg, + Some(rgb_color((0, 95, 135))) + ); + assert!(palette.uses_color); +} + +#[test] +fn ansi16_palette_uses_theme_accent_without_green_fallback() { + let default_fg = Some((240, 240, 240)); + let default_bg = Some((0, 0, 0)); + let active_style = Style::default().fg(Color::Magenta).bold(); + let palette = TokenActivityPalette::from_parts( + default_fg, + default_bg, + StdoutColorLevel::Ansi16, + active_style, + ); + + assert_eq!(palette.for_level(/*level*/ 0), Style::default().dim()); + assert_eq!(palette.for_level(/*level*/ 1), active_style); + assert_eq!(palette.for_bar_level(/*level*/ 4), active_style); + assert!(!palette.uses_color); +} + +#[test] +fn non_rgb_theme_accent_remains_active_fallback() { + let default_fg = Some((240, 240, 240)); + let default_bg = Some((0, 0, 0)); + let active_style = Style::default().fg(Color::Cyan).bold(); + let palette = TokenActivityPalette::from_parts( + default_fg, + default_bg, + StdoutColorLevel::TrueColor, + active_style, + ); + + assert_eq!(palette.for_level(/*level*/ 1), active_style); + assert!( + palette + .for_level(/*level*/ 1) + .add_modifier + .contains(Modifier::BOLD) + ); + assert!(!palette.uses_color); +} + +#[test] +fn missing_terminal_colors_use_theme_accent_fallback() { + let default_fg = None; + let default_bg = Some((0, 0, 0)); + let active_style = Style::default().fg(Color::Blue).bold(); + let palette = TokenActivityPalette::from_parts( + default_fg, + default_bg, + StdoutColorLevel::TrueColor, + active_style, + ); + + assert_eq!(palette.for_level(/*level*/ 0), Style::default().dim()); + assert_eq!(palette.for_level(/*level*/ 4), active_style); + assert!(!palette.uses_color); +} diff --git a/codex-rs/tui/src/chatwidget/tokens/chart_tests.rs b/codex-rs/tui/src/chatwidget/tokens/chart_tests.rs new file mode 100644 index 000000000..20e0b4587 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tokens/chart_tests.rs @@ -0,0 +1,243 @@ +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 rendered = chart_lines(TokenActivityView::Daily, &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 lines = chart_lines(TokenActivityView::Daily, &[], 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 weekly_graph_snapshot_renders_bar_chart_and_caption() { + let today = + NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date"); + let buckets = vec![ + AccountTokenUsageDailyBucket { + start_date: "2026-05-11".to_string(), + tokens: 3, + }, + AccountTokenUsageDailyBucket { + start_date: "2026-05-18".to_string(), + tokens: 6, + }, + AccountTokenUsageDailyBucket { + start_date: "2026-05-25".to_string(), + tokens: 9, + }, + ]; + + let rendered = chart_lines( + TokenActivityView::Weekly, + &buckets, + today, + /*width*/ 22, + ) + .into_iter() + .map(|line| line.to_string().trim_end().to_string()) + .collect::>() + .join("\n"); + + assert_snapshot!(rendered, @" + Apr May + max █ + █ + █ █ + █ █ + █ █ █ + █ █ █ + 0 █ █ █ + + Each column = 1 week · tallest 9 + daily · weekly · cumulative + "); +} + +#[test] +fn cumulative_graph_snapshot_renders_running_total_bar_chart_and_caption() { + let today = + NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date"); + let buckets = vec![ + AccountTokenUsageDailyBucket { + start_date: "2026-05-11".to_string(), + tokens: 3, + }, + AccountTokenUsageDailyBucket { + start_date: "2026-05-18".to_string(), + tokens: 6, + }, + AccountTokenUsageDailyBucket { + start_date: "2026-05-25".to_string(), + tokens: 9, + }, + ]; + + let rendered = chart_lines( + TokenActivityView::Cumulative, + &buckets, + today, + /*width*/ 22, + ) + .into_iter() + .map(|line| line.to_string().trim_end().to_string()) + .collect::>() + .join("\n"); + + assert_snapshot!(rendered, @" + Apr May + max █ + █ + █ + █ █ + █ █ + █ █ █ + 0 █ █ █ + + Running total · top 18 + 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/chatwidget/tokens_tests.rs b/codex-rs/tui/src/chatwidget/tokens_tests.rs new file mode 100644 index 000000000..042baa84d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tokens_tests.rs @@ -0,0 +1,38 @@ +use super::*; +use codex_app_server_protocol::AccountTokenUsageSummary; +use pretty_assertions::assert_eq; + +#[test] +fn loaded_state_freezes_chart_anchor_date_at_completion() { + let state = Arc::new(RwLock::new(TokenActivityState::Loading)); + let handle = TokenActivityHandle { + state: Arc::clone(&state), + }; + let today = + NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date"); + + handle.finish_with_today( + Ok(GetAccountTokenUsageResponse { + summary: AccountTokenUsageSummary { + lifetime_tokens: None, + peak_daily_tokens: None, + longest_running_turn_sec: None, + current_streak_days: None, + longest_streak_days: None, + }, + daily_usage_buckets: None, + }), + today, + ); + + let state = state.read().expect("token activity state poisoned"); + match &*state { + TokenActivityState::Loaded { + today: loaded_today, + .. + } => { + assert_eq!(*loaded_today, today); + } + other => panic!("expected loaded state, got {other:?}"), + } +} diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index 3e413c82a..badab1c85 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -51,7 +51,9 @@ impl ChatWidget { self.turn_lifecycle.start(Instant::now()); self.transcript.reset_turn_flags(); self.adaptive_chunking.reset(); - self.plan_stream_controller = None; + if self.plan_stream_controller.take().is_some() { + self.request_completed_token_activity_output_insertion(); + } self.turn_runtime_metrics = RuntimeMetricsSummary::default(); self.session_telemetry.reset_runtime_metrics(); self.bottom_pane.clear_quit_shortcut_hint(); @@ -122,9 +124,11 @@ impl ChatWidget { self.add_boxed_history(cell); } if let Some(source) = source { + self.note_stream_consolidation_queued(); self.app_event_tx .send(AppEvent::ConsolidateProposedPlan(source)); } + self.request_completed_token_activity_output_insertion(); } self.flush_unified_exec_wait_streak(); if !from_replay { @@ -313,6 +317,7 @@ impl ChatWidget { self.adaptive_chunking.reset(); self.stream_controller = None; self.plan_stream_controller = None; + self.request_completed_token_activity_output_insertion(); self.status_state.pending_status_indicator_restore = false; self.clear_cancel_edit(); self.request_status_line_branch_refresh(); diff --git a/codex-rs/tui/src/render/renderable.rs b/codex-rs/tui/src/render/renderable.rs index 6455b0f78..bd22353d0 100644 --- a/codex-rs/tui/src/render/renderable.rs +++ b/codex-rs/tui/src/render/renderable.rs @@ -276,15 +276,13 @@ impl<'a> FlexRenderable<'a> { let mut allocated_rects = Vec::with_capacity(self.children.len()); let mut child_sizes = vec![0; self.children.len()]; let mut allocated_size = 0; - let mut total_flex = 0; + let mut flex_children = Vec::new(); // 1. Allocate space to non-flex children. let max_size = area.height; - let mut last_flex_child_idx = 0; for (i, FlexChild { flex, child }) in self.children.iter().enumerate() { if *flex > 0 { - total_flex += flex; - last_flex_child_idx = i; + flex_children.push((i, *flex as u16, child.desired_height(area.width))); } else { child_sizes[i] = child .desired_height(area.width) @@ -293,25 +291,42 @@ impl<'a> FlexRenderable<'a> { } } let free_space = max_size.saturating_sub(allocated_size); - // 2. Allocate space to flex children, proportional to their flex factor. - let mut allocated_flex_space = 0; - if total_flex > 0 { - let space_per_flex = free_space / total_flex as u16; - for (i, FlexChild { flex, child }) in self.children.iter().enumerate() { - if *flex > 0 { - // Last flex child gets all the remaining space, to prevent a rounding error - // from not allocating all the space. - let max_child_extent = if i == last_flex_child_idx { - free_space - allocated_flex_space - } else { - space_per_flex * *flex as u16 - }; - let child_size = child.desired_height(area.width).min(max_child_extent); - child_sizes[i] = child_size; - allocated_flex_space += child_size; + // 2. Satisfy flex children that need less than their proportional share so their unused + // space can be redistributed instead of leaving blank rows. + let mut remaining_space = free_space; + while !flex_children.is_empty() { + let total_flex = flex_children.iter().map(|(_, flex, _)| *flex).sum::(); + let mut satisfied_any = false; + flex_children.retain(|(i, flex, desired_height)| { + let proportional_share = + (u32::from(remaining_space) * u32::from(*flex) / u32::from(total_flex)) as u16; + if *desired_height <= proportional_share { + child_sizes[*i] = *desired_height; + remaining_space = remaining_space.saturating_sub(*desired_height); + satisfied_any = true; + false + } else { + true } + }); + if !satisfied_any { + break; } } + // 3. Divide the remaining space proportionally. The final child absorbs rounding slack. + let total_flex = flex_children.iter().map(|(_, flex, _)| *flex).sum::(); + let mut allocated_flex_space = 0; + let last_flex_child_idx = flex_children.last().map(|(i, _, _)| *i); + for (i, flex, desired_height) in flex_children { + let max_child_extent = if Some(i) == last_flex_child_idx { + remaining_space.saturating_sub(allocated_flex_space) + } else { + (u32::from(remaining_space) * u32::from(flex) / u32::from(total_flex)) as u16 + }; + let child_size = desired_height.min(max_child_extent); + child_sizes[i] = child_size; + allocated_flex_space += child_size; + } let mut y = area.y; for size in child_sizes { @@ -482,3 +497,7 @@ where RenderableItem::Owned(Box::new(InsetRenderable { child, insets })) } } + +#[cfg(test)] +#[path = "renderable_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/render/renderable_tests.rs b/codex-rs/tui/src/render/renderable_tests.rs new file mode 100644 index 000000000..9c14130a9 --- /dev/null +++ b/codex-rs/tui/src/render/renderable_tests.rs @@ -0,0 +1,72 @@ +use super::*; +use pretty_assertions::assert_eq; + +struct HeightRenderable(u16); + +impl HeightRenderable { + fn with_height(height: u16) -> Self { + Self(height) + } +} + +impl Renderable for HeightRenderable { + fn render(&self, _area: Rect, _buf: &mut Buffer) {} + + fn desired_height(&self, _width: u16) -> u16 { + self.0 + } +} + +#[test] +fn flex_redistributes_space_unused_by_short_children() { + let mut flex = FlexRenderable::new(); + flex.push( + /*flex*/ 1, + RenderableItem::Owned(Box::new(HeightRenderable::with_height(/*height*/ 20))), + ); + flex.push( + /*flex*/ 1, + RenderableItem::Owned(Box::new(HeightRenderable::with_height(/*height*/ 2))), + ); + + let allocated = flex.allocate(Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 10, + )); + + assert_eq!( + allocated + .into_iter() + .map(|area| area.height) + .collect::>(), + vec![8, 2], + ); +} + +#[test] +fn flex_reserves_non_flex_space_before_flexible_children() { + let mut flex = FlexRenderable::new(); + flex.push( + /*flex*/ 1, + RenderableItem::Owned(Box::new(HeightRenderable::with_height(/*height*/ 20))), + ); + flex.push( + /*flex*/ 0, + RenderableItem::Owned(Box::new(HeightRenderable::with_height(/*height*/ 2))), + ); + flex.push( + /*flex*/ 1, + RenderableItem::Owned(Box::new(HeightRenderable::with_height(/*height*/ 20))), + ); + + let allocated = flex.allocate(Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 10, + )); + + assert_eq!( + allocated + .into_iter() + .map(|area| area.height) + .collect::>(), + vec![4, 2, 4], + ); +} diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 5008bab37..05dcefe93 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -48,6 +48,7 @@ pub enum SlashCommand { Diff, Mention, Status, + Usage, DebugConfig, Title, Statusline, @@ -102,6 +103,7 @@ impl SlashCommand { SlashCommand::Import => "import setup, this project, and recent chats from Claude Code", SlashCommand::Hooks => "view and manage lifecycle hooks", SlashCommand::Status => "show current session configuration and token usage", + SlashCommand::Usage => "show account usage 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", @@ -159,6 +161,7 @@ impl SlashCommand { | SlashCommand::Keymap | SlashCommand::Mcp | SlashCommand::Raw + | SlashCommand::Usage | SlashCommand::Pets | SlashCommand::Side | SlashCommand::Btw @@ -176,6 +179,7 @@ impl SlashCommand { | SlashCommand::Diff | SlashCommand::Mention | SlashCommand::Status + | SlashCommand::Usage | SlashCommand::Ide ) } @@ -214,6 +218,7 @@ impl SlashCommand { | SlashCommand::Skills | SlashCommand::Hooks | SlashCommand::Status + | SlashCommand::Usage | SlashCommand::DebugConfig | SlashCommand::Ps | SlashCommand::Stop diff --git a/codex-rs/tui/src/status/account.rs b/codex-rs/tui/src/status/account.rs index 23255f437..7d814a27a 100644 --- a/codex-rs/tui/src/status/account.rs +++ b/codex-rs/tui/src/status/account.rs @@ -1,4 +1,4 @@ -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum StatusAccountDisplay { ChatGpt { email: Option, diff --git a/codex-rs/tui/src/terminal_palette.rs b/codex-rs/tui/src/terminal_palette.rs index 1b539f94c..a602e548c 100644 --- a/codex-rs/tui/src/terminal_palette.rs +++ b/codex-rs/tui/src/terminal_palette.rs @@ -35,6 +35,11 @@ pub fn best_color(target: (u8, u8, u8)) -> Color { best_color_for_color_level(target, effective_stdout_color_level()) } +/// Returns the closest color to the target color for a known terminal color level. +pub fn best_color_for_level(target: (u8, u8, u8), color_level: StdoutColorLevel) -> Color { + best_color_for_color_level(target, color_level) +} + fn effective_stdout_color_level() -> StdoutColorLevel { stdout_color_level_for_terminal( stdout_color_level(), diff --git a/codex-rs/tui/src/transcript_reflow.rs b/codex-rs/tui/src/transcript_reflow.rs index 33318a4d0..56e76a686 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();