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..3d1194f06176 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,48 @@ 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( + AppEvent::OpenRateLimitResetConfirmation { + picker_request_id, + confirmation_gate, + credit_id, + reset_title, + reset_detail, + reset_description, + } => { + self.chat_widget.show_rate_limit_reset_confirmation( + picker_request_id, + confirmation_gate, + credit_id, + reset_title, + reset_detail, + reset_description, + ); + } + AppEvent::ConsumeRateLimitResetCredit { + idempotency_key, + credit_id, + } => { + if let Some(request_id) = self + .chat_widget + .start_rate_limit_reset_consumption(&idempotency_key) + { + self.consume_rate_limit_reset_credit( + app_server, request_id, - Vec::new(), - Err(err), + idempotency_key, + credit_id, ); } - }, - 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); } AppEvent::RateLimitResetCreditConsumed { request_id, idempotency_key, + credit_id, result, } => { if let Err(err) = &result { @@ -956,6 +983,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..2c84d5868f65 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -9,6 +9,8 @@ //! quits without reaching into the app loop or coupling to shutdown/exit sequencing. use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::AddCreditsNudgeEmailStatus; @@ -120,7 +122,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 +133,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 +328,27 @@ 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, + /// Confirm the reset credit selected from the reset-credit picker. + OpenRateLimitResetConfirmation { + picker_request_id: u64, + confirmation_gate: Arc, + credit_id: Option, + reset_title: String, + reset_detail: Option, + reset_description: String, }, /// 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..7a5b01aa9f58 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; @@ -557,6 +558,8 @@ pub(crate) struct ChatWidget { completed_token_activity_output: Option, next_token_activity_request_id: u64, pending_rate_limit_reset_request_id: Option, + pending_rate_limit_reset_idempotency_key: Option, + rate_limit_reset_picker_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, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 7fb6fe54e9a6..49265e03a14e 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -130,6 +130,8 @@ impl ChatWidget { completed_token_activity_output: None, next_token_activity_request_id: 0, pending_rate_limit_reset_request_id: None, + pending_rate_limit_reset_idempotency_key: None, + rate_limit_reset_picker_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, 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..66087727c421 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/reset_credits.rs @@ -0,0 +1,77 @@ +use chrono::DateTime; +use chrono::Local; +use chrono::Utc; +use codex_app_server_protocol::RateLimitResetCreditStatus; +use codex_app_server_protocol::RateLimitResetCreditsSummary; + +#[derive(Debug, Eq, PartialEq)] +pub(super) struct ResetCreditOption { + pub(super) credit_id: Option, + pub(super) name: String, + pub(super) detail: Option, + pub(super) description: String, +} + +pub(super) fn reset_credit_options( + summary: &RateLimitResetCreditsSummary, +) -> 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_title = credit + .title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()) + .unwrap_or("Full reset"); + let reset_description = credit + .description + .as_deref() + .map(str::trim) + .filter(|description| !description.is_empty()) + .unwrap_or("Reset your current usage limits."); + ResetCreditOption { + credit_id: Some(credit.id.clone()), + name: reset_title.to_string(), + detail: Some(format!("{expiration}.")), + description: reset_description.to_string(), + } + }) + .collect::>(); + + if options.is_empty() { + options.push(ResetCreditOption { + credit_id: None, + name: "Full reset".to_string(), + detail: None, + description: "Reset your current usage limits.".to_string(), + }); + } + + options +} diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_confirmation.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_confirmation.snap new file mode 100644 index 000000000000..a5b303273faa --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_confirmation.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/usage.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Use this reset? + Full reset (Weekly + 5 hr) · Expires 09:39 on 18 Jun 2026. + + 1. Yes, use reset Reset your weekly and 5-hour usage limits. +› 2. No, go back Choose a different reset. + + 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_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 deleted file mode 100644 index 08a595d4a284..000000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_limit_reset_confirmation_monthly.snap +++ /dev/null @@ -1,30 +0,0 @@ ---- -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/tests/usage.rs b/codex-rs/tui/src/chatwidget/tests/usage.rs index 22908b11121f..cdbbcca4add7 100644 --- a/codex-rs/tui/src/chatwidget/tests/usage.rs +++ b/codex-rs/tui/src/chatwidget/tests/usage.rs @@ -1,11 +1,107 @@ +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() +} + +fn show_rate_limit_reset_confirmation_from_event( + chat: &mut ChatWidget, + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> u64 { + let Ok(AppEvent::OpenRateLimitResetConfirmation { + picker_request_id, + confirmation_gate, + credit_id, + reset_title, + reset_detail, + reset_description, + }) = rx.try_recv() + else { + panic!("expected reset confirmation event"); + }; + assert!(chat.show_rate_limit_reset_confirmation( + picker_request_id, + confirmation_gate, + credit_id, + reset_title, + reset_detail, + reset_description, + )); + picker_request_id +} + +#[test] +fn reset_credit_options_use_generic_copy_when_backend_copy_is_missing() { + 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], + )), + vec![ResetCreditOption { + credit_id: Some("future-credit".to_string()), + name: "Full reset".to_string(), + detail: Some("Does not expire.".to_string()), + description: "Reset your current usage limits.".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 +110,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 +131,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 +159,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 +172,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 +188,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 +218,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 +235,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 +281,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 +299,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 +339,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 +358,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,10 +395,7 @@ 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); @@ -311,107 +403,267 @@ async fn rate_limit_reset_popup_states_snapshot() { } #[tokio::test] -async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_snapshot() { +async fn rate_limit_reset_picker_wraps_expiry_details_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(); + 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( - free_request_id, + request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 1, - credits: None - }), - )); - states.push(format!( - "Free:\n{}", - render_bottom_popup(&chat, /*width*/ 80) + Ok(detailed_reset_credits( + /*available_count*/ 2, + vec![ + reset_credit("credit-2", /*expires_at*/ None), + reset_credit("credit-1", Some(expiry)), + ], + )), )); - dismiss_popup(&mut chat); - chat.plan_type = Some(PlanType::Go); - let go_request_id = chat.show_rate_limit_reset_loading_popup(); + assert_chatwidget_snapshot!( + "rate_limit_reset_picker_narrow", + render_bottom_popup(&chat, /*width*/ 44) + ); +} + +#[tokio::test] +async fn rate_limit_reset_confirmation_uses_backend_copy_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let expiry = expiry_timestamp(/*day*/ 18, /*hour*/ 9, /*minute*/ 39); + let mut credit = + reset_credit_with_title("weekly-credit", Some(expiry), "Full reset (Weekly + 5 hr)"); + credit.description = Some("Reset your weekly and 5-hour usage limits.".to_string()); + let request_id = chat.show_rate_limit_reset_loading_popup(); assert!(chat.finish_rate_limit_reset_credits_refresh( - go_request_id, + request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 1, - credits: None - }), + Ok(detailed_reset_credits( + /*available_count*/ 1, + vec![credit], + )), )); - states.push(format!("Go:\n{}", render_bottom_popup(&chat, /*width*/ 80))); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - 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, - credits: None - }), - )); - states.push(format!( - "Business with monthly window:\n{}", - render_bottom_popup(&chat, /*width*/ 80) + let Ok(AppEvent::OpenRateLimitResetConfirmation { + picker_request_id, + confirmation_gate, + credit_id, + reset_title, + reset_detail, + reset_description, + }) = rx.try_recv() + else { + panic!("expected reset confirmation event"); + }; + assert_eq!( + ( + picker_request_id, + credit_id.as_deref(), + reset_title.as_str(), + reset_detail.as_deref(), + reset_description.as_str(), + ), + ( + request_id, + Some("weekly-credit"), + "Full reset (Weekly + 5 hr)", + Some("Expires 09:39 on 18 Jun 2026."), + "Reset your weekly and 5-hour usage limits.", + ) + ); + assert!(chat.show_rate_limit_reset_confirmation( + picker_request_id, + confirmation_gate, + credit_id, + reset_title, + reset_detail, + reset_description, )); assert_chatwidget_snapshot!( - "usage_limit_reset_confirmation_monthly", - states.join("\n---\n") + "rate_limit_reset_confirmation", + render_bottom_popup(&chat, /*width*/ 80) ); } #[tokio::test] -async fn rate_limit_reset_confirmation_selects_cancel_by_default() { +async fn rate_limit_reset_confirmation_no_and_escape_return_to_picker() { 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, - credits: None - }), + Ok(reset_credits(/*available_count*/ 1)), )); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + let picker = render_bottom_popup(&chat, /*width*/ 80); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + show_rate_limit_reset_confirmation_from_event(&mut chat, &mut rx); + assert!(rx.try_recv().is_err()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(render_bottom_popup(&chat, /*width*/ 80), picker); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + show_rate_limit_reset_confirmation_from_event(&mut chat, &mut rx); + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(render_bottom_popup(&chat, /*width*/ 80), picker); - assert!(chat.bottom_pane.no_modal_or_popup_active()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + show_rate_limit_reset_confirmation_from_event(&mut chat, &mut rx); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(render_bottom_popup(&chat, /*width*/ 80), picker); assert!(rx.try_recv().is_err()); } #[tokio::test] -async fn rate_limit_reset_confirmation_can_use_reset() { +async fn rejected_reset_confirmation_rearms_its_picker_row() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let request_id = chat.show_rate_limit_reset_loading_popup(); + let first_expiry = expiry_timestamp(/*day*/ 18, /*hour*/ 9, /*minute*/ 39); assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 1, - credits: None - }), + Ok(detailed_reset_credits( + /*available_count*/ 2, + vec![ + reset_credit_with_title("credit-1", Some(first_expiry), "First reset"), + reset_credit_with_title("credit-2", /*expires_at*/ None, "Second reset"), + ], + )), )); - 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)); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + show_rate_limit_reset_confirmation_from_event(&mut chat, &mut rx); + let Ok(AppEvent::OpenRateLimitResetConfirmation { + picker_request_id, + confirmation_gate, + credit_id, + reset_title, + reset_detail, + reset_description, + }) = rx.try_recv() + else { + panic!("expected second reset confirmation event"); + }; + assert_eq!(reset_title, "Second reset"); + assert!(!chat.show_rate_limit_reset_confirmation( + picker_request_id, + confirmation_gate, + credit_id, + reset_title, + reset_detail, + reset_description, + )); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + show_rate_limit_reset_confirmation_from_event(&mut chat, &mut rx); + + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Second reset · Does not expire.")); +} + +#[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; + 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 }) - if Uuid::parse_str(&idempotency_key).is_ok() + Ok(AppEvent::OpenRateLimitResetConfirmation { + picker_request_id, + confirmation_gate: _, + credit_id, + reset_title, + reset_detail, + reset_description, + }) if picker_request_id == request_id + && credit_id.as_deref() == Some("credit-8") + && reset_title == "Full reset" + && reset_detail.as_deref() == Some("Expires 09:39 on 26 Jun 2026.") + && reset_description == "Reset your current usage limits." + ); +} + +#[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; + let request_id = chat.show_rate_limit_reset_loading_popup(); + assert!(chat.finish_rate_limit_reset_credits_refresh( + request_id, + Vec::new(), + Ok(detailed_reset_credits( + /*available_count*/ 1, + vec![reset_credit("credit-1", /*expires_at*/ None)], + )), + )); + + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + show_rate_limit_reset_confirmation_from_event(&mut chat, &mut rx); + assert!(rx.try_recv().is_err()); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let Ok(AppEvent::ConsumeRateLimitResetCredit { + idempotency_key, + credit_id, + }) = rx.try_recv() + else { + panic!("expected reset consume event"); + }; + assert!(Uuid::parse_str(&idempotency_key).is_ok()); + assert_eq!(credit_id.as_deref(), Some("credit-1")); + + assert_eq!(chat.start_rate_limit_reset_consumption("wrong-key"), None); + let consume_request_id = chat + .start_rate_limit_reset_consumption(&idempotency_key) + .expect("confirmed reset should start consumption"); + assert_eq!( + chat.start_rate_limit_reset_consumption(&idempotency_key), + None ); + assert!(!chat.finish_rate_limit_reset_consume( + consume_request_id, + idempotency_key, + credit_id, + Ok(consume_response( + ConsumeAccountRateLimitResetCreditOutcome::NothingToReset, + )), + )); + dismiss_popup(&mut chat); + assert!(chat.bottom_pane.no_modal_or_popup_active()); } #[tokio::test] @@ -421,15 +673,27 @@ 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()), )); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_matches!( - rx.try_recv(), - Ok(AppEvent::ConsumeRateLimitResetCredit { idempotency_key }) - if idempotency_key == "stable-redeem-id" + let Ok(AppEvent::ConsumeRateLimitResetCredit { + idempotency_key, + credit_id, + }) = rx.try_recv() + else { + panic!("expected reset retry event"); + }; + assert_eq!( + (idempotency_key.as_str(), credit_id.as_deref()), + ("stable-redeem-id", Some("credit-1")) + ); + assert_eq!(chat.start_rate_limit_reset_consumption("wrong-key"), None); + assert!( + chat.start_rate_limit_reset_consumption(&idempotency_key) + .is_some() ); } @@ -441,10 +705,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 +727,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 +767,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 +798,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 +814,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 +851,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 +865,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 +888,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 +896,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 +914,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(), @@ -676,15 +930,49 @@ async fn rate_limit_reset_success_updates_popup_beneath_overlay() { #[tokio::test] async fn account_change_dismisses_reset_popup_beneath_overlay() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); - chat.show_rate_limit_reset_loading_popup(); + let request_id = chat.show_rate_limit_reset_loading_popup(); + assert!(chat.finish_rate_limit_reset_credits_refresh( + request_id, + Vec::new(), + Ok(reset_credits(/*available_count*/ 1)), + )); + assert!(chat.show_rate_limit_reset_confirmation( + request_id, + Arc::new(AtomicBool::new(true)), + /*credit_id*/ None, + "Full reset".to_string(), + /*reset_detail*/ None, + "Reset your current usage limits.".to_string(), + )); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let Ok(AppEvent::ConsumeRateLimitResetCredit { + idempotency_key, + credit_id: None, + }) = rx.try_recv() + else { + panic!("expected reset consume event"); + }; show_usage_test_overlay(&mut chat); chat.update_account_state( /*status_account_display*/ None, /*plan_type*/ None, /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false, ); + assert!(!chat.show_rate_limit_reset_confirmation( + request_id, + Arc::new(AtomicBool::new(true)), + /*credit_id*/ None, + "Stale reset".to_string(), + /*reset_detail*/ None, + "This stale confirmation should be ignored.".to_string(), + )); + assert_eq!( + chat.start_rate_limit_reset_consumption(&idempotency_key), + None + ); assert_eq!( chat.bottom_pane.active_view_id(), Some(TEST_OVERLAY_VIEW_ID) @@ -703,10 +991,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 +1014,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 +1045,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 +1058,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 +1076,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 +1091,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 +1112,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..7f8b7a80740b 100644 --- a/codex-rs/tui/src/chatwidget/usage.rs +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -1,13 +1,17 @@ use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditOutcome; use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse; use codex_app_server_protocol::RateLimitResetCreditsSummary; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use uuid::Uuid; -use super::rate_limits::get_limits_duration; +use super::reset_credits::reset_credit_options; use super::*; const USAGE_MENU_VIEW_ID: &str = "usage-menu"; const RATE_LIMIT_RESET_VIEW_ID: &str = "rate-limit-reset"; +const RATE_LIMIT_RESET_CONFIRMATION_VIEW_ID: &str = "rate-limit-reset-confirmation"; impl ChatWidget { pub(super) fn open_usage_menu(&mut self) { @@ -98,6 +102,8 @@ impl ChatWidget { pub(crate) fn show_rate_limit_reset_loading_popup(&mut self) -> u64 { self.clear_pending_rate_limit_reset_hint(); + self.pending_rate_limit_reset_idempotency_key = None; + self.rate_limit_reset_picker_request_id = None; let request_id = self.take_next_rate_limit_reset_request_id(); self.pending_rate_limit_reset_request_id = Some(request_id); self.bottom_pane.show_selection_view(SelectionViewParams { @@ -129,82 +135,161 @@ impl ChatWidget { self.on_rate_limit_snapshot(Some(snapshot)); } + let mut shows_picker = false; 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 { + shows_picker = true; + self.rate_limit_reset_picker_params(request_id, &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 .replace_selection_view_if_present(RATE_LIMIT_RESET_VIEW_ID, params); + self.rate_limit_reset_picker_request_id = (replaced && shows_picker).then_some(request_id); if replaced { self.request_redraw(); } 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_picker_params( + &self, + picker_request_id: u64, + reset_credits: &RateLimitResetCreditsSummary, + ) -> SelectionViewParams { + let mut items = vec![SelectionItem { + name: "Cancel".to_string(), + dismiss_on_select: true, + ..Default::default() + }]; + items.extend( + reset_credit_options(reset_credits) + .into_iter() + .map(|option| { + let confirmation_gate = Arc::new(AtomicBool::new(true)); + let credit_id = option.credit_id; + let reset_title = option.name.clone(); + let reset_detail = option.detail; + let reset_description = option.description; + let picker_description = reset_detail + .clone() + .unwrap_or_else(|| reset_description.clone()); + SelectionItem { + name: option.name, + description: Some(picker_description), + actions: vec![Box::new(move |tx| { + if confirmation_gate.swap(false, Ordering::AcqRel) { + tx.send(AppEvent::OpenRateLimitResetConfirmation { + picker_request_id, + confirmation_gate: confirmation_gate.clone(), + credit_id: credit_id.clone(), + reset_title: reset_title.clone(), + reset_detail: reset_detail.clone(), + reset_description: reset_description.clone(), + }); + } + })], + ..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, + initial_selected_idx: Some(0), + ..Default::default() + } + } + + pub(crate) fn show_rate_limit_reset_confirmation( + &mut self, + picker_request_id: u64, + confirmation_gate: Arc, + credit_id: Option, + reset_title: String, + reset_detail: Option, + reset_description: String, + ) -> bool { + if self.rate_limit_reset_picker_request_id != Some(picker_request_id) + || self + .bottom_pane + .selected_index_for_active_view(RATE_LIMIT_RESET_VIEW_ID) + .is_none() + { + confirmation_gate.store(true, Ordering::Release); + return false; + } + let idempotency_key = Uuid::new_v4().to_string(); + self.pending_rate_limit_reset_idempotency_key = Some(idempotency_key.clone()); + let no_confirmation_gate = confirmation_gate.clone(); + let subtitle = reset_detail.map_or_else( + || reset_title.clone(), + |reset_detail| format!("{reset_title} · {reset_detail}"), + ); + self.bottom_pane.show_selection_view(SelectionViewParams { + view_id: Some(RATE_LIMIT_RESET_CONFIRMATION_VIEW_ID), + title: Some("Use this reset?".to_string()), + subtitle: Some(subtitle), + footer_hint: Some(standard_popup_hint_line()), items: vec![ SelectionItem { - name: "Use a reset".to_string(), - description: Some(reset_description.to_string()), + name: "Yes, use reset".to_string(), + description: Some(reset_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() }, SelectionItem { - name: "Cancel".to_string(), + name: "No, go back".to_string(), + description: Some("Choose a different reset.".to_string()), + actions: vec![Box::new(move |_| { + no_confirmation_gate.store(true, Ordering::Release); + })], dismiss_on_select: true, ..Default::default() }, ], initial_selected_idx: Some(1), + on_cancel: Some(Box::new(move |_| { + confirmation_gate.store(true, Ordering::Release); + })), ..Default::default() + }); + true + } + + pub(crate) fn start_rate_limit_reset_consumption( + &mut self, + idempotency_key: &str, + ) -> Option { + if self.pending_rate_limit_reset_idempotency_key.as_deref() != Some(idempotency_key) { + return None; } + Some(self.show_rate_limit_reset_consuming_popup()) } fn rate_limit_reset_message_params(message: &str) -> SelectionViewParams { @@ -221,10 +306,40 @@ 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(); + self.pending_rate_limit_reset_idempotency_key = None; + self.rate_limit_reset_picker_request_id = None; let request_id = self.take_next_rate_limit_reset_request_id(); self.pending_rate_limit_reset_request_id = Some(request_id); + self.bottom_pane + .dismiss_view_by_id(RATE_LIMIT_RESET_CONFIRMATION_VIEW_ID); + self.bottom_pane + .dismiss_view_by_id(RATE_LIMIT_RESET_VIEW_ID); self.bottom_pane.show_selection_view(SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), title: Some("Usage limit resets".to_string()), @@ -245,6 +360,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 +385,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." @@ -281,6 +404,7 @@ impl ChatWidget { } Err(_) => { self.pending_rate_limit_reset_request_id = None; + self.pending_rate_limit_reset_idempotency_key = Some(idempotency_key.clone()); self.replace_rate_limit_reset_popup(SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), title: Some("Usage limit resets".to_string()), @@ -291,6 +415,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 +450,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,14 +511,17 @@ 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 } pub(crate) fn clear_pending_rate_limit_reset_requests(&mut self) { self.pending_rate_limit_reset_request_id = None; + self.pending_rate_limit_reset_idempotency_key = None; + self.rate_limit_reset_picker_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(); @@ -401,6 +529,8 @@ impl ChatWidget { self.bottom_pane.dismiss_view_by_id(USAGE_MENU_VIEW_ID); self.bottom_pane .dismiss_view_by_id(RATE_LIMIT_RESET_VIEW_ID); + self.bottom_pane + .dismiss_view_by_id(RATE_LIMIT_RESET_CONFIRMATION_VIEW_ID); } pub(crate) fn clear_pending_rate_limit_reset_hint(&mut self) {