[codex] Render backend-selected near-limit prompts in TUI

This commit is contained in:
James Chu
2026-04-29 15:23:13 -04:00
parent 9b42be8d6b
commit 48c732db62
9 changed files with 562 additions and 1 deletions
+6
View File
@@ -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"
},
+8
View File
@@ -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",
+105 -1
View File
@@ -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<PlanType>,
codex_rate_limit_reached_type: Option<RateLimitReachedType>,
rate_limit_warnings: RateLimitWarningState,
current_usage_limit_nudge_prompt: CurrentUsageLimitNudgePromptState,
rate_limit_switch_prompt: RateLimitSwitchPromptState,
add_credits_nudge_email_in_flight: Option<AddCreditsNudgeCreditType>,
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<SelectionAction> = 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,
@@ -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<UsageLimitNudge>,
pending: Option<UsageLimitNudge>,
last_shown_key: Option<String>,
}
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<UsageLimitNudge> {
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<PlanType>) -> &'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,
}
}
@@ -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
+3
View File
@@ -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;
@@ -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<AppEvent>) -> Option<String> {
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::<String>();
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}");
}
@@ -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(),
@@ -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;