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 10:59:40 -07:00
committed by GitHub
Unverified
parent 1e6970542e
commit f8f5a6e78f
30 changed files with 1552 additions and 109 deletions
+9 -3
View File
@@ -113,7 +113,6 @@ use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginUninstallParams;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::SandboxMode as AppServerSandboxMode;
use codex_app_server_protocol::SendAddCreditsNudgeEmailParams;
use codex_app_server_protocol::ServerNotification;
@@ -1116,9 +1115,16 @@ See the Codex keymap documentation for supported actions and examples."
);
app.refresh_startup_skills(&app_server);
// Kick off a non-blocking rate-limit prefetch so the first `/status`
// already has data, without delaying the initial frame render.
// already has data and available reset credits can be surfaced, without
// delaying the initial frame render.
if requires_openai_auth && has_chatgpt_account {
app.refresh_rate_limits(&app_server, RateLimitRefreshOrigin::StartupPrefetch);
let reset_hint_request_id = app.chat_widget.start_rate_limit_reset_startup_check();
app.refresh_rate_limits(
&app_server,
RateLimitRefreshOrigin::StartupPrefetch {
reset_hint_request_id,
},
);
}
let mut listen_for_app_server_events = true;
+99 -11
View File
@@ -10,6 +10,8 @@ use crate::app_event::ConnectorsSnapshot;
use crate::config_update::format_config_error;
use codex_app_server_protocol::AppsListParams;
use codex_app_server_protocol::AppsListResponse;
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditParams;
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse;
use codex_app_server_protocol::MarketplaceAddParams;
use codex_app_server_protocol::MarketplaceAddResponse;
use codex_app_server_protocol::MarketplaceRemoveParams;
@@ -17,6 +19,7 @@ use codex_app_server_protocol::MarketplaceRemoveResponse;
use codex_app_server_protocol::MarketplaceUpgradeParams;
use codex_app_server_protocol::MarketplaceUpgradeResponse;
use codex_app_server_protocol::RateLimitResetCreditsSummary;
use codex_app_server_protocol::RequestId;
use crate::hooks_rpc::fetch_hooks_list;
@@ -26,6 +29,8 @@ use codex_utils_absolute_path::AbsolutePathBuf;
const TOKEN_ACTIVITY_FETCH_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(/*secs*/ 15);
const RATE_LIMIT_RESET_REQUEST_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(/*secs*/ 15);
impl App {
pub(super) fn fetch_mcp_inventory(
@@ -63,9 +68,9 @@ impl App {
/// result as a `RateLimitsLoaded` event.
///
/// The `origin` is forwarded to the completion handler so it can distinguish
/// a startup prefetch (which only updates cached snapshots and schedules a
/// frame) from a `/status`-triggered refresh (which must finalize the
/// corresponding status card).
/// a startup prefetch (which updates cached snapshots and may surface a
/// reset-credit notice) from a `/status`-triggered refresh (which must
/// finalize the corresponding status card).
pub(super) fn refresh_rate_limits(
&mut self,
app_server: &AppServerSession,
@@ -74,9 +79,19 @@ impl App {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = fetch_account_rate_limits(request_handle)
.await
.map_err(|err| err.to_string());
let request = fetch_account_rate_limits(request_handle);
let result = match origin {
RateLimitRefreshOrigin::ResetConsume { .. } => {
tokio::time::timeout(RATE_LIMIT_RESET_REQUEST_TIMEOUT, request)
.await
.map_err(|_| "account/rateLimits/read timed out in TUI".to_string())
.and_then(|result| result.map_err(|err| err.to_string()))
}
RateLimitRefreshOrigin::StartupPrefetch { .. }
| RateLimitRefreshOrigin::StatusCommand { .. } => {
request.await.map_err(|err| err.to_string())
}
};
app_event_tx.send(AppEvent::RateLimitsLoaded { origin, result });
});
}
@@ -100,6 +115,49 @@ impl App {
});
}
pub(super) fn refresh_rate_limit_reset_credits(
&mut self,
app_server: &AppServerSession,
request_id: u64,
) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = tokio::time::timeout(
RATE_LIMIT_RESET_REQUEST_TIMEOUT,
fetch_rate_limit_reset_credits(request_handle),
)
.await
.map_err(|_| "account/rateLimits/read timed out in TUI".to_string())
.and_then(|result| result.map_err(|err| err.to_string()));
app_event_tx.send(AppEvent::RateLimitResetCreditsLoaded { request_id, result });
});
}
pub(super) fn consume_rate_limit_reset_credit(
&mut self,
app_server: &AppServerSession,
request_id: u64,
idempotency_key: String,
) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = tokio::time::timeout(
RATE_LIMIT_RESET_REQUEST_TIMEOUT,
consume_rate_limit_reset_credit_request(request_handle, idempotency_key.clone()),
)
.await
.map_err(|_| "account/rateLimitResetCredit/consume timed out in TUI".to_string())
.and_then(|result| result.map_err(|err| err.to_string()));
app_event_tx.send(AppEvent::RateLimitResetCreditConsumed {
request_id,
idempotency_key,
result,
});
});
}
pub(super) fn send_add_credits_nudge_email(
&mut self,
app_server: &AppServerSession,
@@ -682,17 +740,15 @@ pub(super) async fn fetch_all_mcp_server_statuses(
pub(super) async fn fetch_account_rate_limits(
request_handle: AppServerRequestHandle,
) -> Result<Vec<RateLimitSnapshot>> {
) -> Result<GetAccountRateLimitsResponse> {
let request_id = RequestId::String(format!("account-rate-limits-{}", Uuid::new_v4()));
let response: GetAccountRateLimitsResponse = request_handle
request_handle
.request_typed(ClientRequest::GetAccountRateLimits {
request_id,
params: None,
})
.await
.wrap_err("account/rateLimits/read failed in TUI")?;
Ok(app_server_rate_limit_snapshots(response))
.wrap_err("account/rateLimits/read failed in TUI")
}
pub(super) async fn fetch_account_token_activity(
@@ -708,6 +764,38 @@ pub(super) async fn fetch_account_token_activity(
.wrap_err("account/usage/read failed in TUI")
}
pub(super) async fn fetch_rate_limit_reset_credits(
request_handle: AppServerRequestHandle,
) -> Result<RateLimitResetCreditsSummary> {
let request_id = RequestId::String(format!("account-rate-limit-resets-{}", Uuid::new_v4()));
let response: GetAccountRateLimitsResponse = request_handle
.request_typed(ClientRequest::GetAccountRateLimits {
request_id,
params: None,
})
.await
.wrap_err("account/rateLimits/read failed in TUI")?;
response.rate_limit_reset_credits.ok_or_else(|| {
color_eyre::eyre::eyre!(
"account/rateLimits/read response did not include rateLimitResetCredits"
)
})
}
pub(super) async fn consume_rate_limit_reset_credit_request(
request_handle: AppServerRequestHandle,
idempotency_key: String,
) -> Result<ConsumeAccountRateLimitResetCreditResponse> {
let request_id = RequestId::String(format!("consume-rate-limit-reset-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::ConsumeAccountRateLimitResetCredit {
request_id,
params: ConsumeAccountRateLimitResetCreditParams { idempotency_key },
})
.await
.wrap_err("account/rateLimitResetCredit/consume failed in TUI")
}
pub(super) async fn send_add_credits_nudge_email(
request_handle: AppServerRequestHandle,
credit_type: AddCreditsNudgeCreditType,
+92 -11
View File
@@ -246,7 +246,7 @@ impl App {
deferred_history_cell,
)?;
self.chat_widget.note_stream_consolidation_completed();
self.insert_completed_token_activity_output_after_stream_shutdown(tui);
self.insert_pending_usage_output_after_stream_shutdown(tui);
}
AppEvent::ConsolidateProposedPlan(source) => {
let end = self.transcript_cells.len();
@@ -282,7 +282,7 @@ impl App {
self.maybe_finish_stream_reflow(tui)?;
}
self.chat_widget.note_stream_consolidation_completed();
self.insert_completed_token_activity_output_after_stream_shutdown(tui);
self.insert_pending_usage_output_after_stream_shutdown(tui);
}
AppEvent::ApplyThreadRollback { num_turns } => {
if self.apply_non_pending_thread_rollback(num_turns) {
@@ -750,12 +750,34 @@ impl App {
.finish_add_credits_nudge_email_request(result);
}
AppEvent::RateLimitsLoaded { origin, result } => match result {
Ok(snapshots) => {
for snapshot in snapshots {
Ok(response) => {
let rate_limit_reset_credits = response.rate_limit_reset_credits.clone();
for snapshot in app_server_rate_limit_snapshots(response) {
self.chat_widget.on_rate_limit_snapshot(Some(snapshot));
}
match origin {
RateLimitRefreshOrigin::StartupPrefetch => {
RateLimitRefreshOrigin::StartupPrefetch {
reset_hint_request_id,
} => {
if self.chat_widget.finish_rate_limit_reset_hint_refresh(
reset_hint_request_id,
rate_limit_reset_credits.ok_or_else(|| {
"account/rateLimits/read response did not include rateLimitResetCredits"
.to_string()
}),
) {
self.insert_pending_usage_output_if_ready(tui);
}
tui.frame_requester().schedule_frame();
}
RateLimitRefreshOrigin::ResetConsume { request_id } => {
self.chat_widget.finish_post_consume_reset_credits_refresh(
request_id,
rate_limit_reset_credits.ok_or_else(|| {
"account/rateLimits/read response did not include rateLimitResetCredits"
.to_string()
}),
);
tui.frame_requester().schedule_frame();
}
RateLimitRefreshOrigin::StatusCommand { request_id } => {
@@ -766,12 +788,68 @@ impl App {
}
Err(err) => {
tracing::warn!("account/rateLimits/read failed during TUI refresh: {err}");
if let RateLimitRefreshOrigin::StatusCommand { request_id } = origin {
self.chat_widget
.finish_status_rate_limit_refresh(request_id);
match origin {
RateLimitRefreshOrigin::StartupPrefetch {
reset_hint_request_id,
} => {
self.chat_widget.finish_rate_limit_reset_hint_refresh(
reset_hint_request_id,
Err(err),
);
}
RateLimitRefreshOrigin::ResetConsume { request_id } => {
self.chat_widget
.finish_post_consume_reset_credits_refresh(request_id, Err(err));
}
RateLimitRefreshOrigin::StatusCommand { request_id } => {
self.chat_widget
.finish_status_rate_limit_refresh(request_id);
}
}
}
},
AppEvent::OpenTokenActivity => {
self.chat_widget
.add_token_activity_output(crate::chatwidget::TokenActivityView::Daily);
}
AppEvent::OpenRateLimitResetCredits => {
let request_id = self.chat_widget.show_rate_limit_reset_loading_popup();
self.refresh_rate_limit_reset_credits(app_server, request_id);
}
AppEvent::RateLimitResetCreditsLoaded { request_id, result } => {
if let Err(err) = &result {
tracing::warn!(
"account/rateLimits/read failed during reset-credit refresh: {err}"
);
}
self.chat_widget
.finish_rate_limit_reset_credits_refresh(request_id, result);
}
AppEvent::ConsumeRateLimitResetCredit { idempotency_key } => {
let request_id = self.chat_widget.show_rate_limit_reset_consuming_popup();
self.consume_rate_limit_reset_credit(app_server, request_id, idempotency_key);
}
AppEvent::RateLimitResetCreditConsumed {
request_id,
idempotency_key,
result,
} => {
if let Err(err) = &result {
tracing::warn!(
"account/rateLimitResetCredit/consume failed during TUI request: {err}"
);
}
if self.chat_widget.finish_rate_limit_reset_consume(
request_id,
idempotency_key,
result,
) {
self.refresh_rate_limits(
app_server,
RateLimitRefreshOrigin::ResetConsume { request_id },
);
}
}
AppEvent::TokenActivityLoaded { request_id, result } => {
if let Err(err) = &result {
tracing::warn!("account/usage/read failed during TUI refresh: {err}");
@@ -785,11 +863,14 @@ impl App {
// active work, and flushing an in-progress tool cell would corrupt its lifecycle.
// If an answer stream is active, keep the settled card transient until its
// provisional transcript cells have been consolidated.
self.insert_completed_token_activity_output_if_ready(tui);
self.insert_pending_usage_output_if_ready(tui);
}
}
AppEvent::CommitCompletedTokenActivityOutput => {
self.insert_completed_token_activity_output_after_stream_shutdown(tui);
AppEvent::CommitPendingUsageOutput => {
self.insert_pending_usage_output_if_ready(tui);
}
AppEvent::CommitPendingUsageOutputAfterStreamShutdown => {
self.insert_pending_usage_output_after_stream_shutdown(tui);
}
AppEvent::ConnectorsLoaded { result, is_final } => {
self.chat_widget.on_connectors_loaded(result, is_final);
+22 -19
View File
@@ -32,36 +32,38 @@ impl App {
}
// A committed cell can unblock a settled /usage card that was waiting
// behind a transient active cell or a provisional stream tail.
self.chat_widget
.request_completed_token_activity_output_insertion();
self.chat_widget.request_pending_usage_output_insertion();
}
pub(super) fn insert_completed_token_activity_output_if_ready(&mut self, tui: &mut tui::Tui) {
if self.chat_widget.token_activity_history_insertion_blocked()
|| self.transcript_cells.last().is_some_and(|cell| {
cell.as_any().is::<history_cell::AgentMessageCell>()
|| cell.as_any().is::<history_cell::ProposedPlanStreamCell>()
})
{
return;
}
self.insert_completed_token_activity_output(tui);
pub(super) fn pending_usage_output_insertion_blocked(&self) -> bool {
self.chat_widget.usage_history_insertion_blocked()
|| self
.transcript_cells
.last()
.is_some_and(|cell| cell.as_any().is::<history_cell::AgentMessageCell>())
}
pub(super) fn insert_completed_token_activity_output(&mut self, tui: &mut tui::Tui) {
fn insert_pending_usage_output(&mut self, tui: &mut tui::Tui) {
if let Some(cell) = self.chat_widget.take_completed_token_activity_output() {
self.insert_history_cell(tui, Box::new(cell));
}
if let Some(cell) = self.chat_widget.take_pending_rate_limit_reset_hint() {
self.insert_history_cell(tui, Box::new(cell));
}
}
pub(super) fn insert_completed_token_activity_output_after_stream_shutdown(
&mut self,
tui: &mut tui::Tui,
) {
if self.chat_widget.token_activity_history_insertion_blocked() {
pub(super) fn insert_pending_usage_output_if_ready(&mut self, tui: &mut tui::Tui) {
if self.pending_usage_output_insertion_blocked() {
return;
}
self.insert_completed_token_activity_output(tui);
self.insert_pending_usage_output(tui);
}
pub(super) fn insert_pending_usage_output_after_stream_shutdown(&mut self, tui: &mut tui::Tui) {
if self.chat_widget.usage_history_insertion_blocked() {
return;
}
self.insert_pending_usage_output(tui);
}
pub(super) fn open_url_in_browser(&mut self, url: String) {
@@ -167,6 +169,7 @@ impl App {
self.has_emitted_history_lines = false;
self.transcript_reflow.clear();
self.chat_widget.clear_pending_token_activity_refreshes();
self.chat_widget.clear_pending_rate_limit_reset_hint();
self.initial_history_replay_buffer = None;
self.backtrack = BacktrackState::default();
self.backtrack_render_pending = false;
+33 -1
View File
@@ -5538,7 +5538,7 @@ async fn queued_rollback_syncs_overlay_and_clears_deferred_history() {
/*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true,
);
app.chat_widget
.set_composer_text("/usage".to_string(), Vec::new(), Vec::new());
.set_composer_text("/usage daily".to_string(), Vec::new(), Vec::new());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
app.chat_widget
@@ -5579,6 +5579,38 @@ async fn queued_rollback_syncs_overlay_and_clears_deferred_history() {
assert_eq!(overlay_cell_count, app.transcript_cells.len());
}
#[tokio::test]
async fn late_usage_result_can_follow_finalized_plan() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
app.chat_widget
.add_token_activity_output(crate::chatwidget::TokenActivityView::Daily);
let request_id = match app_event_rx.try_recv() {
Ok(AppEvent::RefreshTokenActivity { request_id }) => request_id,
other => panic!("expected token activity refresh request, got {other:?}"),
};
app.chat_widget.note_stream_consolidation_queued();
app.transcript_cells
.push(Arc::new(history_cell::new_proposed_plan_stream(
vec![Line::from("finalized plan")],
/*is_stream_continuation*/ false,
)));
app.chat_widget.note_stream_consolidation_completed();
assert!(
app.chat_widget.finish_token_activity_refresh(
request_id,
Err("token activity unavailable".to_string()),
)
);
assert!(!app.pending_usage_output_insertion_blocked());
assert!(
app.chat_widget
.take_completed_token_activity_output()
.is_some()
);
}
#[tokio::test]
async fn thread_rollback_response_discards_queued_active_thread_events() {
let mut app = make_test_app().await;
+2
View File
@@ -541,6 +541,7 @@ impl App {
return false;
}
self.chat_widget.clear_pending_token_activity_refreshes();
self.chat_widget.clear_pending_rate_limit_reset_hint();
self.chat_widget
.truncate_agent_copy_history_to_user_turn_count(user_count(&self.transcript_cells));
self.sync_overlay_after_transcript_trim();
@@ -565,6 +566,7 @@ impl App {
pending.selection.nth_user_message,
) {
self.chat_widget.clear_pending_token_activity_refreshes();
self.chat_widget.clear_pending_rate_limit_reset_hint();
self.chat_widget
.truncate_agent_copy_history_to_user_turn_count(user_count(&self.transcript_cells));
self.sync_overlay_after_transcript_trim();
+41 -10
View File
@@ -13,6 +13,8 @@ use std::path::PathBuf;
use codex_app_server_protocol::AddCreditsNudgeCreditType;
use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse;
use codex_app_server_protocol::GetAccountRateLimitsResponse;
use codex_app_server_protocol::GetAccountTokenUsageResponse;
use codex_app_server_protocol::MarketplaceAddResponse;
use codex_app_server_protocol::MarketplaceRemoveResponse;
@@ -25,7 +27,7 @@ use codex_app_server_protocol::PluginMarketplaceEntry;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RateLimitResetCreditsSummary;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::ThreadGoalStatus;
use codex_file_search::FileMatch;
@@ -114,17 +116,19 @@ pub(crate) struct PluginRemoteSectionError {
/// handler can route the result correctly.
///
/// A `StartupPrefetch` fires once, concurrently with the rest of TUI init, and
/// only updates the cached snapshots (no status card to finalize). A
/// `StatusCommand` is tied to a specific `/status` invocation and must call
/// `finish_status_rate_limit_refresh` when done so the card stops showing a
/// "refreshing" state.
/// updates the cached snapshots and any available reset-credit notice (no
/// status card to finalize). A `StatusCommand` is tied to a specific `/status`
/// invocation and must call `finish_status_rate_limit_refresh` when done so the
/// card stops showing a "refreshing" state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RateLimitRefreshOrigin {
/// Eagerly fetched after bootstrap so the first `/status` already has data.
StartupPrefetch,
/// Eagerly fetched after bootstrap for `/status` data and reset availability.
StartupPrefetch { reset_hint_request_id: u64 },
/// User-initiated via `/status`; the `request_id` correlates with the
/// status card that should be updated when the fetch completes.
StatusCommand { request_id: u64 },
/// Refresh requested after a reset credit was successfully consumed.
ResetConsume { request_id: u64 },
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -299,7 +303,31 @@ pub(crate) enum AppEvent {
/// Result of refreshing rate limits.
RateLimitsLoaded {
origin: RateLimitRefreshOrigin,
result: Result<Vec<RateLimitSnapshot>, String>,
result: Result<GetAccountRateLimitsResponse, String>,
},
/// Open the default token-activity view selected from the `/usage` menu.
OpenTokenActivity,
/// Open the reset-credit flow selected from the `/usage` menu.
OpenRateLimitResetCredits,
/// Result of reading the current reset-credit balance.
RateLimitResetCreditsLoaded {
request_id: u64,
result: Result<RateLimitResetCreditsSummary, String>,
},
/// Consume one reset credit using a stable idempotency key.
ConsumeRateLimitResetCredit {
idempotency_key: String,
},
/// Result of consuming one reset credit.
RateLimitResetCreditConsumed {
request_id: u64,
idempotency_key: String,
result: Result<ConsumeAccountRateLimitResetCreditResponse, String>,
},
/// Fetch account-wide token activity for a `/usage` history card.
@@ -313,8 +341,11 @@ pub(crate) enum AppEvent {
result: Result<GetAccountTokenUsageResponse, String>,
},
/// Commit a settled token activity card after a stream shutdown barrier.
CommitCompletedTokenActivityOutput,
/// Commit settled asynchronous usage output after active-output barriers clear.
CommitPendingUsageOutput,
/// Commit settled asynchronous usage output after stream shutdown.
CommitPendingUsageOutputAfterStreamShutdown,
/// Send a user-confirmed request to notify the workspace owner.
SendAddCreditsNudgeEmail {
@@ -200,6 +200,9 @@ pub(crate) struct SelectionViewParams {
/// Receives the *actual* item index, not the filtered/visible index.
pub on_selection_changed: OnSelectionChangedCallback,
/// Whether cancellation keys can dismiss the picker.
pub allow_cancel: bool,
/// Called when the picker is dismissed via Esc/Ctrl+C without selecting.
pub on_cancel: OnCancelCallback,
}
@@ -229,6 +232,7 @@ impl Default for SelectionViewParams {
stacked_side_content: None,
preserve_side_content_bg: false,
on_selection_changed: None,
allow_cancel: true,
on_cancel: None,
}
}
@@ -270,6 +274,8 @@ pub(crate) struct ListSelectionView {
/// Called when the highlighted item changes (navigation, filter, number-key).
on_selection_changed: OnSelectionChangedCallback,
allow_cancel: bool,
/// Called when the picker is dismissed via Esc/Ctrl+C without selecting.
on_cancel: OnCancelCallback,
keymap: ListKeymap,
@@ -342,6 +348,7 @@ impl ListSelectionView {
stacked_side_content: params.stacked_side_content,
preserve_side_content_bg: params.preserve_side_content_bg,
on_selection_changed: params.on_selection_changed,
allow_cancel: params.allow_cancel,
on_cancel: params.on_cancel,
keymap,
};
@@ -968,7 +975,7 @@ impl BottomPaneView for ListSelectionView {
} if self.is_searchable
&& self.search_query.is_empty()
&& self.selected_item_has_toggle_placeholder() => {}
_ if self.keymap.cancel.is_pressed(key_event) => {
_ if self.allow_cancel && self.keymap.cancel.is_pressed(key_event) => {
self.on_ctrl_c();
}
_ if self.keymap.accept.is_pressed(key_event) => self.accept(),
@@ -1062,6 +1069,9 @@ impl BottomPaneView for ListSelectionView {
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
if !self.allow_cancel {
return CancellationEvent::NotHandled;
}
if let Some(cb) = &self.on_cancel {
cb(&self.app_event_tx);
}
+55
View File
@@ -1086,6 +1086,14 @@ impl BottomPane {
}
fn apply_standard_popup_hint(&self, params: &mut list_selection_view::SelectionViewParams) {
if !params.allow_cancel {
if params.footer_hint.is_none()
|| params.footer_hint.as_ref() == Some(&popup_consts::standard_popup_hint_line())
{
params.footer_hint = None;
}
return;
}
if params.footer_hint.is_none()
|| params.footer_hint.as_ref() == Some(&popup_consts::standard_popup_hint_line())
{
@@ -1118,6 +1126,34 @@ impl BottomPane {
true
}
/// Replace the newest matching selection view without disturbing views stacked above it.
pub(crate) fn replace_selection_view_if_present(
&mut self,
view_id: &'static str,
mut params: list_selection_view::SelectionViewParams,
) -> bool {
let Some(index) = self
.view_stack
.iter()
.rposition(|view| view.view_id() == Some(view_id))
else {
return false;
};
let replaces_active_view = index + 1 == self.view_stack.len();
self.apply_standard_popup_hint(&mut params);
self.view_stack[index] = Box::new(list_selection_view::ListSelectionView::new(
params,
self.app_event_tx.clone(),
self.keymap.list.clone(),
));
if replaces_active_view {
self.schedule_active_view_frame();
}
self.request_redraw();
true
}
pub(crate) fn standard_popup_hint_line(&self) -> Line<'static> {
popup_consts::standard_popup_hint_line_for_keymap(&self.keymap.list)
}
@@ -1188,6 +1224,25 @@ impl BottomPane {
true
}
/// Dismiss the newest matching view without disturbing views stacked above it.
pub(crate) fn dismiss_view_by_id(&mut self, view_id: &'static str) -> bool {
let Some(index) = self
.view_stack
.iter()
.rposition(|view| view.view_id() == Some(view_id))
else {
return false;
};
let removed_active_view = index + 1 == self.view_stack.len();
self.view_stack.remove(index);
if removed_active_view {
self.schedule_active_view_frame();
}
self.request_redraw();
true
}
/// Update the pending-input preview shown above the composer.
pub(crate) fn set_pending_input_preview(
&mut self,
+25 -6
View File
@@ -408,6 +408,7 @@ mod status_surfaces;
mod streaming;
use self::status_surfaces::CachedProjectRootName;
mod tokens;
pub(crate) use self::tokens::TokenActivityView;
mod tool_lifecycle;
mod tool_requests;
mod transcript;
@@ -415,6 +416,7 @@ use self::transcript::TranscriptState;
mod turn_lifecycle;
mod turn_runtime;
use self::turn_lifecycle::TurnLifecycleState;
mod usage;
mod user_messages;
use self::user_messages::PendingSteer;
use self::user_messages::PendingSteerCompareKey;
@@ -551,6 +553,11 @@ pub(crate) struct ChatWidget {
refreshing_token_activity_output: Option<tokens::PendingTokenActivityOutput>,
completed_token_activity_output: Option<history_cell::CompositeHistoryCell>,
next_token_activity_request_id: u64,
pending_rate_limit_reset_request_id: Option<u64>,
pending_rate_limit_reset_hint_request_id: Option<u64>,
pending_rate_limit_reset_hint: Option<PlainHistoryCell>,
available_rate_limit_reset_credits: Option<i64>,
next_rate_limit_reset_request_id: u64,
plan_type: Option<PlanType>,
codex_rate_limit_reached_type: Option<RateLimitReachedType>,
rate_limit_warnings: RateLimitWarningState,
@@ -1178,7 +1185,7 @@ impl ChatWidget {
if let Some(active) = self.transcript.active_cell.take() {
self.transcript.needs_final_message_separator = true;
self.app_event_tx.send(AppEvent::InsertHistoryCell(active));
self.request_completed_token_activity_output_insertion();
self.request_pending_usage_output_insertion();
}
}
@@ -1393,7 +1400,7 @@ impl ChatWidget {
tool.mark_failed();
}
self.add_boxed_history(cell);
self.request_completed_token_activity_output_insertion();
self.request_pending_usage_output_insertion();
}
}
@@ -1884,9 +1891,9 @@ impl ChatWidget {
/// Returns a cache key describing the current in-flight cells for the transcript overlay.
///
/// `Ctrl+T` renders committed transcript cells plus a render-only live tail derived from the
/// current active, hook, and token activity cells, and the overlay caches that tail; this key is
/// what it uses to decide whether it must recompute. When there are no live cells, this returns
/// `None` so the overlay can drop the tail entirely.
/// current active, hook, and asynchronous usage cells, and the overlay caches that tail; this
/// key is what it uses to decide whether it must recompute. When there are no live cells, this
/// returns `None` so the overlay can drop the tail entirely.
///
/// If callers mutate the active cell's transcript output without bumping the revision (or
/// providing an appropriate animation tick), the overlay will keep showing a stale tail while
@@ -1895,7 +1902,12 @@ impl ChatWidget {
let cell = self.transcript.active_cell.as_ref();
let hook_cell = self.active_hook_cell.as_ref();
let token_activity_cell = self.pending_token_activity_output();
if cell.is_none() && hook_cell.is_none() && token_activity_cell.is_none() {
let rate_limit_reset_hint = self.pending_rate_limit_reset_hint();
if cell.is_none()
&& hook_cell.is_none()
&& token_activity_cell.is_none()
&& rate_limit_reset_hint.is_none()
{
return None;
}
Some(ActiveCellTranscriptKey {
@@ -1940,6 +1952,13 @@ impl ChatWidget {
}
lines.extend(token_activity_lines);
}
if let Some(rate_limit_reset_hint) = self.pending_rate_limit_reset_hint() {
let hint_lines = rate_limit_reset_hint.transcript_hyperlink_lines(width);
if !hint_lines.is_empty() && !lines.is_empty() {
lines.push(HyperlinkLine::from(""));
}
lines.extend(hint_lines);
}
(!lines.is_empty()).then_some(lines)
}
@@ -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"
}
}
+1 -1
View File
@@ -103,7 +103,7 @@ impl SlashCommand {
SlashCommand::Import => "import setup, this project, and recent chats from Claude Code",
SlashCommand::Hooks => "view and manage lifecycle hooks",
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::Usage => "show account usage activity",
SlashCommand::Usage => "view account usage or use a rate-limit reset",
SlashCommand::DebugConfig => "show config layers and requirement sources for debugging",
SlashCommand::Title => "configure which items appear in the terminal title",
SlashCommand::Statusline => "configure which items appear in the status line",