diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 9162b2c868c6..6e2fd0ecbd41 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -83,7 +83,8 @@ impl App { tokio::spawn(async move { let request = fetch_account_rate_limits(request_handle); let result = match origin { - RateLimitRefreshOrigin::ResetConsume { .. } => { + RateLimitRefreshOrigin::ResetConsume { .. } + | RateLimitRefreshOrigin::ResetPicker { .. } => { tokio::time::timeout(RATE_LIMIT_RESET_REQUEST_TIMEOUT, request) .await .map_err(|_| "account/rateLimits/read timed out in TUI".to_string()) @@ -118,37 +119,23 @@ impl App { }); } - pub(super) fn refresh_rate_limit_reset_credits( - &mut self, - app_server: &AppServerSession, - request_id: u64, - ) { - let request_handle = app_server.request_handle(); - let app_event_tx = self.app_event_tx.clone(); - tokio::spawn(async move { - let result = tokio::time::timeout( - RATE_LIMIT_RESET_REQUEST_TIMEOUT, - fetch_account_rate_limits(request_handle), - ) - .await - .map_err(|_| "account/rateLimits/read timed out in TUI".to_string()) - .and_then(|result| result.map_err(|err| err.to_string())); - app_event_tx.send(AppEvent::RateLimitResetCreditsLoaded { request_id, result }); - }); - } - pub(super) fn consume_rate_limit_reset_credit( &mut self, app_server: &AppServerSession, request_id: u64, idempotency_key: String, + credit_id: Option, ) { let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { let result = tokio::time::timeout( RATE_LIMIT_RESET_REQUEST_TIMEOUT, - consume_rate_limit_reset_credit_request(request_handle, idempotency_key.clone()), + consume_rate_limit_reset_credit_request( + request_handle, + idempotency_key.clone(), + credit_id.clone(), + ), ) .await .map_err(|_| "account/rateLimitResetCredit/consume timed out in TUI".to_string()) @@ -156,6 +143,7 @@ impl App { app_event_tx.send(AppEvent::RateLimitResetCreditConsumed { request_id, idempotency_key, + credit_id, result, }); }); @@ -793,6 +781,7 @@ pub(super) async fn fetch_account_token_activity( pub(super) async fn consume_rate_limit_reset_credit_request( request_handle: AppServerRequestHandle, idempotency_key: String, + credit_id: Option, ) -> Result { let request_id = RequestId::String(format!("consume-rate-limit-reset-{}", Uuid::new_v4())); request_handle @@ -800,7 +789,7 @@ pub(super) async fn consume_rate_limit_reset_credit_request( request_id, params: ConsumeAccountRateLimitResetCreditParams { idempotency_key, - credit_id: None, + credit_id, }, }) .await diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index afe269195af4..8713537fbca0 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -865,6 +865,16 @@ impl App { } 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() + }), + ); + } + RateLimitRefreshOrigin::ResetPicker { request_id } => { + self.chat_widget.finish_rate_limit_reset_credits_refresh( request_id, snapshots, rate_limit_reset_credits.ok_or_else(|| { @@ -905,6 +915,13 @@ impl App { Err(err), ); } + RateLimitRefreshOrigin::ResetPicker { request_id } => { + self.chat_widget.finish_rate_limit_reset_credits_refresh( + request_id, + Vec::new(), + Err(err), + ); + } } } }, @@ -914,38 +931,27 @@ impl App { } AppEvent::OpenRateLimitResetCredits => { let request_id = self.chat_widget.show_rate_limit_reset_loading_popup(); - self.refresh_rate_limit_reset_credits(app_server, request_id); + self.refresh_rate_limits( + app_server, + RateLimitRefreshOrigin::ResetPicker { request_id }, + ); } - 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), - ); - } - }, - AppEvent::ConsumeRateLimitResetCredit { idempotency_key } => { + AppEvent::ConsumeRateLimitResetCredit { + idempotency_key, + credit_id, + } => { let request_id = self.chat_widget.show_rate_limit_reset_consuming_popup(); - self.consume_rate_limit_reset_credit(app_server, request_id, idempotency_key); + self.consume_rate_limit_reset_credit( + app_server, + request_id, + idempotency_key, + credit_id, + ); } AppEvent::RateLimitResetCreditConsumed { request_id, idempotency_key, + credit_id, result, } => { if let Err(err) = &result { @@ -956,6 +962,7 @@ impl App { if self.chat_widget.finish_rate_limit_reset_consume( request_id, idempotency_key, + credit_id, result, ) { self.refresh_rate_limits( diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 23e42ce8fe15..fa2c9f29fc67 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -120,7 +120,8 @@ pub(crate) struct PluginRemoteSectionError { /// invocation and must call `finish_status_rate_limit_refresh` when done so the /// 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. +/// restart. A `ResetPicker` refreshes the rate limits and detailed reset-credit +/// rows before showing redemption choices. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum RateLimitRefreshOrigin { /// Eagerly fetched after bootstrap for `/status` data and reset availability. @@ -130,6 +131,8 @@ pub(crate) enum RateLimitRefreshOrigin { StatusCommand { request_id: u64 }, /// User reopened `/usage` while the cached reset-credit count was zero. UsageMenu { request_id: u64 }, + /// User opened the reset-credit picker. + ResetPicker { request_id: u64 }, /// Refresh requested after a reset credit was successfully consumed. ResetConsume { request_id: u64 }, } @@ -323,21 +326,17 @@ pub(crate) enum AppEvent { /// Open the reset-credit flow selected from the `/usage` menu. OpenRateLimitResetCredits, - /// Result of reading the current reset-credit balance. - RateLimitResetCreditsLoaded { - request_id: u64, - result: Result, - }, - /// Consume one reset credit using a stable idempotency key. ConsumeRateLimitResetCredit { idempotency_key: String, + credit_id: Option, }, /// Result of consuming one reset credit. RateLimitResetCreditConsumed { request_id: u64, idempotency_key: String, + credit_id: Option, result: Result, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 42903d2e7fa8..fa745c652e33 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -386,6 +386,7 @@ use self::rate_limits::RateLimitWarningState; use self::rate_limits::app_server_rate_limit_error_kind; pub(crate) use self::rate_limits::fallback_limit_label; use self::rate_limits::is_app_server_cyber_policy_error; +mod reset_credits; pub(crate) use self::rate_limits::limit_label_for_window; mod reasoning_shortcuts; mod rendering; diff --git a/codex-rs/tui/src/chatwidget/reset_credits.rs b/codex-rs/tui/src/chatwidget/reset_credits.rs new file mode 100644 index 000000000000..4e58ec3518fd --- /dev/null +++ b/codex-rs/tui/src/chatwidget/reset_credits.rs @@ -0,0 +1,129 @@ +use crate::status::RateLimitSnapshotDisplay; +use chrono::DateTime; +use chrono::Local; +use chrono::Utc; +use codex_app_server_protocol::RateLimitResetCreditStatus; +use codex_app_server_protocol::RateLimitResetCreditsSummary; +use codex_app_server_protocol::RateLimitResetType; +use codex_protocol::account::PlanType; +use std::collections::BTreeMap; + +use super::rate_limits::get_limits_duration; + +pub(super) enum RateLimitResetScope { + Monthly, + WeeklyAndFiveHour, + Unknown, +} + +impl RateLimitResetScope { + pub(super) fn picker_label(&self) -> &'static str { + match self { + Self::Monthly => "Full reset (Monthly)", + Self::WeeklyAndFiveHour => "Full reset (Weekly + 5h)", + Self::Unknown => "Full reset", + } + } + + pub(super) fn usage_description(&self) -> &'static str { + match self { + Self::Monthly => "Reset your current monthly usage limit.", + Self::WeeklyAndFiveHour => "Reset your current 5-hour and weekly usage limits.", + Self::Unknown => "Reset your current usage limits.", + } + } +} + +#[derive(Debug, Eq, PartialEq)] +pub(super) struct ResetCreditOption { + pub(super) credit_id: Option, + pub(super) name: String, + pub(super) description: String, +} + +pub(super) fn rate_limit_reset_scope( + rate_limits: &BTreeMap, + plan_type: Option, +) -> RateLimitResetScope { + let window_labels = rate_limits + .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() + .filter_map(|window| window.window_minutes.and_then(get_limits_duration)) + .collect::>(); + + if window_labels.iter().any(|label| label == "monthly") + || matches!(plan_type, Some(PlanType::Free | PlanType::Go)) + { + RateLimitResetScope::Monthly + } else if window_labels + .iter() + .any(|label| label == "5h" || label == "weekly") + { + RateLimitResetScope::WeeklyAndFiveHour + } else { + RateLimitResetScope::Unknown + } +} + +pub(super) fn reset_credit_options( + summary: &RateLimitResetCreditsSummary, + scope: RateLimitResetScope, +) -> Vec { + let available_count = summary.available_count.max(0); + let detail_limit = usize::try_from(available_count).unwrap_or(usize::MAX); + let mut available_credits = summary + .credits + .as_deref() + .unwrap_or_default() + .iter() + .filter(|credit| credit.status == RateLimitResetCreditStatus::Available) + .collect::>(); + available_credits.sort_by_key(|credit| credit.expires_at.unwrap_or(i64::MAX)); + + let mut options = available_credits + .into_iter() + .take(detail_limit) + .map(|credit| { + let expiration = match credit.expires_at { + Some(expires_at) => DateTime::::from_timestamp(expires_at, 0) + .map(|expires_at| { + format!( + "Expires {}", + expires_at + .with_timezone(&Local) + .format("%H:%M on %-d %b %Y") + ) + }) + .unwrap_or_else(|| "Expiration unavailable".to_string()), + None => "Does not expire".to_string(), + }; + let reset_label = credit + .title + .as_deref() + .filter(|title| !title.trim().is_empty()) + .unwrap_or_else(|| match credit.reset_type { + RateLimitResetType::CodexRateLimits | RateLimitResetType::Unknown => { + scope.picker_label() + } + }); + ResetCreditOption { + credit_id: Some(credit.id.clone()), + name: reset_label.to_string(), + description: format!("{expiration}."), + } + }) + .collect::>(); + + if options.is_empty() { + options.push(ResetCreditOption { + credit_id: None, + name: "Use a reset".to_string(), + description: scope.usage_description().to_string(), + }); + } + + options +} diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_picker_narrow.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_picker_narrow.snap new file mode 100644 index 000000000000..d64823547ffc --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_picker_narrow.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests/usage.rs +expression: "render_bottom_popup(&chat, 44)" +--- + Usage limit resets + 2 usage limit resets available. + +› 1. Cancel + 2. Full reset Expires 09:39 on 18 Jun + 2026. + 3. Full reset Does not expire. + + Press enter to confirm or esc to go back 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 12b1568f55ad..b018313adc5a 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 @@ -10,10 +10,11 @@ expression: "states.join(\"\\n---\\n\")" Press enter to confirm or esc to go back --- Usage limit resets - You have 2 usage limit resets available. + 2 usage limit resets available. - 1. Use a reset Reset your current 5-hour and weekly usage limits. -› 2. Cancel +› 1. Cancel + 2. Full reset (Weekly + 5 hr) Expires 09:39 on 18 Jun 2026. + 3. Full reset (Weekly + 5 hr) Expires 08:59 on 27 Jun 2026. Press enter to confirm or esc to go back --- @@ -27,7 +28,8 @@ expression: "states.join(\"\\n---\\n\")" Usage limit resets Couldn't load usage limit resets. Please try again. -› 1. Close +› 1. Try again + 2. Close 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 index 08a595d4a284..9ace222f468a 100644 --- 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 @@ -4,27 +4,27 @@ expression: "states.join(\"\\n---\\n\")" --- Free: Usage limit resets - You have 1 usage limit reset available. + 1 usage limit reset available. - 1. Use a reset Reset your current monthly usage limit. -› 2. Cancel +› 1. Cancel + 2. Full reset (Monthly) Does not expire. Press enter to confirm or esc to go back --- Go: Usage limit resets - You have 1 usage limit reset available. + 1 usage limit reset available. - 1. Use a reset Reset your current monthly usage limit. -› 2. Cancel +› 1. Cancel + 2. Use a reset Reset your current monthly usage limit. Press enter to confirm or esc to go back --- Business with monthly window: Usage limit resets - You have 1 usage limit reset available. + 1 usage limit reset available. - 1. Use a reset Reset your current monthly usage limit. -› 2. Cancel +› 1. Cancel + 2. Full reset (Monthly) Does not expire. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests/usage.rs b/codex-rs/tui/src/chatwidget/tests/usage.rs index 22908b11121f..2261110b60de 100644 --- a/codex-rs/tui/src/chatwidget/tests/usage.rs +++ b/codex-rs/tui/src/chatwidget/tests/usage.rs @@ -1,11 +1,81 @@ +use super::super::reset_credits::RateLimitResetScope; +use super::super::reset_credits::ResetCreditOption; +use super::super::reset_credits::reset_credit_options; use super::*; +use chrono::TimeZone; use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditOutcome; use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse; +use codex_app_server_protocol::RateLimitResetCredit; +use codex_app_server_protocol::RateLimitResetCreditStatus; use codex_app_server_protocol::RateLimitResetCreditsSummary; +use codex_app_server_protocol::RateLimitResetType; +use pretty_assertions::assert_eq; use uuid::Uuid; const TEST_OVERLAY_VIEW_ID: &str = "usage-test-overlay"; +fn reset_credits(available_count: i64) -> RateLimitResetCreditsSummary { + RateLimitResetCreditsSummary { + available_count, + credits: None, + } +} + +fn detailed_reset_credits( + available_count: i64, + credits: Vec, +) -> RateLimitResetCreditsSummary { + RateLimitResetCreditsSummary { + available_count, + credits: Some(credits), + } +} + +fn reset_credit(id: &str, expires_at: Option) -> RateLimitResetCredit { + RateLimitResetCredit { + id: id.to_string(), + reset_type: RateLimitResetType::CodexRateLimits, + status: RateLimitResetCreditStatus::Available, + granted_at: 0, + expires_at, + title: None, + description: None, + } +} + +fn reset_credit_with_title(id: &str, expires_at: Option, title: &str) -> RateLimitResetCredit { + RateLimitResetCredit { + title: Some(title.to_string()), + ..reset_credit(id, expires_at) + } +} + +fn expiry_timestamp(day: u32, hour: u32, minute: u32) -> i64 { + chrono::Local + .with_ymd_and_hms(2026, 6, day, hour, minute, 0) + .single() + .expect("valid test timestamp") + .timestamp() +} + +#[test] +fn reset_credit_options_use_scope_label_for_unknown_reset_type() { + let mut credit = reset_credit("future-credit", /*expires_at*/ None); + credit.reset_type = RateLimitResetType::Unknown; + + assert_eq!( + reset_credit_options( + &detailed_reset_credits(/*available_count*/ 1, vec![credit]), + RateLimitResetScope::WeeklyAndFiveHour, + ), + vec![ResetCreditOption { + credit_id: Some("future-credit".to_string()), + name: "Full reset (Weekly + 5h)".to_string(), + description: "Does not expire.".to_string(), + }] + ); +} + #[tokio::test] async fn usage_command_opens_menu_when_reset_is_available_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -14,10 +84,7 @@ async fn usage_command_opens_menu_when_reset_is_available_snapshot() { assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 2, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); chat.dispatch_command(SlashCommand::Usage); @@ -38,10 +105,7 @@ async fn usage_command_disables_reset_after_cached_zero_snapshot() { assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 0, - credits: None - }), + Ok(reset_credits(/*available_count*/ 0)), )); chat.dispatch_command(SlashCommand::Usage); @@ -69,10 +133,7 @@ async fn usage_menu_refresh_enables_newly_available_reset() { assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 0, - credits: None - }), + Ok(reset_credits(/*available_count*/ 0)), )); chat.dispatch_command(SlashCommand::Usage); @@ -85,10 +146,7 @@ async fn usage_menu_refresh_enables_newly_available_reset() { chat.finish_usage_menu_rate_limit_refresh( /*request_id*/ 1, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 1, - credits: None, - }), + Ok(reset_credits(/*available_count*/ 1)), ); chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -104,10 +162,7 @@ async fn usage_menu_refresh_failure_preserves_disabled_known_zero() { assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 0, - credits: None - }), + Ok(reset_credits(/*available_count*/ 0)), )); chat.dispatch_command(SlashCommand::Usage); @@ -137,10 +192,7 @@ async fn account_update_invalidates_usage_menu_refresh_when_visible_state_is_unc assert!(chat.finish_rate_limit_reset_hint_refresh( startup_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 0, - credits: None - }), + Ok(reset_credits(/*available_count*/ 0)), )); chat.dispatch_command(SlashCommand::Usage); assert_matches!( @@ -157,10 +209,7 @@ async fn account_update_invalidates_usage_menu_refresh_when_visible_state_is_unc chat.finish_usage_menu_rate_limit_refresh( /*request_id*/ 1, vec![snapshot(/*percent*/ 92.0)], - Ok(RateLimitResetCreditsSummary { - available_count: 2, - credits: None, - }), + Ok(reset_credits(/*available_count*/ 2)), ); assert_eq!(chat.available_rate_limit_reset_credits, None); @@ -206,10 +255,7 @@ async fn usage_menu_rate_limit_reset_entry_opens_reset_flow() { assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 2, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); chat.dispatch_command(SlashCommand::Usage); @@ -227,13 +273,38 @@ async fn rate_limit_reset_popup_states_snapshot() { let loading_request_id = chat.show_rate_limit_reset_loading_popup(); record_popup(&chat, &mut states); + let first_expiry = expiry_timestamp(/*day*/ 18, /*hour*/ 9, /*minute*/ 39); + let second_expiry = expiry_timestamp(/*day*/ 27, /*hour*/ 8, /*minute*/ 59); + let mut rate_limit_snapshot = snapshot(/*percent*/ 50.0); + rate_limit_snapshot.limit_id = Some("codex".to_string()); + rate_limit_snapshot + .primary + .as_mut() + .expect("primary window") + .window_duration_mins = Some(5 * 60); + rate_limit_snapshot.secondary = Some(RateLimitWindow { + used_percent: 50, + window_duration_mins: Some(7 * 24 * 60), + resets_at: None, + }); assert!(chat.finish_rate_limit_reset_credits_refresh( loading_request_id, - Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 2, - credits: None - }), + vec![rate_limit_snapshot], + Ok(detailed_reset_credits( + /*available_count*/ 2, + vec![ + reset_credit_with_title( + "credit-2", + Some(second_expiry), + "Full reset (Weekly + 5 hr)", + ), + reset_credit_with_title( + "credit-1", + Some(first_expiry), + "Full reset (Weekly + 5 hr)", + ), + ], + )), )); record_popup(&chat, &mut states); @@ -242,10 +313,7 @@ async fn rate_limit_reset_popup_states_snapshot() { assert!(chat.finish_rate_limit_reset_credits_refresh( empty_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 0, - credits: None - }), + Ok(reset_credits(/*available_count*/ 0)), )); record_popup(&chat, &mut states); @@ -264,6 +332,7 @@ async fn rate_limit_reset_popup_states_snapshot() { assert!(!chat.finish_rate_limit_reset_consume( consuming_request_id, "redeem-1".to_string(), + /*credit_id*/ None, Err("request timed out".to_string()), )); record_popup(&chat, &mut states); @@ -300,16 +369,36 @@ async fn rate_limit_reset_popup_states_snapshot() { assert!(chat.finish_post_consume_reset_credits_refresh( success_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 1, - credits: None - }), + Ok(reset_credits(/*available_count*/ 1)), )); record_popup(&chat, &mut states); assert_chatwidget_snapshot!("rate_limit_reset_popup_states", states.join("\n---\n")); } +#[tokio::test] +async fn rate_limit_reset_picker_wraps_expiry_details_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let request_id = chat.show_rate_limit_reset_loading_popup(); + let expiry = expiry_timestamp(/*day*/ 18, /*hour*/ 9, /*minute*/ 39); + assert!(chat.finish_rate_limit_reset_credits_refresh( + request_id, + Vec::new(), + Ok(detailed_reset_credits( + /*available_count*/ 2, + vec![ + reset_credit("credit-2", /*expires_at*/ None), + reset_credit("credit-1", Some(expiry)), + ], + )), + )); + + assert_chatwidget_snapshot!( + "rate_limit_reset_picker_narrow", + render_bottom_popup(&chat, /*width*/ 44) + ); +} + #[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; @@ -321,10 +410,10 @@ async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_sna assert!(chat.finish_rate_limit_reset_credits_refresh( free_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 1, - credits: None - }), + Ok(detailed_reset_credits( + /*available_count*/ 1, + vec![reset_credit("free-credit", /*expires_at*/ None)], + )), )); states.push(format!( "Free:\n{}", @@ -337,10 +426,7 @@ async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_sna assert!(chat.finish_rate_limit_reset_credits_refresh( go_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 1, - credits: None - }), + Ok(reset_credits(/*available_count*/ 1)), )); states.push(format!("Go:\n{}", render_bottom_popup(&chat, /*width*/ 80))); @@ -356,10 +442,10 @@ async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_sna assert!(chat.finish_rate_limit_reset_credits_refresh( business_request_id, vec![monthly_business_snapshot], - Ok(RateLimitResetCreditsSummary { - available_count: 1, - credits: None - }), + Ok(detailed_reset_credits( + /*available_count*/ 1, + vec![reset_credit("business-credit", /*expires_at*/ None)], + )), )); states.push(format!( "Business with monthly window:\n{}", @@ -379,10 +465,7 @@ async fn rate_limit_reset_confirmation_selects_cancel_by_default() { assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 1, - credits: None - }), + Ok(reset_credits(/*available_count*/ 1)), )); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -391,6 +474,49 @@ async fn rate_limit_reset_confirmation_selects_cancel_by_default() { assert!(rx.try_recv().is_err()); } +#[tokio::test] +async fn rate_limit_reset_picker_starts_with_soonest_expiries_and_keeps_all_rows_reachable() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.plan_type = Some(PlanType::Business); + let request_id = chat.show_rate_limit_reset_loading_popup(); + let first_expiry = expiry_timestamp(/*day*/ 18, /*hour*/ 9, /*minute*/ 39); + let credits = (0..9) + .rev() + .map(|index| { + reset_credit( + &format!("credit-{index}"), + Some(first_expiry + i64::from(index) * 86_400), + ) + }) + .collect(); + assert!(chat.finish_rate_limit_reset_credits_refresh( + request_id, + Vec::new(), + Ok(detailed_reset_credits(/*available_count*/ 9, credits)), + )); + + let rendered = render_bottom_popup(&chat, /*width*/ 80); + assert!( + rendered.contains("Expires 09:39 on 18 Jun 2026."), + "{rendered}" + ); + assert!(!rendered.contains("Full reset ("), "{rendered}"); + + for _ in 0..9 { + 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::ConsumeRateLimitResetCredit { + idempotency_key, + credit_id, + }) if Uuid::parse_str(&idempotency_key).is_ok() + && credit_id.as_deref() == Some("credit-8") + ); +} + #[tokio::test] async fn rate_limit_reset_confirmation_can_use_reset() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -398,19 +524,18 @@ async fn rate_limit_reset_confirmation_can_use_reset() { assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 1, - credits: None - }), + Ok(reset_credits(/*available_count*/ 1)), )); - chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + 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::ConsumeRateLimitResetCredit { idempotency_key }) - if Uuid::parse_str(&idempotency_key).is_ok() + Ok(AppEvent::ConsumeRateLimitResetCredit { + idempotency_key, + credit_id, + }) if Uuid::parse_str(&idempotency_key).is_ok() && credit_id.is_none() ); } @@ -421,6 +546,7 @@ async fn rate_limit_reset_retry_reuses_idempotency_key() { assert!(!chat.finish_rate_limit_reset_consume( request_id, "stable-redeem-id".to_string(), + Some("credit-1".to_string()), Err("response lost".to_string()), )); @@ -428,8 +554,11 @@ async fn rate_limit_reset_retry_reuses_idempotency_key() { assert_matches!( rx.try_recv(), - Ok(AppEvent::ConsumeRateLimitResetCredit { idempotency_key }) - if idempotency_key == "stable-redeem-id" + Ok(AppEvent::ConsumeRateLimitResetCredit { + idempotency_key, + credit_id, + }) if idempotency_key == "stable-redeem-id" + && credit_id.as_deref() == Some("credit-1") ); } @@ -441,10 +570,7 @@ async fn no_credit_outcome_disables_reset_entry_in_usage_menu() { assert!(chat.finish_rate_limit_reset_hint_refresh( startup_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 1, - credits: None - }), + Ok(reset_credits(/*available_count*/ 1)), )); let consume_request_id = chat.show_rate_limit_reset_consuming_popup(); assert!(!finish_reset_consume_outcome( @@ -466,6 +592,23 @@ async fn no_credit_outcome_disables_reset_entry_in_usage_menu() { chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity)); + + chat.available_rate_limit_reset_credits = Some(2); + let consume_request_id = chat.show_rate_limit_reset_consuming_popup(); + assert!(!chat.finish_rate_limit_reset_consume( + consume_request_id, + "redeem-selected".to_string(), + Some("stale-credit".to_string()), + Ok(consume_response( + ConsumeAccountRateLimitResetCreditOutcome::NoCredit + )), + )); + assert_eq!(chat.available_rate_limit_reset_credits, None); + assert!( + render_bottom_popup(&chat, /*width*/ 80).contains("That reset is no longer available.") + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits)); } #[tokio::test] @@ -489,10 +632,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, - credits: None - }), + Ok(reset_credits(/*available_count*/ 1)), )); dismiss_popup(&mut chat); assert!(chat.bottom_pane.no_modal_or_popup_active()); @@ -523,10 +663,7 @@ 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, - credits: None - }), + Ok(reset_credits(/*available_count*/ 0)), )); assert!( render_bottom_popup(&chat, /*width*/ 80) @@ -542,10 +679,7 @@ async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() { assert!(chat.finish_rate_limit_reset_hint_refresh( startup_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 2, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); let consume_request_id = chat.show_rate_limit_reset_consuming_popup(); assert!(finish_reset_consume_outcome( @@ -582,10 +716,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, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); assert!(chat.bottom_pane.no_modal_or_popup_active()); } @@ -599,10 +730,7 @@ async fn clearing_pending_reset_hint_preserves_in_flight_redemption() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 2, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); chat.clear_pending_rate_limit_reset_hint(); @@ -625,10 +753,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, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); assert_eq!( chat.bottom_pane.active_view_id(), @@ -636,10 +761,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 usage limit resets available.") - ); + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("2 usage limit resets available.")); } #[tokio::test] @@ -657,10 +779,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, - credits: None - }), + Ok(reset_credits(/*available_count*/ 1)), )); assert_eq!( chat.bottom_pane.active_view_id(), @@ -703,10 +822,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, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); let rendered = lines_to_single_string( &chat @@ -729,10 +845,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, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); assert!(chat.usage_history_insertion_blocked()); @@ -763,10 +876,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, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); assert!(chat.pending_rate_limit_reset_hint().is_none()); } @@ -779,10 +889,7 @@ async fn starting_rate_limit_reset_redemption_clears_deferred_startup_hint() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 2, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); assert!(chat.pending_rate_limit_reset_hint().is_some()); @@ -800,10 +907,7 @@ 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, - credits: None - }), + Ok(reset_credits(/*available_count*/ 0)), )); assert!(chat.pending_rate_limit_reset_hint().is_none()); } @@ -818,10 +922,7 @@ async fn startup_check_shows_reset_hint_for_workspace_account_with_credit() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 2, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); assert!(chat.pending_rate_limit_reset_hint().is_some()); assert_eq!(chat.available_rate_limit_reset_credits, Some(2)); @@ -842,6 +943,7 @@ fn finish_reset_consume_outcome( chat.finish_rate_limit_reset_consume( request_id, idempotency_key.to_string(), + /*credit_id*/ None, Ok(consume_response(outcome)), ) } diff --git a/codex-rs/tui/src/chatwidget/usage.rs b/codex-rs/tui/src/chatwidget/usage.rs index 53925e86d90a..731c0a994a2b 100644 --- a/codex-rs/tui/src/chatwidget/usage.rs +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -3,7 +3,8 @@ use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse; use codex_app_server_protocol::RateLimitResetCreditsSummary; use uuid::Uuid; -use super::rate_limits::get_limits_duration; +use super::reset_credits::rate_limit_reset_scope; +use super::reset_credits::reset_credit_options; use super::*; const USAGE_MENU_VIEW_ID: &str = "usage-menu"; @@ -131,18 +132,20 @@ impl ChatWidget { 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) + let available_count = response.available_count; + let params = if available_count > 0 { + self.rate_limit_reset_confirmation_params(&response) } else { Self::rate_limit_reset_message_params( "You don't have any usage limit resets available.", ) - } + }; + self.available_rate_limit_reset_credits = Some(available_count); + params + } + Err(_) => { + Self::reset_refresh_params("Couldn't load usage limit resets. Please try again.") } - Err(_) => Self::rate_limit_reset_message_params( - "Couldn't load usage limit resets. Please try again.", - ), }; let replaced = self .bottom_pane @@ -153,56 +156,47 @@ impl ChatWidget { replaced } - 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." - }; + fn rate_limit_reset_confirmation_params( + &self, + reset_credits: &RateLimitResetCreditsSummary, + ) -> SelectionViewParams { + let scope = rate_limit_reset_scope(&self.rate_limit_snapshots_by_limit_id, self.plan_type); + let mut items = vec![SelectionItem { + name: "Cancel".to_string(), + dismiss_on_select: true, + ..Default::default() + }]; + items.extend( + reset_credit_options(reset_credits, scope) + .into_iter() + .map(|option| { + let idempotency_key = Uuid::new_v4().to_string(); + let credit_id = option.credit_id; + SelectionItem { + name: option.name, + description: Some(option.description), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ConsumeRateLimitResetCredit { + idempotency_key: idempotency_key.clone(), + credit_id: credit_id.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + } + }), + ); SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), title: Some("Usage limit resets".to_string()), subtitle: Some(format!( - "You have {available_count} {} available.", - reset_label(available_count) + "{} {} available.", + reset_credits.available_count, + reset_label(reset_credits.available_count) )), footer_hint: Some(standard_popup_hint_line()), - items: vec![ - SelectionItem { - name: "Use a reset".to_string(), - description: Some(reset_description.to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::ConsumeRateLimitResetCredit { - idempotency_key: idempotency_key.clone(), - }); - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Cancel".to_string(), - dismiss_on_select: true, - ..Default::default() - }, - ], - initial_selected_idx: Some(1), + items, + initial_selected_idx: Some(0), ..Default::default() } } @@ -221,6 +215,30 @@ impl ChatWidget { } } + fn reset_refresh_params(message: &str) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(RATE_LIMIT_RESET_VIEW_ID), + title: Some("Usage limit resets".to_string()), + subtitle: Some(message.to_string()), + items: vec![ + SelectionItem { + name: "Try again".to_string(), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenRateLimitResetCredits); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Close".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + ], + ..Default::default() + } + } + pub(crate) fn show_rate_limit_reset_consuming_popup(&mut self) -> u64 { self.clear_pending_rate_limit_reset_hint(); let request_id = self.take_next_rate_limit_reset_request_id(); @@ -245,6 +263,7 @@ impl ChatWidget { &mut self, request_id: u64, idempotency_key: String, + credit_id: Option, result: Result, ) -> bool { if self.pending_rate_limit_reset_request_id != Some(request_id) { @@ -269,6 +288,13 @@ impl ChatWidget { ConsumeAccountRateLimitResetCreditOutcome::NothingToReset => { "Your usage does not need a reset right now." } + ConsumeAccountRateLimitResetCreditOutcome::NoCredit if credit_id.is_some() => { + self.available_rate_limit_reset_credits = None; + self.replace_rate_limit_reset_popup(Self::reset_refresh_params( + "That reset is no longer available. Refresh to see your current resets.", + )); + return false; + } ConsumeAccountRateLimitResetCreditOutcome::NoCredit => { self.available_rate_limit_reset_credits = Some(0); "No usage limit resets are available." @@ -291,6 +317,7 @@ impl ChatWidget { actions: vec![Box::new(move |tx| { tx.send(AppEvent::ConsumeRateLimitResetCredit { idempotency_key: idempotency_key.clone(), + credit_id: credit_id.clone(), }); })], dismiss_on_select: true, @@ -325,11 +352,11 @@ impl ChatWidget { let message = match result { Ok(response) => { - self.available_rate_limit_reset_credits = Some(response.available_count); + let available_count = response.available_count; + self.available_rate_limit_reset_credits = Some(available_count); format!( - "Usage reset. You have {} {} left.", - response.available_count, - reset_label(response.available_count) + "Usage reset. You have {available_count} {} left.", + reset_label(available_count) ) } Err(_) => "Usage reset.".to_string(), @@ -386,8 +413,9 @@ impl ChatWidget { return false; } 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); + let available_count = response.available_count; + self.available_rate_limit_reset_credits = Some(available_count); + self.set_rate_limit_reset_available_hint(available_count); } true }