mirror of
https://github.com/openai/codex.git
synced 2026-10-26 07:58:43 +00:00
## Why Codex users can earn personal rate-limit reset credits, but the CLI does not currently provide a way to view or redeem them. The `/usage` command restored in #27925 is intended to be the entry point for usage-related actions, so reset redemption belongs there rather than in a separate dashed slash command. Depends on #28143 for the app-server and backend-client reset-credit APIs. ## What changed - Turn bare `/usage` into a menu with entries for token activity and earned rate-limit resets while preserving `/usage daily`, `/usage weekly`, and `/usage cumulative`. - Add loading, empty, confirmation, success, retry, and error states with a caller-generated UUID idempotency key reused across retries of the same logical reset. - Show an availability hint only for backend-classified rate-limit errors with credits available. - Hide the reset entry for workspace accounts. ## Validation - `just test -p codex-tui chatwidget::tests::usage` — 19 passed. - `just fix -p codex-tui` — passed. - `just fmt` — passed. - `cargo insta pending-snapshots` from `codex-rs/tui` — no pending snapshots. ## Examples <img width="1168" height="304" alt="image" src="https://github.com/user-attachments/assets/caa4c1e3-e996-494d-ae17-50b521f5dce8" /> <img width="908" height="260" alt="image" src="https://github.com/user-attachments/assets/e38a726b-77cc-4bd0-9ea8-9f3ad21c5768" /> ### Reset flow <img width="1509" height="312" alt="image" src="https://github.com/user-attachments/assets/d987013c-78a5-48a2-ad8d-c61ad267a327" /> <img width="585" height="190" alt="image" src="https://github.com/user-attachments/assets/de32be19-79b9-4a3e-8574-6f1c208c98ae" /> <img width="600" height="210" alt="image" src="https://github.com/user-attachments/assets/88a165cf-796d-4fdc-a7bc-ea89917573da" /> <img width="512" height="193" alt="image" src="https://github.com/user-attachments/assets/d2353998-5aa8-442e-a5f8-3a8a5b832753" />
383 lines
15 KiB
Rust
383 lines
15 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::*;
|
|
|
|
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 reset_eligible =
|
|
self.has_chatgpt_account && !self.plan_type.is_some_and(PlanType::is_workspace_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, _) => (true, "Check reset availability.".to_string()),
|
|
(false, _) => (false, "No rate-limit resets available.".to_string()),
|
|
};
|
|
self.bottom_pane.show_selection_view(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 rate 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()
|
|
});
|
|
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("Rate-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,
|
|
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;
|
|
|
|
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 rate-limit resets available.",
|
|
)
|
|
}
|
|
}
|
|
Err(_) => Self::rate_limit_reset_message_params(
|
|
"Couldn't load rate-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(available_count: i64) -> SelectionViewParams {
|
|
let idempotency_key = Uuid::new_v4().to_string();
|
|
SelectionViewParams {
|
|
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
|
|
title: Some("Rate-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 your current Codex usage windows.".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("Rate-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("Rate-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 rate-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("Rate-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,
|
|
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;
|
|
|
|
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("Rate-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,
|
|
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;
|
|
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);
|
|
}
|
|
true
|
|
}
|
|
|
|
pub(crate) fn clear_pending_rate_limit_reset_requests(&mut self) {
|
|
self.pending_rate_limit_reset_request_id = None;
|
|
self.available_rate_limit_reset_credits = None;
|
|
self.clear_pending_rate_limit_reset_hint();
|
|
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 {
|
|
"rate-limit reset"
|
|
} else {
|
|
"rate-limit resets"
|
|
}
|
|
}
|