mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+5
@@ -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.
|
||||
+7
@@ -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.
|
||||
+71
@@ -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
|
||||
+11
@@ -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
|
||||
+11
@@ -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
|
||||
+11
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user