[codex-cli] Show reset details in redemption picker (#30488)

## Why

Users can see that usage-limit reset credits are available, but not
which credits they have, when each one expires, or which credit will be
consumed. The TUI should use the supported rate-limit RPC for that
information without maintaining a second reset-credit request path.

## What changed

- load reset-credit details through the existing
`account/rateLimits/read` refresh when the user opens **Redeem usage
limit reset**
- show available credits sorted by expiry, using the backend title when
present and a scope-based fallback otherwise
- consume the exact selected credit and preserve its idempotency key
across retries
- fall back to the existing generic reset action when the RPC returns a
positive count without detail rows
- remove the dedicated list-RPC event, request, response-combining, and
TUI state plumbing
- handle a selected credit becoming unavailable without incorrectly
caching the user's total count as zero
- handle forward-compatible unknown reset types with the existing
scope-based fallback label

## TUI preview

Rendered from the final standard-width and narrow `insta` snapshots in
this PR.

<img width="1280" height="570" alt="pr30488-reset-picker-preview"
src="https://github.com/user-attachments/assets/8b84cd33-cb35-4a37-aaed-dbd6b62bff51"
/>

## Validation

- `just test -p codex-tui chatwidget::tests::usage` (32 passed)
- `just fix -p codex-tui`
- no pending `insta` snapshots

Uses the reset-credit details added by #30395.

Fixes #29618.
This commit is contained in:
jay authored and GitHub committed 2026-07-06 21:19:50 -07:00
1 parent 6cf42cf165
commit d7ab20ce62
10 files changed
+526 -256

No files matched your search

+86 -58
View File
@@ -3,7 +3,8 @@ use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse;
use codex_app_server_protocol::RateLimitResetCreditsSummary;
use uuid::Uuid;
use super::rate_limits::get_limits_duration;
use super::reset_credits::rate_limit_reset_scope;
use super::reset_credits::reset_credit_options;
use super::*;
const USAGE_MENU_VIEW_ID: &str = "usage-menu";
@@ -131,18 +132,20 @@ impl ChatWidget {
let params = match result {
Ok(response) => {
self.available_rate_limit_reset_credits = Some(response.available_count);
if response.available_count > 0 {
self.rate_limit_reset_confirmation_params(response.available_count)
let available_count = response.available_count;
let params = if available_count > 0 {
self.rate_limit_reset_confirmation_params(&response)
} else {
Self::rate_limit_reset_message_params(
"You don't have any usage limit resets available.",
)
}
};
self.available_rate_limit_reset_credits = Some(available_count);
params
}
Err(_) => {
Self::reset_refresh_params("Couldn't load usage limit resets. Please try again.")
}
Err(_) => Self::rate_limit_reset_message_params(
"Couldn't load usage limit resets. Please try again.",
),
};
let replaced = self
.bottom_pane
@@ -153,56 +156,47 @@ impl ChatWidget {
replaced
}
fn rate_limit_reset_confirmation_params(&self, available_count: i64) -> SelectionViewParams {
let idempotency_key = Uuid::new_v4().to_string();
let has_monthly_window = self
.rate_limit_snapshots_by_limit_id
.iter()
.find(|(limit_id, _)| limit_id.eq_ignore_ascii_case("codex"))
.into_iter()
.flat_map(|(_, snapshot)| [snapshot.primary.as_ref(), snapshot.secondary.as_ref()])
.flatten()
.any(|window| {
window
.window_minutes
.and_then(get_limits_duration)
.as_deref()
== Some("monthly")
});
let reset_description = if has_monthly_window
|| matches!(self.plan_type, Some(PlanType::Free | PlanType::Go))
{
"Reset your current monthly usage limit."
} else {
"Reset your current 5-hour and weekly usage limits."
};
fn rate_limit_reset_confirmation_params(
&self,
reset_credits: &RateLimitResetCreditsSummary,
) -> SelectionViewParams {
let scope = rate_limit_reset_scope(&self.rate_limit_snapshots_by_limit_id, self.plan_type);
let mut items = vec![SelectionItem {
name: "Cancel".to_string(),
dismiss_on_select: true,
..Default::default()
}];
items.extend(
reset_credit_options(reset_credits, scope)
.into_iter()
.map(|option| {
let idempotency_key = Uuid::new_v4().to_string();
let credit_id = option.credit_id;
SelectionItem {
name: option.name,
description: Some(option.description),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::ConsumeRateLimitResetCredit {
idempotency_key: idempotency_key.clone(),
credit_id: credit_id.clone(),
});
})],
dismiss_on_select: true,
..Default::default()
}
}),
);
SelectionViewParams {
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
title: Some("Usage limit resets".to_string()),
subtitle: Some(format!(
"You have {available_count} {} available.",
reset_label(available_count)
"{} {} available.",
reset_credits.available_count,
reset_label(reset_credits.available_count)
)),
footer_hint: Some(standard_popup_hint_line()),
items: vec![
SelectionItem {
name: "Use a reset".to_string(),
description: Some(reset_description.to_string()),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::ConsumeRateLimitResetCredit {
idempotency_key: idempotency_key.clone(),
});
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Cancel".to_string(),
dismiss_on_select: true,
..Default::default()
},
],
initial_selected_idx: Some(1),
items,
initial_selected_idx: Some(0),
..Default::default()
}
}
@@ -221,6 +215,30 @@ impl ChatWidget {
}
}
fn reset_refresh_params(message: &str) -> SelectionViewParams {
SelectionViewParams {
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
title: Some("Usage limit resets".to_string()),
subtitle: Some(message.to_string()),
items: vec![
SelectionItem {
name: "Try again".to_string(),
actions: vec![Box::new(|tx| {
tx.send(AppEvent::OpenRateLimitResetCredits);
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Close".to_string(),
dismiss_on_select: true,
..Default::default()
},
],
..Default::default()
}
}
pub(crate) fn show_rate_limit_reset_consuming_popup(&mut self) -> u64 {
self.clear_pending_rate_limit_reset_hint();
let request_id = self.take_next_rate_limit_reset_request_id();
@@ -245,6 +263,7 @@ impl ChatWidget {
&mut self,
request_id: u64,
idempotency_key: String,
credit_id: Option<String>,
result: Result<ConsumeAccountRateLimitResetCreditResponse, String>,
) -> bool {
if self.pending_rate_limit_reset_request_id != Some(request_id) {
@@ -269,6 +288,13 @@ impl ChatWidget {
ConsumeAccountRateLimitResetCreditOutcome::NothingToReset => {
"Your usage does not need a reset right now."
}
ConsumeAccountRateLimitResetCreditOutcome::NoCredit if credit_id.is_some() => {
self.available_rate_limit_reset_credits = None;
self.replace_rate_limit_reset_popup(Self::reset_refresh_params(
"That reset is no longer available. Refresh to see your current resets.",
));
return false;
}
ConsumeAccountRateLimitResetCreditOutcome::NoCredit => {
self.available_rate_limit_reset_credits = Some(0);
"No usage limit resets are available."
@@ -291,6 +317,7 @@ impl ChatWidget {
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::ConsumeRateLimitResetCredit {
idempotency_key: idempotency_key.clone(),
credit_id: credit_id.clone(),
});
})],
dismiss_on_select: true,
@@ -325,11 +352,11 @@ impl ChatWidget {
let message = match result {
Ok(response) => {
self.available_rate_limit_reset_credits = Some(response.available_count);
let available_count = response.available_count;
self.available_rate_limit_reset_credits = Some(available_count);
format!(
"Usage reset. You have {} {} left.",
response.available_count,
reset_label(response.available_count)
"Usage reset. You have {available_count} {} left.",
reset_label(available_count)
)
}
Err(_) => "Usage reset.".to_string(),
@@ -386,8 +413,9 @@ impl ChatWidget {
return false;
}
if let Ok(response) = result {
self.available_rate_limit_reset_credits = Some(response.available_count);
self.set_rate_limit_reset_available_hint(response.available_count);
let available_count = response.available_count;
self.available_rate_limit_reset_credits = Some(available_count);
self.set_rate_limit_reset_available_hint(available_count);
}
true
}