mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat(tui): add rate-limit reset redemption to /usage (#28154)
## Why Codex users can earn personal rate-limit reset credits, but the CLI does not currently provide a way to view or redeem them. The `/usage` command restored in #27925 is intended to be the entry point for usage-related actions, so reset redemption belongs there rather than in a separate dashed slash command. Depends on #28143 for the app-server and backend-client reset-credit APIs. ## What changed - Turn bare `/usage` into a menu with entries for token activity and earned rate-limit resets while preserving `/usage daily`, `/usage weekly`, and `/usage cumulative`. - Add loading, empty, confirmation, success, retry, and error states with a caller-generated UUID idempotency key reused across retries of the same logical reset. - Show an availability hint only for backend-classified rate-limit errors with credits available. - Hide the reset entry for workspace accounts. ## Validation - `just test -p codex-tui chatwidget::tests::usage` — 19 passed. - `just fix -p codex-tui` — passed. - `just fmt` — passed. - `cargo insta pending-snapshots` from `codex-rs/tui` — no pending snapshots. ## Examples <img width="1168" height="304" alt="image" src="https://github.com/user-attachments/assets/caa4c1e3-e996-494d-ae17-50b521f5dce8" /> <img width="908" height="260" alt="image" src="https://github.com/user-attachments/assets/e38a726b-77cc-4bd0-9ea8-9f3ad21c5768" /> ### Reset flow <img width="1509" height="312" alt="image" src="https://github.com/user-attachments/assets/d987013c-78a5-48a2-ad8d-c61ad267a327" /> <img width="585" height="190" alt="image" src="https://github.com/user-attachments/assets/de32be19-79b9-4a3e-8574-6f1c208c98ae" /> <img width="600" height="210" alt="image" src="https://github.com/user-attachments/assets/88a165cf-796d-4fdc-a7bc-ea89917573da" /> <img width="512" height="193" alt="image" src="https://github.com/user-attachments/assets/d2353998-5aa8-442e-a5f8-3a8a5b832753" />
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,16 @@ impl ChatWidget {
|
||||
})),
|
||||
);
|
||||
}
|
||||
if let Some(cell) = self.pending_rate_limit_reset_hint() {
|
||||
flex.push(
|
||||
/*flex*/ 1,
|
||||
RenderableItem::Owned(Box::new(TranscriptAreaRenderable {
|
||||
child: cell,
|
||||
top: 1,
|
||||
right: active_cell_right_reserve,
|
||||
})),
|
||||
);
|
||||
}
|
||||
flex.push(
|
||||
/*flex*/ 0,
|
||||
RenderableItem::Owned(Box::new(BottomPaneComposerReserveRenderable {
|
||||
|
||||
@@ -222,6 +222,7 @@ impl ChatWidget {
|
||||
|| self.has_codex_backend_auth != has_codex_backend_auth;
|
||||
if account_state_changed {
|
||||
self.clear_pending_token_activity_refreshes();
|
||||
self.clear_pending_rate_limit_reset_requests();
|
||||
}
|
||||
self.status_account_display = status_account_display;
|
||||
self.plan_type = plan_type;
|
||||
|
||||
@@ -434,8 +434,8 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
SlashCommand::Usage => {
|
||||
if self.ensure_token_activity_command_available() {
|
||||
self.add_token_activity_output(tokens::TokenActivityView::Daily);
|
||||
if self.ensure_usage_command_available() {
|
||||
self.open_usage_menu();
|
||||
}
|
||||
}
|
||||
SlashCommand::Ide => {
|
||||
@@ -658,7 +658,7 @@ impl ChatWidget {
|
||||
let trimmed = args.trim();
|
||||
match cmd {
|
||||
SlashCommand::Usage => {
|
||||
if self.ensure_token_activity_command_available() {
|
||||
if self.ensure_usage_command_available() {
|
||||
match tokens::TokenActivityView::parse(trimmed) {
|
||||
Some(view) => self.add_token_activity_output(view),
|
||||
None => self.add_error_message(
|
||||
@@ -1017,7 +1017,7 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_token_activity_command_available(&mut self) -> bool {
|
||||
fn ensure_usage_command_available(&mut self) -> bool {
|
||||
if self.has_codex_backend_auth {
|
||||
return true;
|
||||
}
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/usage.rs
|
||||
expression: rendered
|
||||
---
|
||||
• You have 2 rate-limit resets available. Run /usage to use one.
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/usage.rs
|
||||
expression: "lines_to_single_string(&chat.active_cell_transcript_lines(80).expect(\"active output with reset hint\"),)"
|
||||
---
|
||||
active tool
|
||||
|
||||
• You have 2 rate-limit resets available. Run /usage to use one.
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/usage.rs
|
||||
expression: "states.join(\"\\n---\\n\")"
|
||||
---
|
||||
Rate-limit resets
|
||||
Checking your available resets...
|
||||
|
||||
› Loading...
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
---
|
||||
Rate-limit resets
|
||||
You have 2 rate-limit resets available.
|
||||
|
||||
1. Use a reset Reset your current Codex usage windows.
|
||||
› 2. Cancel
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
---
|
||||
Rate-limit resets
|
||||
You don't have any rate-limit resets available.
|
||||
|
||||
› 1. Close
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
---
|
||||
Rate-limit resets
|
||||
Couldn't load rate-limit resets. Please try again.
|
||||
|
||||
› 1. Close
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
---
|
||||
Rate-limit resets
|
||||
Resetting your usage...
|
||||
|
||||
› Using a reset...
|
||||
---
|
||||
Rate-limit resets
|
||||
Couldn't reset usage. Please try again.
|
||||
|
||||
› 1. Try again
|
||||
2. Close
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
---
|
||||
Rate-limit resets
|
||||
Your usage does not need a reset right now.
|
||||
|
||||
› 1. Close
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
---
|
||||
Rate-limit resets
|
||||
No rate-limit resets are available.
|
||||
|
||||
› 1. Close
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
---
|
||||
Rate-limit resets
|
||||
Usage reset. Checking your remaining resets...
|
||||
|
||||
› Refreshing...
|
||||
---
|
||||
Rate-limit resets
|
||||
Usage reset. You have 1 rate-limit reset left.
|
||||
|
||||
› 1. Close
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/usage.rs
|
||||
expression: "render_bottom_popup(&chat, 80)"
|
||||
---
|
||||
Usage
|
||||
View account usage or redeem an earned reset.
|
||||
|
||||
› 1. Show usage View recent account token usage.
|
||||
2. Redeem rate limit reset You have 2 rate-limit resets available.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/usage.rs
|
||||
expression: "render_bottom_popup(&chat, 80)"
|
||||
---
|
||||
Usage
|
||||
View account usage or redeem an earned reset.
|
||||
|
||||
› 1. Show usage View recent account token usage.
|
||||
2. Redeem rate limit reset Check reset availability.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/usage.rs
|
||||
expression: "render_bottom_popup(&chat, 80)"
|
||||
---
|
||||
Usage
|
||||
View account usage or redeem an earned reset.
|
||||
|
||||
› 1. Show usage View recent account token usage.
|
||||
2. Redeem rate limit reset Check reset availability.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -54,7 +54,7 @@ impl ChatWidget {
|
||||
self.app_event_tx.send(AppEvent::StopCommitAnimation);
|
||||
}
|
||||
if had_stream_controller {
|
||||
self.request_completed_token_activity_output_insertion();
|
||||
self.request_pending_usage_output_insertion_after_stream_shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ impl ChatWidget {
|
||||
if should_restore_after_stream {
|
||||
self.status_state.pending_status_indicator_restore = true;
|
||||
self.maybe_restore_status_indicator_after_stream_idle();
|
||||
self.request_completed_token_activity_output_insertion();
|
||||
self.request_pending_usage_output_insertion_after_stream_shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -252,6 +252,7 @@ mod status_and_layout;
|
||||
mod status_command_tests;
|
||||
mod status_surface_previews;
|
||||
mod terminal_title;
|
||||
mod usage;
|
||||
|
||||
pub(crate) use helpers::make_chatwidget_manual_with_sender;
|
||||
pub(crate) use helpers::set_chatgpt_auth;
|
||||
|
||||
@@ -83,7 +83,7 @@ fn dispatch_usage_and_expect_refresh(
|
||||
chat: &mut ChatWidget,
|
||||
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||||
) -> u64 {
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
chat.dispatch_command_with_args(SlashCommand::Usage, "daily".to_string(), Vec::new());
|
||||
expect_token_activity_refresh(rx)
|
||||
}
|
||||
|
||||
@@ -1245,7 +1245,7 @@ async fn usage_command_runs_with_backend_auth_without_chatgpt_account_flag() {
|
||||
/*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true,
|
||||
);
|
||||
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
chat.dispatch_command_with_args(SlashCommand::Usage, "daily".to_string(), Vec::new());
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::RefreshTokenActivity { .. }));
|
||||
assert!(!chat.has_chatgpt_account());
|
||||
@@ -1259,7 +1259,7 @@ async fn usage_command_runs_with_backend_auth_from_widget_init() {
|
||||
)
|
||||
.await;
|
||||
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
chat.dispatch_command_with_args(SlashCommand::Usage, "daily".to_string(), Vec::new());
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::RefreshTokenActivity { .. }));
|
||||
assert!(!chat.has_chatgpt_account());
|
||||
@@ -1375,7 +1375,7 @@ async fn completed_token_activity_refresh_waits_for_active_stream() {
|
||||
|
||||
let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx);
|
||||
chat.on_agent_message_delta("partial response".to_string());
|
||||
assert!(chat.token_activity_history_insertion_blocked());
|
||||
assert!(chat.usage_history_insertion_blocked());
|
||||
|
||||
assert!(
|
||||
chat.finish_token_activity_refresh(
|
||||
@@ -1390,10 +1390,10 @@ async fn completed_token_activity_refresh_waits_for_active_stream() {
|
||||
);
|
||||
|
||||
chat.finalize_turn();
|
||||
assert!(!chat.token_activity_history_insertion_blocked());
|
||||
assert!(!chat.usage_history_insertion_blocked());
|
||||
assert!(
|
||||
std::iter::from_fn(|| rx.try_recv().ok())
|
||||
.any(|event| matches!(event, AppEvent::CommitCompletedTokenActivityOutput))
|
||||
.any(|event| matches!(event, AppEvent::CommitPendingUsageOutputAfterStreamShutdown))
|
||||
);
|
||||
assert!(chat.take_completed_token_activity_output().is_some());
|
||||
}
|
||||
@@ -1414,11 +1414,11 @@ async fn completed_token_activity_refresh_waits_for_queued_stream_consolidation(
|
||||
Err("token activity unavailable".to_string()),
|
||||
)
|
||||
);
|
||||
assert!(chat.token_activity_history_insertion_blocked());
|
||||
assert!(chat.usage_history_insertion_blocked());
|
||||
|
||||
chat.note_stream_consolidation_completed();
|
||||
|
||||
assert!(!chat.token_activity_history_insertion_blocked());
|
||||
assert!(!chat.usage_history_insertion_blocked());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1436,15 +1436,12 @@ async fn completed_token_activity_refresh_waits_for_active_history_cell() {
|
||||
Err("token activity unavailable".to_string()),
|
||||
)
|
||||
);
|
||||
assert!(chat.token_activity_history_insertion_blocked());
|
||||
assert!(chat.usage_history_insertion_blocked());
|
||||
|
||||
chat.flush_active_cell();
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::CommitCompletedTokenActivityOutput)
|
||||
);
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::CommitPendingUsageOutput));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1469,7 +1466,7 @@ async fn completed_token_activity_refresh_waits_for_active_hook() {
|
||||
Err("token activity unavailable".to_string()),
|
||||
)
|
||||
);
|
||||
assert!(chat.token_activity_history_insertion_blocked());
|
||||
assert!(chat.usage_history_insertion_blocked());
|
||||
|
||||
handle_hook_completed(
|
||||
&mut chat,
|
||||
@@ -1486,10 +1483,7 @@ async fn completed_token_activity_refresh_waits_for_active_hook() {
|
||||
);
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::CommitCompletedTokenActivityOutput)
|
||||
);
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::CommitPendingUsageOutput));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1516,7 +1510,7 @@ async fn completed_token_activity_refresh_retries_after_plan_item_completion() {
|
||||
|
||||
assert!(
|
||||
std::iter::from_fn(|| rx.try_recv().ok())
|
||||
.any(|event| matches!(event, AppEvent::CommitCompletedTokenActivityOutput))
|
||||
.any(|event| matches!(event, AppEvent::CommitPendingUsageOutputAfterStreamShutdown))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
use super::*;
|
||||
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditOutcome;
|
||||
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse;
|
||||
use codex_app_server_protocol::RateLimitResetCreditsSummary;
|
||||
use uuid::Uuid;
|
||||
|
||||
const TEST_OVERLAY_VIEW_ID: &str = "usage-test-overlay";
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_command_opens_menu_when_reset_is_available_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let request_id = chat.start_rate_limit_reset_startup_check();
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
|
||||
assert_chatwidget_snapshot!(
|
||||
"usage_command_menu",
|
||||
render_bottom_popup(&chat, /*width*/ 80)
|
||||
);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_command_can_recheck_reset_availability_after_cached_zero_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let request_id = chat.start_rate_limit_reset_startup_check();
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
|
||||
));
|
||||
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
|
||||
assert_chatwidget_snapshot!(
|
||||
"usage_command_menu_without_resets",
|
||||
render_bottom_popup(&chat, /*width*/ 80)
|
||||
);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_command_can_check_reset_availability_before_startup_refresh_finishes_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.start_rate_limit_reset_startup_check();
|
||||
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
|
||||
assert_chatwidget_snapshot!(
|
||||
"usage_command_menu_before_reset_refresh",
|
||||
render_bottom_popup(&chat, /*width*/ 80)
|
||||
);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_command_disables_reset_for_workspace_accounts() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.plan_type = Some(PlanType::Business);
|
||||
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_menu_rate_limit_reset_entry_opens_reset_flow() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let request_id = chat.start_rate_limit_reset_startup_check();
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_reset_popup_states_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let mut states = Vec::new();
|
||||
|
||||
let loading_request_id = chat.show_rate_limit_reset_loading_popup();
|
||||
record_popup(&chat, &mut states);
|
||||
assert!(chat.finish_rate_limit_reset_credits_refresh(
|
||||
loading_request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
record_popup(&chat, &mut states);
|
||||
|
||||
dismiss_popup(&mut chat);
|
||||
let empty_request_id = chat.show_rate_limit_reset_loading_popup();
|
||||
assert!(chat.finish_rate_limit_reset_credits_refresh(
|
||||
empty_request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
|
||||
));
|
||||
record_popup(&chat, &mut states);
|
||||
|
||||
dismiss_popup(&mut chat);
|
||||
let load_error_request_id = chat.show_rate_limit_reset_loading_popup();
|
||||
assert!(chat.finish_rate_limit_reset_credits_refresh(
|
||||
load_error_request_id,
|
||||
Err("backend unavailable".to_string()),
|
||||
));
|
||||
record_popup(&chat, &mut states);
|
||||
|
||||
dismiss_popup(&mut chat);
|
||||
let consuming_request_id = chat.show_rate_limit_reset_consuming_popup();
|
||||
record_popup(&chat, &mut states);
|
||||
assert!(!chat.finish_rate_limit_reset_consume(
|
||||
consuming_request_id,
|
||||
"redeem-1".to_string(),
|
||||
Err("request timed out".to_string()),
|
||||
));
|
||||
record_popup(&chat, &mut states);
|
||||
|
||||
dismiss_popup(&mut chat);
|
||||
let nothing_request_id = chat.show_rate_limit_reset_consuming_popup();
|
||||
assert!(!finish_reset_consume_outcome(
|
||||
&mut chat,
|
||||
nothing_request_id,
|
||||
"redeem-2",
|
||||
ConsumeAccountRateLimitResetCreditOutcome::NothingToReset,
|
||||
));
|
||||
record_popup(&chat, &mut states);
|
||||
|
||||
dismiss_popup(&mut chat);
|
||||
let no_credit_request_id = chat.show_rate_limit_reset_consuming_popup();
|
||||
assert!(!finish_reset_consume_outcome(
|
||||
&mut chat,
|
||||
no_credit_request_id,
|
||||
"redeem-3",
|
||||
ConsumeAccountRateLimitResetCreditOutcome::NoCredit,
|
||||
));
|
||||
record_popup(&chat, &mut states);
|
||||
|
||||
dismiss_popup(&mut chat);
|
||||
let success_request_id = chat.show_rate_limit_reset_consuming_popup();
|
||||
assert!(finish_reset_consume_outcome(
|
||||
&mut chat,
|
||||
success_request_id,
|
||||
"redeem-4",
|
||||
ConsumeAccountRateLimitResetCreditOutcome::Reset,
|
||||
));
|
||||
record_popup(&chat, &mut states);
|
||||
assert!(chat.finish_post_consume_reset_credits_refresh(
|
||||
success_request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
|
||||
));
|
||||
record_popup(&chat, &mut states);
|
||||
|
||||
assert_chatwidget_snapshot!("rate_limit_reset_popup_states", states.join("\n---\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_reset_confirmation_selects_cancel_by_default() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let request_id = chat.show_rate_limit_reset_loading_popup();
|
||||
assert!(chat.finish_rate_limit_reset_credits_refresh(
|
||||
request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
|
||||
));
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert!(chat.bottom_pane.no_modal_or_popup_active());
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_reset_confirmation_can_use_reset() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let request_id = chat.show_rate_limit_reset_loading_popup();
|
||||
assert!(chat.finish_rate_limit_reset_credits_refresh(
|
||||
request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
|
||||
));
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::ConsumeRateLimitResetCredit { idempotency_key })
|
||||
if Uuid::parse_str(&idempotency_key).is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_reset_retry_reuses_idempotency_key() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let request_id = chat.show_rate_limit_reset_consuming_popup();
|
||||
assert!(!chat.finish_rate_limit_reset_consume(
|
||||
request_id,
|
||||
"stable-redeem-id".to_string(),
|
||||
Err("response lost".to_string()),
|
||||
));
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::ConsumeRateLimitResetCredit { idempotency_key })
|
||||
if idempotency_key == "stable-redeem-id"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_credit_outcome_allows_reset_availability_recheck() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let startup_request_id = chat.start_rate_limit_reset_startup_check();
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
startup_request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
|
||||
));
|
||||
let consume_request_id = chat.show_rate_limit_reset_consuming_popup();
|
||||
assert!(!finish_reset_consume_outcome(
|
||||
&mut chat,
|
||||
consume_request_id,
|
||||
"redeem-1",
|
||||
ConsumeAccountRateLimitResetCreditOutcome::NoCredit,
|
||||
));
|
||||
dismiss_popup(&mut chat);
|
||||
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_reset_redemption_cannot_be_dismissed_while_in_flight() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
|
||||
let request_id = chat.show_rate_limit_reset_consuming_popup();
|
||||
dismiss_popup(&mut chat);
|
||||
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Using a reset..."));
|
||||
|
||||
assert!(finish_reset_consume_outcome(
|
||||
&mut chat,
|
||||
request_id,
|
||||
"redeem-123",
|
||||
ConsumeAccountRateLimitResetCreditOutcome::Reset,
|
||||
));
|
||||
dismiss_popup(&mut chat);
|
||||
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Refreshing..."));
|
||||
|
||||
assert!(chat.finish_post_consume_reset_credits_refresh(
|
||||
request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
|
||||
));
|
||||
dismiss_popup(&mut chat);
|
||||
assert!(chat.bottom_pane.no_modal_or_popup_active());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_reset_redemption_allows_ctrl_c_to_quit_while_in_flight() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.show_rate_limit_reset_consuming_popup();
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
|
||||
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Using a reset..."));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn already_redeemed_is_an_idempotent_success() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let request_id = chat.show_rate_limit_reset_consuming_popup();
|
||||
|
||||
assert!(finish_reset_consume_outcome(
|
||||
&mut chat,
|
||||
request_id,
|
||||
"stable-redeem-id",
|
||||
ConsumeAccountRateLimitResetCreditOutcome::AlreadyRedeemed,
|
||||
));
|
||||
assert!(chat.finish_post_consume_reset_credits_refresh(
|
||||
request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
|
||||
));
|
||||
assert!(
|
||||
render_bottom_popup(&chat, /*width*/ 80)
|
||||
.contains("Usage reset. You have 0 rate-limit resets left.")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let startup_request_id = chat.start_rate_limit_reset_startup_check();
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
startup_request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
let consume_request_id = chat.show_rate_limit_reset_consuming_popup();
|
||||
assert!(finish_reset_consume_outcome(
|
||||
&mut chat,
|
||||
consume_request_id,
|
||||
"redeem-with-refresh-error",
|
||||
ConsumeAccountRateLimitResetCreditOutcome::Reset,
|
||||
));
|
||||
|
||||
assert!(chat.finish_post_consume_reset_credits_refresh(
|
||||
consume_request_id,
|
||||
Err("backend unavailable".to_string()),
|
||||
));
|
||||
dismiss_popup(&mut chat);
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
|
||||
let rendered = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert!(rendered.contains("Check reset availability."));
|
||||
assert!(!rendered.contains("You have 2 rate-limit resets available."));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_change_invalidates_pending_reset_requests() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let request_id = chat.show_rate_limit_reset_loading_popup();
|
||||
|
||||
chat.update_account_state(
|
||||
/*status_account_display*/ None, /*plan_type*/ None,
|
||||
/*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false,
|
||||
);
|
||||
|
||||
assert!(!chat.finish_rate_limit_reset_credits_refresh(
|
||||
request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
assert!(chat.bottom_pane.no_modal_or_popup_active());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clearing_pending_reset_hint_preserves_in_flight_redemption() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let consume_request_id = chat.show_rate_limit_reset_consuming_popup();
|
||||
let hint_request_id = chat.start_rate_limit_reset_startup_check();
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
hint_request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
|
||||
chat.clear_pending_rate_limit_reset_hint();
|
||||
|
||||
assert!(chat.pending_rate_limit_reset_hint().is_none());
|
||||
assert!(finish_reset_consume_outcome(
|
||||
&mut chat,
|
||||
consume_request_id,
|
||||
"redeem-after-rollback",
|
||||
ConsumeAccountRateLimitResetCreditOutcome::Reset,
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_reset_load_result_updates_popup_beneath_overlay() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let request_id = chat.show_rate_limit_reset_loading_popup();
|
||||
show_usage_test_overlay(&mut chat);
|
||||
|
||||
assert!(chat.finish_rate_limit_reset_credits_refresh(
|
||||
request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
assert_eq!(
|
||||
chat.bottom_pane.active_view_id(),
|
||||
Some(TEST_OVERLAY_VIEW_ID)
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(
|
||||
render_bottom_popup(&chat, /*width*/ 80)
|
||||
.contains("You have 2 rate-limit resets available.")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_reset_success_updates_popup_beneath_overlay() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let request_id = chat.show_rate_limit_reset_consuming_popup();
|
||||
show_usage_test_overlay(&mut chat);
|
||||
|
||||
assert!(finish_reset_consume_outcome(
|
||||
&mut chat,
|
||||
request_id,
|
||||
"redeem-covered",
|
||||
ConsumeAccountRateLimitResetCreditOutcome::Reset,
|
||||
));
|
||||
assert!(chat.finish_post_consume_reset_credits_refresh(
|
||||
request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
|
||||
));
|
||||
assert_eq!(
|
||||
chat.bottom_pane.active_view_id(),
|
||||
Some(TEST_OVERLAY_VIEW_ID)
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(
|
||||
render_bottom_popup(&chat, /*width*/ 80)
|
||||
.contains("Usage reset. You have 1 rate-limit reset left.")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_change_dismisses_reset_popup_beneath_overlay() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.show_rate_limit_reset_loading_popup();
|
||||
show_usage_test_overlay(&mut chat);
|
||||
|
||||
chat.update_account_state(
|
||||
/*status_account_display*/ None, /*plan_type*/ None,
|
||||
/*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false,
|
||||
);
|
||||
assert_eq!(
|
||||
chat.bottom_pane.active_view_id(),
|
||||
Some(TEST_OVERLAY_VIEW_ID)
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(chat.bottom_pane.no_modal_or_popup_active());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_check_shows_available_reset_hint_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let hint_request_id = chat.start_rate_limit_reset_startup_check();
|
||||
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
hint_request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
let rendered = lines_to_single_string(
|
||||
&chat
|
||||
.pending_rate_limit_reset_hint()
|
||||
.expect("pending reset hint")
|
||||
.display_lines(/*width*/ 80),
|
||||
);
|
||||
assert_chatwidget_snapshot!("rate_limit_reset_available_hint", rendered);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_reset_hint_waits_for_active_output_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let hint_request_id = chat.start_rate_limit_reset_startup_check();
|
||||
chat.transcript.active_cell = Some(Box::new(PlainHistoryCell::new(vec![Line::from(
|
||||
"active tool",
|
||||
)])));
|
||||
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
hint_request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
|
||||
assert!(chat.usage_history_insertion_blocked());
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
assert_chatwidget_snapshot!(
|
||||
"rate_limit_reset_hint_waits_for_active_output",
|
||||
lines_to_single_string(
|
||||
&chat
|
||||
.active_cell_transcript_lines(/*width*/ 80)
|
||||
.expect("active output with reset hint"),
|
||||
)
|
||||
);
|
||||
|
||||
chat.flush_active_cell();
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::CommitPendingUsageOutput));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn opening_rate_limit_reset_flow_invalidates_in_flight_startup_hint() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let hint_request_id = chat.start_rate_limit_reset_startup_check();
|
||||
|
||||
chat.show_rate_limit_reset_loading_popup();
|
||||
|
||||
assert!(!chat.finish_rate_limit_reset_hint_refresh(
|
||||
hint_request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
assert!(chat.pending_rate_limit_reset_hint().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn starting_rate_limit_reset_redemption_clears_deferred_startup_hint() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let hint_request_id = chat.start_rate_limit_reset_startup_check();
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
hint_request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
assert!(chat.pending_rate_limit_reset_hint().is_some());
|
||||
|
||||
chat.show_rate_limit_reset_consuming_popup();
|
||||
|
||||
assert!(chat.pending_rate_limit_reset_hint().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_check_omits_reset_hint_when_none_are_available() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let hint_request_id = chat.start_rate_limit_reset_startup_check();
|
||||
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
hint_request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
|
||||
));
|
||||
assert!(chat.pending_rate_limit_reset_hint().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_check_omits_reset_hint_for_workspace_accounts() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.plan_type = Some(PlanType::Business);
|
||||
let hint_request_id = chat.start_rate_limit_reset_startup_check();
|
||||
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
hint_request_id,
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
assert!(chat.pending_rate_limit_reset_hint().is_none());
|
||||
assert_eq!(chat.available_rate_limit_reset_credits, None);
|
||||
}
|
||||
|
||||
fn consume_response(
|
||||
outcome: ConsumeAccountRateLimitResetCreditOutcome,
|
||||
) -> ConsumeAccountRateLimitResetCreditResponse {
|
||||
ConsumeAccountRateLimitResetCreditResponse { outcome }
|
||||
}
|
||||
|
||||
fn finish_reset_consume_outcome(
|
||||
chat: &mut ChatWidget,
|
||||
request_id: u64,
|
||||
idempotency_key: &str,
|
||||
outcome: ConsumeAccountRateLimitResetCreditOutcome,
|
||||
) -> bool {
|
||||
chat.finish_rate_limit_reset_consume(
|
||||
request_id,
|
||||
idempotency_key.to_string(),
|
||||
Ok(consume_response(outcome)),
|
||||
)
|
||||
}
|
||||
|
||||
fn record_popup(chat: &ChatWidget, states: &mut Vec<String>) {
|
||||
states.push(render_bottom_popup(chat, /*width*/ 80));
|
||||
}
|
||||
|
||||
fn dismiss_popup(chat: &mut ChatWidget) {
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
fn show_usage_test_overlay(chat: &mut ChatWidget) {
|
||||
chat.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
view_id: Some(TEST_OVERLAY_VIEW_ID),
|
||||
title: Some("Covering overlay".to_string()),
|
||||
items: vec![SelectionItem {
|
||||
name: "Close".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
@@ -31,7 +31,7 @@ use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
use crate::history_cell::plain_lines;
|
||||
|
||||
pub(super) use chart::TokenActivityView;
|
||||
pub(crate) use chart::TokenActivityView;
|
||||
|
||||
/// Tracks the renderable lifecycle of one token activity history cell.
|
||||
#[derive(Debug)]
|
||||
@@ -153,7 +153,7 @@ impl ChatWidget {
|
||||
/// Each invocation receives a request ID so background responses update only
|
||||
/// their own card. The card remains outside transcript history until completion,
|
||||
/// which keeps loading visible without disturbing existing transcript content.
|
||||
pub(super) fn add_token_activity_output(&mut self, view: TokenActivityView) {
|
||||
pub(crate) fn add_token_activity_output(&mut self, view: TokenActivityView) {
|
||||
let request_id = self.next_token_activity_request_id;
|
||||
self.next_token_activity_request_id =
|
||||
self.next_token_activity_request_id.wrapping_add(/*rhs*/ 1);
|
||||
@@ -210,12 +210,12 @@ impl ChatWidget {
|
||||
true
|
||||
}
|
||||
|
||||
/// Reports whether a completed token activity card must wait before insertion.
|
||||
/// Reports whether completed asynchronous usage output must wait before insertion.
|
||||
///
|
||||
/// Inserting while a stream, queued consolidation, or active transcript cell is
|
||||
/// present can reorder the card relative to visible output, so callers retry once
|
||||
/// present can reorder output relative to visible work, so callers retry once
|
||||
/// these barriers clear.
|
||||
pub(crate) fn token_activity_history_insertion_blocked(&self) -> bool {
|
||||
pub(crate) fn usage_history_insertion_blocked(&self) -> bool {
|
||||
self.stream_controller.is_some()
|
||||
|| self.plan_stream_controller.is_some()
|
||||
|| self.pending_stream_consolidations > 0
|
||||
@@ -244,7 +244,7 @@ impl ChatWidget {
|
||||
/// Transfers the completed token activity card into the history insertion path.
|
||||
///
|
||||
/// Callers should use this only after
|
||||
/// [`ChatWidget::token_activity_history_insertion_blocked`] returns `false`;
|
||||
/// [`ChatWidget::usage_history_insertion_blocked`] returns `false`;
|
||||
/// taking the card removes it from the transient render area.
|
||||
pub(crate) fn take_completed_token_activity_output(&mut self) -> Option<CompositeHistoryCell> {
|
||||
let output = self.completed_token_activity_output.take()?;
|
||||
@@ -252,14 +252,24 @@ impl ChatWidget {
|
||||
Some(output)
|
||||
}
|
||||
|
||||
/// Requests another insertion attempt when a completed card is waiting.
|
||||
/// Requests another insertion attempt when completed usage output is waiting.
|
||||
///
|
||||
/// This is used after stream or history lifecycle events that may have cleared
|
||||
/// the insertion barriers without directly owning the completed card.
|
||||
pub(crate) fn request_completed_token_activity_output_insertion(&self) {
|
||||
if self.completed_token_activity_output.is_some() {
|
||||
/// the insertion barriers without directly owning the completed output.
|
||||
pub(crate) fn request_pending_usage_output_insertion(&self) {
|
||||
if self.completed_token_activity_output.is_some()
|
||||
|| self.pending_rate_limit_reset_hint().is_some()
|
||||
{
|
||||
self.app_event_tx.send(AppEvent::CommitPendingUsageOutput);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn request_pending_usage_output_insertion_after_stream_shutdown(&self) {
|
||||
if self.completed_token_activity_output.is_some()
|
||||
|| self.pending_rate_limit_reset_hint().is_some()
|
||||
{
|
||||
self.app_event_tx
|
||||
.send(AppEvent::CommitCompletedTokenActivityOutput);
|
||||
.send(AppEvent::CommitPendingUsageOutputAfterStreamShutdown);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ const SUMMARY_INDENT_WIDTH: u16 = 1;
|
||||
|
||||
/// Selects the aggregation represented by the token activity chart.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(in crate::chatwidget) enum TokenActivityView {
|
||||
pub(crate) enum TokenActivityView {
|
||||
Daily,
|
||||
Weekly,
|
||||
Cumulative,
|
||||
|
||||
@@ -52,7 +52,7 @@ impl ChatWidget {
|
||||
self.transcript.reset_turn_flags();
|
||||
self.adaptive_chunking.reset();
|
||||
if self.plan_stream_controller.take().is_some() {
|
||||
self.request_completed_token_activity_output_insertion();
|
||||
self.request_pending_usage_output_insertion_after_stream_shutdown();
|
||||
}
|
||||
self.turn_runtime_metrics = RuntimeMetricsSummary::default();
|
||||
self.session_telemetry.reset_runtime_metrics();
|
||||
@@ -128,7 +128,7 @@ impl ChatWidget {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::ConsolidateProposedPlan(source));
|
||||
}
|
||||
self.request_completed_token_activity_output_insertion();
|
||||
self.request_pending_usage_output_insertion_after_stream_shutdown();
|
||||
}
|
||||
self.flush_unified_exec_wait_streak();
|
||||
if !from_replay {
|
||||
@@ -317,7 +317,7 @@ impl ChatWidget {
|
||||
self.adaptive_chunking.reset();
|
||||
self.stream_controller = None;
|
||||
self.plan_stream_controller = None;
|
||||
self.request_completed_token_activity_output_insertion();
|
||||
self.request_pending_usage_output_insertion_after_stream_shutdown();
|
||||
self.status_state.pending_status_indicator_restore = false;
|
||||
self.clear_cancel_edit();
|
||||
self.request_status_line_branch_refresh();
|
||||
@@ -366,8 +366,9 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(super) fn on_rate_limit_error(&mut self, error_kind: RateLimitErrorKind, message: String) {
|
||||
let usage_limit_error = matches!(error_kind, RateLimitErrorKind::UsageLimit);
|
||||
let rate_limit_reached_type = self.codex_rate_limit_reached_type.map(|kind| {
|
||||
if matches!(error_kind, RateLimitErrorKind::UsageLimit) {
|
||||
if usage_limit_error {
|
||||
match kind {
|
||||
RateLimitReachedType::WorkspaceOwnerCreditsDepleted => {
|
||||
RateLimitReachedType::WorkspaceOwnerUsageLimitReached
|
||||
@@ -382,7 +383,6 @@ impl ChatWidget {
|
||||
}
|
||||
});
|
||||
self.codex_rate_limit_reached_type = rate_limit_reached_type;
|
||||
|
||||
match rate_limit_reached_type {
|
||||
Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted) => {
|
||||
self.on_error(
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditOutcome;
|
||||
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse;
|
||||
use codex_app_server_protocol::RateLimitResetCreditsSummary;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::*;
|
||||
|
||||
const USAGE_MENU_VIEW_ID: &str = "usage-menu";
|
||||
const RATE_LIMIT_RESET_VIEW_ID: &str = "rate-limit-reset";
|
||||
|
||||
impl ChatWidget {
|
||||
pub(super) fn open_usage_menu(&mut self) {
|
||||
self.clear_pending_rate_limit_reset_hint();
|
||||
let reset_eligible =
|
||||
self.has_chatgpt_account && !self.plan_type.is_some_and(PlanType::is_workspace_account);
|
||||
let (reset_action_enabled, reset_description) =
|
||||
match (reset_eligible, self.available_rate_limit_reset_credits) {
|
||||
(true, Some(available_count)) if available_count > 0 => (
|
||||
true,
|
||||
format!(
|
||||
"You have {available_count} {} available.",
|
||||
reset_label(available_count)
|
||||
),
|
||||
),
|
||||
(true, _) => (true, "Check reset availability.".to_string()),
|
||||
(false, _) => (false, "No rate-limit resets available.".to_string()),
|
||||
};
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
view_id: Some(USAGE_MENU_VIEW_ID),
|
||||
title: Some("Usage".to_string()),
|
||||
subtitle: Some("View account usage or redeem an earned reset.".to_string()),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items: vec![
|
||||
SelectionItem {
|
||||
name: "Show usage".to_string(),
|
||||
description: Some("View recent account token usage.".to_string()),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenTokenActivity);
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Redeem rate limit reset".to_string(),
|
||||
description: Some(reset_description),
|
||||
is_disabled: !reset_action_enabled,
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenRateLimitResetCredits);
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn show_rate_limit_reset_loading_popup(&mut self) -> u64 {
|
||||
self.clear_pending_rate_limit_reset_hint();
|
||||
let request_id = self.take_next_rate_limit_reset_request_id();
|
||||
self.pending_rate_limit_reset_request_id = Some(request_id);
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
|
||||
title: Some("Rate-limit resets".to_string()),
|
||||
subtitle: Some("Checking your available resets...".to_string()),
|
||||
items: vec![SelectionItem {
|
||||
name: "Loading...".to_string(),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
self.request_redraw();
|
||||
request_id
|
||||
}
|
||||
|
||||
pub(crate) fn finish_rate_limit_reset_credits_refresh(
|
||||
&mut self,
|
||||
request_id: u64,
|
||||
result: Result<RateLimitResetCreditsSummary, String>,
|
||||
) -> bool {
|
||||
if self.pending_rate_limit_reset_request_id != Some(request_id) {
|
||||
return false;
|
||||
}
|
||||
self.pending_rate_limit_reset_request_id = None;
|
||||
|
||||
let params = match result {
|
||||
Ok(response) => {
|
||||
self.available_rate_limit_reset_credits = Some(response.available_count);
|
||||
if response.available_count > 0 {
|
||||
Self::rate_limit_reset_confirmation_params(response.available_count)
|
||||
} else {
|
||||
Self::rate_limit_reset_message_params(
|
||||
"You don't have any rate-limit resets available.",
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(_) => Self::rate_limit_reset_message_params(
|
||||
"Couldn't load rate-limit resets. Please try again.",
|
||||
),
|
||||
};
|
||||
let replaced = self
|
||||
.bottom_pane
|
||||
.replace_selection_view_if_present(RATE_LIMIT_RESET_VIEW_ID, params);
|
||||
if replaced {
|
||||
self.request_redraw();
|
||||
}
|
||||
replaced
|
||||
}
|
||||
|
||||
fn rate_limit_reset_confirmation_params(available_count: i64) -> SelectionViewParams {
|
||||
let idempotency_key = Uuid::new_v4().to_string();
|
||||
SelectionViewParams {
|
||||
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
|
||||
title: Some("Rate-limit resets".to_string()),
|
||||
subtitle: Some(format!(
|
||||
"You have {available_count} {} available.",
|
||||
reset_label(available_count)
|
||||
)),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items: vec![
|
||||
SelectionItem {
|
||||
name: "Use a reset".to_string(),
|
||||
description: Some("Reset your current Codex usage windows.".to_string()),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::ConsumeRateLimitResetCredit {
|
||||
idempotency_key: idempotency_key.clone(),
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Cancel".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
initial_selected_idx: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn rate_limit_reset_message_params(message: &str) -> SelectionViewParams {
|
||||
SelectionViewParams {
|
||||
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
|
||||
title: Some("Rate-limit resets".to_string()),
|
||||
subtitle: Some(message.to_string()),
|
||||
items: vec![SelectionItem {
|
||||
name: "Close".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn show_rate_limit_reset_consuming_popup(&mut self) -> u64 {
|
||||
self.clear_pending_rate_limit_reset_hint();
|
||||
let request_id = self.take_next_rate_limit_reset_request_id();
|
||||
self.pending_rate_limit_reset_request_id = Some(request_id);
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
|
||||
title: Some("Rate-limit resets".to_string()),
|
||||
subtitle: Some("Resetting your usage...".to_string()),
|
||||
items: vec![SelectionItem {
|
||||
name: "Using a reset...".to_string(),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}],
|
||||
allow_cancel: false,
|
||||
..Default::default()
|
||||
});
|
||||
self.request_redraw();
|
||||
request_id
|
||||
}
|
||||
|
||||
pub(crate) fn finish_rate_limit_reset_consume(
|
||||
&mut self,
|
||||
request_id: u64,
|
||||
idempotency_key: String,
|
||||
result: Result<ConsumeAccountRateLimitResetCreditResponse, String>,
|
||||
) -> bool {
|
||||
if self.pending_rate_limit_reset_request_id != Some(request_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(response)
|
||||
if matches!(
|
||||
response.outcome,
|
||||
ConsumeAccountRateLimitResetCreditOutcome::Reset
|
||||
| ConsumeAccountRateLimitResetCreditOutcome::AlreadyRedeemed
|
||||
) =>
|
||||
{
|
||||
self.available_rate_limit_reset_credits = None;
|
||||
self.replace_rate_limit_reset_popup(Self::rate_limit_reset_success_loading_params());
|
||||
true
|
||||
}
|
||||
Ok(response) => {
|
||||
self.pending_rate_limit_reset_request_id = None;
|
||||
let message = match response.outcome {
|
||||
ConsumeAccountRateLimitResetCreditOutcome::NothingToReset => {
|
||||
"Your usage does not need a reset right now."
|
||||
}
|
||||
ConsumeAccountRateLimitResetCreditOutcome::NoCredit => {
|
||||
self.available_rate_limit_reset_credits = Some(0);
|
||||
"No rate-limit resets are available."
|
||||
}
|
||||
ConsumeAccountRateLimitResetCreditOutcome::Reset
|
||||
| ConsumeAccountRateLimitResetCreditOutcome::AlreadyRedeemed => unreachable!(),
|
||||
};
|
||||
self.replace_rate_limit_reset_popup(Self::rate_limit_reset_message_params(message));
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
self.pending_rate_limit_reset_request_id = None;
|
||||
self.replace_rate_limit_reset_popup(SelectionViewParams {
|
||||
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
|
||||
title: Some("Rate-limit resets".to_string()),
|
||||
subtitle: Some("Couldn't reset usage. Please try again.".to_string()),
|
||||
items: vec![
|
||||
SelectionItem {
|
||||
name: "Try again".to_string(),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::ConsumeRateLimitResetCredit {
|
||||
idempotency_key: idempotency_key.clone(),
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Close".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn finish_post_consume_reset_credits_refresh(
|
||||
&mut self,
|
||||
request_id: u64,
|
||||
result: Result<RateLimitResetCreditsSummary, String>,
|
||||
) -> bool {
|
||||
if self.pending_rate_limit_reset_request_id != Some(request_id) {
|
||||
return false;
|
||||
}
|
||||
self.pending_rate_limit_reset_request_id = None;
|
||||
|
||||
let message = match result {
|
||||
Ok(response) => {
|
||||
self.available_rate_limit_reset_credits = Some(response.available_count);
|
||||
format!(
|
||||
"Usage reset. You have {} {} left.",
|
||||
response.available_count,
|
||||
reset_label(response.available_count)
|
||||
)
|
||||
}
|
||||
Err(_) => "Usage reset.".to_string(),
|
||||
};
|
||||
self.replace_rate_limit_reset_popup(Self::rate_limit_reset_message_params(&message));
|
||||
true
|
||||
}
|
||||
|
||||
fn rate_limit_reset_success_loading_params() -> SelectionViewParams {
|
||||
SelectionViewParams {
|
||||
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
|
||||
title: Some("Rate-limit resets".to_string()),
|
||||
subtitle: Some("Usage reset. Checking your remaining resets...".to_string()),
|
||||
items: vec![SelectionItem {
|
||||
name: "Refreshing...".to_string(),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}],
|
||||
allow_cancel: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_rate_limit_reset_popup(&mut self, params: SelectionViewParams) {
|
||||
if self
|
||||
.bottom_pane
|
||||
.replace_selection_view_if_present(RATE_LIMIT_RESET_VIEW_ID, params)
|
||||
{
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn start_rate_limit_reset_startup_check(&mut self) -> u64 {
|
||||
self.clear_pending_rate_limit_reset_hint();
|
||||
let request_id = self.take_next_rate_limit_reset_request_id();
|
||||
self.pending_rate_limit_reset_hint_request_id = Some(request_id);
|
||||
request_id
|
||||
}
|
||||
|
||||
pub(crate) fn finish_rate_limit_reset_hint_refresh(
|
||||
&mut self,
|
||||
request_id: u64,
|
||||
result: Result<RateLimitResetCreditsSummary, String>,
|
||||
) -> bool {
|
||||
if self.pending_rate_limit_reset_hint_request_id != Some(request_id) {
|
||||
return false;
|
||||
}
|
||||
self.pending_rate_limit_reset_hint_request_id = None;
|
||||
if !self.has_codex_backend_auth {
|
||||
return false;
|
||||
}
|
||||
if self.plan_type.is_some_and(PlanType::is_workspace_account) {
|
||||
return true;
|
||||
}
|
||||
if let Ok(response) = result {
|
||||
self.available_rate_limit_reset_credits = Some(response.available_count);
|
||||
self.set_rate_limit_reset_available_hint(response.available_count);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn clear_pending_rate_limit_reset_requests(&mut self) {
|
||||
self.pending_rate_limit_reset_request_id = None;
|
||||
self.available_rate_limit_reset_credits = None;
|
||||
self.clear_pending_rate_limit_reset_hint();
|
||||
self.bottom_pane
|
||||
.dismiss_view_by_id(RATE_LIMIT_RESET_VIEW_ID);
|
||||
}
|
||||
|
||||
pub(crate) fn clear_pending_rate_limit_reset_hint(&mut self) {
|
||||
self.pending_rate_limit_reset_hint_request_id = None;
|
||||
let cleared_hint = self.pending_rate_limit_reset_hint.take().is_some();
|
||||
if cleared_hint {
|
||||
self.bump_active_cell_revision();
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn pending_rate_limit_reset_hint(&self) -> Option<&PlainHistoryCell> {
|
||||
self.pending_rate_limit_reset_hint.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn take_pending_rate_limit_reset_hint(&mut self) -> Option<PlainHistoryCell> {
|
||||
let hint = self.pending_rate_limit_reset_hint.take()?;
|
||||
self.bump_active_cell_revision();
|
||||
Some(hint)
|
||||
}
|
||||
|
||||
fn set_rate_limit_reset_available_hint(&mut self, available_count: i64) {
|
||||
if available_count <= 0 {
|
||||
return;
|
||||
}
|
||||
self.pending_rate_limit_reset_hint = Some(history_cell::new_info_event(
|
||||
format!(
|
||||
"You have {available_count} {} available. Run /usage to use one.",
|
||||
reset_label(available_count)
|
||||
),
|
||||
/*hint*/ None,
|
||||
));
|
||||
self.bump_active_cell_revision();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn take_next_rate_limit_reset_request_id(&mut self) -> u64 {
|
||||
let request_id = self.next_rate_limit_reset_request_id;
|
||||
self.next_rate_limit_reset_request_id = self
|
||||
.next_rate_limit_reset_request_id
|
||||
.wrapping_add(/*rhs*/ 1);
|
||||
request_id
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_label(count: i64) -> &'static str {
|
||||
if count == 1 {
|
||||
"rate-limit reset"
|
||||
} else {
|
||||
"rate-limit resets"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user