diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 697b831c2d..91972cc91e 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -538,6 +538,9 @@ }, "workspace_dependencies": { "type": "boolean" + }, + "workspace_owner_usage_nudge": { + "type": "boolean" } }, "type": "object" @@ -2510,6 +2513,9 @@ }, "workspace_dependencies": { "type": "boolean" + }, + "workspace_owner_usage_nudge": { + "type": "boolean" } }, "type": "object" diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index f811f6aa1e..e800fc2d0f 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -193,6 +193,8 @@ pub enum Feature { TuiAppServer, /// Prevent idle system sleep while a turn is actively running. PreventIdleSleep, + /// Enable workspace-specific owner nudge copy and prompts in the TUI. + WorkspaceOwnerUsageNudge, /// Legacy rollout flag for Responses API WebSocket transport experiments. ResponsesWebsockets, /// Legacy rollout flag for Responses API WebSocket transport v2 experiments. @@ -968,6 +970,12 @@ pub const FEATURES: &[FeatureSpec] = &[ }, default_enabled: false, }, + FeatureSpec { + id: Feature::WorkspaceOwnerUsageNudge, + key: "workspace_owner_usage_nudge", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::ResponsesWebsockets, key: "responses_websockets", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7ba720c2c3..8d0ba335ba 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -74,6 +74,7 @@ use crate::version::CODEX_CLI_VERSION; use codex_ansi_escape::ansi_escape_line; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_client::TypedRequestError; +use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::ConfigLayerSource; @@ -96,6 +97,7 @@ use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallParams; use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SkillsListParams; @@ -2086,6 +2088,21 @@ impl App { }); } + fn send_add_credits_nudge_email( + &mut self, + app_server: &AppServerSession, + credit_type: AddCreditsNudgeCreditType, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = send_add_credits_nudge_email(request_handle, credit_type) + .await + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::AddCreditsNudgeEmailFinished { result }); + }); + } + /// Starts the initial skills refresh without delaying the first interactive frame. /// /// Startup only needs skill metadata to populate skill mentions and the skills UI; the prompt can be @@ -4973,6 +4990,18 @@ impl App { AppEvent::RefreshRateLimits { origin } => { self.refresh_rate_limits(app_server, origin); } + AppEvent::SendAddCreditsNudgeEmail { credit_type } => { + if self + .chat_widget + .start_add_credits_nudge_email_request(credit_type) + { + self.send_add_credits_nudge_email(app_server, credit_type); + } + } + AppEvent::AddCreditsNudgeEmailFinished { result } => { + self.chat_widget + .finish_add_credits_nudge_email_request(result); + } AppEvent::RateLimitsLoaded { origin, result } => match result { Ok(snapshots) => { for snapshot in snapshots { @@ -6685,6 +6714,22 @@ async fn fetch_account_rate_limits( Ok(app_server_rate_limit_snapshots_to_core(response)) } +async fn send_add_credits_nudge_email( + request_handle: AppServerRequestHandle, + credit_type: AddCreditsNudgeCreditType, +) -> Result { + let request_id = RequestId::String(format!("add-credits-nudge-{}", Uuid::new_v4())); + let response: codex_app_server_protocol::SendAddCreditsNudgeEmailResponse = request_handle + .request_typed(ClientRequest::SendAddCreditsNudgeEmail { + request_id, + params: SendAddCreditsNudgeEmailParams { credit_type }, + }) + .await + .wrap_err("account/sendAddCreditsNudgeEmail failed in TUI")?; + + Ok(response.status) +} + async fn fetch_skills_list( request_handle: AppServerRequestHandle, cwd: PathBuf, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index a758d56c09..ceec282665 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -10,6 +10,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::McpServerStatus; use codex_app_server_protocol::PluginInstallResponse; @@ -190,6 +192,16 @@ pub(crate) enum AppEvent { result: Result, String>, }, + /// Send a user-confirmed request to notify the workspace owner. + SendAddCreditsNudgeEmail { + credit_type: AddCreditsNudgeCreditType, + }, + + /// Result of notifying the workspace owner. + AddCreditsNudgeEmailFinished { + result: Result, + }, + /// Result of prefetching connectors. ConnectorsLoaded { result: Result, 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 bbcd2f1c7e..c4e7e1c706 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -842,6 +842,16 @@ impl BottomPaneView for ListSelectionView { && !modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT) => { + if let Some(idx) = self.items.iter().position(|item| { + item.display_shortcut + .is_some_and(|shortcut| shortcut.is_press(key_event)) + && item.disabled_reason.is_none() + && !item.is_disabled + }) { + self.state.selected_idx = Some(idx); + self.accept(); + return; + } if let Some(idx) = c .to_digit(10) .map(|d| d as usize) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 55fc17b4e7..c5fc25c540 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -78,6 +78,8 @@ use crate::terminal_title::clear_terminal_title; use crate::terminal_title::set_terminal_title; use crate::text_formatting::proper_join; use crate::version::CODEX_CLI_VERSION; +use codex_app_server_protocol::AddCreditsNudgeCreditType; +use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppSummary; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; @@ -192,6 +194,7 @@ use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::Op; use codex_protocol::protocol::PatchApplyBeginEvent; +use codex_protocol::protocol::RateLimitReachedType; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; @@ -781,8 +784,10 @@ pub(crate) struct ChatWidget { refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>, next_status_refresh_request_id: u64, plan_type: Option, + codex_rate_limit_reached_type: Option, rate_limit_warnings: RateLimitWarningState, rate_limit_switch_prompt: RateLimitSwitchPromptState, + add_credits_nudge_email_in_flight: Option, adaptive_chunking: AdaptiveChunkingPolicy, // Stream lifecycle controller stream_controller: Option, @@ -2857,6 +2862,11 @@ 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 + && let Some(rate_limit_reached_type) = snapshot.rate_limit_reached_type + { + self.codex_rate_limit_reached_type = Some(rate_limit_reached_type); + } let warnings = if is_codex_limit { self.rate_limit_warnings.take_warnings( snapshot @@ -2920,6 +2930,7 @@ impl ChatWidget { } } else { self.rate_limit_snapshots_by_limit_id.clear(); + self.codex_rate_limit_reached_type = None; } self.refresh_status_line(); } @@ -2973,6 +2984,62 @@ impl ChatWidget { self.maybe_send_next_queued_input(); } + fn workspace_owner_usage_nudge_enabled(&self) -> bool { + self.config + .features + .enabled(Feature::WorkspaceOwnerUsageNudge) + } + + fn on_rate_limit_error(&mut self, error_kind: RateLimitErrorKind, message: String) { + if !self.workspace_owner_usage_nudge_enabled() { + self.on_error(message); + return; + } + + let rate_limit_reached_type = self.codex_rate_limit_reached_type.map(|kind| { + if matches!(error_kind, RateLimitErrorKind::UsageLimit) { + match kind { + RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { + RateLimitReachedType::WorkspaceOwnerUsageLimitReached + } + RateLimitReachedType::WorkspaceMemberCreditsDepleted => { + RateLimitReachedType::WorkspaceMemberUsageLimitReached + } + other => other, + } + } else { + kind + } + }); + self.codex_rate_limit_reached_type = rate_limit_reached_type; + + match rate_limit_reached_type { + Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted) => { + self.on_error( + "You're out of credits. Your workspace is out of credits. Add credits to continue using Codex." + .to_string(), + ); + } + Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached) => { + self.on_error( + "Usage limit reached. You've reached your usage limit. Increase your limits to continue using codex." + .to_string(), + ); + } + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) => { + self.on_error(message); + self.open_workspace_owner_nudge_prompt(AddCreditsNudgeCreditType::Credits); + } + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) => { + self.on_error(message); + self.open_workspace_owner_nudge_prompt(AddCreditsNudgeCreditType::UsageLimit); + } + Some(RateLimitReachedType::RateLimitReached) | None => { + self.on_error(message); + } + } + } + fn handle_non_retry_error( &mut self, message: String, @@ -2989,7 +3056,7 @@ impl ChatWidget { match info { RateLimitErrorKind::ServerOverloaded => self.on_server_overloaded_error(message), RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => { - self.on_error(message) + self.on_rate_limit_error(info, message) } } } else { @@ -4996,8 +5063,10 @@ impl ChatWidget { refreshing_status_outputs: Vec::new(), next_status_refresh_request_id: 0, plan_type: initial_plan_type, + codex_rate_limit_reached_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), + add_credits_nudge_email_in_flight: None, adaptive_chunking: AdaptiveChunkingPolicy::default(), stream_controller: None, plan_stream_controller: None, @@ -6942,7 +7011,7 @@ impl ChatWidget { self.on_server_overloaded_error(message) } RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => { - self.on_error(message) + self.on_rate_limit_error(kind, message) } } } else { @@ -7794,6 +7863,105 @@ impl ChatWidget { }); } + fn open_workspace_owner_nudge_prompt(&mut self, credit_type: AddCreditsNudgeCreditType) { + if !self.workspace_owner_usage_nudge_enabled() + || self.add_credits_nudge_email_in_flight.is_some() + { + return; + } + + let (title, prompt) = match credit_type { + AddCreditsNudgeCreditType::Credits => ( + "You've reached your workspace credit limit", + "Your workspace is out of credits. Ask your workspace owner to add more. Notify owner?", + ), + AddCreditsNudgeCreditType::UsageLimit => ( + "Usage limit reached", + "Request a limit increase from your owner to continue using codex. Request increase?", + ), + }; + let send_actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::SendAddCreditsNudgeEmail { credit_type }); + })]; + let items = vec![ + SelectionItem { + name: "Yes".to_string(), + display_shortcut: Some(key_hint::plain(KeyCode::Char('y'))), + actions: send_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() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some(title.to_string()), + subtitle: Some(prompt.to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + initial_selected_idx: Some(1), + ..Default::default() + }); + } + + pub(crate) fn start_add_credits_nudge_email_request( + &mut self, + credit_type: AddCreditsNudgeCreditType, + ) -> bool { + if !self.workspace_owner_usage_nudge_enabled() { + return false; + } + + self.add_credits_nudge_email_in_flight = Some(credit_type); + true + } + + pub(crate) fn finish_add_credits_nudge_email_request( + &mut self, + result: Result, + ) { + let credit_type = self + .add_credits_nudge_email_in_flight + .take() + .unwrap_or(AddCreditsNudgeCreditType::Credits); + if !self.workspace_owner_usage_nudge_enabled() { + return; + } + let message = match (credit_type, result) { + (AddCreditsNudgeCreditType::Credits, Ok(AddCreditsNudgeEmailStatus::Sent)) => { + "Workspace owner notified." + } + ( + AddCreditsNudgeCreditType::Credits, + Ok(AddCreditsNudgeEmailStatus::CooldownActive), + ) => "Workspace owner was already notified recently.", + (AddCreditsNudgeCreditType::Credits, Err(_)) => { + "Could not notify your workspace owner. Please try again." + } + (AddCreditsNudgeCreditType::UsageLimit, Ok(AddCreditsNudgeEmailStatus::Sent)) => { + "Limit increase requested." + } + ( + AddCreditsNudgeCreditType::UsageLimit, + Ok(AddCreditsNudgeEmailStatus::CooldownActive), + ) => "A limit increase was already requested recently.", + (AddCreditsNudgeCreditType::UsageLimit, Err(_)) => { + "Could not request a limit increase. Please try again." + } + }; + self.add_to_history(history_cell::new_info_event( + message.to_string(), + /*hint*/ None, + )); + self.request_redraw(); + } + /// Open a popup to choose a quick auto model. Selecting "All models" /// opens the full picker with every available preset. pub(crate) fn open_model_popup(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_credits_depleted_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_credits_depleted_prompt.snap new file mode 100644 index 0000000000..283dcd5f0f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_credits_depleted_prompt.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: popup +--- + You've reached your workspace credit limit + Your workspace is out of credits. Ask your workspace owner to add more. Notify owner? + + 1. Yes (y) +› 2. No (default) (n) + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_usage_limit_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_usage_limit_prompt.snap new file mode 100644 index 0000000000..ee8322ab7f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_member_usage_limit_prompt.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: popup +--- + Usage limit reached + Request a limit increase from your owner to continue using codex. Request increase? + + 1. Yes (y) +› 2. No (default) (n) + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_credits_nudge_completion_feedback.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_credits_nudge_completion_feedback.snap new file mode 100644 index 0000000000..e2b0885ed7 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_credits_nudge_completion_feedback.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: "rendered_cases.join(\"\\n---\\n\")" +--- +• Workspace owner notified. + +--- +• Workspace owner was already notified recently. + +--- +• Could not notify your workspace owner. Please try again. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_limit_state_messages.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_limit_state_messages.snap new file mode 100644 index 0000000000..b3c1022a07 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_limit_state_messages.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: "rendered_cases.join(\"\\n---\\n\")" +--- +■ You're out of credits. Your workspace is out of credits. Add credits to continue using Codex. + +--- +■ Usage limit reached. You've reached your usage limit. Increase your limits to continue using codex. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_usage_limit_nudge_completion_feedback.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_usage_limit_nudge_completion_feedback.snap new file mode 100644 index 0000000000..8091deebf4 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workspace_owner_usage_limit_nudge_completion_feedback.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: "rendered_cases.join(\"\\n---\\n\")" +--- +• Limit increase requested. + +--- +• A limit increase was already requested recently. + +--- +• Could not request a limit increase. Please try again. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 3846b4dc71..48e189c447 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -33,6 +33,8 @@ pub(super) use crate::test_support::test_path_buf; pub(super) use crate::test_support::test_path_display; pub(super) use crate::tui::FrameRequester; pub(super) use assert_matches::assert_matches; +pub(super) use codex_app_server_protocol::AddCreditsNudgeCreditType; +pub(super) use codex_app_server_protocol::AddCreditsNudgeEmailStatus; pub(super) use codex_app_server_protocol::AdditionalFileSystemPermissions as AppServerAdditionalFileSystemPermissions; pub(super) use codex_app_server_protocol::AdditionalNetworkPermissions as AppServerAdditionalNetworkPermissions; pub(super) use codex_app_server_protocol::AdditionalPermissionProfile as AppServerAdditionalPermissionProfile; @@ -170,6 +172,8 @@ pub(super) use codex_protocol::protocol::Op; pub(super) use codex_protocol::protocol::PatchApplyBeginEvent; pub(super) use codex_protocol::protocol::PatchApplyEndEvent; pub(super) use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; +pub(super) use codex_protocol::protocol::RateLimitReachedType; +pub(super) use codex_protocol::protocol::RateLimitSnapshot; pub(super) use codex_protocol::protocol::RateLimitWindow; pub(super) use codex_protocol::protocol::ReadOnlyAccess; pub(super) use codex_protocol::protocol::RealtimeConversationClosedEvent; diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index bde4505b3b..a0b8882b6d 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -201,8 +201,10 @@ pub(super) async fn make_chatwidget_manual( refreshing_status_outputs: Vec::new(), next_status_refresh_request_id: 0, plan_type: None, + codex_rate_limit_reached_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), + add_credits_nudge_email_in_flight: None, adaptive_chunking: crate::streaming::chunking::AdaptiveChunkingPolicy::default(), stream_controller: None, plan_stream_controller: None, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 7c1dd56042..39b72906b9 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -501,6 +501,370 @@ async fn rate_limit_switch_prompt_popup_snapshot() { assert_chatwidget_snapshot!("rate_limit_switch_prompt_popup", popup); } +#[tokio::test] +async fn workspace_owner_usage_nudge_flag_disabled_keeps_generic_rate_limit_error() { + { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let mut limits = snapshot(/*percent*/ 100.0); + limits.rate_limit_reached_type = + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); + chat.on_rate_limit_snapshot(Some(limits)); + + chat.on_rate_limit_error( + RateLimitErrorKind::UsageLimit, + "Usage limit reached.".to_string(), + ); + let rendered = drain_insert_history(&mut rx) + .into_iter() + .map(|lines| lines_to_single_string(&lines)) + .collect::(); + assert!( + rendered.contains("Usage limit reached."), + "rendered: {rendered}" + ); + } + + { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let mut limits = snapshot(/*percent*/ 100.0); + limits.rate_limit_reached_type = + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); + chat.on_rate_limit_snapshot(Some(limits)); + + chat.on_rate_limit_error( + RateLimitErrorKind::UsageLimit, + "Usage limit reached.".to_string(), + ); + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + !popup.contains("Request a limit increase from your owner"), + "popup: {popup}" + ); + assert_no_owner_nudge_or_rate_limit_refresh(&mut rx); + } +} + +fn enable_workspace_owner_usage_nudge(chat: &mut ChatWidget) { + chat.set_feature_enabled(Feature::WorkspaceOwnerUsageNudge, /*enabled*/ true); +} + +#[tokio::test] +async fn workspace_member_credits_depleted_prompts_and_sends_credits() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + enable_workspace_owner_usage_nudge(&mut chat); + let mut limits = snapshot(/*percent*/ 100.0); + limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted); + chat.on_rate_limit_snapshot(Some(limits)); + + chat.on_rate_limit_error( + RateLimitErrorKind::Generic, + "Usage limit reached.".to_string(), + ); + let popup = render_bottom_popup(&chat, /*width*/ 90); + assert_chatwidget_snapshot!("workspace_member_credits_depleted_prompt", popup); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + let event = next_send_add_credits_nudge_email_event(&mut rx); + assert_eq!(event, AddCreditsNudgeCreditType::Credits); +} + +#[tokio::test] +async fn workspace_member_usage_limit_prompts_and_sends_usage_limit() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + enable_workspace_owner_usage_nudge(&mut chat); + let mut limits = snapshot(/*percent*/ 100.0); + limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); + chat.on_rate_limit_snapshot(Some(limits)); + + chat.on_rate_limit_error( + RateLimitErrorKind::UsageLimit, + "Usage limit reached.".to_string(), + ); + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert_chatwidget_snapshot!("workspace_member_usage_limit_prompt", popup); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + let event = next_send_add_credits_nudge_email_event(&mut rx); + assert_eq!(event, AddCreditsNudgeCreditType::UsageLimit); +} + +#[tokio::test] +async fn header_rate_limit_snapshot_preserves_member_limit_type_for_error_prompt() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + enable_workspace_owner_usage_nudge(&mut chat); + let mut usage_limits = snapshot(/*percent*/ 100.0); + usage_limits.rate_limit_reached_type = + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); + chat.on_rate_limit_snapshot(Some(usage_limits)); + + // Turn-failure snapshots are derived from response headers and do not carry + // the backend-classified reached type. They arrive before the Error event. + let mut header_limits = snapshot(/*percent*/ 100.0); + header_limits.rate_limit_reached_type = None; + chat.on_rate_limit_snapshot(Some(header_limits)); + + chat.on_rate_limit_error( + RateLimitErrorKind::UsageLimit, + "Usage limit reached.".to_string(), + ); + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup.contains("Request a limit increase from your owner"), + "popup: {popup}" + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + let event = next_send_add_credits_nudge_email_event(&mut rx); + assert_eq!(event, AddCreditsNudgeCreditType::UsageLimit); +} + +#[tokio::test] +async fn usage_limit_error_remaps_stale_member_credits_state_to_usage_limit_prompt() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + enable_workspace_owner_usage_nudge(&mut chat); + let mut limits = snapshot(/*percent*/ 100.0); + limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted); + chat.on_rate_limit_snapshot(Some(limits)); + + chat.on_rate_limit_error( + RateLimitErrorKind::UsageLimit, + "Usage limit reached.".to_string(), + ); + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup.contains("Request a limit increase from your owner"), + "popup: {popup}" + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + let event = next_send_add_credits_nudge_email_event(&mut rx); + assert_eq!(event, AddCreditsNudgeCreditType::UsageLimit); +} + +#[tokio::test] +async fn workspace_owner_limit_states_do_not_prompt_for_owner_nudge() { + for (limit_type, error_kind) in [ + ( + RateLimitReachedType::WorkspaceOwnerCreditsDepleted, + RateLimitErrorKind::Generic, + ), + ( + RateLimitReachedType::WorkspaceOwnerUsageLimitReached, + RateLimitErrorKind::UsageLimit, + ), + ( + RateLimitReachedType::RateLimitReached, + RateLimitErrorKind::Generic, + ), + ] { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + enable_workspace_owner_usage_nudge(&mut chat); + let mut limits = snapshot(/*percent*/ 100.0); + limits.rate_limit_reached_type = Some(limit_type); + chat.on_rate_limit_snapshot(Some(limits)); + + chat.on_rate_limit_error(error_kind, "Usage limit reached.".to_string()); + let popup = render_bottom_popup(&chat, /*width*/ 90); + assert!(!popup.contains("workspace owner")); + assert_no_owner_nudge_or_rate_limit_refresh(&mut rx); + } +} + +#[tokio::test] +async fn workspace_owner_limit_states_render_state_specific_messages() { + let cases = [ + ( + RateLimitReachedType::WorkspaceOwnerCreditsDepleted, + RateLimitErrorKind::Generic, + "You're out of credits. Your workspace is out of credits. Add credits to continue using Codex.", + ), + ( + RateLimitReachedType::WorkspaceOwnerUsageLimitReached, + RateLimitErrorKind::UsageLimit, + "Usage limit reached. You've reached your usage limit. Increase your limits to continue using codex.", + ), + ]; + + let mut rendered_cases = Vec::new(); + for (limit_type, error_kind, expected) in cases { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + enable_workspace_owner_usage_nudge(&mut chat); + let mut limits = snapshot(/*percent*/ 100.0); + limits.rate_limit_reached_type = Some(limit_type); + chat.on_rate_limit_snapshot(Some(limits)); + + chat.on_rate_limit_error(error_kind, "Usage limit reached.".to_string()); + let rendered = drain_insert_history(&mut rx) + .into_iter() + .map(|lines| lines_to_single_string(&lines)) + .collect::(); + assert!(rendered.contains(expected), "rendered: {rendered}"); + rendered_cases.push(rendered); + } + + assert_chatwidget_snapshot!( + "workspace_owner_limit_state_messages", + rendered_cases.join("\n---\n") + ); +} + +#[tokio::test] +async fn missing_rate_limit_reached_type_does_not_prompt_or_refresh() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + enable_workspace_owner_usage_nudge(&mut chat); + chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 100.0))); + + chat.on_rate_limit_error( + RateLimitErrorKind::UsageLimit, + "Usage limit reached.".to_string(), + ); + let popup = render_bottom_popup(&chat, /*width*/ 90); + assert!(!popup.contains("workspace owner")); + assert_no_owner_nudge_or_rate_limit_refresh(&mut rx); +} + +#[tokio::test] +async fn workspace_owner_nudge_default_no_dismisses_without_sending() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + enable_workspace_owner_usage_nudge(&mut chat); + let mut limits = snapshot(/*percent*/ 100.0); + limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted); + chat.on_rate_limit_snapshot(Some(limits)); + + chat.on_rate_limit_error( + RateLimitErrorKind::Generic, + "Usage limit reached.".to_string(), + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_no_owner_nudge_or_rate_limit_refresh(&mut rx); +} + +#[tokio::test] +async fn workspace_owner_nudge_reappears_after_dismissing_no() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + enable_workspace_owner_usage_nudge(&mut chat); + let mut limits = snapshot(/*percent*/ 100.0); + limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); + chat.on_rate_limit_snapshot(Some(limits)); + + chat.on_rate_limit_error( + RateLimitErrorKind::UsageLimit, + "Usage limit reached.".to_string(), + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_no_owner_nudge_or_rate_limit_refresh(&mut rx); + + chat.on_rate_limit_error( + RateLimitErrorKind::UsageLimit, + "Usage limit reached.".to_string(), + ); + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup.contains("Request a limit increase from your owner"), + "popup: {popup}" + ); +} + +#[tokio::test] +async fn workspace_owner_credits_nudge_completion_renders_feedback() { + let cases = [ + ( + Ok(AddCreditsNudgeEmailStatus::Sent), + "Workspace owner notified.", + ), + ( + Ok(AddCreditsNudgeEmailStatus::CooldownActive), + "Workspace owner was already notified recently.", + ), + ( + Err("request failed".to_string()), + "Could not notify your workspace owner. Please try again.", + ), + ]; + + let mut rendered_cases = Vec::new(); + for (result, expected) in cases { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + enable_workspace_owner_usage_nudge(&mut chat); + chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::Credits); + chat.finish_add_credits_nudge_email_request(result); + let rendered = drain_insert_history(&mut rx) + .into_iter() + .map(|lines| lines_to_single_string(&lines)) + .collect::(); + assert!(rendered.contains(expected), "rendered: {rendered}"); + rendered_cases.push(rendered); + } + + assert_chatwidget_snapshot!( + "workspace_owner_credits_nudge_completion_feedback", + rendered_cases.join("\n---\n") + ); +} + +#[tokio::test] +async fn workspace_owner_usage_limit_nudge_completion_renders_feedback() { + let cases = [ + ( + Ok(AddCreditsNudgeEmailStatus::Sent), + "Limit increase requested.", + ), + ( + Ok(AddCreditsNudgeEmailStatus::CooldownActive), + "A limit increase was already requested recently.", + ), + ( + Err("request failed".to_string()), + "Could not request a limit increase. Please try again.", + ), + ]; + + let mut rendered_cases = Vec::new(); + for (result, expected) in cases { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + enable_workspace_owner_usage_nudge(&mut chat); + chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::UsageLimit); + chat.finish_add_credits_nudge_email_request(result); + let rendered = drain_insert_history(&mut rx) + .into_iter() + .map(|lines| lines_to_single_string(&lines)) + .collect::(); + assert!(rendered.contains(expected), "rendered: {rendered}"); + rendered_cases.push(rendered); + } + + assert_chatwidget_snapshot!( + "workspace_owner_usage_limit_nudge_completion_feedback", + rendered_cases.join("\n---\n") + ); +} + +fn next_send_add_credits_nudge_email_event( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> AddCreditsNudgeCreditType { + while let Ok(event) = rx.try_recv() { + if let AppEvent::SendAddCreditsNudgeEmail { credit_type } = event { + return credit_type; + } + } + panic!("expected SendAddCreditsNudgeEmail app event"); +} + +fn assert_no_owner_nudge_or_rate_limit_refresh( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) { + while let Ok(event) = rx.try_recv() { + assert!( + !matches!( + event, + AppEvent::SendAddCreditsNudgeEmail { .. } | AppEvent::RefreshRateLimits { .. } + ), + "unexpected event: {event:?}" + ); + } +} + #[tokio::test] async fn streaming_final_answer_keeps_task_running_state() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;