From ae057e0bb9f3cde31c54fcca59a78456f7805282 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 31 Mar 2026 17:03:05 -0600 Subject: [PATCH] Fix stale /status rate limits in active TUI sessions (#16201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix stale weekly limit in `/status` (#16194): /status reused the session’s cached rate-limit snapshot, so the weekly remaining limit could stay frozen within an active session. With this change, we now dynamically update the rate limits after status is displayed. I needed to delete a few low-value test cases from the chatWidget tests because the test.rs file is really large, and the new tests in this PR pushed us over the 512K mandated limit. I'm working on a separate PR to refactor that test file. --- codex-rs/tui/src/app.rs | 47 ++++- codex-rs/tui/src/app_event.rs | 13 +- codex-rs/tui/src/app_server_session.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 60 +++++- codex-rs/tui/src/chatwidget/tests.rs | 1 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 2 + .../src/chatwidget/tests/status_and_layout.rs | 4 +- .../chatwidget/tests/status_command_tests.rs | 126 ++++++++++++ codex-rs/tui/src/status/card.rs | 182 +++++++++++++++--- codex-rs/tui/src/status/mod.rs | 3 + ...apshot_shows_refreshing_limits_notice.snap | 24 +++ codex-rs/tui/src/status/tests.rs | 65 +++++++ 12 files changed, 487 insertions(+), 42 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/tests/status_command_tests.rs create mode 100644 codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_refreshing_limits_notice.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 861c289a4..add9e6374 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -11,6 +11,7 @@ use crate::app_event_sender::AppEventSender; use crate::app_server_session::AppServerSession; use crate::app_server_session::AppServerStartedThread; use crate::app_server_session::ThreadSessionState; +use crate::app_server_session::app_server_rate_limit_snapshots_to_core; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::FeedbackAudience; use crate::bottom_pane::McpServerElicitationFormRequest; @@ -58,6 +59,7 @@ use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::FeedbackUploadParams; use codex_app_server_protocol::FeedbackUploadResponse; +use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; use codex_app_server_protocol::McpServerStatus; @@ -111,6 +113,7 @@ use codex_protocol::protocol::ListSkillsResponseEvent; #[cfg(test)] use codex_protocol::protocol::McpAuthStatus; use codex_protocol::protocol::Op; +use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillErrorInfo; @@ -1877,6 +1880,17 @@ impl App { }); } + fn refresh_rate_limits(&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 = fetch_account_rate_limits(request_handle) + .await + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::RateLimitsLoaded { request_id, result }); + }); + } + fn fetch_plugins_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) { let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); @@ -4364,9 +4378,23 @@ impl App { AppEvent::FileSearchResult { query, matches } => { self.chat_widget.apply_file_search_result(query, matches); } - AppEvent::RateLimitSnapshotFetched(snapshot) => { - self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); + AppEvent::RefreshRateLimits { request_id } => { + self.refresh_rate_limits(app_server, request_id); } + AppEvent::RateLimitsLoaded { request_id, result } => match result { + Ok(snapshots) => { + for snapshot in snapshots { + self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); + } + self.chat_widget + .finish_status_rate_limit_refresh(request_id); + } + Err(err) => { + tracing::warn!("account/rateLimits/read failed during TUI refresh: {err}"); + self.chat_widget + .finish_status_rate_limit_refresh(request_id); + } + }, AppEvent::ConnectorsLoaded { result, is_final } => { self.chat_widget.on_connectors_loaded(result, is_final); } @@ -5957,6 +5985,21 @@ async fn fetch_all_mcp_server_statuses( Ok(statuses) } +async fn fetch_account_rate_limits( + request_handle: AppServerRequestHandle, +) -> Result> { + let request_id = RequestId::String(format!("account-rate-limits-{}", Uuid::new_v4())); + let response: GetAccountRateLimitsResponse = request_handle + .request_typed(ClientRequest::GetAccountRateLimits { + request_id, + params: None, + }) + .await + .wrap_err("account/rateLimits/read failed in TUI")?; + + Ok(app_server_rate_limit_snapshots_to_core(response)) +} + async fn fetch_plugins_list( request_handle: AppServerRequestHandle, cwd: PathBuf, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index a666860e4..76ca3b24f 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -137,9 +137,16 @@ pub(crate) enum AppEvent { matches: Vec, }, - /// Result of refreshing rate limits - #[allow(dead_code)] - RateLimitSnapshotFetched(RateLimitSnapshot), + /// Refresh account rate limits in the background. + RefreshRateLimits { + request_id: u64, + }, + + /// Result of refreshing rate limits. + RateLimitsLoaded { + request_id: u64, + result: Result, String>, + }, /// Result of prefetching connectors. ConnectorsLoaded { diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index eb6c17607..68424de82 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1063,7 +1063,7 @@ async fn thread_session_state_from_thread_response( }) } -fn app_server_rate_limit_snapshots_to_core( +pub(crate) fn app_server_rate_limit_snapshots_to_core( response: GetAccountRateLimitsResponse, ) -> Vec { let mut snapshots = Vec::new(); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f6ff6a3f1..c83cd01e8 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -57,6 +57,7 @@ use crate::model_catalog::ModelCatalog; use crate::multi_agents; use crate::status::RateLimitWindowDisplay; use crate::status::StatusAccountDisplay; +use crate::status::StatusHistoryHandle; use crate::status::format_directory_display; use crate::status::format_tokens_compact; use crate::status::rate_limit_snapshot_display_for_limit; @@ -761,6 +762,8 @@ pub(crate) struct ChatWidget { status_account_display: Option, token_info: Option, rate_limit_snapshots_by_limit_id: BTreeMap, + refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>, + next_status_refresh_request_id: u64, plan_type: Option, rate_limit_warnings: RateLimitWarningState, rate_limit_switch_prompt: RateLimitSwitchPromptState, @@ -4705,6 +4708,8 @@ impl ChatWidget { status_account_display, token_info: None, rate_limit_snapshots_by_limit_id: BTreeMap::new(), + refreshing_status_outputs: Vec::new(), + next_status_refresh_request_id: 0, plan_type: initial_plan_type, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), @@ -5328,7 +5333,18 @@ impl ChatWidget { self.open_skills_menu(); } SlashCommand::Status => { - self.add_status_output(); + if self.should_prefetch_rate_limits() { + let request_id = self.next_status_refresh_request_id; + self.next_status_refresh_request_id = + self.next_status_refresh_request_id.wrapping_add(1); + self.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id)); + self.app_event_tx + .send(AppEvent::RefreshRateLimits { request_id }); + } else { + self.add_status_output( + /*refreshing_rate_limits*/ false, /*request_id*/ None, + ); + } } SlashCommand::DebugConfig => { self.add_debug_config_output(); @@ -7344,7 +7360,11 @@ impl ChatWidget { self.request_redraw(); } - pub(crate) fn add_status_output(&mut self) { + pub(crate) fn add_status_output( + &mut self, + refreshing_rate_limits: bool, + request_id: Option, + ) { let default_usage = TokenUsage::default(); let token_info = self.token_info.as_ref(); let total_usage = token_info @@ -7357,7 +7377,7 @@ impl ChatWidget { .values() .cloned() .collect(); - self.add_to_history(crate::status::new_status_output_with_rate_limits( + let (cell, handle) = crate::status::new_status_output_with_rate_limits_handle( &self.config, self.status_account_display.as_ref(), token_info, @@ -7371,7 +7391,39 @@ impl ChatWidget { self.model_display_name(), collaboration_mode, reasoning_effort_override, - )); + refreshing_rate_limits, + ); + if let Some(request_id) = request_id { + self.refreshing_status_outputs.push((request_id, handle)); + } + self.add_to_history(cell); + } + + pub(crate) fn finish_status_rate_limit_refresh(&mut self, request_id: u64) { + if self.refreshing_status_outputs.is_empty() { + return; + } + + let rate_limit_snapshots: Vec = self + .rate_limit_snapshots_by_limit_id + .values() + .cloned() + .collect(); + let now = Local::now(); + let mut remaining = Vec::with_capacity(self.refreshing_status_outputs.len()); + let mut updated_any = false; + for (pending_request_id, handle) in self.refreshing_status_outputs.drain(..) { + if pending_request_id == request_id { + updated_any = true; + handle.finish_rate_limit_refresh(rate_limit_snapshots.as_slice(), now); + } else { + remaining.push((pending_request_id, handle)); + } + } + self.refreshing_status_outputs = remaining; + if updated_any { + self.request_redraw(); + } } pub(crate) fn add_debug_config_output(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 4a81c9314..1ac68bc42 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -235,6 +235,7 @@ mod popups_and_settings; mod review_mode; mod slash_commands; mod status_and_layout; +mod status_command_tests; pub(crate) use helpers::make_chatwidget_manual_with_sender; pub(crate) use helpers::set_chatgpt_auth; diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 875aee405..46a4129a4 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -196,6 +196,8 @@ pub(super) async fn make_chatwidget_manual( status_account_display: None, token_info: None, rate_limit_snapshots_by_limit_id: BTreeMap::new(), + refreshing_status_outputs: Vec::new(), + next_status_refresh_request_id: 0, plan_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), 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 d581d4f09..d7e1a6ba8 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -84,7 +84,9 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { ); assert_eq!(chat.bottom_pane.context_window_percent(), Some(100)); - chat.add_status_output(); + chat.add_status_output( + /*refreshing_rate_limits*/ false, /*request_id*/ None, + ); let cells = drain_insert_history(&mut rx); let context_line = cells diff --git a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs new file mode 100644 index 000000000..9673401a7 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs @@ -0,0 +1,126 @@ +use super::*; +use assert_matches::assert_matches; + +#[tokio::test] +async fn status_command_renders_immediately_and_refreshes_rate_limits_for_chatgpt_auth() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + + chat.dispatch_command(SlashCommand::Status); + + let rendered = match rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => { + lines_to_single_string(&cell.display_lines(/*width*/ 80)) + } + other => panic!("expected status output before refresh request, got {other:?}"), + }; + assert!( + rendered.contains("refreshing limits"), + "expected /status to explain the background refresh, got: {rendered}" + ); + let request_id = match rx.try_recv() { + Ok(AppEvent::RefreshRateLimits { request_id }) => request_id, + other => panic!("expected rate-limit refresh request, got {other:?}"), + }; + pretty_assertions::assert_eq!(request_id, 0); +} + +#[tokio::test] +async fn status_command_updates_rendered_cell_after_rate_limit_refresh() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + + chat.dispatch_command(SlashCommand::Status); + + let cell = match rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + other => panic!("expected status output before refresh request, got {other:?}"), + }; + let first_request_id = match rx.try_recv() { + Ok(AppEvent::RefreshRateLimits { request_id }) => request_id, + other => panic!("expected rate-limit refresh request, got {other:?}"), + }; + + let initial = lines_to_single_string(&cell.display_lines(/*width*/ 80)); + assert!( + initial.contains("refreshing limits"), + "expected initial /status output to show refresh notice, got: {initial}" + ); + + chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 92.0))); + chat.finish_status_rate_limit_refresh(first_request_id); + + let updated = lines_to_single_string(&cell.display_lines(/*width*/ 80)); + assert_ne!( + initial, updated, + "expected refreshed /status output to change" + ); + assert!( + !updated.contains("refreshing limits"), + "expected refresh notice to clear after background update, got: {updated}" + ); +} + +#[tokio::test] +async fn status_command_renders_immediately_without_rate_limit_refresh() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Status); + + assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_))); + assert!( + !std::iter::from_fn(|| rx.try_recv().ok()) + .any(|event| matches!(event, AppEvent::RefreshRateLimits { .. })), + "non-ChatGPT sessions should not request a rate-limit refresh for /status" + ); +} + +#[tokio::test] +async fn status_command_overlapping_refreshes_update_matching_cells_only() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + + chat.dispatch_command(SlashCommand::Status); + let first_cell = match rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + other => panic!("expected first status output, got {other:?}"), + }; + let first_request_id = match rx.try_recv() { + Ok(AppEvent::RefreshRateLimits { request_id }) => request_id, + other => panic!("expected first refresh request, got {other:?}"), + }; + + chat.dispatch_command(SlashCommand::Status); + let second_cell = match rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + other => panic!("expected second status output, got {other:?}"), + }; + let second_request_id = match rx.try_recv() { + Ok(AppEvent::RefreshRateLimits { request_id }) => request_id, + other => panic!("expected second refresh request, got {other:?}"), + }; + + assert_ne!(first_request_id, second_request_id); + + chat.finish_status_rate_limit_refresh(first_request_id); + + let first_after_failure = lines_to_single_string(&first_cell.display_lines(/*width*/ 80)); + let second_still_refreshing = lines_to_single_string(&second_cell.display_lines(/*width*/ 80)); + assert!( + !first_after_failure.contains("refreshing limits"), + "expected first status cell to stop refreshing after its request completed, got: {first_after_failure}" + ); + assert!( + second_still_refreshing.contains("refreshing limits"), + "expected later status cell to keep refreshing until its own request completes, got: {second_still_refreshing}" + ); + + chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 92.0))); + chat.finish_status_rate_limit_refresh(second_request_id); + + let second_after_success = lines_to_single_string(&second_cell.display_lines(/*width*/ 80)); + assert!( + !second_after_success.contains("refreshing limits"), + "expected second status cell to refresh once its own request completed, got: {second_after_success}" + ); +} diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index d7556154d..e4eff0923 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -42,6 +42,8 @@ use super::rate_limits::format_status_limit_summary; use super::rate_limits::render_status_limit_progress_bar; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_lines; +use std::sync::Arc; +use std::sync::RwLock; #[derive(Debug, Clone)] struct StatusContextWindowData { @@ -58,6 +60,37 @@ pub(crate) struct StatusTokenUsageData { context_window: Option, } +#[derive(Debug)] +struct StatusRateLimitState { + rate_limits: StatusRateLimitData, + refreshing_rate_limits: bool, +} + +#[derive(Debug, Clone)] +pub(crate) struct StatusHistoryHandle { + rate_limit_state: Arc>, +} + +impl StatusHistoryHandle { + pub(crate) fn finish_rate_limit_refresh( + &self, + rate_limits: &[RateLimitSnapshotDisplay], + now: DateTime, + ) { + let rate_limits = if rate_limits.len() <= 1 { + compose_rate_limit_data(rate_limits.first(), now) + } else { + compose_rate_limit_data_many(rate_limits, now) + }; + let mut state = self + .rate_limit_state + .write() + .expect("status history rate-limit state poisoned"); + state.rate_limits = rate_limits; + state.refreshing_rate_limits = false; + } +} + #[derive(Debug)] struct StatusHistoryCell { model_name: String, @@ -72,7 +105,7 @@ struct StatusHistoryCell { session_id: Option, forked_from: Option, token_usage: StatusTokenUsageData, - rate_limits: StatusRateLimitData, + rate_limit_state: Arc>, } #[cfg(test)] @@ -107,9 +140,11 @@ pub(crate) fn new_status_output( model_name, collaboration_mode, reasoning_effort_override, + /*refreshing_rate_limits*/ false, ) } +#[cfg(test)] #[allow(clippy::too_many_arguments)] pub(crate) fn new_status_output_with_rate_limits( config: &Config, @@ -125,9 +160,9 @@ pub(crate) fn new_status_output_with_rate_limits( model_name: &str, collaboration_mode: Option<&str>, reasoning_effort_override: Option>, + refreshing_rate_limits: bool, ) -> CompositeHistoryCell { - let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); - let card = StatusHistoryCell::new( + new_status_output_with_rate_limits_handle( config, account_display, token_info, @@ -141,9 +176,50 @@ pub(crate) fn new_status_output_with_rate_limits( model_name, collaboration_mode, reasoning_effort_override, + refreshing_rate_limits, + ) + .0 +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn new_status_output_with_rate_limits_handle( + config: &Config, + account_display: Option<&StatusAccountDisplay>, + token_info: Option<&TokenUsageInfo>, + total_usage: &TokenUsage, + session_id: &Option, + thread_name: Option, + forked_from: Option, + rate_limits: &[RateLimitSnapshotDisplay], + _plan_type: Option, + now: DateTime, + model_name: &str, + collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, + refreshing_rate_limits: bool, +) -> (CompositeHistoryCell, StatusHistoryHandle) { + let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); + let (card, handle) = StatusHistoryCell::new( + config, + account_display, + token_info, + total_usage, + session_id, + thread_name, + forked_from, + rate_limits, + _plan_type, + now, + model_name, + collaboration_mode, + reasoning_effort_override, + refreshing_rate_limits, ); - CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) + ( + CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]), + handle, + ) } impl StatusHistoryCell { @@ -162,7 +238,8 @@ impl StatusHistoryCell { model_name: &str, collaboration_mode: Option<&str>, reasoning_effort_override: Option>, - ) -> Self { + refreshing_rate_limits: bool, + ) -> (Self, StatusHistoryHandle) { let mut config_entries = vec![ ("workdir", config.cwd.display().to_string()), ("model", model_name.to_string()), @@ -251,22 +328,29 @@ impl StatusHistoryCell { } else { compose_rate_limit_data_many(rate_limits, now) }; - - Self { - model_name, - model_details, - directory: config.cwd.to_path_buf(), - permissions, - agents_summary, - collaboration_mode: collaboration_mode.map(ToString::to_string), - model_provider, - account, - thread_name, - session_id, - forked_from, - token_usage, + let rate_limit_state = Arc::new(RwLock::new(StatusRateLimitState { rate_limits, - } + refreshing_rate_limits, + })); + + ( + Self { + model_name, + model_details, + directory: config.cwd.to_path_buf(), + permissions, + agents_summary, + collaboration_mode: collaboration_mode.map(ToString::to_string), + model_provider, + account, + thread_name, + session_id, + forked_from, + token_usage, + rate_limit_state: rate_limit_state.clone(), + }, + StatusHistoryHandle { rate_limit_state }, + ) } fn token_usage_spans(&self) -> Vec> { @@ -305,30 +389,57 @@ impl StatusHistoryCell { fn rate_limit_lines( &self, + state: &StatusRateLimitState, available_inner_width: usize, formatter: &FieldFormatter, ) -> Vec> { - match &self.rate_limits { + match &state.rate_limits { StatusRateLimitData::Available(rows_data) => { if rows_data.is_empty() { - return vec![ - formatter.line("Limits", vec![Span::from("data not available yet").dim()]), - ]; + return vec![formatter.line( + "Limits", + vec![if state.refreshing_rate_limits { + Span::from("refreshing cached limits...").dim() + } else { + Span::from("data not available yet").dim() + }], + )]; } - self.rate_limit_row_lines(rows_data, available_inner_width, formatter) + let mut lines = + self.rate_limit_row_lines(rows_data, available_inner_width, formatter); + if state.refreshing_rate_limits { + lines.push(formatter.line( + "Notice", + vec![Span::from("refreshing limits in background...").dim()], + )); + } + lines } StatusRateLimitData::Stale(rows_data) => { let mut lines = self.rate_limit_row_lines(rows_data, available_inner_width, formatter); lines.push(formatter.line( "Warning", - vec![Span::from("limits may be stale - start new turn to refresh.").dim()], + vec![Span::from(if state.refreshing_rate_limits { + "limits may be stale - refreshing in background..." + } else { + "limits may be stale - start new turn to refresh." + }) + .dim()], )); lines } StatusRateLimitData::Missing => { - vec![formatter.line("Limits", vec![Span::from("data not available yet").dim()])] + vec![formatter.line( + "Limits", + vec![Span::from(if state.refreshing_rate_limits { + "refreshing limits..." + } else { + "data not available yet" + }) + .dim()], + )] } } } @@ -386,8 +497,13 @@ impl StatusHistoryCell { lines } - fn collect_rate_limit_labels(&self, seen: &mut BTreeSet, labels: &mut Vec) { - match &self.rate_limits { + fn collect_rate_limit_labels( + &self, + state: &StatusRateLimitState, + seen: &mut BTreeSet, + labels: &mut Vec, + ) { + match &state.rate_limits { StatusRateLimitData::Available(rows) => { if rows.is_empty() { push_label(labels, seen, "Limits"); @@ -442,6 +558,10 @@ impl HistoryCell for StatusHistoryCell { .collect(); let mut seen: BTreeSet = labels.iter().cloned().collect(); let thread_name = self.thread_name.as_deref().filter(|name| !name.is_empty()); + let rate_limit_state = self + .rate_limit_state + .read() + .expect("status history rate-limit state poisoned"); if self.model_provider.is_some() { push_label(&mut labels, &mut seen, "Model provider"); @@ -466,7 +586,7 @@ impl HistoryCell for StatusHistoryCell { push_label(&mut labels, &mut seen, "Context window"); } - self.collect_rate_limit_labels(&mut seen, &mut labels); + self.collect_rate_limit_labels(&rate_limit_state, &mut seen, &mut labels); let formatter = FieldFormatter::from_labels(labels.iter().map(String::as_str)); let value_width = formatter.value_width(available_inner_width); @@ -534,7 +654,7 @@ impl HistoryCell for StatusHistoryCell { lines.push(formatter.line("Context window", spans)); } - lines.extend(self.rate_limit_lines(available_inner_width, &formatter)); + lines.extend(self.rate_limit_lines(&rate_limit_state, available_inner_width, &formatter)); let content_width = lines.iter().map(line_display_width).max().unwrap_or(0); let inner_width = content_width.min(available_inner_width); diff --git a/codex-rs/tui/src/status/mod.rs b/codex-rs/tui/src/status/mod.rs index 6483e8bc1..e09955a6a 100644 --- a/codex-rs/tui/src/status/mod.rs +++ b/codex-rs/tui/src/status/mod.rs @@ -13,9 +13,12 @@ mod helpers; mod rate_limits; pub(crate) use account::StatusAccountDisplay; +pub(crate) use card::StatusHistoryHandle; #[cfg(test)] pub(crate) use card::new_status_output; +#[cfg(test)] pub(crate) use card::new_status_output_with_rate_limits; +pub(crate) use card::new_status_output_with_rate_limits_handle; pub(crate) use helpers::format_directory_display; pub(crate) use helpers::format_tokens_compact; pub(crate) use helpers::plan_type_display_name; diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_refreshing_limits_notice.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_refreshing_limits_notice.snap new file mode 100644 index 000000000..eafeec995 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_refreshing_limits_notice.snap @@ -0,0 +1,24 @@ +--- +source: tui/src/status/tests.rs +assertion_line: 765 +expression: sanitized +--- +/status + +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (read-only, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ 5h limit: [███████████░░░░░░░░░] 55% left (resets 08:24) │ +│ Weekly limit: [██████████████░░░░░░] 70% left (resets 08:54) │ +│ Notice: refreshing limits in background... │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 69097c6ef..4e090c126 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -1,4 +1,5 @@ use super::new_status_output; +use super::new_status_output_with_rate_limits; use super::rate_limit_snapshot_display; use crate::history_cell::HistoryCell; use crate::status::StatusAccountDisplay; @@ -700,6 +701,70 @@ async fn status_snapshot_shows_missing_limits_message() { assert_snapshot!(sanitized); } +#[tokio::test] +async fn status_snapshot_shows_refreshing_limits_notice() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.cwd = PathBuf::from("/workspace/tests").abs(); + + let usage = TokenUsage { + input_tokens: 500, + cached_input_tokens: 0, + output_tokens: 250, + reasoning_output_tokens: 0, + total_tokens: 750, + }; + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 6, 7, 8, 9, 10) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 45.0, + window_minutes: Some(300), + resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 900)), + }), + secondary: Some(RateLimitWindow { + used_percent: 30.0, + window_minutes: Some(10_080), + resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 2_700)), + }), + credits: None, + plan_type: None, + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + + let model_slug = codex_core::test_support::get_model_offline(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let composite = new_status_output_with_rate_limits( + &config, + /*account_display*/ None, + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + std::slice::from_ref(&rate_display), + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + /*refreshing_rate_limits*/ true, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + #[tokio::test] async fn status_snapshot_includes_credits_and_limits() { let temp_home = TempDir::new().expect("temp home");