diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 3356b66c14ba..d0263bd1b90a 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -633,6 +633,7 @@ "anstyle_1.0.13": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.5\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "anstyle_1.0.14": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.23\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "anyhow_1.0.102": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.6\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"backtrace\":[],\"default\":[\"std\"],\"std\":[]}}", + "anyhow_1.0.103": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.6\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"backtrace\":[],\"default\":[\"std\"],\"std\":[]}}", "arbitrary_1.4.2": "{\"dependencies\":[{\"name\":\"derive_arbitrary\",\"optional\":true,\"req\":\"~1.4.0\"},{\"kind\":\"dev\",\"name\":\"exhaustigen\",\"req\":\"^0.1.0\"}],\"features\":{\"derive\":[\"derive_arbitrary\"]}}", "arboard_3.6.1": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"clipboard-win\",\"req\":\"^5.3.1\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.2\"},{\"default_features\":false,\"features\":[\"png\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"default_features\":false,\"features\":[\"tiff\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"png\",\"bmp\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(windows)\"},{\"name\":\"log\",\"req\":\"^0.4\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"log\",\"req\":\"^0.4\",\"target\":\"cfg(windows)\"},{\"name\":\"objc2\",\"req\":\"^0.6.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"objc2-core-graphics\",\"NSPasteboard\",\"NSPasteboardItem\",\"NSImage\"],\"name\":\"objc2-app-kit\",\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"CFCGTypes\"],\"name\":\"objc2-core-foundation\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"CGImage\",\"CGColorSpace\",\"CGDataProvider\"],\"name\":\"objc2-core-graphics\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"NSArray\",\"NSString\",\"NSEnumerator\",\"NSGeometry\",\"NSValue\"],\"name\":\"objc2-foundation\",\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.1\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"features\":[\"Win32_Foundation\",\"Win32_Storage_FileSystem\",\"Win32_System_DataExchange\",\"Win32_System_Memory\",\"Win32_System_Ole\",\"Win32_UI_Shell\"],\"name\":\"windows-sys\",\"req\":\">=0.52.0, <0.61.0\",\"target\":\"cfg(windows)\"},{\"name\":\"wl-clipboard-rs\",\"optional\":true,\"req\":\"^0.9.0\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"x11rb\",\"req\":\"^0.13\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"}],\"features\":{\"core-graphics\":[\"dep:objc2-core-graphics\"],\"default\":[\"image-data\"],\"image\":[\"dep:image\"],\"image-data\":[\"dep:objc2-core-graphics\",\"dep:objc2-core-foundation\",\"image\",\"windows-sys\",\"core-graphics\"],\"wayland-data-control\":[\"wl-clipboard-rs\"],\"windows-sys\":[\"windows-sys/Win32_Graphics_Gdi\"],\"wl-clipboard-rs\":[\"dep:wl-clipboard-rs\"]}}", "arc-swap_1.9.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adaptive-barrier\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"~0.7\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"~0.8\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"~0.12\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.177\"}],\"features\":{\"experimental-strategies\":[],\"experimental-thread-local\":[],\"internal-test-strategies\":[],\"weak\":[]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b80e8bc9d0c0..76542f6f4994 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -388,9 +388,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "app_test_support" diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 8713537fbca0..3d1194f06176 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -936,18 +936,39 @@ impl App { RateLimitRefreshOrigin::ResetPicker { request_id }, ); } - AppEvent::ConsumeRateLimitResetCredit { - idempotency_key, + AppEvent::OpenRateLimitResetConfirmation { + picker_request_id, + confirmation_gate, credit_id, + reset_title, + reset_detail, + reset_description, } => { - 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.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, + idempotency_key, + credit_id, + ); + } + } AppEvent::RateLimitResetCreditConsumed { request_id, idempotency_key, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index fa2c9f29fc67..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; @@ -326,6 +328,16 @@ pub(crate) enum AppEvent { /// Open the reset-credit flow selected from the `/usage` menu. OpenRateLimitResetCredits, + /// 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, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fa745c652e33..7a5b01aa9f58 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -558,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 index 4e58ec3518fd..66087727c421 100644 --- a/codex-rs/tui/src/chatwidget/reset_credits.rs +++ b/codex-rs/tui/src/chatwidget/reset_credits.rs @@ -1,76 +1,19 @@ -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) detail: Option, 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); @@ -100,19 +43,23 @@ pub(super) fn reset_credit_options( .unwrap_or_else(|| "Expiration unavailable".to_string()), None => "Does not expire".to_string(), }; - let reset_label = credit + let reset_title = credit .title .as_deref() - .filter(|title| !title.trim().is_empty()) - .unwrap_or_else(|| match credit.reset_type { - RateLimitResetType::CodexRateLimits | RateLimitResetType::Unknown => { - scope.picker_label() - } - }); + .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_label.to_string(), - description: format!("{expiration}."), + name: reset_title.to_string(), + detail: Some(format!("{expiration}.")), + description: reset_description.to_string(), } }) .collect::>(); @@ -120,8 +67,9 @@ pub(super) fn reset_credit_options( if options.is_empty() { options.push(ResetCreditOption { credit_id: None, - name: "Use a reset".to_string(), - description: scope.usage_description().to_string(), + name: "Full reset".to_string(), + detail: None, + description: "Reset your current usage limits.".to_string(), }); } 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 index d64823547ffc..ccfcbf4677b5 100644 --- 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 @@ -5,9 +5,9 @@ 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 +› 1. Full reset Expires 09:39 on 18 Jun 2026. - 3. Full reset Does not expire. + 2. Full reset Does not expire. + 3. Cancel 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 b018313adc5a..9f92c0674fce 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 @@ -12,9 +12,9 @@ expression: "states.join(\"\\n---\\n\")" Usage limit resets 2 usage limit resets available. -› 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. +› 1. Full reset (Weekly + 5 hr) Expires 09:39 on 18 Jun 2026. + 2. Full reset (Weekly + 5 hr) Expires 08:59 on 27 Jun 2026. + 3. Cancel 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 9ace222f468a..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 - 1 usage limit reset available. - -› 1. Cancel - 2. Full reset (Monthly) Does not expire. - - Press enter to confirm or esc to go back ---- -Go: - Usage limit resets - 1 usage limit reset available. - -› 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 - 1 usage limit reset available. - -› 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 2261110b60de..06bc142670ab 100644 --- a/codex-rs/tui/src/chatwidget/tests/usage.rs +++ b/codex-rs/tui/src/chatwidget/tests/usage.rs @@ -1,4 +1,3 @@ -use super::super::reset_credits::RateLimitResetScope; use super::super::reset_credits::ResetCreditOption; use super::super::reset_credits::reset_credit_options; use super::*; @@ -58,20 +57,47 @@ fn expiry_timestamp(day: u32, hour: u32, minute: u32) -> i64 { .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_scope_label_for_unknown_reset_type() { +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]), - RateLimitResetScope::WeeklyAndFiveHour, - ), + reset_credit_options(&detailed_reset_credits( + /*available_count*/ 1, + vec![credit], + )), vec![ResetCreditOption { credit_id: Some("future-credit".to_string()), - name: "Full reset (Weekly + 5h)".to_string(), - description: "Does not expire.".to_string(), + name: "Full reset".to_string(), + detail: Some("Does not expire.".to_string()), + description: "Reset your current usage limits.".to_string(), }] ); } @@ -400,84 +426,147 @@ async fn rate_limit_reset_picker_wraps_expiry_details_snapshot() { } #[tokio::test] -async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - set_chatgpt_auth(&mut chat); - let mut states = Vec::new(); - - chat.plan_type = Some(PlanType::Free); - let free_request_id = chat.show_rate_limit_reset_loading_popup(); +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( - free_request_id, + request_id, Vec::new(), Ok(detailed_reset_credits( /*available_count*/ 1, - vec![reset_credit("free-credit", /*expires_at*/ None)], + vec![credit], )), )); - states.push(format!( - "Free:\n{}", - render_bottom_popup(&chat, /*width*/ 80) + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + 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, )); - 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_confirmation", + render_bottom_popup(&chat, /*width*/ 80) + ); +} + +#[tokio::test] +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( - go_request_id, + request_id, Vec::new(), Ok(reset_credits(/*available_count*/ 1)), )); - states.push(format!("Go:\n{}", render_bottom_popup(&chat, /*width*/ 80))); + 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); - 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(detailed_reset_credits( - /*available_count*/ 1, - vec![reset_credit("business-credit", /*expires_at*/ None)], - )), - )); - states.push(format!( - "Business with monthly window:\n{}", - render_bottom_popup(&chat, /*width*/ 80) - )); + 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_chatwidget_snapshot!( - "usage_limit_reset_confirmation_monthly", - states.join("\n---\n") - ); + 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_selects_cancel_by_default() { +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(reset_credits(/*available_count*/ 1)), + 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::Enter, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(chat.bottom_pane.no_modal_or_popup_active()); - assert!(rx.try_recv().is_err()); + 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; - 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) @@ -502,18 +591,25 @@ async fn rate_limit_reset_picker_starts_with_soonest_expiries_and_keeps_all_rows ); assert!(!rendered.contains("Full reset ("), "{rendered}"); - for _ in 0..9 { + for _ in 0..8 { 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, + Ok(AppEvent::OpenRateLimitResetConfirmation { + picker_request_id, + confirmation_gate: _, credit_id, - }) if Uuid::parse_str(&idempotency_key).is_ok() + 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." ); } @@ -524,19 +620,46 @@ async fn rate_limit_reset_confirmation_can_use_reset() { assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, Vec::new(), - Ok(reset_credits(/*available_count*/ 1)), + 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)); - assert_matches!( - rx.try_recv(), - Ok(AppEvent::ConsumeRateLimitResetCredit { - idempotency_key, - credit_id, - }) if Uuid::parse_str(&idempotency_key).is_ok() && credit_id.is_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] @@ -552,13 +675,21 @@ async fn rate_limit_reset_retry_reuses_idempotency_key() { chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_matches!( - rx.try_recv(), - Ok(AppEvent::ConsumeRateLimitResetCredit { - idempotency_key, - credit_id, - }) if idempotency_key == "stable-redeem-id" - && credit_id.as_deref() == Some("credit-1") + 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() ); } @@ -795,15 +926,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) diff --git a/codex-rs/tui/src/chatwidget/usage.rs b/codex-rs/tui/src/chatwidget/usage.rs index 731c0a994a2b..fdb013110885 100644 --- a/codex-rs/tui/src/chatwidget/usage.rs +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -1,14 +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::reset_credits::rate_limit_reset_scope; 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) { @@ -99,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 { @@ -130,11 +135,13 @@ impl ChatWidget { self.on_rate_limit_snapshot(Some(snapshot)); } + let mut shows_picker = false; let params = match result { Ok(response) => { let available_count = response.available_count; let params = if available_count > 0 { - self.rate_limit_reset_confirmation_params(&response) + 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.", @@ -150,42 +157,53 @@ impl ChatWidget { 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( + fn rate_limit_reset_picker_params( &self, + picker_request_id: u64, 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 { + let mut items = 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() + } + }) + .collect::>(); + items.push(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()), @@ -201,6 +219,78 @@ impl ChatWidget { } } + 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: "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: "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 { SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), @@ -241,8 +331,14 @@ impl ChatWidget { 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()), @@ -307,6 +403,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()), @@ -422,6 +519,8 @@ impl ChatWidget { 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(); @@ -429,6 +528,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) {