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 authored and copyberry committed 2026-07-17 15:28:19 +00:00
1 parent a238b753af
commit a50e822398
13 files changed
+432 -217

No files matched your search

+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.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\":[]}}", "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.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\"]}}", "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\"]}}", "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\":[]}}", "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]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]] [[package]]
name = "app_test_support" name = "app_test_support"
+28 -7
View File
@@ -975,17 +975,38 @@ impl App {
RateLimitRefreshOrigin::ResetPicker { request_id }, 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 { AppEvent::ConsumeRateLimitResetCredit {
idempotency_key, idempotency_key,
credit_id, credit_id,
} => { } => {
let request_id = self.chat_widget.show_rate_limit_reset_consuming_popup(); if let Some(request_id) = self
self.consume_rate_limit_reset_credit( .chat_widget
app_server, .start_rate_limit_reset_consumption(&idempotency_key)
request_id, {
idempotency_key, self.consume_rate_limit_reset_credit(
credit_id, app_server,
); request_id,
idempotency_key,
credit_id,
);
}
} }
AppEvent::RateLimitResetCreditConsumed { AppEvent::RateLimitResetCreditConsumed {
request_id, request_id,
+12
View File
@@ -9,6 +9,8 @@
//! quits without reaching into the app loop or coupling to shutdown/exit sequencing. //! quits without reaching into the app loop or coupling to shutdown/exit sequencing.
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::AddCreditsNudgeCreditType;
use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
@@ -332,6 +334,16 @@ pub(crate) enum AppEvent {
/// Open the reset-credit flow selected from the `/usage` menu. /// Open the reset-credit flow selected from the `/usage` menu.
OpenRateLimitResetCredits, 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. /// Consume one reset credit using a stable idempotency key.
ConsumeRateLimitResetCredit { ConsumeRateLimitResetCredit {
idempotency_key: String, idempotency_key: String,
+2
View File
@@ -563,6 +563,8 @@ pub(crate) struct ChatWidget {
completed_token_activity_output: Option<history_cell::CompositeHistoryCell>, completed_token_activity_output: Option<history_cell::CompositeHistoryCell>,
next_token_activity_request_id: u64, next_token_activity_request_id: u64,
pending_rate_limit_reset_request_id: Option<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_rate_limit_reset_hint_request_id: Option<u64>,
pending_usage_menu_rate_limit_request_id: Option<u64>, pending_usage_menu_rate_limit_request_id: Option<u64>,
pending_rate_limit_reset_hint: Option<PlainHistoryCell>, pending_rate_limit_reset_hint: Option<PlainHistoryCell>,
@@ -130,6 +130,8 @@ impl ChatWidget {
completed_token_activity_output: None, completed_token_activity_output: None,
next_token_activity_request_id: 0, next_token_activity_request_id: 0,
pending_rate_limit_reset_request_id: None, 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_rate_limit_reset_hint_request_id: None,
pending_usage_menu_rate_limit_request_id: None, pending_usage_menu_rate_limit_request_id: None,
pending_rate_limit_reset_hint: None, pending_rate_limit_reset_hint: None,
+17 -69
View File
@@ -1,76 +1,19 @@
use crate::status::RateLimitSnapshotDisplay;
use chrono::DateTime; use chrono::DateTime;
use chrono::Local; use chrono::Local;
use chrono::Utc; use chrono::Utc;
use codex_app_server_protocol::RateLimitResetCreditStatus; use codex_app_server_protocol::RateLimitResetCreditStatus;
use codex_app_server_protocol::RateLimitResetCreditsSummary; 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)] #[derive(Debug, Eq, PartialEq)]
pub(super) struct ResetCreditOption { pub(super) struct ResetCreditOption {
pub(super) credit_id: Option<String>, pub(super) credit_id: Option<String>,
pub(super) name: String, pub(super) name: String,
pub(super) detail: Option<String>,
pub(super) description: 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( pub(super) fn reset_credit_options(
summary: &RateLimitResetCreditsSummary, summary: &RateLimitResetCreditsSummary,
scope: RateLimitResetScope,
) -> Vec<ResetCreditOption> { ) -> Vec<ResetCreditOption> {
let available_count = summary.available_count.max(0); let available_count = summary.available_count.max(0);
let detail_limit = usize::try_from(available_count).unwrap_or(usize::MAX); 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()), .unwrap_or_else(|| "Expiration unavailable".to_string()),
None => "Does not expire".to_string(), None => "Does not expire".to_string(),
}; };
let reset_label = credit let reset_title = credit
.title .title
.as_deref() .as_deref()
.filter(|title| !title.trim().is_empty()) .map(str::trim)
.unwrap_or_else(|| match credit.reset_type { .filter(|title| !title.is_empty())
RateLimitResetType::CodexRateLimits | RateLimitResetType::Unknown => { .unwrap_or("Full reset");
scope.picker_label() let reset_description = credit
} .description
}); .as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
.unwrap_or("Reset your current usage limits.");
ResetCreditOption { ResetCreditOption {
credit_id: Some(credit.id.clone()), credit_id: Some(credit.id.clone()),
name: reset_label.to_string(), name: reset_title.to_string(),
description: format!("{expiration}."), detail: Some(format!("{expiration}.")),
description: reset_description.to_string(),
} }
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -120,8 +67,9 @@ pub(super) fn reset_credit_options(
if options.is_empty() { if options.is_empty() {
options.push(ResetCreditOption { options.push(ResetCreditOption {
credit_id: None, credit_id: None,
name: "Use a reset".to_string(), name: "Full reset".to_string(),
description: scope.usage_description().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 Usage limit resets
2 usage limit resets available. 2 usage limit resets available.
› 1. Cancel › 1. Full reset Expires 09:39 on 18 Jun
2. Full reset Expires 09:39 on 18 Jun
2026. 2026.
3. Full reset Does not expire. 2. Full reset Does not expire.
3. Cancel
Press enter to confirm or esc to go back Press enter to confirm or esc to go back
@@ -12,9 +12,9 @@ expression: "states.join(\"\\n---\\n\")"
Usage limit resets Usage limit resets
2 usage limit resets available. 2 usage limit resets available.
› 1. Cancel › 1. Full reset (Weekly + 5 hr) Expires 09:39 on 18 Jun 2026.
2. 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. Full reset (Weekly + 5 hr) Expires 08:59 on 27 Jun 2026. 3. Cancel
Press enter to confirm or esc to go back 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::ResetCreditOption;
use super::super::reset_credits::reset_credit_options; use super::super::reset_credits::reset_credit_options;
use super::*; use super::*;
@@ -58,20 +57,47 @@ fn expiry_timestamp(day: u32, hour: u32, minute: u32) -> i64 {
.timestamp() .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] #[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); let mut credit = reset_credit("future-credit", /*expires_at*/ None);
credit.reset_type = RateLimitResetType::Unknown; credit.reset_type = RateLimitResetType::Unknown;
assert_eq!( assert_eq!(
reset_credit_options( reset_credit_options(&detailed_reset_credits(
&detailed_reset_credits(/*available_count*/ 1, vec![credit]), /*available_count*/ 1,
RateLimitResetScope::WeeklyAndFiveHour, vec![credit],
), )),
vec![ResetCreditOption { vec![ResetCreditOption {
credit_id: Some("future-credit".to_string()), credit_id: Some("future-credit".to_string()),
name: "Full reset (Weekly + 5h)".to_string(), name: "Full reset".to_string(),
description: "Does not expire.".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] #[tokio::test]
async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_snapshot() { async fn rate_limit_reset_confirmation_uses_backend_copy_snapshot() {
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); let expiry = expiry_timestamp(/*day*/ 18, /*hour*/ 9, /*minute*/ 39);
let mut states = Vec::new(); let mut credit =
reset_credit_with_title("weekly-credit", Some(expiry), "Full reset (Weekly + 5 hr)");
chat.plan_type = Some(PlanType::Free); credit.description = Some("Reset your weekly and 5-hour usage limits.".to_string());
let free_request_id = 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( assert!(chat.finish_rate_limit_reset_credits_refresh(
free_request_id, request_id,
Vec::new(), Vec::new(),
Ok(detailed_reset_credits( Ok(detailed_reset_credits(
/*available_count*/ 1, /*available_count*/ 1,
vec![reset_credit("free-credit", /*expires_at*/ None)], vec![credit],
)), )),
)); ));
states.push(format!( chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
"Free:\n{}",
render_bottom_popup(&chat, /*width*/ 80)
));
dismiss_popup(&mut chat); let Ok(AppEvent::OpenRateLimitResetConfirmation {
chat.plan_type = Some(PlanType::Go); picker_request_id,
let go_request_id = chat.show_rate_limit_reset_loading_popup(); confirmation_gate,
assert!(chat.finish_rate_limit_reset_credits_refresh( credit_id,
go_request_id, reset_title,
Vec::new(), reset_detail,
Ok(reset_credits(/*available_count*/ 1)), reset_description,
)); }) = rx.try_recv()
states.push(format!("Go:\n{}", render_bottom_popup(&chat, /*width*/ 80))); else {
panic!("expected reset confirmation event");
dismiss_popup(&mut chat); };
let mut monthly_business_snapshot = snapshot(/*percent*/ 50.0); assert_eq!(
monthly_business_snapshot.plan_type = Some(PlanType::Business); (
monthly_business_snapshot picker_request_id,
.primary credit_id.as_deref(),
.as_mut() reset_title.as_str(),
.expect("monthly business snapshot primary window") reset_detail.as_deref(),
.window_duration_mins = Some(30 * 24 * 60); reset_description.as_str(),
let business_request_id = chat.show_rate_limit_reset_loading_popup(); ),
assert!(chat.finish_rate_limit_reset_credits_refresh( (
business_request_id, request_id,
vec![monthly_business_snapshot], Some("weekly-credit"),
Ok(detailed_reset_credits( "Full reset (Weekly + 5 hr)",
/*available_count*/ 1, Some("Expires 09:39 on 18 Jun 2026."),
vec![reset_credit("business-credit", /*expires_at*/ None)], "Reset your weekly and 5-hour usage limits.",
)), )
)); );
states.push(format!( assert!(chat.show_rate_limit_reset_confirmation(
"Business with monthly window:\n{}", picker_request_id,
render_bottom_popup(&chat, /*width*/ 80) confirmation_gate,
credit_id,
reset_title,
reset_detail,
reset_description,
)); ));
assert_chatwidget_snapshot!( assert_chatwidget_snapshot!(
"usage_limit_reset_confirmation_monthly", "rate_limit_reset_confirmation",
states.join("\n---\n") render_bottom_popup(&chat, /*width*/ 80)
); );
} }
#[tokio::test] #[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 (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let request_id = 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( assert!(chat.finish_rate_limit_reset_credits_refresh(
@@ -467,17 +494,60 @@ async fn rate_limit_reset_confirmation_selects_cancel_by_default() {
Vec::new(), Vec::new(),
Ok(reset_credits(/*available_count*/ 1)), 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)); 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()); 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] #[tokio::test]
async fn rate_limit_reset_picker_starts_with_soonest_expiries_and_keeps_all_rows_reachable() { 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 (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 request_id = chat.show_rate_limit_reset_loading_popup();
let first_expiry = expiry_timestamp(/*day*/ 18, /*hour*/ 9, /*minute*/ 39); let first_expiry = expiry_timestamp(/*day*/ 18, /*hour*/ 9, /*minute*/ 39);
let credits = (0..9) 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}"); 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::Down, KeyModifiers::NONE));
} }
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!( assert_matches!(
rx.try_recv(), rx.try_recv(),
Ok(AppEvent::ConsumeRateLimitResetCredit { Ok(AppEvent::OpenRateLimitResetConfirmation {
idempotency_key, picker_request_id,
confirmation_gate: _,
credit_id, 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") && 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( assert!(chat.finish_rate_limit_reset_credits_refresh(
request_id, request_id,
Vec::new(), 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)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!( let Ok(AppEvent::ConsumeRateLimitResetCredit {
rx.try_recv(), idempotency_key,
Ok(AppEvent::ConsumeRateLimitResetCredit { credit_id,
idempotency_key, }) = rx.try_recv()
credit_id, else {
}) if Uuid::parse_str(&idempotency_key).is_ok() && credit_id.is_none() 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] #[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)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!( let Ok(AppEvent::ConsumeRateLimitResetCredit {
rx.try_recv(), idempotency_key,
Ok(AppEvent::ConsumeRateLimitResetCredit { credit_id,
idempotency_key, }) = rx.try_recv()
credit_id, else {
}) if idempotency_key == "stable-redeem-id" panic!("expected reset retry event");
&& credit_id.as_deref() == Some("credit-1") };
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] #[tokio::test]
async fn account_change_dismisses_reset_popup_beneath_overlay() { 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); 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); show_usage_test_overlay(&mut chat);
chat.update_account_state( chat.update_account_state(
/*status_account_display*/ None, /*plan_type*/ None, /*status_account_display*/ None, /*plan_type*/ None,
/*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false, /*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!( assert_eq!(
chat.bottom_pane.active_view_id(), chat.bottom_pane.active_view_id(),
Some(TEST_OVERLAY_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::ConsumeAccountRateLimitResetCreditOutcome;
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse; use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse;
use codex_app_server_protocol::RateLimitResetCreditsSummary; use codex_app_server_protocol::RateLimitResetCreditsSummary;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use uuid::Uuid; use uuid::Uuid;
use super::reset_credits::rate_limit_reset_scope;
use super::reset_credits::reset_credit_options; use super::reset_credits::reset_credit_options;
use super::*; use super::*;
const USAGE_MENU_VIEW_ID: &str = "usage-menu"; const USAGE_MENU_VIEW_ID: &str = "usage-menu";
const RATE_LIMIT_RESET_VIEW_ID: &str = "rate-limit-reset"; const RATE_LIMIT_RESET_VIEW_ID: &str = "rate-limit-reset";
const RATE_LIMIT_RESET_CONFIRMATION_VIEW_ID: &str = "rate-limit-reset-confirmation";
impl ChatWidget { impl ChatWidget {
pub(super) fn open_usage_menu(&mut self) { 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 { pub(crate) fn show_rate_limit_reset_loading_popup(&mut self) -> u64 {
self.clear_pending_rate_limit_reset_hint(); 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(); let request_id = self.take_next_rate_limit_reset_request_id();
self.pending_rate_limit_reset_request_id = Some(request_id); self.pending_rate_limit_reset_request_id = Some(request_id);
self.bottom_pane.show_selection_view(SelectionViewParams { self.bottom_pane.show_selection_view(SelectionViewParams {
@@ -130,11 +135,13 @@ impl ChatWidget {
self.on_rate_limit_snapshot(Some(snapshot)); self.on_rate_limit_snapshot(Some(snapshot));
} }
let mut shows_picker = false;
let params = match result { let params = match result {
Ok(response) => { Ok(response) => {
let available_count = response.available_count; let available_count = response.available_count;
let params = if available_count > 0 { 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 { } else {
Self::rate_limit_reset_message_params( Self::rate_limit_reset_message_params(
"You don't have any usage limit resets available.", "You don't have any usage limit resets available.",
@@ -150,42 +157,54 @@ impl ChatWidget {
let replaced = self let replaced = self
.bottom_pane .bottom_pane
.replace_selection_view_if_present(RATE_LIMIT_RESET_VIEW_ID, params); .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 { if replaced {
self.request_redraw(); self.request_redraw();
} }
replaced replaced
} }
fn rate_limit_reset_confirmation_params( fn rate_limit_reset_picker_params(
&self, &self,
picker_request_id: u64,
reset_credits: &RateLimitResetCreditsSummary, reset_credits: &RateLimitResetCreditsSummary,
) -> SelectionViewParams { ) -> SelectionViewParams {
let scope = rate_limit_reset_scope(&self.rate_limit_snapshots_by_limit_id, self.plan_type); let confirmation_gate = Arc::new(AtomicBool::new(true));
let mut items = vec![SelectionItem { 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(), name: "Cancel".to_string(),
dismiss_on_select: true, dismiss_on_select: true,
..Default::default() ..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 { SelectionViewParams {
view_id: Some(RATE_LIMIT_RESET_VIEW_ID), view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
title: Some("Usage limit resets".to_string()), 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 { fn rate_limit_reset_message_params(message: &str) -> SelectionViewParams {
SelectionViewParams { SelectionViewParams {
view_id: Some(RATE_LIMIT_RESET_VIEW_ID), 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 { pub(crate) fn show_rate_limit_reset_consuming_popup(&mut self) -> u64 {
self.clear_pending_rate_limit_reset_hint(); 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(); let request_id = self.take_next_rate_limit_reset_request_id();
self.pending_rate_limit_reset_request_id = Some(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 { self.bottom_pane.show_selection_view(SelectionViewParams {
view_id: Some(RATE_LIMIT_RESET_VIEW_ID), view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
title: Some("Usage limit resets".to_string()), title: Some("Usage limit resets".to_string()),
@@ -307,6 +404,7 @@ impl ChatWidget {
} }
Err(_) => { Err(_) => {
self.pending_rate_limit_reset_request_id = None; 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 { self.replace_rate_limit_reset_popup(SelectionViewParams {
view_id: Some(RATE_LIMIT_RESET_VIEW_ID), view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
title: Some("Usage limit resets".to_string()), title: Some("Usage limit resets".to_string()),
@@ -422,6 +520,8 @@ impl ChatWidget {
pub(crate) fn clear_pending_rate_limit_reset_requests(&mut self) { pub(crate) fn clear_pending_rate_limit_reset_requests(&mut self) {
self.pending_rate_limit_reset_request_id = None; 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.pending_usage_menu_rate_limit_request_id = None;
self.available_rate_limit_reset_credits = None; self.available_rate_limit_reset_credits = None;
self.rate_limit_snapshots_by_limit_id.clear(); 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(USAGE_MENU_VIEW_ID);
self.bottom_pane self.bottom_pane
.dismiss_view_by_id(RATE_LIMIT_RESET_VIEW_ID); .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) { pub(crate) fn clear_pending_rate_limit_reset_hint(&mut self) {