diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 08e7f9b07..4a0652166 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -19,7 +19,6 @@ use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::MarketplaceUpgradeParams; use codex_app_server_protocol::MarketplaceUpgradeResponse; -use codex_app_server_protocol::RateLimitResetCreditsSummary; use codex_app_server_protocol::RequestId; use crate::hooks_rpc::fetch_hooks_list; @@ -90,7 +89,8 @@ impl App { .and_then(|result| result.map_err(|err| err.to_string())) } RateLimitRefreshOrigin::StartupPrefetch { .. } - | RateLimitRefreshOrigin::StatusCommand { .. } => { + | RateLimitRefreshOrigin::StatusCommand { .. } + | RateLimitRefreshOrigin::UsageMenu { .. } => { request.await.map_err(|err| err.to_string()) } }; @@ -127,7 +127,7 @@ impl App { tokio::spawn(async move { let result = tokio::time::timeout( RATE_LIMIT_RESET_REQUEST_TIMEOUT, - fetch_rate_limit_reset_credits(request_handle), + fetch_account_rate_limits(request_handle), ) .await .map_err(|_| "account/rateLimits/read timed out in TUI".to_string()) @@ -789,24 +789,6 @@ pub(super) async fn fetch_account_token_activity( .wrap_err("account/usage/read failed in TUI") } -pub(super) async fn fetch_rate_limit_reset_credits( - request_handle: AppServerRequestHandle, -) -> Result { - let request_id = RequestId::String(format!("account-rate-limit-resets-{}", 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")?; - response.rate_limit_reset_credits.ok_or_else(|| { - color_eyre::eyre::eyre!( - "account/rateLimits/read response did not include rateLimitResetCredits" - ) - }) -} - pub(super) async fn consume_rate_limit_reset_credit_request( request_handle: AppServerRequestHandle, idempotency_key: String, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 0830c69b0..174c4d14e 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -758,15 +758,14 @@ impl App { AppEvent::RateLimitsLoaded { origin, result } => match result { Ok(response) => { let rate_limit_reset_credits = response.rate_limit_reset_credits.clone(); - for snapshot in app_server_rate_limit_snapshots(response) { - self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); - } + let snapshots = app_server_rate_limit_snapshots(response); match origin { RateLimitRefreshOrigin::StartupPrefetch { reset_hint_request_id, } => { if self.chat_widget.finish_rate_limit_reset_hint_refresh( reset_hint_request_id, + snapshots, rate_limit_reset_credits.ok_or_else(|| { "account/rateLimits/read response did not include rateLimitResetCredits" .to_string() @@ -779,6 +778,7 @@ impl App { RateLimitRefreshOrigin::ResetConsume { request_id } => { self.chat_widget.finish_post_consume_reset_credits_refresh( request_id, + snapshots, rate_limit_reset_credits.ok_or_else(|| { "account/rateLimits/read response did not include rateLimitResetCredits" .to_string() @@ -788,7 +788,17 @@ impl App { } RateLimitRefreshOrigin::StatusCommand { request_id } => { self.chat_widget - .finish_status_rate_limit_refresh(request_id); + .finish_status_rate_limit_refresh(request_id, snapshots); + } + RateLimitRefreshOrigin::UsageMenu { request_id } => { + self.chat_widget.finish_usage_menu_rate_limit_refresh( + request_id, + snapshots, + rate_limit_reset_credits.ok_or_else(|| { + "account/rateLimits/read response did not include rateLimitResetCredits" + .to_string() + }), + ); } } } @@ -800,16 +810,27 @@ impl App { } => { self.chat_widget.finish_rate_limit_reset_hint_refresh( reset_hint_request_id, + Vec::new(), Err(err), ); } RateLimitRefreshOrigin::ResetConsume { request_id } => { - self.chat_widget - .finish_post_consume_reset_credits_refresh(request_id, Err(err)); + self.chat_widget.finish_post_consume_reset_credits_refresh( + request_id, + Vec::new(), + Err(err), + ); } RateLimitRefreshOrigin::StatusCommand { request_id } => { self.chat_widget - .finish_status_rate_limit_refresh(request_id); + .finish_status_rate_limit_refresh(request_id, Vec::new()); + } + RateLimitRefreshOrigin::UsageMenu { request_id } => { + self.chat_widget.finish_usage_menu_rate_limit_refresh( + request_id, + Vec::new(), + Err(err), + ); } } } @@ -822,15 +843,29 @@ impl App { let request_id = self.chat_widget.show_rate_limit_reset_loading_popup(); self.refresh_rate_limit_reset_credits(app_server, request_id); } - AppEvent::RateLimitResetCreditsLoaded { request_id, result } => { - if let Err(err) = &result { + AppEvent::RateLimitResetCreditsLoaded { request_id, result } => match result { + Ok(response) => { + let rate_limit_reset_credits = response.rate_limit_reset_credits.clone(); + self.chat_widget.finish_rate_limit_reset_credits_refresh( + request_id, + app_server_rate_limit_snapshots(response), + rate_limit_reset_credits.ok_or_else(|| { + "account/rateLimits/read response did not include rateLimitResetCredits" + .to_string() + }), + ); + } + Err(err) => { tracing::warn!( "account/rateLimits/read failed during reset-credit refresh: {err}" ); + self.chat_widget.finish_rate_limit_reset_credits_refresh( + request_id, + Vec::new(), + Err(err), + ); } - self.chat_widget - .finish_rate_limit_reset_credits_refresh(request_id, result); - } + }, AppEvent::ConsumeRateLimitResetCredit { idempotency_key } => { let request_id = self.chat_widget.show_rate_limit_reset_consuming_popup(); self.consume_rate_limit_reset_credit(app_server, request_id, idempotency_key); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 26686c3b5..55d67f292 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -27,7 +27,6 @@ use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallResponse; -use codex_app_server_protocol::RateLimitResetCreditsSummary; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadGoalStatus; use codex_file_search::FileMatch; @@ -119,7 +118,9 @@ pub(crate) struct PluginRemoteSectionError { /// updates the cached snapshots and any available reset-credit notice (no /// status card to finalize). A `StatusCommand` is tied to a specific `/status` /// invocation and must call `finish_status_rate_limit_refresh` when done so the -/// card stops showing a "refreshing" state. +/// card stops showing a "refreshing" state. A `UsageMenu` refreshes a cached +/// zero reset count so the disabled menu entry can become available without a +/// restart. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum RateLimitRefreshOrigin { /// Eagerly fetched after bootstrap for `/status` data and reset availability. @@ -127,6 +128,8 @@ pub(crate) enum RateLimitRefreshOrigin { /// User-initiated via `/status`; the `request_id` correlates with the /// status card that should be updated when the fetch completes. StatusCommand { request_id: u64 }, + /// User reopened `/usage` while the cached reset-credit count was zero. + UsageMenu { request_id: u64 }, /// Refresh requested after a reset credit was successfully consumed. ResetConsume { request_id: u64 }, } @@ -315,7 +318,7 @@ pub(crate) enum AppEvent { /// Result of reading the current reset-credit balance. RateLimitResetCreditsLoaded { request_id: u64, - result: Result, + result: Result, }, /// Consume one reset credit using a stable idempotency key. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 20bef58a1..aa7879f76 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -557,6 +557,7 @@ pub(crate) struct ChatWidget { next_token_activity_request_id: u64, pending_rate_limit_reset_request_id: Option, pending_rate_limit_reset_hint_request_id: Option, + pending_usage_menu_rate_limit_request_id: Option, pending_rate_limit_reset_hint: Option, available_rate_limit_reset_credits: Option, next_rate_limit_reset_request_id: u64, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 17f2c8c3b..414a693bd 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -131,6 +131,7 @@ impl ChatWidget { next_token_activity_request_id: 0, pending_rate_limit_reset_request_id: None, pending_rate_limit_reset_hint_request_id: None, + pending_usage_menu_rate_limit_request_id: None, pending_rate_limit_reset_hint: None, available_rate_limit_reset_credits: None, next_rate_limit_reset_request_id: 0, diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index efb0b6362..77a0c5c7e 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -5,6 +5,7 @@ use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini"; pub(super) const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; +pub(super) const RATE_LIMIT_SWITCH_PROMPT_VIEW_ID: &str = "rate-limit-switch-prompt"; const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; const PRIMARY_LIMIT_FALLBACK_LABEL: &str = "usage"; @@ -399,6 +400,7 @@ impl ChatWidget { ]; self.bottom_pane.show_selection_view(SelectionViewParams { + view_id: Some(RATE_LIMIT_SWITCH_PROMPT_VIEW_ID), title: Some("Approaching rate limits".to_string()), subtitle: Some(format!("Switch to {switch_model} for lower credit usage?")), footer_hint: Some(standard_popup_hint_line()), diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 75c4cc66e..269606098 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -2,6 +2,7 @@ use super::*; use crate::app_event::AppEvent; +use crate::chatwidget::rate_limits::RATE_LIMIT_SWITCH_PROMPT_VIEW_ID; impl ChatWidget { /// Set the approval policy in the widget's config copy. @@ -217,12 +218,22 @@ impl ChatWidget { 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.clear_pending_rate_limit_reset_requests(); + // Account-update notifications are the identity boundary. The visible account fields can + // be identical across two accounts, so always invalidate account-scoped requests and data. + self.clear_pending_token_activity_refreshes(); + self.clear_pending_rate_limit_reset_requests(); + self.codex_rate_limit_reached_type = None; + self.rate_limit_warnings = RateLimitWarningState::default(); + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + self.bottom_pane + .dismiss_view_by_id(RATE_LIMIT_SWITCH_PROMPT_VIEW_ID); + let had_refreshing_status_outputs = !self.refreshing_status_outputs.is_empty(); + let now = Local::now(); + for (_, handle) in self.refreshing_status_outputs.drain(..) { + handle.finish_rate_limit_refresh(&[], now); + } + if had_refreshing_status_outputs { + self.request_redraw(); } self.status_line_workspace_headline = None; self.status_line_workspace_headline_pending_request_id = None; @@ -236,7 +247,7 @@ impl ChatWidget { .set_connectors_enabled(self.connectors_enabled()); self.bottom_pane .set_token_activity_command_enabled(has_codex_backend_auth); - self.refresh_status_line(); + self.refresh_status_surfaces(); } /// Set the syntax theme override in the widget's config copy. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_available_hint.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_available_hint.snap index 5b528bb1d..56f0fc2f8 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_available_hint.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_available_hint.snap @@ -2,4 +2,4 @@ source: tui/src/chatwidget/tests/usage.rs expression: rendered --- -• You have 2 rate-limit resets available. Run /usage to use one. +• You have 2 usage limit resets available. Run /usage to use one. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_hint_waits_for_active_output.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_hint_waits_for_active_output.snap index 9e332a925..e18178d46 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_hint_waits_for_active_output.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_hint_waits_for_active_output.snap @@ -4,4 +4,4 @@ expression: "lines_to_single_string(&chat.active_cell_transcript_lines(80).expec --- active tool -• You have 2 rate-limit resets available. Run /usage to use one. +• You have 2 usage limit resets available. Run /usage to use one. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap index ba6066e0b..12b1568f5 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap @@ -2,41 +2,41 @@ source: tui/src/chatwidget/tests/usage.rs expression: "states.join(\"\\n---\\n\")" --- - Rate-limit resets + Usage limit resets Checking your available resets... › Loading... Press enter to confirm or esc to go back --- - Rate-limit resets - You have 2 rate-limit resets available. + Usage limit resets + You have 2 usage limit resets available. - 1. Use a reset Reset your current Codex usage windows. + 1. Use a reset Reset your current 5-hour and weekly usage limits. › 2. Cancel Press enter to confirm or esc to go back --- - Rate-limit resets - You don't have any rate-limit resets available. + Usage limit resets + You don't have any usage limit resets available. › 1. Close Press enter to confirm or esc to go back --- - Rate-limit resets - Couldn't load rate-limit resets. Please try again. + Usage limit resets + Couldn't load usage limit resets. Please try again. › 1. Close Press enter to confirm or esc to go back --- - Rate-limit resets + Usage limit resets Resetting your usage... › Using a reset... --- - Rate-limit resets + Usage limit resets Couldn't reset usage. Please try again. › 1. Try again @@ -44,27 +44,27 @@ expression: "states.join(\"\\n---\\n\")" Press enter to confirm or esc to go back --- - Rate-limit resets + Usage limit resets Your usage does not need a reset right now. › 1. Close Press enter to confirm or esc to go back --- - Rate-limit resets - No rate-limit resets are available. + Usage limit resets + No usage limit resets are available. › 1. Close Press enter to confirm or esc to go back --- - Rate-limit resets + Usage limit resets Usage reset. Checking your remaining resets... › Refreshing... --- - Rate-limit resets - Usage reset. You have 1 rate-limit reset left. + Usage limit resets + Usage reset. You have 1 usage limit reset left. › 1. Close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap index c3fc73a49..70aa225aa 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap @@ -5,7 +5,7 @@ expression: "render_bottom_popup(&chat, 80)" Usage View account usage or redeem an earned reset. -› 1. Show usage View recent account token usage. - 2. Redeem rate limit reset You have 2 rate-limit resets available. +› 1. Show usage View recent account token usage. + 2. Redeem usage limit reset You have 2 usage limit resets available. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap index 6bfee11de..d4dc5deff 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap @@ -5,7 +5,7 @@ expression: "render_bottom_popup(&chat, 80)" Usage View account usage or redeem an earned reset. -› 1. Show usage View recent account token usage. - 2. Redeem rate limit reset Check reset availability. +› 1. Show usage View recent account token usage. + 2. Redeem usage limit reset Check reset availability. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap index 6bfee11de..127e37f7a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap @@ -5,7 +5,7 @@ expression: "render_bottom_popup(&chat, 80)" Usage View account usage or redeem an earned reset. -› 1. Show usage View recent account token usage. - 2. Redeem rate limit reset Check reset availability. +› 1. Show usage View recent account token usage. + Redeem usage limit reset No usage limit resets available. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_limit_reset_confirmation_monthly.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_limit_reset_confirmation_monthly.snap new file mode 100644 index 000000000..08a595d4a --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_limit_reset_confirmation_monthly.snap @@ -0,0 +1,30 @@ +--- +source: tui/src/chatwidget/tests/usage.rs +expression: "states.join(\"\\n---\\n\")" +--- +Free: + Usage limit resets + You have 1 usage limit reset available. + + 1. Use a reset Reset your current monthly usage limit. +› 2. Cancel + + Press enter to confirm or esc to go back +--- +Go: + Usage limit resets + You have 1 usage limit reset available. + + 1. Use a reset Reset your current monthly usage limit. +› 2. Cancel + + Press enter to confirm or esc to go back +--- +Business with monthly window: + Usage limit resets + You have 1 usage limit reset available. + + 1. Use a reset Reset your current monthly usage limit. +› 2. Cancel + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs index d4de73831..2c58be937 100644 --- a/codex-rs/tui/src/chatwidget/status_controls.rs +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -247,11 +247,23 @@ impl ChatWidget { 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() { + pub(crate) fn finish_status_rate_limit_refresh( + &mut self, + request_id: u64, + snapshots: Vec, + ) { + if !self + .refreshing_status_outputs + .iter() + .any(|(pending_request_id, _)| *pending_request_id == request_id) + { return; } + for snapshot in snapshots { + self.on_rate_limit_snapshot(Some(snapshot)); + } + let rate_limit_snapshots: Vec = self .rate_limit_snapshots_by_limit_id .values() 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 9e528fb6c..d484c1ee5 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1032,6 +1032,39 @@ async fn rate_limit_switch_prompt_shows_once_per_session() { )); } +#[tokio::test] +async fn account_update_clears_derived_usage_limit_state_and_prompt() { + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await; + set_chatgpt_auth(&mut chat); + let mut limits = snapshot(/*percent*/ 95.0); + limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); + chat.on_rate_limit_snapshot(Some(limits)); + chat.maybe_show_pending_rate_limit_prompt(); + + assert!(chat.rate_limit_warnings.primary_index > 0); + assert!(chat.codex_rate_limit_reached_type.is_some()); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + )); + assert!(!chat.bottom_pane.no_modal_or_popup_active()); + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + + assert_eq!(chat.rate_limit_warnings.primary_index, 0); + assert_eq!(chat.rate_limit_warnings.secondary_index, 0); + assert_eq!(chat.codex_rate_limit_reached_type, None); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Idle + )); + assert!(chat.rate_limit_snapshots_by_limit_id.is_empty()); + assert!(chat.bottom_pane.no_modal_or_popup_active()); +} + #[tokio::test] async fn rate_limit_switch_prompt_respects_hidden_notice() { let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await; diff --git a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs index 953de6b89..21e02db25 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs @@ -46,8 +46,7 @@ async fn status_command_refresh_updates_cached_limits_for_future_status_outputs( other => panic!("expected rate-limit refresh request, got {other:?}"), }; - chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 92.0))); - chat.finish_status_rate_limit_refresh(first_request_id); + chat.finish_status_rate_limit_refresh(first_request_id, vec![snapshot(/*percent*/ 92.0)]); drain_insert_history(&mut rx); chat.dispatch_command(SlashCommand::Status); @@ -170,10 +169,31 @@ async fn status_command_overlapping_refreshes_update_matching_cells_only() { "expected /status to avoid transient refresh text in terminal history, got: {second_rendered}" ); - chat.finish_status_rate_limit_refresh(first_request_id); + chat.finish_status_rate_limit_refresh(first_request_id, Vec::new()); pretty_assertions::assert_eq!(chat.refreshing_status_outputs.len(), 1); - chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 92.0))); - chat.finish_status_rate_limit_refresh(second_request_id); + chat.finish_status_rate_limit_refresh(second_request_id, vec![snapshot(/*percent*/ 92.0)]); assert!(chat.refreshing_status_outputs.is_empty()); } + +#[tokio::test] +async fn account_update_rejects_stale_status_rate_limit_snapshots() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + chat.dispatch_command(SlashCommand::Status); + assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_))); + let request_id = match rx.try_recv() { + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::StatusCommand { request_id }, + }) => request_id, + other => panic!("expected status refresh request, got {other:?}"), + }; + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + chat.finish_status_rate_limit_refresh(request_id, vec![snapshot(/*percent*/ 92.0)]); + + assert!(chat.rate_limit_snapshots_by_limit_id.is_empty()); +} diff --git a/codex-rs/tui/src/chatwidget/tests/usage.rs b/codex-rs/tui/src/chatwidget/tests/usage.rs index 4e49e3561..c05b4a62f 100644 --- a/codex-rs/tui/src/chatwidget/tests/usage.rs +++ b/codex-rs/tui/src/chatwidget/tests/usage.rs @@ -13,6 +13,7 @@ async fn usage_command_opens_menu_when_reset_is_available_snapshot() { let request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); @@ -27,12 +28,13 @@ async fn usage_command_opens_menu_when_reset_is_available_snapshot() { } #[tokio::test] -async fn usage_command_can_recheck_reset_availability_after_cached_zero_snapshot() { +async fn usage_command_disables_reset_after_cached_zero_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); let request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 0 }), )); @@ -42,11 +44,109 @@ async fn usage_command_can_recheck_reset_availability_after_cached_zero_snapshot "usage_command_menu_without_resets", render_bottom_popup(&chat, /*width*/ 80) ); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id: 1 } + }) + ); chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity)); +} + +#[tokio::test] +async fn usage_menu_refresh_enables_newly_available_reset() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let request_id = chat.start_rate_limit_reset_startup_check(); + assert!(chat.finish_rate_limit_reset_hint_refresh( + request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 0 }), + )); + + chat.dispatch_command(SlashCommand::Usage); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id: 1 } + }) + ); + chat.finish_usage_menu_rate_limit_refresh( + /*request_id*/ 1, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 1 }), + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits)); } +#[tokio::test] +async fn usage_menu_refresh_failure_preserves_disabled_known_zero() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let request_id = chat.start_rate_limit_reset_startup_check(); + assert!(chat.finish_rate_limit_reset_hint_refresh( + request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 0 }), + )); + + chat.dispatch_command(SlashCommand::Usage); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id: 1 } + }) + ); + chat.finish_usage_menu_rate_limit_refresh( + /*request_id*/ 1, + Vec::new(), + Err("backend unavailable".to_string()), + ); + + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("No usage limit resets available.")); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity)); +} + +#[tokio::test] +async fn account_update_invalidates_usage_menu_refresh_when_visible_state_is_unchanged() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let startup_request_id = chat.start_rate_limit_reset_startup_check(); + assert!(chat.finish_rate_limit_reset_hint_refresh( + startup_request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 0 }), + )); + chat.dispatch_command(SlashCommand::Usage); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id: 1 } + }) + ); + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + chat.finish_usage_menu_rate_limit_refresh( + /*request_id*/ 1, + vec![snapshot(/*percent*/ 92.0)], + Ok(RateLimitResetCreditsSummary { available_count: 2 }), + ); + + assert_eq!(chat.available_rate_limit_reset_credits, None); + assert!(chat.rate_limit_snapshots_by_limit_id.is_empty()); + assert!(chat.bottom_pane.no_modal_or_popup_active()); +} + #[tokio::test] async fn usage_command_can_check_reset_availability_before_startup_refresh_finishes_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -65,7 +165,7 @@ async fn usage_command_can_check_reset_availability_before_startup_refresh_finis } #[tokio::test] -async fn usage_command_disables_reset_for_workspace_accounts() { +async fn usage_command_can_check_reset_availability_for_workspace_accounts() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); chat.plan_type = Some(PlanType::Business); @@ -74,7 +174,7 @@ async fn usage_command_disables_reset_for_workspace_accounts() { chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits)); } #[tokio::test] @@ -84,6 +184,7 @@ async fn usage_menu_rate_limit_reset_entry_opens_reset_flow() { let request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); chat.dispatch_command(SlashCommand::Usage); @@ -104,6 +205,7 @@ async fn rate_limit_reset_popup_states_snapshot() { record_popup(&chat, &mut states); assert!(chat.finish_rate_limit_reset_credits_refresh( loading_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); record_popup(&chat, &mut states); @@ -112,6 +214,7 @@ async fn rate_limit_reset_popup_states_snapshot() { let empty_request_id = chat.show_rate_limit_reset_loading_popup(); assert!(chat.finish_rate_limit_reset_credits_refresh( empty_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 0 }), )); record_popup(&chat, &mut states); @@ -120,6 +223,7 @@ async fn rate_limit_reset_popup_states_snapshot() { let load_error_request_id = chat.show_rate_limit_reset_loading_popup(); assert!(chat.finish_rate_limit_reset_credits_refresh( load_error_request_id, + Vec::new(), Err("backend unavailable".to_string()), )); record_popup(&chat, &mut states); @@ -165,6 +269,7 @@ async fn rate_limit_reset_popup_states_snapshot() { record_popup(&chat, &mut states); assert!(chat.finish_post_consume_reset_credits_refresh( success_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); record_popup(&chat, &mut states); @@ -172,12 +277,66 @@ async fn rate_limit_reset_popup_states_snapshot() { assert_chatwidget_snapshot!("rate_limit_reset_popup_states", states.join("\n---\n")); } +#[tokio::test] +async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let mut states = Vec::new(); + + chat.plan_type = Some(PlanType::Free); + let free_request_id = chat.show_rate_limit_reset_loading_popup(); + assert!(chat.finish_rate_limit_reset_credits_refresh( + free_request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 1 }), + )); + states.push(format!( + "Free:\n{}", + render_bottom_popup(&chat, /*width*/ 80) + )); + + dismiss_popup(&mut chat); + chat.plan_type = Some(PlanType::Go); + let go_request_id = chat.show_rate_limit_reset_loading_popup(); + assert!(chat.finish_rate_limit_reset_credits_refresh( + go_request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 1 }), + )); + states.push(format!("Go:\n{}", render_bottom_popup(&chat, /*width*/ 80))); + + dismiss_popup(&mut chat); + let mut monthly_business_snapshot = snapshot(/*percent*/ 50.0); + monthly_business_snapshot.plan_type = Some(PlanType::Business); + monthly_business_snapshot + .primary + .as_mut() + .expect("monthly business snapshot primary window") + .window_duration_mins = Some(30 * 24 * 60); + let business_request_id = chat.show_rate_limit_reset_loading_popup(); + assert!(chat.finish_rate_limit_reset_credits_refresh( + business_request_id, + vec![monthly_business_snapshot], + Ok(RateLimitResetCreditsSummary { available_count: 1 }), + )); + states.push(format!( + "Business with monthly window:\n{}", + render_bottom_popup(&chat, /*width*/ 80) + )); + + assert_chatwidget_snapshot!( + "usage_limit_reset_confirmation_monthly", + states.join("\n---\n") + ); +} + #[tokio::test] async fn rate_limit_reset_confirmation_selects_cancel_by_default() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let request_id = chat.show_rate_limit_reset_loading_popup(); assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); @@ -193,6 +352,7 @@ async fn rate_limit_reset_confirmation_can_use_reset() { let request_id = chat.show_rate_limit_reset_loading_popup(); assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); @@ -226,12 +386,13 @@ async fn rate_limit_reset_retry_reuses_idempotency_key() { } #[tokio::test] -async fn no_credit_outcome_allows_reset_availability_recheck() { +async fn no_credit_outcome_disables_reset_entry_in_usage_menu() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); let startup_request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( startup_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); let consume_request_id = chat.show_rate_limit_reset_consuming_popup(); @@ -244,10 +405,16 @@ async fn no_credit_outcome_allows_reset_availability_recheck() { dismiss_popup(&mut chat); chat.dispatch_command(SlashCommand::Usage); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id: 2 } + }) + ); chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity)); } #[tokio::test] @@ -270,6 +437,7 @@ async fn rate_limit_reset_redemption_cannot_be_dismissed_while_in_flight() { assert!(chat.finish_post_consume_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); dismiss_popup(&mut chat); @@ -300,11 +468,12 @@ async fn already_redeemed_is_an_idempotent_success() { )); assert!(chat.finish_post_consume_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 0 }), )); assert!( render_bottom_popup(&chat, /*width*/ 80) - .contains("Usage reset. You have 0 rate-limit resets left.") + .contains("Usage reset. You have 0 usage limit resets left.") ); } @@ -315,6 +484,7 @@ async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() { let startup_request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( startup_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); let consume_request_id = chat.show_rate_limit_reset_consuming_popup(); @@ -327,6 +497,7 @@ async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() { assert!(chat.finish_post_consume_reset_credits_refresh( consume_request_id, + Vec::new(), Err("backend unavailable".to_string()), )); dismiss_popup(&mut chat); @@ -334,7 +505,7 @@ async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() { let rendered = render_bottom_popup(&chat, /*width*/ 80); assert!(rendered.contains("Check reset availability.")); - assert!(!rendered.contains("You have 2 rate-limit resets available.")); + assert!(!rendered.contains("You have 2 usage limit resets available.")); } #[tokio::test] @@ -350,6 +521,7 @@ async fn account_change_invalidates_pending_reset_requests() { assert!(!chat.finish_rate_limit_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); assert!(chat.bottom_pane.no_modal_or_popup_active()); @@ -363,6 +535,7 @@ async fn clearing_pending_reset_hint_preserves_in_flight_redemption() { let hint_request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); @@ -385,6 +558,7 @@ async fn rate_limit_reset_load_result_updates_popup_beneath_overlay() { assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); assert_eq!( @@ -395,7 +569,7 @@ async fn rate_limit_reset_load_result_updates_popup_beneath_overlay() { chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!( render_bottom_popup(&chat, /*width*/ 80) - .contains("You have 2 rate-limit resets available.") + .contains("You have 2 usage limit resets available.") ); } @@ -413,6 +587,7 @@ async fn rate_limit_reset_success_updates_popup_beneath_overlay() { )); assert!(chat.finish_post_consume_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); assert_eq!( @@ -423,7 +598,7 @@ async fn rate_limit_reset_success_updates_popup_beneath_overlay() { chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!( render_bottom_popup(&chat, /*width*/ 80) - .contains("Usage reset. You have 1 rate-limit reset left.") + .contains("Usage reset. You have 1 usage limit reset left.") ); } @@ -455,6 +630,7 @@ async fn startup_check_shows_available_reset_hint_snapshot() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); let rendered = lines_to_single_string( @@ -477,6 +653,7 @@ async fn startup_reset_hint_waits_for_active_output_snapshot() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); @@ -507,6 +684,7 @@ async fn opening_rate_limit_reset_flow_invalidates_in_flight_startup_hint() { assert!(!chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); assert!(chat.pending_rate_limit_reset_hint().is_none()); @@ -519,6 +697,7 @@ async fn starting_rate_limit_reset_redemption_clears_deferred_startup_hint() { let hint_request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); assert!(chat.pending_rate_limit_reset_hint().is_some()); @@ -536,13 +715,14 @@ async fn startup_check_omits_reset_hint_when_none_are_available() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 0 }), )); assert!(chat.pending_rate_limit_reset_hint().is_none()); } #[tokio::test] -async fn startup_check_omits_reset_hint_for_workspace_accounts() { +async fn startup_check_shows_reset_hint_for_workspace_account_with_credit() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); chat.plan_type = Some(PlanType::Business); @@ -550,10 +730,11 @@ async fn startup_check_omits_reset_hint_for_workspace_accounts() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); - assert!(chat.pending_rate_limit_reset_hint().is_none()); - assert_eq!(chat.available_rate_limit_reset_credits, None); + assert!(chat.pending_rate_limit_reset_hint().is_some()); + assert_eq!(chat.available_rate_limit_reset_credits, Some(2)); } fn consume_response( diff --git a/codex-rs/tui/src/chatwidget/usage.rs b/codex-rs/tui/src/chatwidget/usage.rs index 4cee18af1..53925e86d 100644 --- a/codex-rs/tui/src/chatwidget/usage.rs +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -3,6 +3,7 @@ use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse; use codex_app_server_protocol::RateLimitResetCreditsSummary; use uuid::Uuid; +use super::rate_limits::get_limits_duration; use super::*; const USAGE_MENU_VIEW_ID: &str = "usage-menu"; @@ -11,8 +12,21 @@ const RATE_LIMIT_RESET_VIEW_ID: &str = "rate-limit-reset"; impl ChatWidget { pub(super) fn open_usage_menu(&mut self) { self.clear_pending_rate_limit_reset_hint(); - let reset_eligible = - self.has_chatgpt_account && !self.plan_type.is_some_and(PlanType::is_workspace_account); + let should_refresh_reset_availability = self.available_rate_limit_reset_credits == Some(0); + self.bottom_pane + .show_selection_view(self.usage_menu_params()); + if should_refresh_reset_availability { + let request_id = self.take_next_rate_limit_reset_request_id(); + self.pending_usage_menu_rate_limit_request_id = Some(request_id); + self.app_event_tx.send(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id }, + }); + } + self.request_redraw(); + } + + fn usage_menu_params(&self) -> SelectionViewParams { + let reset_eligible = self.has_chatgpt_account; let (reset_action_enabled, reset_description) = match (reset_eligible, self.available_rate_limit_reset_credits) { (true, Some(available_count)) if available_count > 0 => ( @@ -22,10 +36,12 @@ impl ChatWidget { reset_label(available_count) ), ), - (true, _) => (true, "Check reset availability.".to_string()), - (false, _) => (false, "No rate-limit resets available.".to_string()), + (true, None) => (true, "Check reset availability.".to_string()), + (true, Some(_)) | (false, _) => { + (false, "No usage limit resets available.".to_string()) + } }; - self.bottom_pane.show_selection_view(SelectionViewParams { + SelectionViewParams { view_id: Some(USAGE_MENU_VIEW_ID), title: Some("Usage".to_string()), subtitle: Some("View account usage or redeem an earned reset.".to_string()), @@ -41,7 +57,7 @@ impl ChatWidget { ..Default::default() }, SelectionItem { - name: "Redeem rate limit reset".to_string(), + name: "Redeem usage limit reset".to_string(), description: Some(reset_description), is_disabled: !reset_action_enabled, actions: vec![Box::new(|tx| { @@ -52,8 +68,32 @@ impl ChatWidget { }, ], ..Default::default() - }); - self.request_redraw(); + } + } + + pub(crate) fn finish_usage_menu_rate_limit_refresh( + &mut self, + request_id: u64, + snapshots: Vec, + result: Result, + ) { + if self.pending_usage_menu_rate_limit_request_id != Some(request_id) { + return; + } + self.pending_usage_menu_rate_limit_request_id = None; + for snapshot in snapshots { + self.on_rate_limit_snapshot(Some(snapshot)); + } + if let Ok(response) = result { + self.available_rate_limit_reset_credits = Some(response.available_count); + } + let params = self.usage_menu_params(); + if self + .bottom_pane + .replace_selection_view_if_present(USAGE_MENU_VIEW_ID, params) + { + self.request_redraw(); + } } pub(crate) fn show_rate_limit_reset_loading_popup(&mut self) -> u64 { @@ -62,7 +102,7 @@ impl ChatWidget { self.pending_rate_limit_reset_request_id = Some(request_id); self.bottom_pane.show_selection_view(SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some("Checking your available resets...".to_string()), items: vec![SelectionItem { name: "Loading...".to_string(), @@ -78,26 +118,30 @@ impl ChatWidget { pub(crate) fn finish_rate_limit_reset_credits_refresh( &mut self, request_id: u64, + snapshots: Vec, result: Result, ) -> bool { if self.pending_rate_limit_reset_request_id != Some(request_id) { return false; } self.pending_rate_limit_reset_request_id = None; + for snapshot in snapshots { + self.on_rate_limit_snapshot(Some(snapshot)); + } let params = match result { Ok(response) => { self.available_rate_limit_reset_credits = Some(response.available_count); if response.available_count > 0 { - Self::rate_limit_reset_confirmation_params(response.available_count) + self.rate_limit_reset_confirmation_params(response.available_count) } else { Self::rate_limit_reset_message_params( - "You don't have any rate-limit resets available.", + "You don't have any usage limit resets available.", ) } } Err(_) => Self::rate_limit_reset_message_params( - "Couldn't load rate-limit resets. Please try again.", + "Couldn't load usage limit resets. Please try again.", ), }; let replaced = self @@ -109,11 +153,32 @@ impl ChatWidget { replaced } - fn rate_limit_reset_confirmation_params(available_count: i64) -> SelectionViewParams { + fn rate_limit_reset_confirmation_params(&self, available_count: i64) -> SelectionViewParams { let idempotency_key = Uuid::new_v4().to_string(); + let has_monthly_window = self + .rate_limit_snapshots_by_limit_id + .iter() + .find(|(limit_id, _)| limit_id.eq_ignore_ascii_case("codex")) + .into_iter() + .flat_map(|(_, snapshot)| [snapshot.primary.as_ref(), snapshot.secondary.as_ref()]) + .flatten() + .any(|window| { + window + .window_minutes + .and_then(get_limits_duration) + .as_deref() + == Some("monthly") + }); + let reset_description = if has_monthly_window + || matches!(self.plan_type, Some(PlanType::Free | PlanType::Go)) + { + "Reset your current monthly usage limit." + } else { + "Reset your current 5-hour and weekly usage limits." + }; SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some(format!( "You have {available_count} {} available.", reset_label(available_count) @@ -122,7 +187,7 @@ impl ChatWidget { items: vec![ SelectionItem { name: "Use a reset".to_string(), - description: Some("Reset your current Codex usage windows.".to_string()), + description: Some(reset_description.to_string()), actions: vec![Box::new(move |tx| { tx.send(AppEvent::ConsumeRateLimitResetCredit { idempotency_key: idempotency_key.clone(), @@ -145,7 +210,7 @@ impl ChatWidget { fn rate_limit_reset_message_params(message: &str) -> SelectionViewParams { SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some(message.to_string()), items: vec![SelectionItem { name: "Close".to_string(), @@ -162,7 +227,7 @@ impl ChatWidget { self.pending_rate_limit_reset_request_id = Some(request_id); self.bottom_pane.show_selection_view(SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some("Resetting your usage...".to_string()), items: vec![SelectionItem { name: "Using a reset...".to_string(), @@ -206,7 +271,7 @@ impl ChatWidget { } ConsumeAccountRateLimitResetCreditOutcome::NoCredit => { self.available_rate_limit_reset_credits = Some(0); - "No rate-limit resets are available." + "No usage limit resets are available." } ConsumeAccountRateLimitResetCreditOutcome::Reset | ConsumeAccountRateLimitResetCreditOutcome::AlreadyRedeemed => unreachable!(), @@ -218,7 +283,7 @@ impl ChatWidget { self.pending_rate_limit_reset_request_id = None; self.replace_rate_limit_reset_popup(SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some("Couldn't reset usage. Please try again.".to_string()), items: vec![ SelectionItem { @@ -247,12 +312,16 @@ impl ChatWidget { pub(crate) fn finish_post_consume_reset_credits_refresh( &mut self, request_id: u64, + snapshots: Vec, result: Result, ) -> bool { if self.pending_rate_limit_reset_request_id != Some(request_id) { return false; } self.pending_rate_limit_reset_request_id = None; + for snapshot in snapshots { + self.on_rate_limit_snapshot(Some(snapshot)); + } let message = match result { Ok(response) => { @@ -272,7 +341,7 @@ impl ChatWidget { fn rate_limit_reset_success_loading_params() -> SelectionViewParams { SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some("Usage reset. Checking your remaining resets...".to_string()), items: vec![SelectionItem { name: "Refreshing...".to_string(), @@ -303,18 +372,19 @@ impl ChatWidget { pub(crate) fn finish_rate_limit_reset_hint_refresh( &mut self, request_id: u64, + snapshots: Vec, result: Result, ) -> bool { if self.pending_rate_limit_reset_hint_request_id != Some(request_id) { return false; } self.pending_rate_limit_reset_hint_request_id = None; + for snapshot in snapshots { + self.on_rate_limit_snapshot(Some(snapshot)); + } if !self.has_codex_backend_auth { return false; } - if self.plan_type.is_some_and(PlanType::is_workspace_account) { - return true; - } if let Ok(response) = result { self.available_rate_limit_reset_credits = Some(response.available_count); self.set_rate_limit_reset_available_hint(response.available_count); @@ -324,8 +394,11 @@ impl ChatWidget { pub(crate) fn clear_pending_rate_limit_reset_requests(&mut self) { self.pending_rate_limit_reset_request_id = None; + self.pending_usage_menu_rate_limit_request_id = None; self.available_rate_limit_reset_credits = None; + self.rate_limit_snapshots_by_limit_id.clear(); self.clear_pending_rate_limit_reset_hint(); + self.bottom_pane.dismiss_view_by_id(USAGE_MENU_VIEW_ID); self.bottom_pane .dismiss_view_by_id(RATE_LIMIT_RESET_VIEW_ID); } @@ -375,8 +448,8 @@ impl ChatWidget { fn reset_label(count: i64) -> &'static str { if count == 1 { - "rate-limit reset" + "usage limit reset" } else { - "rate-limit resets" + "usage limit resets" } } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 5e3e906ba..646380a2f 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -103,7 +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 => "view account usage or use a rate-limit reset", + SlashCommand::Usage => "view account usage or use a usage limit reset", 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",