mirror of
https://github.com/openai/codex.git
synced 2026-11-04 00:24:43 +00:00
## 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" />
456 lines
17 KiB
Rust
456 lines
17 KiB
Rust
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditOutcome;
|
|
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";
|
|
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 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 => (
|
|
true,
|
|
format!(
|
|
"You have {available_count} {} available.",
|
|
reset_label(available_count)
|
|
),
|
|
),
|
|
(true, None) => (true, "Check reset availability.".to_string()),
|
|
(true, Some(_)) | (false, _) => {
|
|
(false, "No usage limit resets available.".to_string())
|
|
}
|
|
};
|
|
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()),
|
|
footer_hint: Some(standard_popup_hint_line()),
|
|
items: vec![
|
|
SelectionItem {
|
|
name: "Show usage".to_string(),
|
|
description: Some("View recent account token usage.".to_string()),
|
|
actions: vec![Box::new(|tx| {
|
|
tx.send(AppEvent::OpenTokenActivity);
|
|
})],
|
|
dismiss_on_select: true,
|
|
..Default::default()
|
|
},
|
|
SelectionItem {
|
|
name: "Redeem usage limit reset".to_string(),
|
|
description: Some(reset_description),
|
|
is_disabled: !reset_action_enabled,
|
|
actions: vec![Box::new(|tx| {
|
|
tx.send(AppEvent::OpenRateLimitResetCredits);
|
|
})],
|
|
dismiss_on_select: true,
|
|
..Default::default()
|
|
},
|
|
],
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
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 {
|
|
self.clear_pending_rate_limit_reset_hint();
|
|
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 {
|
|
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
|
|
title: Some("Usage limit resets".to_string()),
|
|
subtitle: Some("Checking your available resets...".to_string()),
|
|
items: vec![SelectionItem {
|
|
name: "Loading...".to_string(),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
});
|
|
self.request_redraw();
|
|
request_id
|
|
}
|
|
|
|
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)
|
|
} else {
|
|
Self::rate_limit_reset_message_params(
|
|
"You don't have any usage limit resets available.",
|
|
)
|
|
}
|
|
}
|
|
Err(_) => Self::rate_limit_reset_message_params(
|
|
"Couldn't load usage limit resets. Please try again.",
|
|
),
|
|
};
|
|
let replaced = self
|
|
.bottom_pane
|
|
.replace_selection_view_if_present(RATE_LIMIT_RESET_VIEW_ID, params);
|
|
if replaced {
|
|
self.request_redraw();
|
|
}
|
|
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."
|
|
};
|
|
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)
|
|
)),
|
|
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),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn rate_limit_reset_message_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: "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();
|
|
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("Usage limit resets".to_string()),
|
|
subtitle: Some("Resetting your usage...".to_string()),
|
|
items: vec![SelectionItem {
|
|
name: "Using a reset...".to_string(),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
}],
|
|
allow_cancel: false,
|
|
..Default::default()
|
|
});
|
|
self.request_redraw();
|
|
request_id
|
|
}
|
|
|
|
pub(crate) fn finish_rate_limit_reset_consume(
|
|
&mut self,
|
|
request_id: u64,
|
|
idempotency_key: String,
|
|
result: Result<ConsumeAccountRateLimitResetCreditResponse, String>,
|
|
) -> bool {
|
|
if self.pending_rate_limit_reset_request_id != Some(request_id) {
|
|
return false;
|
|
}
|
|
|
|
match result {
|
|
Ok(response)
|
|
if matches!(
|
|
response.outcome,
|
|
ConsumeAccountRateLimitResetCreditOutcome::Reset
|
|
| ConsumeAccountRateLimitResetCreditOutcome::AlreadyRedeemed
|
|
) =>
|
|
{
|
|
self.available_rate_limit_reset_credits = None;
|
|
self.replace_rate_limit_reset_popup(Self::rate_limit_reset_success_loading_params());
|
|
true
|
|
}
|
|
Ok(response) => {
|
|
self.pending_rate_limit_reset_request_id = None;
|
|
let message = match response.outcome {
|
|
ConsumeAccountRateLimitResetCreditOutcome::NothingToReset => {
|
|
"Your usage does not need a reset right now."
|
|
}
|
|
ConsumeAccountRateLimitResetCreditOutcome::NoCredit => {
|
|
self.available_rate_limit_reset_credits = Some(0);
|
|
"No usage limit resets are available."
|
|
}
|
|
ConsumeAccountRateLimitResetCreditOutcome::Reset
|
|
| ConsumeAccountRateLimitResetCreditOutcome::AlreadyRedeemed => unreachable!(),
|
|
};
|
|
self.replace_rate_limit_reset_popup(Self::rate_limit_reset_message_params(message));
|
|
false
|
|
}
|
|
Err(_) => {
|
|
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("Usage limit resets".to_string()),
|
|
subtitle: Some("Couldn't reset usage. Please try again.".to_string()),
|
|
items: vec![
|
|
SelectionItem {
|
|
name: "Try again".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: "Close".to_string(),
|
|
dismiss_on_select: true,
|
|
..Default::default()
|
|
},
|
|
],
|
|
..Default::default()
|
|
});
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
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) => {
|
|
self.available_rate_limit_reset_credits = Some(response.available_count);
|
|
format!(
|
|
"Usage reset. You have {} {} left.",
|
|
response.available_count,
|
|
reset_label(response.available_count)
|
|
)
|
|
}
|
|
Err(_) => "Usage reset.".to_string(),
|
|
};
|
|
self.replace_rate_limit_reset_popup(Self::rate_limit_reset_message_params(&message));
|
|
true
|
|
}
|
|
|
|
fn rate_limit_reset_success_loading_params() -> SelectionViewParams {
|
|
SelectionViewParams {
|
|
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
|
|
title: Some("Usage limit resets".to_string()),
|
|
subtitle: Some("Usage reset. Checking your remaining resets...".to_string()),
|
|
items: vec![SelectionItem {
|
|
name: "Refreshing...".to_string(),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
}],
|
|
allow_cancel: false,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn replace_rate_limit_reset_popup(&mut self, params: SelectionViewParams) {
|
|
if self
|
|
.bottom_pane
|
|
.replace_selection_view_if_present(RATE_LIMIT_RESET_VIEW_ID, params)
|
|
{
|
|
self.request_redraw();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn start_rate_limit_reset_startup_check(&mut self) -> u64 {
|
|
self.clear_pending_rate_limit_reset_hint();
|
|
let request_id = self.take_next_rate_limit_reset_request_id();
|
|
self.pending_rate_limit_reset_hint_request_id = Some(request_id);
|
|
request_id
|
|
}
|
|
|
|
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 let Ok(response) = result {
|
|
self.available_rate_limit_reset_credits = Some(response.available_count);
|
|
self.set_rate_limit_reset_available_hint(response.available_count);
|
|
}
|
|
true
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
pub(crate) fn clear_pending_rate_limit_reset_hint(&mut self) {
|
|
self.pending_rate_limit_reset_hint_request_id = None;
|
|
let cleared_hint = self.pending_rate_limit_reset_hint.take().is_some();
|
|
if cleared_hint {
|
|
self.bump_active_cell_revision();
|
|
self.request_redraw();
|
|
}
|
|
}
|
|
|
|
pub(super) fn pending_rate_limit_reset_hint(&self) -> Option<&PlainHistoryCell> {
|
|
self.pending_rate_limit_reset_hint.as_ref()
|
|
}
|
|
|
|
pub(crate) fn take_pending_rate_limit_reset_hint(&mut self) -> Option<PlainHistoryCell> {
|
|
let hint = self.pending_rate_limit_reset_hint.take()?;
|
|
self.bump_active_cell_revision();
|
|
Some(hint)
|
|
}
|
|
|
|
fn set_rate_limit_reset_available_hint(&mut self, available_count: i64) {
|
|
if available_count <= 0 {
|
|
return;
|
|
}
|
|
self.pending_rate_limit_reset_hint = Some(history_cell::new_info_event(
|
|
format!(
|
|
"You have {available_count} {} available. Run /usage to use one.",
|
|
reset_label(available_count)
|
|
),
|
|
/*hint*/ None,
|
|
));
|
|
self.bump_active_cell_revision();
|
|
self.request_redraw();
|
|
}
|
|
|
|
fn take_next_rate_limit_reset_request_id(&mut self) -> u64 {
|
|
let request_id = self.next_rate_limit_reset_request_id;
|
|
self.next_rate_limit_reset_request_id = self
|
|
.next_rate_limit_reset_request_id
|
|
.wrapping_add(/*rhs*/ 1);
|
|
request_id
|
|
}
|
|
}
|
|
|
|
fn reset_label(count: i64) -> &'static str {
|
|
if count == 1 {
|
|
"usage limit reset"
|
|
} else {
|
|
"usage limit resets"
|
|
}
|
|
}
|