feat(tui): add rate-limit reset redemption to /usage (#28154)

## 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"
/>
This commit is contained in:
jay
2026-06-16 17:59:40 +00:00
committed by GitHub
parent 1e6970542e
commit f8f5a6e78f
30 changed files with 1552 additions and 109 deletions
@@ -129,6 +129,11 @@ impl ChatWidget {
refreshing_token_activity_output: None,
completed_token_activity_output: None,
next_token_activity_request_id: 0,
pending_rate_limit_reset_request_id: None,
pending_rate_limit_reset_hint_request_id: None,
pending_rate_limit_reset_hint: None,
available_rate_limit_reset_credits: None,
next_rate_limit_reset_request_id: 0,
plan_type: initial_plan_type,
codex_rate_limit_reached_type: None,
rate_limit_warnings: RateLimitWarningState::default(),
@@ -10,7 +10,7 @@ impl ChatWidget {
pub(super) fn clear_active_hook_cell(&mut self) {
if self.active_hook_cell.take().is_some() {
self.bump_active_cell_revision();
self.request_completed_token_activity_output_insertion();
self.request_pending_usage_output_insertion();
}
}
@@ -85,7 +85,7 @@ impl ChatWidget {
self.transcript.needs_final_message_separator = true;
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(completed_cell)));
self.request_completed_token_activity_output_insertion();
self.request_pending_usage_output_insertion();
}
pub(super) fn finish_active_hook_cell_if_idle(&mut self) {
@@ -95,7 +95,7 @@ impl ChatWidget {
if cell.is_empty() {
self.active_hook_cell = None;
self.bump_active_cell_revision();
self.request_completed_token_activity_output_insertion();
self.request_pending_usage_output_insertion();
return;
}
if cell.should_flush()
@@ -105,7 +105,7 @@ impl ChatWidget {
self.transcript.needs_final_message_separator = true;
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(cell)));
self.request_completed_token_activity_output_insertion();
self.request_pending_usage_output_insertion();
}
}
+10
View File
@@ -36,6 +36,16 @@ impl ChatWidget {
})),
);
}
if let Some(cell) = self.pending_rate_limit_reset_hint() {
flex.push(
/*flex*/ 1,
RenderableItem::Owned(Box::new(TranscriptAreaRenderable {
child: cell,
top: 1,
right: active_cell_right_reserve,
})),
);
}
flex.push(
/*flex*/ 0,
RenderableItem::Owned(Box::new(BottomPaneComposerReserveRenderable {
+1
View File
@@ -222,6 +222,7 @@ impl ChatWidget {
|| self.has_codex_backend_auth != has_codex_backend_auth;
if account_state_changed {
self.clear_pending_token_activity_refreshes();
self.clear_pending_rate_limit_reset_requests();
}
self.status_account_display = status_account_display;
self.plan_type = plan_type;
@@ -434,8 +434,8 @@ impl ChatWidget {
}
}
SlashCommand::Usage => {
if self.ensure_token_activity_command_available() {
self.add_token_activity_output(tokens::TokenActivityView::Daily);
if self.ensure_usage_command_available() {
self.open_usage_menu();
}
}
SlashCommand::Ide => {
@@ -658,7 +658,7 @@ impl ChatWidget {
let trimmed = args.trim();
match cmd {
SlashCommand::Usage => {
if self.ensure_token_activity_command_available() {
if self.ensure_usage_command_available() {
match tokens::TokenActivityView::parse(trimmed) {
Some(view) => self.add_token_activity_output(view),
None => self.add_error_message(
@@ -1017,7 +1017,7 @@ impl ChatWidget {
}
}
fn ensure_token_activity_command_available(&mut self) -> bool {
fn ensure_usage_command_available(&mut self) -> bool {
if self.has_codex_backend_auth {
return true;
}
@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests/usage.rs
expression: rendered
---
• You have 2 rate-limit resets available. Run /usage to use one.
@@ -0,0 +1,7 @@
---
source: tui/src/chatwidget/tests/usage.rs
expression: "lines_to_single_string(&chat.active_cell_transcript_lines(80).expect(\"active output with reset hint\"),)"
---
active tool
• You have 2 rate-limit resets available. Run /usage to use one.
@@ -0,0 +1,71 @@
---
source: tui/src/chatwidget/tests/usage.rs
expression: "states.join(\"\\n---\\n\")"
---
Rate-limit resets
Checking your available resets...
Loading...
Press enter to confirm or esc to go back
---
Rate-limit resets
You have 2 rate-limit resets available.
1. Use a reset Reset your current Codex usage windows.
2. Cancel
Press enter to confirm or esc to go back
---
Rate-limit resets
You don't have any rate-limit resets available.
1. Close
Press enter to confirm or esc to go back
---
Rate-limit resets
Couldn't load rate-limit resets. Please try again.
1. Close
Press enter to confirm or esc to go back
---
Rate-limit resets
Resetting your usage...
Using a reset...
---
Rate-limit resets
Couldn't reset usage. Please try again.
1. Try again
2. Close
Press enter to confirm or esc to go back
---
Rate-limit resets
Your usage does not need a reset right now.
1. Close
Press enter to confirm or esc to go back
---
Rate-limit resets
No rate-limit resets are available.
1. Close
Press enter to confirm or esc to go back
---
Rate-limit resets
Usage reset. Checking your remaining resets...
Refreshing...
---
Rate-limit resets
Usage reset. You have 1 rate-limit reset left.
1. Close
Press enter to confirm or esc to go back
@@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests/usage.rs
expression: "render_bottom_popup(&chat, 80)"
---
Usage
View account usage or redeem an earned reset.
1. Show usage View recent account token usage.
2. Redeem rate limit reset You have 2 rate-limit resets available.
Press enter to confirm or esc to go back
@@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests/usage.rs
expression: "render_bottom_popup(&chat, 80)"
---
Usage
View account usage or redeem an earned reset.
1. Show usage View recent account token usage.
2. Redeem rate limit reset Check reset availability.
Press enter to confirm or esc to go back
@@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests/usage.rs
expression: "render_bottom_popup(&chat, 80)"
---
Usage
View account usage or redeem an earned reset.
1. Show usage View recent account token usage.
2. Redeem rate limit reset Check reset availability.
Press enter to confirm or esc to go back
+2 -2
View File
@@ -54,7 +54,7 @@ impl ChatWidget {
self.app_event_tx.send(AppEvent::StopCommitAnimation);
}
if had_stream_controller {
self.request_completed_token_activity_output_insertion();
self.request_pending_usage_output_insertion_after_stream_shutdown();
}
}
@@ -193,7 +193,7 @@ impl ChatWidget {
if should_restore_after_stream {
self.status_state.pending_status_indicator_restore = true;
self.maybe_restore_status_indicator_after_stream_idle();
self.request_completed_token_activity_output_insertion();
self.request_pending_usage_output_insertion_after_stream_shutdown();
}
}
+1
View File
@@ -252,6 +252,7 @@ mod status_and_layout;
mod status_command_tests;
mod status_surface_previews;
mod terminal_title;
mod usage;
pub(crate) use helpers::make_chatwidget_manual_with_sender;
pub(crate) use helpers::set_chatgpt_auth;
@@ -83,7 +83,7 @@ fn dispatch_usage_and_expect_refresh(
chat: &mut ChatWidget,
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
) -> u64 {
chat.dispatch_command(SlashCommand::Usage);
chat.dispatch_command_with_args(SlashCommand::Usage, "daily".to_string(), Vec::new());
expect_token_activity_refresh(rx)
}
@@ -1245,7 +1245,7 @@ async fn usage_command_runs_with_backend_auth_without_chatgpt_account_flag() {
/*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true,
);
chat.dispatch_command(SlashCommand::Usage);
chat.dispatch_command_with_args(SlashCommand::Usage, "daily".to_string(), Vec::new());
assert_matches!(rx.try_recv(), Ok(AppEvent::RefreshTokenActivity { .. }));
assert!(!chat.has_chatgpt_account());
@@ -1259,7 +1259,7 @@ async fn usage_command_runs_with_backend_auth_from_widget_init() {
)
.await;
chat.dispatch_command(SlashCommand::Usage);
chat.dispatch_command_with_args(SlashCommand::Usage, "daily".to_string(), Vec::new());
assert_matches!(rx.try_recv(), Ok(AppEvent::RefreshTokenActivity { .. }));
assert!(!chat.has_chatgpt_account());
@@ -1375,7 +1375,7 @@ async fn completed_token_activity_refresh_waits_for_active_stream() {
let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx);
chat.on_agent_message_delta("partial response".to_string());
assert!(chat.token_activity_history_insertion_blocked());
assert!(chat.usage_history_insertion_blocked());
assert!(
chat.finish_token_activity_refresh(
@@ -1390,10 +1390,10 @@ async fn completed_token_activity_refresh_waits_for_active_stream() {
);
chat.finalize_turn();
assert!(!chat.token_activity_history_insertion_blocked());
assert!(!chat.usage_history_insertion_blocked());
assert!(
std::iter::from_fn(|| rx.try_recv().ok())
.any(|event| matches!(event, AppEvent::CommitCompletedTokenActivityOutput))
.any(|event| matches!(event, AppEvent::CommitPendingUsageOutputAfterStreamShutdown))
);
assert!(chat.take_completed_token_activity_output().is_some());
}
@@ -1414,11 +1414,11 @@ async fn completed_token_activity_refresh_waits_for_queued_stream_consolidation(
Err("token activity unavailable".to_string()),
)
);
assert!(chat.token_activity_history_insertion_blocked());
assert!(chat.usage_history_insertion_blocked());
chat.note_stream_consolidation_completed();
assert!(!chat.token_activity_history_insertion_blocked());
assert!(!chat.usage_history_insertion_blocked());
}
#[tokio::test]
@@ -1436,15 +1436,12 @@ async fn completed_token_activity_refresh_waits_for_active_history_cell() {
Err("token activity unavailable".to_string()),
)
);
assert!(chat.token_activity_history_insertion_blocked());
assert!(chat.usage_history_insertion_blocked());
chat.flush_active_cell();
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::CommitCompletedTokenActivityOutput)
);
assert_matches!(rx.try_recv(), Ok(AppEvent::CommitPendingUsageOutput));
}
#[tokio::test]
@@ -1469,7 +1466,7 @@ async fn completed_token_activity_refresh_waits_for_active_hook() {
Err("token activity unavailable".to_string()),
)
);
assert!(chat.token_activity_history_insertion_blocked());
assert!(chat.usage_history_insertion_blocked());
handle_hook_completed(
&mut chat,
@@ -1486,10 +1483,7 @@ async fn completed_token_activity_refresh_waits_for_active_hook() {
);
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::CommitCompletedTokenActivityOutput)
);
assert_matches!(rx.try_recv(), Ok(AppEvent::CommitPendingUsageOutput));
}
#[tokio::test]
@@ -1516,7 +1510,7 @@ async fn completed_token_activity_refresh_retries_after_plan_item_completion() {
assert!(
std::iter::from_fn(|| rx.try_recv().ok())
.any(|event| matches!(event, AppEvent::CommitCompletedTokenActivityOutput))
.any(|event| matches!(event, AppEvent::CommitPendingUsageOutputAfterStreamShutdown))
);
}
+597
View File
@@ -0,0 +1,597 @@
use super::*;
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditOutcome;
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse;
use codex_app_server_protocol::RateLimitResetCreditsSummary;
use uuid::Uuid;
const TEST_OVERLAY_VIEW_ID: &str = "usage-test-overlay";
#[tokio::test]
async fn usage_command_opens_menu_when_reset_is_available_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let request_id = chat.start_rate_limit_reset_startup_check();
assert!(chat.finish_rate_limit_reset_hint_refresh(
request_id,
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
));
chat.dispatch_command(SlashCommand::Usage);
assert_chatwidget_snapshot!(
"usage_command_menu",
render_bottom_popup(&chat, /*width*/ 80)
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity));
}
#[tokio::test]
async fn usage_command_can_recheck_reset_availability_after_cached_zero_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let request_id = chat.start_rate_limit_reset_startup_check();
assert!(chat.finish_rate_limit_reset_hint_refresh(
request_id,
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
));
chat.dispatch_command(SlashCommand::Usage);
assert_chatwidget_snapshot!(
"usage_command_menu_without_resets",
render_bottom_popup(&chat, /*width*/ 80)
);
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits));
}
#[tokio::test]
async fn usage_command_can_check_reset_availability_before_startup_refresh_finishes_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.start_rate_limit_reset_startup_check();
chat.dispatch_command(SlashCommand::Usage);
assert_chatwidget_snapshot!(
"usage_command_menu_before_reset_refresh",
render_bottom_popup(&chat, /*width*/ 80)
);
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits));
}
#[tokio::test]
async fn usage_command_disables_reset_for_workspace_accounts() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.plan_type = Some(PlanType::Business);
chat.dispatch_command(SlashCommand::Usage);
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity));
}
#[tokio::test]
async fn usage_menu_rate_limit_reset_entry_opens_reset_flow() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let request_id = chat.start_rate_limit_reset_startup_check();
assert!(chat.finish_rate_limit_reset_hint_refresh(
request_id,
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
));
chat.dispatch_command(SlashCommand::Usage);
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits));
}
#[tokio::test]
async fn rate_limit_reset_popup_states_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let mut states = Vec::new();
let loading_request_id = chat.show_rate_limit_reset_loading_popup();
record_popup(&chat, &mut states);
assert!(chat.finish_rate_limit_reset_credits_refresh(
loading_request_id,
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
));
record_popup(&chat, &mut states);
dismiss_popup(&mut chat);
let empty_request_id = chat.show_rate_limit_reset_loading_popup();
assert!(chat.finish_rate_limit_reset_credits_refresh(
empty_request_id,
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
));
record_popup(&chat, &mut states);
dismiss_popup(&mut chat);
let load_error_request_id = chat.show_rate_limit_reset_loading_popup();
assert!(chat.finish_rate_limit_reset_credits_refresh(
load_error_request_id,
Err("backend unavailable".to_string()),
));
record_popup(&chat, &mut states);
dismiss_popup(&mut chat);
let consuming_request_id = chat.show_rate_limit_reset_consuming_popup();
record_popup(&chat, &mut states);
assert!(!chat.finish_rate_limit_reset_consume(
consuming_request_id,
"redeem-1".to_string(),
Err("request timed out".to_string()),
));
record_popup(&chat, &mut states);
dismiss_popup(&mut chat);
let nothing_request_id = chat.show_rate_limit_reset_consuming_popup();
assert!(!finish_reset_consume_outcome(
&mut chat,
nothing_request_id,
"redeem-2",
ConsumeAccountRateLimitResetCreditOutcome::NothingToReset,
));
record_popup(&chat, &mut states);
dismiss_popup(&mut chat);
let no_credit_request_id = chat.show_rate_limit_reset_consuming_popup();
assert!(!finish_reset_consume_outcome(
&mut chat,
no_credit_request_id,
"redeem-3",
ConsumeAccountRateLimitResetCreditOutcome::NoCredit,
));
record_popup(&chat, &mut states);
dismiss_popup(&mut chat);
let success_request_id = chat.show_rate_limit_reset_consuming_popup();
assert!(finish_reset_consume_outcome(
&mut chat,
success_request_id,
"redeem-4",
ConsumeAccountRateLimitResetCreditOutcome::Reset,
));
record_popup(&chat, &mut states);
assert!(chat.finish_post_consume_reset_credits_refresh(
success_request_id,
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
));
record_popup(&chat, &mut states);
assert_chatwidget_snapshot!("rate_limit_reset_popup_states", states.join("\n---\n"));
}
#[tokio::test]
async fn rate_limit_reset_confirmation_selects_cancel_by_default() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let request_id = chat.show_rate_limit_reset_loading_popup();
assert!(chat.finish_rate_limit_reset_credits_refresh(
request_id,
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(chat.bottom_pane.no_modal_or_popup_active());
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn rate_limit_reset_confirmation_can_use_reset() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let request_id = chat.show_rate_limit_reset_loading_popup();
assert!(chat.finish_rate_limit_reset_credits_refresh(
request_id,
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
));
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::ConsumeRateLimitResetCredit { idempotency_key })
if Uuid::parse_str(&idempotency_key).is_ok()
);
}
#[tokio::test]
async fn rate_limit_reset_retry_reuses_idempotency_key() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let request_id = chat.show_rate_limit_reset_consuming_popup();
assert!(!chat.finish_rate_limit_reset_consume(
request_id,
"stable-redeem-id".to_string(),
Err("response lost".to_string()),
));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::ConsumeRateLimitResetCredit { idempotency_key })
if idempotency_key == "stable-redeem-id"
);
}
#[tokio::test]
async fn no_credit_outcome_allows_reset_availability_recheck() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let startup_request_id = chat.start_rate_limit_reset_startup_check();
assert!(chat.finish_rate_limit_reset_hint_refresh(
startup_request_id,
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
));
let consume_request_id = chat.show_rate_limit_reset_consuming_popup();
assert!(!finish_reset_consume_outcome(
&mut chat,
consume_request_id,
"redeem-1",
ConsumeAccountRateLimitResetCreditOutcome::NoCredit,
));
dismiss_popup(&mut chat);
chat.dispatch_command(SlashCommand::Usage);
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits));
}
#[tokio::test]
async fn rate_limit_reset_redemption_cannot_be_dismissed_while_in_flight() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let request_id = chat.show_rate_limit_reset_consuming_popup();
dismiss_popup(&mut chat);
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Using a reset..."));
assert!(finish_reset_consume_outcome(
&mut chat,
request_id,
"redeem-123",
ConsumeAccountRateLimitResetCreditOutcome::Reset,
));
dismiss_popup(&mut chat);
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Refreshing..."));
assert!(chat.finish_post_consume_reset_credits_refresh(
request_id,
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
));
dismiss_popup(&mut chat);
assert!(chat.bottom_pane.no_modal_or_popup_active());
}
#[tokio::test]
async fn rate_limit_reset_redemption_allows_ctrl_c_to_quit_while_in_flight() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.show_rate_limit_reset_consuming_popup();
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Using a reset..."));
}
#[tokio::test]
async fn already_redeemed_is_an_idempotent_success() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let request_id = chat.show_rate_limit_reset_consuming_popup();
assert!(finish_reset_consume_outcome(
&mut chat,
request_id,
"stable-redeem-id",
ConsumeAccountRateLimitResetCreditOutcome::AlreadyRedeemed,
));
assert!(chat.finish_post_consume_reset_credits_refresh(
request_id,
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
));
assert!(
render_bottom_popup(&chat, /*width*/ 80)
.contains("Usage reset. You have 0 rate-limit resets left.")
);
}
#[tokio::test]
async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let startup_request_id = chat.start_rate_limit_reset_startup_check();
assert!(chat.finish_rate_limit_reset_hint_refresh(
startup_request_id,
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
));
let consume_request_id = chat.show_rate_limit_reset_consuming_popup();
assert!(finish_reset_consume_outcome(
&mut chat,
consume_request_id,
"redeem-with-refresh-error",
ConsumeAccountRateLimitResetCreditOutcome::Reset,
));
assert!(chat.finish_post_consume_reset_credits_refresh(
consume_request_id,
Err("backend unavailable".to_string()),
));
dismiss_popup(&mut chat);
chat.dispatch_command(SlashCommand::Usage);
let rendered = render_bottom_popup(&chat, /*width*/ 80);
assert!(rendered.contains("Check reset availability."));
assert!(!rendered.contains("You have 2 rate-limit resets available."));
}
#[tokio::test]
async fn account_change_invalidates_pending_reset_requests() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let request_id = chat.show_rate_limit_reset_loading_popup();
chat.update_account_state(
/*status_account_display*/ None, /*plan_type*/ None,
/*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false,
);
assert!(!chat.finish_rate_limit_reset_credits_refresh(
request_id,
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
));
assert!(chat.bottom_pane.no_modal_or_popup_active());
}
#[tokio::test]
async fn clearing_pending_reset_hint_preserves_in_flight_redemption() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let consume_request_id = chat.show_rate_limit_reset_consuming_popup();
let hint_request_id = chat.start_rate_limit_reset_startup_check();
assert!(chat.finish_rate_limit_reset_hint_refresh(
hint_request_id,
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
));
chat.clear_pending_rate_limit_reset_hint();
assert!(chat.pending_rate_limit_reset_hint().is_none());
assert!(finish_reset_consume_outcome(
&mut chat,
consume_request_id,
"redeem-after-rollback",
ConsumeAccountRateLimitResetCreditOutcome::Reset,
));
}
#[tokio::test]
async fn rate_limit_reset_load_result_updates_popup_beneath_overlay() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let request_id = chat.show_rate_limit_reset_loading_popup();
show_usage_test_overlay(&mut chat);
assert!(chat.finish_rate_limit_reset_credits_refresh(
request_id,
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
));
assert_eq!(
chat.bottom_pane.active_view_id(),
Some(TEST_OVERLAY_VIEW_ID)
);
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(
render_bottom_popup(&chat, /*width*/ 80)
.contains("You have 2 rate-limit resets available.")
);
}
#[tokio::test]
async fn rate_limit_reset_success_updates_popup_beneath_overlay() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let request_id = chat.show_rate_limit_reset_consuming_popup();
show_usage_test_overlay(&mut chat);
assert!(finish_reset_consume_outcome(
&mut chat,
request_id,
"redeem-covered",
ConsumeAccountRateLimitResetCreditOutcome::Reset,
));
assert!(chat.finish_post_consume_reset_credits_refresh(
request_id,
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
));
assert_eq!(
chat.bottom_pane.active_view_id(),
Some(TEST_OVERLAY_VIEW_ID)
);
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(
render_bottom_popup(&chat, /*width*/ 80)
.contains("Usage reset. You have 1 rate-limit reset left.")
);
}
#[tokio::test]
async fn account_change_dismisses_reset_popup_beneath_overlay() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.show_rate_limit_reset_loading_popup();
show_usage_test_overlay(&mut chat);
chat.update_account_state(
/*status_account_display*/ None, /*plan_type*/ None,
/*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false,
);
assert_eq!(
chat.bottom_pane.active_view_id(),
Some(TEST_OVERLAY_VIEW_ID)
);
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(chat.bottom_pane.no_modal_or_popup_active());
}
#[tokio::test]
async fn startup_check_shows_available_reset_hint_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let hint_request_id = chat.start_rate_limit_reset_startup_check();
assert!(chat.finish_rate_limit_reset_hint_refresh(
hint_request_id,
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
));
let rendered = lines_to_single_string(
&chat
.pending_rate_limit_reset_hint()
.expect("pending reset hint")
.display_lines(/*width*/ 80),
);
assert_chatwidget_snapshot!("rate_limit_reset_available_hint", rendered);
}
#[tokio::test]
async fn startup_reset_hint_waits_for_active_output_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let hint_request_id = chat.start_rate_limit_reset_startup_check();
chat.transcript.active_cell = Some(Box::new(PlainHistoryCell::new(vec![Line::from(
"active tool",
)])));
assert!(chat.finish_rate_limit_reset_hint_refresh(
hint_request_id,
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
));
assert!(chat.usage_history_insertion_blocked());
assert!(drain_insert_history(&mut rx).is_empty());
assert_chatwidget_snapshot!(
"rate_limit_reset_hint_waits_for_active_output",
lines_to_single_string(
&chat
.active_cell_transcript_lines(/*width*/ 80)
.expect("active output with reset hint"),
)
);
chat.flush_active_cell();
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
assert_matches!(rx.try_recv(), Ok(AppEvent::CommitPendingUsageOutput));
}
#[tokio::test]
async fn opening_rate_limit_reset_flow_invalidates_in_flight_startup_hint() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let hint_request_id = chat.start_rate_limit_reset_startup_check();
chat.show_rate_limit_reset_loading_popup();
assert!(!chat.finish_rate_limit_reset_hint_refresh(
hint_request_id,
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
));
assert!(chat.pending_rate_limit_reset_hint().is_none());
}
#[tokio::test]
async fn starting_rate_limit_reset_redemption_clears_deferred_startup_hint() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let hint_request_id = chat.start_rate_limit_reset_startup_check();
assert!(chat.finish_rate_limit_reset_hint_refresh(
hint_request_id,
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
));
assert!(chat.pending_rate_limit_reset_hint().is_some());
chat.show_rate_limit_reset_consuming_popup();
assert!(chat.pending_rate_limit_reset_hint().is_none());
}
#[tokio::test]
async fn startup_check_omits_reset_hint_when_none_are_available() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let hint_request_id = chat.start_rate_limit_reset_startup_check();
assert!(chat.finish_rate_limit_reset_hint_refresh(
hint_request_id,
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
));
assert!(chat.pending_rate_limit_reset_hint().is_none());
}
#[tokio::test]
async fn startup_check_omits_reset_hint_for_workspace_accounts() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.plan_type = Some(PlanType::Business);
let hint_request_id = chat.start_rate_limit_reset_startup_check();
assert!(chat.finish_rate_limit_reset_hint_refresh(
hint_request_id,
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
));
assert!(chat.pending_rate_limit_reset_hint().is_none());
assert_eq!(chat.available_rate_limit_reset_credits, None);
}
fn consume_response(
outcome: ConsumeAccountRateLimitResetCreditOutcome,
) -> ConsumeAccountRateLimitResetCreditResponse {
ConsumeAccountRateLimitResetCreditResponse { outcome }
}
fn finish_reset_consume_outcome(
chat: &mut ChatWidget,
request_id: u64,
idempotency_key: &str,
outcome: ConsumeAccountRateLimitResetCreditOutcome,
) -> bool {
chat.finish_rate_limit_reset_consume(
request_id,
idempotency_key.to_string(),
Ok(consume_response(outcome)),
)
}
fn record_popup(chat: &ChatWidget, states: &mut Vec<String>) {
states.push(render_bottom_popup(chat, /*width*/ 80));
}
fn dismiss_popup(chat: &mut ChatWidget) {
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
}
fn show_usage_test_overlay(chat: &mut ChatWidget) {
chat.bottom_pane.show_selection_view(SelectionViewParams {
view_id: Some(TEST_OVERLAY_VIEW_ID),
title: Some("Covering overlay".to_string()),
items: vec![SelectionItem {
name: "Close".to_string(),
dismiss_on_select: true,
..Default::default()
}],
..Default::default()
});
}
+21 -11
View File
@@ -31,7 +31,7 @@ use crate::history_cell::HistoryCell;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::plain_lines;
pub(super) use chart::TokenActivityView;
pub(crate) use chart::TokenActivityView;
/// Tracks the renderable lifecycle of one token activity history cell.
#[derive(Debug)]
@@ -153,7 +153,7 @@ impl ChatWidget {
/// Each invocation receives a request ID so background responses update only
/// their own card. The card remains outside transcript history until completion,
/// which keeps loading visible without disturbing existing transcript content.
pub(super) fn add_token_activity_output(&mut self, view: TokenActivityView) {
pub(crate) fn add_token_activity_output(&mut self, view: TokenActivityView) {
let request_id = self.next_token_activity_request_id;
self.next_token_activity_request_id =
self.next_token_activity_request_id.wrapping_add(/*rhs*/ 1);
@@ -210,12 +210,12 @@ impl ChatWidget {
true
}
/// Reports whether a completed token activity card must wait before insertion.
/// Reports whether completed asynchronous usage output must wait before insertion.
///
/// Inserting while a stream, queued consolidation, or active transcript cell is
/// present can reorder the card relative to visible output, so callers retry once
/// present can reorder output relative to visible work, so callers retry once
/// these barriers clear.
pub(crate) fn token_activity_history_insertion_blocked(&self) -> bool {
pub(crate) fn usage_history_insertion_blocked(&self) -> bool {
self.stream_controller.is_some()
|| self.plan_stream_controller.is_some()
|| self.pending_stream_consolidations > 0
@@ -244,7 +244,7 @@ impl ChatWidget {
/// Transfers the completed token activity card into the history insertion path.
///
/// Callers should use this only after
/// [`ChatWidget::token_activity_history_insertion_blocked`] returns `false`;
/// [`ChatWidget::usage_history_insertion_blocked`] returns `false`;
/// taking the card removes it from the transient render area.
pub(crate) fn take_completed_token_activity_output(&mut self) -> Option<CompositeHistoryCell> {
let output = self.completed_token_activity_output.take()?;
@@ -252,14 +252,24 @@ impl ChatWidget {
Some(output)
}
/// Requests another insertion attempt when a completed card is waiting.
/// Requests another insertion attempt when completed usage output is waiting.
///
/// This is used after stream or history lifecycle events that may have cleared
/// the insertion barriers without directly owning the completed card.
pub(crate) fn request_completed_token_activity_output_insertion(&self) {
if self.completed_token_activity_output.is_some() {
/// the insertion barriers without directly owning the completed output.
pub(crate) fn request_pending_usage_output_insertion(&self) {
if self.completed_token_activity_output.is_some()
|| self.pending_rate_limit_reset_hint().is_some()
{
self.app_event_tx.send(AppEvent::CommitPendingUsageOutput);
}
}
pub(crate) fn request_pending_usage_output_insertion_after_stream_shutdown(&self) {
if self.completed_token_activity_output.is_some()
|| self.pending_rate_limit_reset_hint().is_some()
{
self.app_event_tx
.send(AppEvent::CommitCompletedTokenActivityOutput);
.send(AppEvent::CommitPendingUsageOutputAfterStreamShutdown);
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ const SUMMARY_INDENT_WIDTH: u16 = 1;
/// Selects the aggregation represented by the token activity chart.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(in crate::chatwidget) enum TokenActivityView {
pub(crate) enum TokenActivityView {
Daily,
Weekly,
Cumulative,
+5 -5
View File
@@ -52,7 +52,7 @@ impl ChatWidget {
self.transcript.reset_turn_flags();
self.adaptive_chunking.reset();
if self.plan_stream_controller.take().is_some() {
self.request_completed_token_activity_output_insertion();
self.request_pending_usage_output_insertion_after_stream_shutdown();
}
self.turn_runtime_metrics = RuntimeMetricsSummary::default();
self.session_telemetry.reset_runtime_metrics();
@@ -128,7 +128,7 @@ impl ChatWidget {
self.app_event_tx
.send(AppEvent::ConsolidateProposedPlan(source));
}
self.request_completed_token_activity_output_insertion();
self.request_pending_usage_output_insertion_after_stream_shutdown();
}
self.flush_unified_exec_wait_streak();
if !from_replay {
@@ -317,7 +317,7 @@ impl ChatWidget {
self.adaptive_chunking.reset();
self.stream_controller = None;
self.plan_stream_controller = None;
self.request_completed_token_activity_output_insertion();
self.request_pending_usage_output_insertion_after_stream_shutdown();
self.status_state.pending_status_indicator_restore = false;
self.clear_cancel_edit();
self.request_status_line_branch_refresh();
@@ -366,8 +366,9 @@ impl ChatWidget {
}
pub(super) fn on_rate_limit_error(&mut self, error_kind: RateLimitErrorKind, message: String) {
let usage_limit_error = matches!(error_kind, RateLimitErrorKind::UsageLimit);
let rate_limit_reached_type = self.codex_rate_limit_reached_type.map(|kind| {
if matches!(error_kind, RateLimitErrorKind::UsageLimit) {
if usage_limit_error {
match kind {
RateLimitReachedType::WorkspaceOwnerCreditsDepleted => {
RateLimitReachedType::WorkspaceOwnerUsageLimitReached
@@ -382,7 +383,6 @@ impl ChatWidget {
}
});
self.codex_rate_limit_reached_type = rate_limit_reached_type;
match rate_limit_reached_type {
Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted) => {
self.on_error(
+382
View File
@@ -0,0 +1,382 @@
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"
}
}