diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 076dd780e..52823d838 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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; diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index a486e11e3..bb05e1a3f 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -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> { +) -> Result { 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 { + 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 { + 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, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 1f7b8e39f..16e4aaa62 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -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); diff --git a/codex-rs/tui/src/app/history_ui.rs b/codex-rs/tui/src/app/history_ui.rs index 59ec0d3dc..d527df7a9 100644 --- a/codex-rs/tui/src/app/history_ui.rs +++ b/codex-rs/tui/src/app/history_ui.rs @@ -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::() - || cell.as_any().is::() - }) - { - 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::()) } - 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; diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index c3c83e6d4..87538251a 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -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; diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 04020787a..087009388 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -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(); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 3f6189f27..1143848d1 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -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, String>, + result: Result, + }, + + /// 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, + }, + + /// 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, }, /// Fetch account-wide token activity for a `/usage` history card. @@ -313,8 +341,11 @@ pub(crate) enum AppEvent { result: Result, }, - /// 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 { diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 317d26965..7af981d7d 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -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); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index f6446f543..e49202476 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 80be78555..6495b8f1f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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, completed_token_activity_output: Option, next_token_activity_request_id: u64, + pending_rate_limit_reset_request_id: Option, + pending_rate_limit_reset_hint_request_id: Option, + pending_rate_limit_reset_hint: Option, + available_rate_limit_reset_credits: Option, + next_rate_limit_reset_request_id: u64, plan_type: Option, codex_rate_limit_reached_type: Option, 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) } diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 363d8e4ce..77fdd74e5 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -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(), diff --git a/codex-rs/tui/src/chatwidget/hook_lifecycle.rs b/codex-rs/tui/src/chatwidget/hook_lifecycle.rs index 129e6381f..54a3e7eee 100644 --- a/codex-rs/tui/src/chatwidget/hook_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/hook_lifecycle.rs @@ -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(); } } diff --git a/codex-rs/tui/src/chatwidget/rendering.rs b/codex-rs/tui/src/chatwidget/rendering.rs index 37af21a01..710af6b7e 100644 --- a/codex-rs/tui/src/chatwidget/rendering.rs +++ b/codex-rs/tui/src/chatwidget/rendering.rs @@ -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 { diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 5df31139e..75f8d1ad4 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -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; diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 32ab70ed3..7c1179681 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -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; } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_available_hint.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_available_hint.snap new file mode 100644 index 000000000..5b528bb1d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_available_hint.snap @@ -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. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_hint_waits_for_active_output.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_hint_waits_for_active_output.snap new file mode 100644 index 000000000..9e332a925 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_hint_waits_for_active_output.snap @@ -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. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap new file mode 100644 index 000000000..ba6066e0b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap new file mode 100644 index 000000000..c3fc73a49 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap new file mode 100644 index 000000000..6bfee11de --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap new file mode 100644 index 000000000..6bfee11de --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/streaming.rs b/codex-rs/tui/src/chatwidget/streaming.rs index f6eaa8099..897094b1f 100644 --- a/codex-rs/tui/src/chatwidget/streaming.rs +++ b/codex-rs/tui/src/chatwidget/streaming.rs @@ -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(); } } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a34d39384..ab9fa2a79 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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; diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 37f20ad97..6fe4500fe 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -83,7 +83,7 @@ fn dispatch_usage_and_expect_refresh( chat: &mut ChatWidget, rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) -> 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)) ); } diff --git a/codex-rs/tui/src/chatwidget/tests/usage.rs b/codex-rs/tui/src/chatwidget/tests/usage.rs new file mode 100644 index 000000000..4e49e3561 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/usage.rs @@ -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) { + 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() + }); +} diff --git a/codex-rs/tui/src/chatwidget/tokens.rs b/codex-rs/tui/src/chatwidget/tokens.rs index 85475899b..52db0a60f 100644 --- a/codex-rs/tui/src/chatwidget/tokens.rs +++ b/codex-rs/tui/src/chatwidget/tokens.rs @@ -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 { 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); } } diff --git a/codex-rs/tui/src/chatwidget/tokens/chart.rs b/codex-rs/tui/src/chatwidget/tokens/chart.rs index 58249c6b7..9a9fc27e2 100644 --- a/codex-rs/tui/src/chatwidget/tokens/chart.rs +++ b/codex-rs/tui/src/chatwidget/tokens/chart.rs @@ -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, diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index badab1c85..df3c03ed5 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -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( diff --git a/codex-rs/tui/src/chatwidget/usage.rs b/codex-rs/tui/src/chatwidget/usage.rs new file mode 100644 index 000000000..4cee18af1 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -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, + ) -> 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, + ) -> 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, + ) -> 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, + ) -> 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 { + 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" + } +} diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 05dcefe93..e68b3e1cf 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -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",