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");