[codex] Fix usage-limit reset copy and state (#28793)

## Why

The reset flow introduced in #28154 still describes earned reset credits
as "rate-limit resets" and uses generic reset-scope copy. It can also
retain a stale available-credit count after redemption or an account
change, leaving the reset action enabled after the last credit is used.

This follow-up updates terminology only within that reset feature.
Existing rate-limit wording elsewhere in the CLI and TUI is unchanged.

## What changed

- Rename reset-specific `/usage` menu items, startup hints, and reset
dialogs to "usage limit reset."
- Describe monthly resets for Free, Go, and accounts that report a
monthly usage window; otherwise describe the current 5-hour and weekly
limits.
- Recheck a cached zero balance when `/usage` is reopened, and refresh
the balance after redemption so the final reset immediately disables the
action.
- Correlate async refresh results before updating snapshots and clear
account-derived reset state, warnings, prompts, and status surfaces when
the account changes.

## Validation

- `just test -p codex-tui chatwidget::tests::usage` — 29 passed.
- `just test -p codex-tui chatwidget::tests::status_command_tests` — 7
passed.
- Account-boundary prompt and plan-mode prompt regression tests passed.
- `cargo insta pending-snapshots` from `codex-rs/tui` — no pending
snapshots.\

<img width="814" height="318" alt="image"
src="https://github.com/user-attachments/assets/2a460e96-458b-4805-8d9f-c759382d21a4"
/>
view for monthly
<img width="905" height="243" alt="image"
src="https://github.com/user-attachments/assets/179f88e3-08fb-4af5-8dc6-ce6a944ed681"
/>
This commit is contained in:
jay authored and GitHub committed 2026-06-22 18:45:41 +00:00
1 parent 20431d49a0
commit 5c0fbf3492
20 files changed
+496 -112

No files matched your search

+98 -25
View File
@@ -3,6 +3,7 @@ use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse;
use codex_app_server_protocol::RateLimitResetCreditsSummary;
use uuid::Uuid;
use super::rate_limits::get_limits_duration;
use super::*;
const USAGE_MENU_VIEW_ID: &str = "usage-menu";
@@ -11,8 +12,21 @@ const RATE_LIMIT_RESET_VIEW_ID: &str = "rate-limit-reset";
impl ChatWidget {
pub(super) fn open_usage_menu(&mut self) {
self.clear_pending_rate_limit_reset_hint();
let reset_eligible =
self.has_chatgpt_account && !self.plan_type.is_some_and(PlanType::is_workspace_account);
let should_refresh_reset_availability = self.available_rate_limit_reset_credits == Some(0);
self.bottom_pane
.show_selection_view(self.usage_menu_params());
if should_refresh_reset_availability {
let request_id = self.take_next_rate_limit_reset_request_id();
self.pending_usage_menu_rate_limit_request_id = Some(request_id);
self.app_event_tx.send(AppEvent::RefreshRateLimits {
origin: RateLimitRefreshOrigin::UsageMenu { request_id },
});
}
self.request_redraw();
}
fn usage_menu_params(&self) -> SelectionViewParams {
let reset_eligible = self.has_chatgpt_account;
let (reset_action_enabled, reset_description) =
match (reset_eligible, self.available_rate_limit_reset_credits) {
(true, Some(available_count)) if available_count > 0 => (
@@ -22,10 +36,12 @@ impl ChatWidget {
reset_label(available_count)
),
),
(true, _) => (true, "Check reset availability.".to_string()),
(false, _) => (false, "No rate-limit resets available.".to_string()),
(true, None) => (true, "Check reset availability.".to_string()),
(true, Some(_)) | (false, _) => {
(false, "No usage limit resets available.".to_string())
}
};
self.bottom_pane.show_selection_view(SelectionViewParams {
SelectionViewParams {
view_id: Some(USAGE_MENU_VIEW_ID),
title: Some("Usage".to_string()),
subtitle: Some("View account usage or redeem an earned reset.".to_string()),
@@ -41,7 +57,7 @@ impl ChatWidget {
..Default::default()
},
SelectionItem {
name: "Redeem rate limit reset".to_string(),
name: "Redeem usage limit reset".to_string(),
description: Some(reset_description),
is_disabled: !reset_action_enabled,
actions: vec![Box::new(|tx| {
@@ -52,8 +68,32 @@ impl ChatWidget {
},
],
..Default::default()
});
self.request_redraw();
}
}
pub(crate) fn finish_usage_menu_rate_limit_refresh(
&mut self,
request_id: u64,
snapshots: Vec<RateLimitSnapshot>,
result: Result<RateLimitResetCreditsSummary, String>,
) {
if self.pending_usage_menu_rate_limit_request_id != Some(request_id) {
return;
}
self.pending_usage_menu_rate_limit_request_id = None;
for snapshot in snapshots {
self.on_rate_limit_snapshot(Some(snapshot));
}
if let Ok(response) = result {
self.available_rate_limit_reset_credits = Some(response.available_count);
}
let params = self.usage_menu_params();
if self
.bottom_pane
.replace_selection_view_if_present(USAGE_MENU_VIEW_ID, params)
{
self.request_redraw();
}
}
pub(crate) fn show_rate_limit_reset_loading_popup(&mut self) -> u64 {
@@ -62,7 +102,7 @@ impl ChatWidget {
self.pending_rate_limit_reset_request_id = Some(request_id);
self.bottom_pane.show_selection_view(SelectionViewParams {
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
title: Some("Rate-limit resets".to_string()),
title: Some("Usage limit resets".to_string()),
subtitle: Some("Checking your available resets...".to_string()),
items: vec![SelectionItem {
name: "Loading...".to_string(),
@@ -78,26 +118,30 @@ impl ChatWidget {
pub(crate) fn finish_rate_limit_reset_credits_refresh(
&mut self,
request_id: u64,
snapshots: Vec<RateLimitSnapshot>,
result: Result<RateLimitResetCreditsSummary, String>,
) -> bool {
if self.pending_rate_limit_reset_request_id != Some(request_id) {
return false;
}
self.pending_rate_limit_reset_request_id = None;
for snapshot in snapshots {
self.on_rate_limit_snapshot(Some(snapshot));
}
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)
self.rate_limit_reset_confirmation_params(response.available_count)
} else {
Self::rate_limit_reset_message_params(
"You don't have any rate-limit resets available.",
"You don't have any usage limit resets available.",
)
}
}
Err(_) => Self::rate_limit_reset_message_params(
"Couldn't load rate-limit resets. Please try again.",
"Couldn't load usage limit resets. Please try again.",
),
};
let replaced = self
@@ -109,11 +153,32 @@ impl ChatWidget {
replaced
}
fn rate_limit_reset_confirmation_params(available_count: i64) -> SelectionViewParams {
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."
};
SelectionViewParams {
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
title: Some("Rate-limit resets".to_string()),
title: Some("Usage limit resets".to_string()),
subtitle: Some(format!(
"You have {available_count} {} available.",
reset_label(available_count)
@@ -122,7 +187,7 @@ impl ChatWidget {
items: vec![
SelectionItem {
name: "Use a reset".to_string(),
description: Some("Reset your current Codex usage windows.".to_string()),
description: Some(reset_description.to_string()),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::ConsumeRateLimitResetCredit {
idempotency_key: idempotency_key.clone(),
@@ -145,7 +210,7 @@ impl ChatWidget {
fn rate_limit_reset_message_params(message: &str) -> SelectionViewParams {
SelectionViewParams {
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
title: Some("Rate-limit resets".to_string()),
title: Some("Usage limit resets".to_string()),
subtitle: Some(message.to_string()),
items: vec![SelectionItem {
name: "Close".to_string(),
@@ -162,7 +227,7 @@ impl ChatWidget {
self.pending_rate_limit_reset_request_id = Some(request_id);
self.bottom_pane.show_selection_view(SelectionViewParams {
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
title: Some("Rate-limit resets".to_string()),
title: Some("Usage limit resets".to_string()),
subtitle: Some("Resetting your usage...".to_string()),
items: vec![SelectionItem {
name: "Using a reset...".to_string(),
@@ -206,7 +271,7 @@ impl ChatWidget {
}
ConsumeAccountRateLimitResetCreditOutcome::NoCredit => {
self.available_rate_limit_reset_credits = Some(0);
"No rate-limit resets are available."
"No usage limit resets are available."
}
ConsumeAccountRateLimitResetCreditOutcome::Reset
| ConsumeAccountRateLimitResetCreditOutcome::AlreadyRedeemed => unreachable!(),
@@ -218,7 +283,7 @@ impl ChatWidget {
self.pending_rate_limit_reset_request_id = None;
self.replace_rate_limit_reset_popup(SelectionViewParams {
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
title: Some("Rate-limit resets".to_string()),
title: Some("Usage limit resets".to_string()),
subtitle: Some("Couldn't reset usage. Please try again.".to_string()),
items: vec![
SelectionItem {
@@ -247,12 +312,16 @@ impl ChatWidget {
pub(crate) fn finish_post_consume_reset_credits_refresh(
&mut self,
request_id: u64,
snapshots: Vec<RateLimitSnapshot>,
result: Result<RateLimitResetCreditsSummary, String>,
) -> bool {
if self.pending_rate_limit_reset_request_id != Some(request_id) {
return false;
}
self.pending_rate_limit_reset_request_id = None;
for snapshot in snapshots {
self.on_rate_limit_snapshot(Some(snapshot));
}
let message = match result {
Ok(response) => {
@@ -272,7 +341,7 @@ impl ChatWidget {
fn rate_limit_reset_success_loading_params() -> SelectionViewParams {
SelectionViewParams {
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
title: Some("Rate-limit resets".to_string()),
title: Some("Usage limit resets".to_string()),
subtitle: Some("Usage reset. Checking your remaining resets...".to_string()),
items: vec![SelectionItem {
name: "Refreshing...".to_string(),
@@ -303,18 +372,19 @@ impl ChatWidget {
pub(crate) fn finish_rate_limit_reset_hint_refresh(
&mut self,
request_id: u64,
snapshots: Vec<RateLimitSnapshot>,
result: Result<RateLimitResetCreditsSummary, String>,
) -> bool {
if self.pending_rate_limit_reset_hint_request_id != Some(request_id) {
return false;
}
self.pending_rate_limit_reset_hint_request_id = None;
for snapshot in snapshots {
self.on_rate_limit_snapshot(Some(snapshot));
}
if !self.has_codex_backend_auth {
return false;
}
if self.plan_type.is_some_and(PlanType::is_workspace_account) {
return true;
}
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);
@@ -324,8 +394,11 @@ impl ChatWidget {
pub(crate) fn clear_pending_rate_limit_reset_requests(&mut self) {
self.pending_rate_limit_reset_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();
self.clear_pending_rate_limit_reset_hint();
self.bottom_pane.dismiss_view_by_id(USAGE_MENU_VIEW_ID);
self.bottom_pane
.dismiss_view_by_id(RATE_LIMIT_RESET_VIEW_ID);
}
@@ -375,8 +448,8 @@ impl ChatWidget {
fn reset_label(count: i64) -> &'static str {
if count == 1 {
"rate-limit reset"
"usage limit reset"
} else {
"rate-limit resets"
"usage limit resets"
}
}