Confirm usage-limit resets before redemption (#33845)

## Why

Selecting a usage-limit reset from the picker immediately started redemption,
leaving no opportunity to verify the choice.

## What changed

- Open a separate confirmation popup before redeeming a selected reset, with
  `No, go back` selected by default.
- Show backend-provided reset titles and descriptions in the picker and
  confirmation, with generic fallback copy when those details are absent.
- Ignore duplicate or stale confirmation and redemption events, while retaining
  the idempotency key needed to retry a failed redemption.

## Testing

- Added coverage for confirmation, cancellation, backend copy, duplicate and
  stale events, successful redemption, and retry behavior.

GitOrigin-RevId: 828945f99f06e8c73941fd864e4aa9ac52729f98
This commit is contained in:
jay
2026-07-17 15:28:19 +00:00
committed by copyberry
parent a238b753af
commit a50e822398
13 changed files with 432 additions and 217 deletions
+1
View File
@@ -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\":[]}}",
+2 -2
View File
@@ -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"
+28 -7
View File
@@ -975,17 +975,38 @@ impl App {
RateLimitRefreshOrigin::ResetPicker { request_id },
);
}
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,
} => {
let request_id = self.chat_widget.show_rate_limit_reset_consuming_popup();
self.consume_rate_limit_reset_credit(
app_server,
request_id,
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,
+12
View File
@@ -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;
@@ -332,6 +334,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<AtomicBool>,
credit_id: Option<String>,
reset_title: String,
reset_detail: Option<String>,
reset_description: String,
},
/// Consume one reset credit using a stable idempotency key.
ConsumeRateLimitResetCredit {
idempotency_key: String,
+2
View File
@@ -563,6 +563,8 @@ pub(crate) struct ChatWidget {
completed_token_activity_output: Option<history_cell::CompositeHistoryCell>,
next_token_activity_request_id: u64,
pending_rate_limit_reset_request_id: Option<u64>,
pending_rate_limit_reset_idempotency_key: Option<String>,
rate_limit_reset_picker_request_id: Option<u64>,
pending_rate_limit_reset_hint_request_id: Option<u64>,
pending_usage_menu_rate_limit_request_id: Option<u64>,
pending_rate_limit_reset_hint: Option<PlainHistoryCell>,
@@ -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,
+17 -69
View File
@@ -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<String>,
pub(super) name: String,
pub(super) detail: Option<String>,
pub(super) description: String,
}
pub(super) fn rate_limit_reset_scope(
rate_limits: &BTreeMap<String, RateLimitSnapshotDisplay>,
plan_type: Option<PlanType>,
) -> 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::<Vec<_>>();
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<ResetCreditOption> {
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::<Vec<_>>();
@@ -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(),
});
}
@@ -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
@@ -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
@@ -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
---
@@ -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
+223 -77
View File
@@ -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<AppEvent>,
) -> 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,66 +426,67 @@ 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));
dismiss_popup(&mut chat);
chat.plan_type = Some(PlanType::Go);
let go_request_id = chat.show_rate_limit_reset_loading_popup();
assert!(chat.finish_rate_limit_reset_credits_refresh(
go_request_id,
Vec::new(),
Ok(reset_credits(/*available_count*/ 1)),
));
states.push(format!("Go:\n{}", render_bottom_popup(&chat, /*width*/ 80)));
dismiss_popup(&mut chat);
let mut monthly_business_snapshot = snapshot(/*percent*/ 50.0);
monthly_business_snapshot.plan_type = Some(PlanType::Business);
monthly_business_snapshot
.primary
.as_mut()
.expect("monthly business snapshot primary window")
.window_duration_mins = Some(30 * 24 * 60);
let business_request_id = chat.show_rate_limit_reset_loading_popup();
assert!(chat.finish_rate_limit_reset_credits_refresh(
business_request_id,
vec![monthly_business_snapshot],
Ok(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)
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(
@@ -467,17 +494,60 @@ async fn rate_limit_reset_confirmation_selects_cancel_by_default() {
Vec::new(),
Ok(reset_credits(/*available_count*/ 1)),
));
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 reset_picker_allows_only_one_pending_confirmation() {
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(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));
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));
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 +572,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 +601,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 +656,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 +907,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)
+128 -26
View File
@@ -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,54 @@ 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 confirmation_gate = Arc::new(AtomicBool::new(true));
let mut items = reset_credit_options(reset_credits)
.into_iter()
.map(|option| {
let confirmation_gate = confirmation_gate.clone();
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::<Vec<_>>();
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 +220,78 @@ impl ChatWidget {
}
}
pub(crate) fn show_rate_limit_reset_confirmation(
&mut self,
picker_request_id: u64,
confirmation_gate: Arc<AtomicBool>,
credit_id: Option<String>,
reset_title: String,
reset_detail: Option<String>,
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<u64> {
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 +332,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 +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()),
@@ -422,6 +520,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 +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) {