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

## Why

Codex users can earn personal rate-limit reset credits, but the CLI does
not currently provide a way to view or redeem them. The `/usage` command
restored in #27925 is intended to be the entry point for usage-related
actions, so reset redemption belongs there rather than in a separate
dashed slash command.

Depends on #28143 for the app-server and backend-client reset-credit
APIs.

## What changed

- Turn bare `/usage` into a menu with entries for token activity and
earned rate-limit resets while preserving `/usage daily`, `/usage
weekly`, and `/usage cumulative`.
- Add loading, empty, confirmation, success, retry, and error states
with a caller-generated UUID idempotency key reused across retries of
the same logical reset.
- Show an availability hint only for backend-classified rate-limit
errors with credits available.
- Hide the reset entry for workspace accounts.

## Validation

- `just test -p codex-tui chatwidget::tests::usage` — 19 passed.
- `just fix -p codex-tui` — passed.
- `just fmt` — passed.
- `cargo insta pending-snapshots` from `codex-rs/tui` — no pending
snapshots.

## Examples
<img width="1168" height="304" alt="image"
src="https://github.com/user-attachments/assets/caa4c1e3-e996-494d-ae17-50b521f5dce8"
/>
<img width="908" height="260" alt="image"
src="https://github.com/user-attachments/assets/e38a726b-77cc-4bd0-9ea8-9f3ad21c5768"
/>


### Reset flow
<img width="1509" height="312" alt="image"
src="https://github.com/user-attachments/assets/d987013c-78a5-48a2-ad8d-c61ad267a327"
/>
<img width="585" height="190" alt="image"
src="https://github.com/user-attachments/assets/de32be19-79b9-4a3e-8574-6f1c208c98ae"
/>
<img width="600" height="210" alt="image"
src="https://github.com/user-attachments/assets/88a165cf-796d-4fdc-a7bc-ea89917573da"
/>

<img width="512" height="193" alt="image"
src="https://github.com/user-attachments/assets/d2353998-5aa8-442e-a5f8-3a8a5b832753"
/>
This commit is contained in:
jay
2026-06-16 17:59:40 +00:00
committed by GitHub
parent 1e6970542e
commit f8f5a6e78f
30 changed files with 1552 additions and 109 deletions
+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",