From 48c732db6205fcb9c92f196d8f40d2e6ab831a83 Mon Sep 17 00:00:00 2001 From: James Chu Date: Tue, 28 Apr 2026 17:13:55 -0400 Subject: [PATCH] [codex] Render backend-selected near-limit prompts in TUI --- codex-rs/core/config.schema.json | 6 + codex-rs/features/src/lib.rs | 8 + codex-rs/tui/src/chatwidget.rs | 106 ++++++- .../chatwidget/current_usage_limit_nudge.rs | 70 +++++ ...ests__proactive_usage_prompt_variants.snap | 35 +++ codex-rs/tui/src/chatwidget/tests.rs | 3 + .../tests/current_usage_limit_nudge.rs | 294 ++++++++++++++++++ codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 40 +++ 9 files changed, 562 insertions(+), 1 deletion(-) create mode 100644 codex-rs/tui/src/chatwidget/current_usage_limit_nudge.rs create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__proactive_usage_prompt_variants.snap create mode 100644 codex-rs/tui/src/chatwidget/tests/current_usage_limit_nudge.rs diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index f0e7c23c3b..2399d69530 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -388,6 +388,9 @@ "connectors": { "type": "boolean" }, + "current_usage_limit_nudge": { + "type": "boolean" + }, "default_mode_request_user_input": { "type": "boolean" }, @@ -3298,6 +3301,9 @@ "connectors": { "type": "boolean" }, + "current_usage_limit_nudge": { + "type": "boolean" + }, "default_mode_request_user_input": { "type": "boolean" }, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index f20ca6577c..24c96c3c60 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -214,6 +214,8 @@ pub enum Feature { PreventIdleSleep, /// Enable workspace-specific owner nudge copy and prompts in the TUI. WorkspaceOwnerUsageNudge, + /// Render backend-selected proactive usage-limit prompts in the TUI. + CurrentUsageLimitNudge, /// Legacy rollout flag for Responses API WebSocket transport experiments. ResponsesWebsockets, /// Legacy rollout flag for Responses API WebSocket transport v2 experiments. @@ -1030,6 +1032,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::CurrentUsageLimitNudge, + key: "current_usage_limit_nudge", + stage: Stage::Stable, + default_enabled: true, + }, FeatureSpec { id: Feature::ResponsesWebsockets, key: "responses_websockets", diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fc2f200850..8329aa082a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -169,6 +169,7 @@ use codex_protocol::protocol::CollabAgentRef; use codex_protocol::protocol::CollabAgentSpawnBeginEvent; use codex_protocol::protocol::CollabAgentStatusEntry; use codex_protocol::protocol::CreditsSnapshot; +use codex_protocol::protocol::CurrentUsageLimitNudgeState; use codex_protocol::protocol::DeprecationNoticeEvent; #[cfg(test)] use codex_protocol::protocol::ErrorEvent; @@ -274,6 +275,7 @@ const PLAN_MODE_REASONING_SCOPE_TITLE: &str = "Apply reasoning change"; const PLAN_MODE_REASONING_SCOPE_PLAN_ONLY: &str = "Apply to Plan mode override"; const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and Plan mode override"; const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection"; +const RATE_LIMIT_SWITCH_PROMPT_VIEW_ID: &str = "rate-limit-switch-prompt"; const TUI_STUB_MESSAGE: &str = "Not available in TUI yet."; /// Choose the keybinding used to edit the most-recently queued message. @@ -387,7 +389,17 @@ use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; +mod current_usage_limit_nudge; mod goal_status; +#[cfg(test)] +use self::current_usage_limit_nudge::CURRENT_USAGE_LIMIT_NUDGE_URL; +use self::current_usage_limit_nudge::CurrentUsageLimitNudgePromptState; +#[cfg(test)] +use self::current_usage_limit_nudge::UPGRADE_USAGE_LIMIT_NUDGE_URL; +#[cfg(test)] +use self::current_usage_limit_nudge::WORKSPACE_OWNER_USAGE_LIMIT_NUDGE_URL; +use self::current_usage_limit_nudge::prompt_subtitle as current_usage_limit_nudge_prompt_subtitle; +use self::current_usage_limit_nudge::prompt_url as current_usage_limit_nudge_prompt_url; use self::goal_status::GoalStatusState; #[cfg(test)] use self::goal_status::goal_status_indicator_from_app_goal; @@ -836,6 +848,7 @@ pub(crate) struct ChatWidget { plan_type: Option, codex_rate_limit_reached_type: Option, rate_limit_warnings: RateLimitWarningState, + current_usage_limit_nudge_prompt: CurrentUsageLimitNudgePromptState, rate_limit_switch_prompt: RateLimitSwitchPromptState, add_credits_nudge_email_in_flight: Option, adaptive_chunking: AdaptiveChunkingPolicy, @@ -2912,7 +2925,8 @@ impl ChatWidget { if matches!( self.rate_limit_switch_prompt, RateLimitSwitchPromptState::Pending - ) { + ) || self.current_usage_limit_nudge_prompt.has_pending() + { return; } @@ -3234,6 +3248,16 @@ impl ChatWidget { self.plan_type = snapshot.plan_type.or(self.plan_type); let is_codex_limit = limit_id.eq_ignore_ascii_case("codex"); + if is_codex_limit && self.current_usage_limit_nudge_enabled() { + self.current_usage_limit_nudge_prompt + .update(snapshot.current_usage_limit_nudge_state()); + } else if is_codex_limit { + self.current_usage_limit_nudge_prompt + .update(CurrentUsageLimitNudgeState::Inactive); + } + if self.current_usage_limit_nudge_prompt.is_active() { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + } if is_codex_limit && let Some(rate_limit_reached_type) = snapshot.rate_limit_reached_type { @@ -3279,6 +3303,7 @@ impl ChatWidget { if high_usage && !has_workspace_credits + && !self.current_usage_limit_nudge_prompt.is_active() && !self.rate_limit_switch_prompt_hidden() && self.current_model() != NUDGE_MODEL_SLUG && !matches!( @@ -3373,6 +3398,12 @@ impl ChatWidget { .enabled(Feature::WorkspaceOwnerUsageNudge) } + fn current_usage_limit_nudge_enabled(&self) -> bool { + self.config + .features + .enabled(Feature::CurrentUsageLimitNudge) + } + fn on_rate_limit_error(&mut self, error_kind: RateLimitErrorKind, message: String) { if !self.workspace_owner_usage_nudge_enabled() { self.on_error(message); @@ -5561,6 +5592,7 @@ impl ChatWidget { plan_type: initial_plan_type, codex_rate_limit_reached_type: None, rate_limit_warnings: RateLimitWarningState::default(), + current_usage_limit_nudge_prompt: CurrentUsageLimitNudgePromptState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), add_credits_nudge_email_in_flight: None, adaptive_chunking: AdaptiveChunkingPolicy::default(), @@ -8538,6 +8570,12 @@ impl ChatWidget { } fn maybe_show_pending_rate_limit_prompt(&mut self) { + if self.current_usage_limit_nudge_prompt.is_active() { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + } + if self.maybe_show_pending_current_usage_limit_nudge_prompt() { + return; + } if self.rate_limit_switch_prompt_hidden() { self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; return; @@ -8556,6 +8594,67 @@ impl ChatWidget { } } + fn maybe_show_pending_current_usage_limit_nudge_prompt(&mut self) -> bool { + if !self.current_usage_limit_nudge_enabled() { + return false; + } + let Some(nudge) = self.current_usage_limit_nudge_prompt.take_pending() else { + return false; + }; + self.open_current_usage_limit_nudge_prompt(nudge); + true + } + + fn open_current_usage_limit_nudge_prompt( + &mut self, + nudge: codex_protocol::protocol::UsageLimitNudge, + ) { + if !self.bottom_pane.replace_selection_view_if_active( + RATE_LIMIT_SWITCH_PROMPT_VIEW_ID, + self.current_usage_limit_nudge_prompt_params(&nudge), + ) { + self.bottom_pane + .show_selection_view(self.current_usage_limit_nudge_prompt_params(&nudge)); + } + } + + fn current_usage_limit_nudge_prompt_params( + &self, + nudge: &codex_protocol::protocol::UsageLimitNudge, + ) -> SelectionViewParams { + let prompt_url = current_usage_limit_nudge_prompt_url(&nudge, self.plan_type).to_string(); + let yes_actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenUrlInBrowser { + url: prompt_url.clone(), + }); + })]; + let items = vec![ + SelectionItem { + name: "Yes".to_string(), + display_shortcut: Some(key_hint::plain(KeyCode::Char('y'))), + actions: yes_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "No".to_string(), + display_shortcut: Some(key_hint::plain(KeyCode::Char('n'))), + is_default: true, + dismiss_on_select: true, + ..Default::default() + }, + ]; + + SelectionViewParams { + title: Some("Approaching usage limit".to_string()), + subtitle: Some(current_usage_limit_nudge_prompt_subtitle(&nudge)), + footer_hint: Some(standard_popup_hint_line()), + items, + initial_selected_idx: Some(1), + ..Default::default() + } + } + fn open_rate_limit_switch_prompt(&mut self, preset: ModelPreset) { let switch_model = preset.model; let switch_model_for_events = switch_model.clone(); @@ -8626,6 +8725,7 @@ impl ChatWidget { ]; self.bottom_pane.show_selection_view(SelectionViewParams { + view_id: Some(RATE_LIMIT_SWITCH_PROMPT_VIEW_ID), title: Some("Approaching rate limits".to_string()), subtitle: Some(format!("Switch to {switch_model} for lower credit usage?")), footer_hint: Some(standard_popup_hint_line()), @@ -10507,6 +10607,10 @@ impl ChatWidget { self.turn_sleep_inhibitor .set_turn_running(self.agent_turn_running); } + if feature == Feature::CurrentUsageLimitNudge && !enabled { + self.current_usage_limit_nudge_prompt + .update(CurrentUsageLimitNudgeState::Inactive); + } #[cfg(target_os = "windows")] if matches!( feature, diff --git a/codex-rs/tui/src/chatwidget/current_usage_limit_nudge.rs b/codex-rs/tui/src/chatwidget/current_usage_limit_nudge.rs new file mode 100644 index 0000000000..c1a4de8ca6 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/current_usage_limit_nudge.rs @@ -0,0 +1,70 @@ +use codex_protocol::account::PlanType; +use codex_protocol::protocol::CurrentUsageLimitNudgeState; +use codex_protocol::protocol::UsageLimitNudge; +use codex_protocol::protocol::UsageLimitNudgeAction; + +pub(super) const CURRENT_USAGE_LIMIT_NUDGE_URL: &str = "https://chatgpt.com/codex/settings/usage"; +pub(super) const WORKSPACE_OWNER_USAGE_LIMIT_NUDGE_URL: &str = "https://chatgpt.com/admin/billing"; +pub(super) const UPGRADE_USAGE_LIMIT_NUDGE_URL: &str = "https://chatgpt.com/explore/pro"; + +#[derive(Default)] +pub(super) struct CurrentUsageLimitNudgePromptState { + current: Option, + pending: Option, + last_shown_key: Option, +} + +impl CurrentUsageLimitNudgePromptState { + pub(super) fn update(&mut self, state: CurrentUsageLimitNudgeState) { + match state { + CurrentUsageLimitNudgeState::Unknown => {} + CurrentUsageLimitNudgeState::Inactive => { + self.current = None; + self.pending = None; + self.last_shown_key = None; + } + CurrentUsageLimitNudgeState::Active(nudge) => { + let already_shown = self.last_shown_key.as_deref() == Some(nudge.key.as_str()); + self.current = Some(nudge.clone()); + self.pending = (!already_shown).then_some(nudge); + } + } + } + + pub(super) fn take_pending(&mut self) -> Option { + let nudge = self.pending.take()?; + self.last_shown_key = Some(nudge.key.clone()); + Some(nudge) + } + + pub(super) fn is_active(&self) -> bool { + self.current.is_some() + } + + pub(super) fn has_pending(&self) -> bool { + self.pending.is_some() + } +} + +pub(super) fn prompt_subtitle(nudge: &UsageLimitNudge) -> String { + let action = match nudge.action { + UsageLimitNudgeAction::AddCredits => "Add credits", + UsageLimitNudgeAction::Upgrade => "Upgrade", + }; + format!( + "You're at {}% of your Codex usage limit. {action} now to keep going?", + nudge.threshold.as_percent() + ) +} + +pub(super) fn prompt_url(nudge: &UsageLimitNudge, plan_type: Option) -> &'static str { + match nudge.action { + UsageLimitNudgeAction::Upgrade => UPGRADE_USAGE_LIMIT_NUDGE_URL, + UsageLimitNudgeAction::AddCredits + if plan_type.is_some_and(PlanType::is_workspace_account) => + { + WORKSPACE_OWNER_USAGE_LIMIT_NUDGE_URL + } + UsageLimitNudgeAction::AddCredits => CURRENT_USAGE_LIMIT_NUDGE_URL, + } +} diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__proactive_usage_prompt_variants.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__proactive_usage_prompt_variants.snap new file mode 100644 index 0000000000..4c6617822c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__proactive_usage_prompt_variants.snap @@ -0,0 +1,35 @@ +--- +source: tui/src/chatwidget/tests/current_usage_limit_nudge.rs +expression: "rendered_cases.join(\"\\n---\\n\")" +--- + Approaching usage limit + You're at 75% of your Codex usage limit. Add credits now to keep going? + + 1. Yes (y) +› 2. No (default) (n) + + Press enter to confirm or esc to go back +--- + Approaching usage limit + You're at 75% of your Codex usage limit. Upgrade now to keep going? + + 1. Yes (y) +› 2. No (default) (n) + + Press enter to confirm or esc to go back +--- + Approaching usage limit + You're at 90% of your Codex usage limit. Add credits now to keep going? + + 1. Yes (y) +› 2. No (default) (n) + + Press enter to confirm or esc to go back +--- + Approaching usage limit + You're at 90% of your Codex usage limit. Upgrade now to keep going? + + 1. Yes (y) +› 2. No (default) (n) + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 547c425093..32f4b2ee62 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -200,6 +200,8 @@ pub(super) use codex_protocol::protocol::TurnCompleteEvent; pub(super) use codex_protocol::protocol::TurnStartedEvent; pub(super) use codex_protocol::protocol::UndoCompletedEvent; pub(super) use codex_protocol::protocol::UndoStartedEvent; +pub(super) use codex_protocol::protocol::UsageLimitNudgeAction; +pub(super) use codex_protocol::protocol::UsageLimitNudgeStatePayload; pub(super) use codex_protocol::protocol::ViewImageToolCallEvent; pub(super) use codex_protocol::protocol::WarningEvent; pub(super) use codex_protocol::request_permissions::RequestPermissionProfile; @@ -268,6 +270,7 @@ mod app_server; mod approval_requests; mod background_events; mod composer_submission; +mod current_usage_limit_nudge; mod exec_flow; mod goal_menu; mod guardian; diff --git a/codex-rs/tui/src/chatwidget/tests/current_usage_limit_nudge.rs b/codex-rs/tui/src/chatwidget/tests/current_usage_limit_nudge.rs new file mode 100644 index 0000000000..2784ecef56 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/current_usage_limit_nudge.rs @@ -0,0 +1,294 @@ +use super::*; +use pretty_assertions::assert_eq; + +fn snapshot_with_nudge( + key: &str, + threshold: u8, + action: UsageLimitNudgeAction, +) -> RateLimitSnapshot { + RateLimitSnapshot { + current_usage_limit_nudge: Some(UsageLimitNudgeStatePayload::Active { + key: key.to_string(), + threshold, + action, + }), + ..snapshot(f64::from(threshold)) + } +} + +fn inactive_snapshot() -> RateLimitSnapshot { + RateLimitSnapshot { + current_usage_limit_nudge: Some(UsageLimitNudgeStatePayload::Inactive), + ..snapshot(/*percent*/ 75.0) + } +} + +fn next_open_url_event(rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Option { + while let Ok(event) = rx.try_recv() { + if let AppEvent::OpenUrlInBrowser { url } = event { + return Some(url); + } + } + None +} + +#[tokio::test] +async fn proactive_usage_prompt_renders_backend_actions() { + let mut rendered_cases = Vec::new(); + + for (threshold, action) in [ + (75, UsageLimitNudgeAction::AddCredits), + (75, UsageLimitNudgeAction::Upgrade), + (90, UsageLimitNudgeAction::AddCredits), + (90, UsageLimitNudgeAction::Upgrade), + ] { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.on_rate_limit_snapshot(Some(snapshot_with_nudge( + &format!("{threshold}-{action:?}"), + threshold, + action, + ))); + chat.maybe_show_pending_rate_limit_prompt(); + rendered_cases.push(render_bottom_popup(&chat, /*width*/ 88)); + } + + assert_chatwidget_snapshot!( + "proactive_usage_prompt_variants", + rendered_cases.join("\n---\n") + ); +} + +#[tokio::test] +async fn proactive_usage_prompt_dedupes_same_key() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let nudge = snapshot_with_nudge( + "same-key", + /*threshold*/ 75, + UsageLimitNudgeAction::AddCredits, + ); + + chat.on_rate_limit_snapshot(Some(nudge.clone())); + assert!(chat.maybe_show_pending_current_usage_limit_nudge_prompt()); + + chat.on_rate_limit_snapshot(Some(nudge)); + assert!(!chat.maybe_show_pending_current_usage_limit_nudge_prompt()); +} + +#[tokio::test] +async fn proactive_usage_prompt_shows_changed_key_again() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(snapshot_with_nudge( + "first-key", + /*threshold*/ 75, + UsageLimitNudgeAction::AddCredits, + ))); + assert!(chat.maybe_show_pending_current_usage_limit_nudge_prompt()); + + chat.on_rate_limit_snapshot(Some(snapshot_with_nudge( + "second-key", + /*threshold*/ 75, + UsageLimitNudgeAction::AddCredits, + ))); + assert!(chat.maybe_show_pending_current_usage_limit_nudge_prompt()); +} + +#[tokio::test] +async fn proactive_usage_prompt_explicit_inactive_clears_suppression() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(snapshot_with_nudge( + "repeatable-key", + /*threshold*/ 75, + UsageLimitNudgeAction::AddCredits, + ))); + assert!(chat.maybe_show_pending_current_usage_limit_nudge_prompt()); + + chat.on_rate_limit_snapshot(Some(inactive_snapshot())); + chat.on_rate_limit_snapshot(Some(snapshot_with_nudge( + "repeatable-key", + /*threshold*/ 75, + UsageLimitNudgeAction::AddCredits, + ))); + assert!(chat.maybe_show_pending_current_usage_limit_nudge_prompt()); +} + +#[tokio::test] +async fn proactive_usage_prompt_unknown_state_preserves_suppression() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(snapshot_with_nudge( + "repeatable-key", + /*threshold*/ 75, + UsageLimitNudgeAction::AddCredits, + ))); + assert!(chat.maybe_show_pending_current_usage_limit_nudge_prompt()); + + chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 75.0))); + chat.on_rate_limit_snapshot(Some(snapshot_with_nudge( + "repeatable-key", + /*threshold*/ 75, + UsageLimitNudgeAction::AddCredits, + ))); + assert!(!chat.maybe_show_pending_current_usage_limit_nudge_prompt()); +} + +#[tokio::test] +async fn proactive_usage_prompt_yes_opens_upgrade_destination() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(snapshot_with_nudge( + "browser-key", + /*threshold*/ 90, + UsageLimitNudgeAction::Upgrade, + ))); + chat.maybe_show_pending_rate_limit_prompt(); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + + assert_eq!( + next_open_url_event(&mut rx), + Some(UPGRADE_USAGE_LIMIT_NUDGE_URL.to_string()) + ); +} + +#[tokio::test] +async fn proactive_usage_prompt_yes_opens_personal_add_credits_destination() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(snapshot_with_nudge( + "personal-browser-key", + /*threshold*/ 90, + UsageLimitNudgeAction::AddCredits, + ))); + chat.maybe_show_pending_rate_limit_prompt(); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + + assert_eq!( + next_open_url_event(&mut rx), + Some(CURRENT_USAGE_LIMIT_NUDGE_URL.to_string()) + ); +} + +#[tokio::test] +async fn proactive_usage_prompt_yes_opens_workspace_owner_billing_destination() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.plan_type = Some(PlanType::SelfServeBusinessUsageBased); + + chat.on_rate_limit_snapshot(Some(snapshot_with_nudge( + "workspace-browser-key", + /*threshold*/ 90, + UsageLimitNudgeAction::AddCredits, + ))); + chat.maybe_show_pending_rate_limit_prompt(); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + + assert_eq!( + next_open_url_event(&mut rx), + Some(WORKSPACE_OWNER_USAGE_LIMIT_NUDGE_URL.to_string()) + ); +} + +#[tokio::test] +async fn proactive_usage_prompt_no_dismisses_without_opening_browser() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(snapshot_with_nudge( + "dismiss-key", + /*threshold*/ 90, + UsageLimitNudgeAction::Upgrade, + ))); + chat.maybe_show_pending_rate_limit_prompt(); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + + assert_eq!(next_open_url_event(&mut rx), None); +} + +#[tokio::test] +async fn proactive_usage_prompt_waits_for_between_turn_hook() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(snapshot_with_nudge( + "between-turn-key", + /*threshold*/ 75, + UsageLimitNudgeAction::AddCredits, + ))); + let popup = render_bottom_popup(&chat, /*width*/ 88); + assert!(!popup.contains("Approaching usage limit"), "popup: {popup}"); + + chat.maybe_show_pending_rate_limit_prompt(); + assert!(render_bottom_popup(&chat, /*width*/ 88).contains("Approaching usage limit")); +} + +#[tokio::test] +async fn proactive_usage_prompt_flag_disabled_skips_prompt_and_keeps_passive_warning() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::CurrentUsageLimitNudge, /*enabled*/ false); + + chat.on_rate_limit_snapshot(Some(snapshot_with_nudge( + "disabled-key", + /*threshold*/ 90, + UsageLimitNudgeAction::AddCredits, + ))); + assert!(!chat.maybe_show_pending_current_usage_limit_nudge_prompt()); + let popup = render_bottom_popup(&chat, /*width*/ 88); + assert!(!popup.contains("Approaching usage limit"), "popup: {popup}"); + + let rendered = drain_insert_history(&mut rx) + .into_iter() + .map(|lines| lines_to_single_string(&lines)) + .collect::(); + assert!( + rendered.contains("less than 10% of your 1h limit left"), + "rendered: {rendered}" + ); +} + +#[tokio::test] +async fn proactive_usage_prompt_suppresses_later_rate_limit_switch_prompt() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(snapshot_with_nudge( + "threshold-90", + /*threshold*/ 90, + UsageLimitNudgeAction::AddCredits, + ))); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Idle + )); + + chat.maybe_show_pending_rate_limit_prompt(); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + chat.maybe_show_pending_rate_limit_prompt(); + + let popup = render_bottom_popup(&chat, /*width*/ 88); + assert!(!popup.contains("Approaching rate limits"), "popup: {popup}"); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Idle + )); +} + +#[tokio::test] +async fn proactive_usage_prompt_replaces_shown_rate_limit_switch_prompt() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.has_chatgpt_account = true; + + chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 92.0))); + chat.maybe_show_pending_rate_limit_prompt(); + assert!(render_bottom_popup(&chat, /*width*/ 88).contains("Approaching rate limits")); + + chat.on_rate_limit_snapshot(Some(snapshot_with_nudge( + "threshold-90", + /*threshold*/ 90, + UsageLimitNudgeAction::AddCredits, + ))); + chat.maybe_show_pending_rate_limit_prompt(); + let popup = render_bottom_popup(&chat, /*width*/ 88); + assert!(popup.contains("Approaching usage limit"), "popup: {popup}"); + assert!(!popup.contains("Approaching rate limits"), "popup: {popup}"); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + let popup = render_bottom_popup(&chat, /*width*/ 88); + assert!(!popup.contains("Approaching rate limits"), "popup: {popup}"); +} diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index e41add9c1c..d14dc88f32 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -206,6 +206,7 @@ pub(super) async fn make_chatwidget_manual( plan_type: None, codex_rate_limit_reached_type: None, rate_limit_warnings: RateLimitWarningState::default(), + current_usage_limit_nudge_prompt: CurrentUsageLimitNudgePromptState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), add_credits_nudge_email_in_flight: None, adaptive_chunking: crate::streaming::chunking::AdaptiveChunkingPolicy::default(), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index d369eb710d..7cc72bcafa 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1078,6 +1078,46 @@ async fn plan_implementation_popup_skips_when_rate_limit_prompt_pending() { ); } +#[tokio::test] +async fn plan_implementation_popup_skips_when_usage_limit_nudge_pending() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.has_chatgpt_account = true; + chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); + let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mask"); + chat.set_collaboration_mask(plan_mask); + + chat.on_task_started(); + chat.on_plan_update(UpdatePlanArgs { + explanation: None, + plan: vec![PlanItemArg { + step: "First".to_string(), + status: StepStatus::Pending, + }], + }); + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + current_usage_limit_nudge: Some(UsageLimitNudgeStatePayload::Active { + key: "threshold-90".to_string(), + threshold: 90, + action: UsageLimitNudgeAction::AddCredits, + }), + ..snapshot(/*percent*/ 92.0) + })); + chat.on_task_complete( + /*last_agent_message*/ None, /*duration_ms*/ None, /*from_replay*/ false, + ); + + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert!( + popup.contains("Approaching usage limit"), + "expected usage-limit popup, got {popup:?}" + ); + assert!( + !popup.contains(PLAN_IMPLEMENTATION_TITLE), + "expected plan popup to be skipped, got {popup:?}" + ); +} + #[tokio::test] async fn plan_completion_restores_status_indicator_after_streaming_plan_output() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;