diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index f1c4329b43..04211b73f8 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -12163,6 +12163,13 @@ "GetAccountRateLimitsResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "accountId": { + "description": "Account associated with this usage snapshot, when supplied by the backend.", + "type": [ + "string", + "null" + ] + }, "rateLimitResetCredits": { "anyOf": [ { @@ -12173,6 +12180,9 @@ } ] }, + "rateLimitUpsell": { + "description": "Optional backend-owned banner from the same usage read. Its nested keys retain the backend's snake_case contract; an absent banner leaves the client's existing UI unchanged." + }, "rateLimits": { "allOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index fc6a00e089..f5c16692a8 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -8174,6 +8174,13 @@ "GetAccountRateLimitsResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "accountId": { + "description": "Account associated with this usage snapshot, when supplied by the backend.", + "type": [ + "string", + "null" + ] + }, "rateLimitResetCredits": { "anyOf": [ { @@ -8184,6 +8191,9 @@ } ] }, + "rateLimitUpsell": { + "description": "Optional backend-owned banner from the same usage read. Its nested keys retain the backend's snake_case contract; an absent banner leaves the client's existing UI unchanged." + }, "rateLimits": { "allOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json index fac45c36ac..abb94b127e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json @@ -277,6 +277,13 @@ } }, "properties": { + "accountId": { + "description": "Account associated with this usage snapshot, when supplied by the backend.", + "type": [ + "string", + "null" + ] + }, "rateLimitResetCredits": { "anyOf": [ { @@ -287,6 +294,9 @@ } ] }, + "rateLimitUpsell": { + "description": "Optional backend-owned banner from the same usage read. Its nested keys retain the backend's snake_case contract; an absent banner leaves the client's existing UI unchanged." + }, "rateLimits": { "allOf": [ { diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 1415cb301a..92088f4c20 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 93d16b7eba..7893421a83 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountRateLimitsResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountRateLimitsResponse.ts index af400634e5..548bd959a8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountRateLimitsResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountRateLimitsResponse.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "../serde_json/JsonValue"; import type { RateLimitResetCreditsSummary } from "./RateLimitResetCreditsSummary"; import type { RateLimitSnapshot } from "./RateLimitSnapshot"; @@ -12,4 +13,13 @@ rateLimits: RateLimitSnapshot, /** * Multi-bucket view keyed by metered `limit_id` (for example, `codex`). */ -rateLimitsByLimitId: { [key in string]?: RateLimitSnapshot } | null, rateLimitResetCredits: RateLimitResetCreditsSummary | null, }; +rateLimitsByLimitId: { [key in string]?: RateLimitSnapshot } | null, rateLimitResetCredits: RateLimitResetCreditsSummary | null, +/** + * Account associated with this usage snapshot, when supplied by the backend. + */ +accountId: string | null, +/** + * Optional backend-owned banner from the same usage read. Its nested keys retain the + * backend's snake_case contract; an absent banner leaves the client's existing UI unchanged. + */ +rateLimitUpsell: JsonValue | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index 017f022efa..47990a49a7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -317,6 +317,11 @@ pub struct GetAccountRateLimitsResponse { /// Multi-bucket view keyed by metered `limit_id` (for example, `codex`). pub rate_limits_by_limit_id: Option>, pub rate_limit_reset_credits: Option, + /// Account associated with this usage snapshot, when supplied by the backend. + pub account_id: Option, + /// Optional backend-owned banner from the same usage read. Its nested keys retain the + /// backend's snake_case contract; an absent banner leaves the client's existing UI unchanged. + pub rate_limit_upsell: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 34151acea5..5b99a2b2d0 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -2470,7 +2470,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`. - `account/logout` — sign out; triggers `account/updated` on success. - `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `bedrockApiKey`, `bedrockAccessKeys`, `chatgpt`, `personalAccessToken`, or `null`) and includes the current ChatGPT `planType` when available. -- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, whether spend control has been reached, and the earned rate-limit resets currently available, including expiry details when provided by the backend. Rate-limit updates arrive via `account/rateLimits/updated` (notify); reset-credit data is snapshot-only. +- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, whether spend control has been reached, and the earned rate-limit resets currently available, including expiry details when provided by the backend. Rate-limit updates arrive via `account/rateLimits/updated` (notify); reset-credit and backend-banner data are snapshot-only. - `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key, optionally selecting a reset-credit ID returned by `account/rateLimits/read`. - `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets, or pass a valid thread UUID as `threadId` to read estimated credits, optional cost, and usage breakdowns for one thread using the app-server's active account. The optional `threadUsage` response field is absent on older servers and `null` when the billing route is unavailable. - `account/workspaceMessages/read` — fetch active workspace messages, including workspace notification headlines when available. @@ -2712,6 +2712,8 @@ Field notes: - `resetsAt` is a Unix timestamp (seconds) for the next reset. - `rateLimitReachedType` identifies the backend-classified limit state when one has been reached. - `individualLimit` describes the effective monthly credit limit when available. In an `account/rateLimits/read` response, `null` means no monthly limit is available. In a sparse `account/rateLimits/updated` notification, nullable account metadata may be unavailable and does not clear a previously observed value. +- `accountId` identifies the account in the usage snapshot when the backend supplies it. +- `rateLimitUpsell` carries the optional backend-owned `rate_limit_upsell` object from the same usage request, preserving its nested snake_case fields. The backend controls eligibility; clients do not evaluate an experiment or issue another request for it. A missing, null, or unsupported banner leaves the existing client UI in place. Banners whose account or user does not match the authenticated identity are omitted. Sparse notifications do not clear this snapshot-only field. - `rateLimitResetCredits` contains the available earned-reset count when the backend provides it; otherwise it is `null`. - `rateLimitResetCredits.credits` is `null` when only the count is available. An empty array means details were fetched and no available credits were returned. - The backend may cap `rateLimitResetCredits.credits`, so `availableCount` is the authoritative total and can be greater than the number of detail rows. diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index d966fd6084..d3e9165a7c 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -1189,6 +1189,16 @@ impl AccountRequestProcessor { }) }); + // Match desktop's account readiness check before exposing account-bound CTA content. + // Normal rate limits remain available when older backends omit identity or banner data. + let rate_limit_upsell = response.rate_limit_upsell.filter(|_| { + !auth.is_fedramp_account() + && response.account_id.is_some() + && response.account_id == auth.get_account_id() + && response.user_id.is_some() + && response.user_id == auth.get_chatgpt_user_id() + }); + Ok(GetAccountRateLimitsResponse { rate_limits: rate_limits.into(), rate_limits_by_limit_id: Some( @@ -1198,6 +1208,8 @@ impl AccountRequestProcessor { .collect(), ), rate_limit_reset_credits, + account_id: response.account_id, + rate_limit_upsell, }) } diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs index 2303f9be18..24fbd1c6b5 100644 --- a/codex-rs/app-server/tests/common/auth_fixtures.rs +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -83,6 +83,7 @@ pub struct ChatGptIdTokenClaims { pub plan_type: Option, pub chatgpt_user_id: Option, pub chatgpt_account_id: Option, + pub chatgpt_account_is_fedramp: bool, } impl ChatGptIdTokenClaims { @@ -127,6 +128,9 @@ pub fn encode_id_token(claims: &ChatGptIdTokenClaims) -> Result { if let Some(chatgpt_account_id) = &claims.chatgpt_account_id { auth_payload.insert("chatgpt_account_id".to_string(), json!(chatgpt_account_id)); } + if claims.chatgpt_account_is_fedramp { + auth_payload.insert("chatgpt_account_is_fedramp".to_string(), json!(true)); + } if !auth_payload.is_empty() { payload.insert( "https://api.openai.com/auth".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index 8103f9fa3f..41d475b726 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -1,5 +1,6 @@ use anyhow::Result; use app_test_support::ChatGptAuthFixture; +use app_test_support::ChatGptIdTokenClaims; use app_test_support::TestAppServer; use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::AddCreditsNudgeCreditType; @@ -109,6 +110,7 @@ async fn get_account_rate_limits_returns_snapshot( codex_home.path(), ChatGptAuthFixture::new("chatgpt-token") .account_id("account-123") + .chatgpt_user_id("user-123") .plan_type("pro"), AuthCredentialsStoreMode::File, )?; @@ -133,7 +135,17 @@ async fn get_account_rate_limits_returns_snapshot( chrono::DateTime::parse_from_rfc3339("2026-06-18T00:00:00Z") .expect("parse second reset credit grant timestamp") .timestamp(); + let banner = json!({ + "banner_type": "selected_model_limit_reached", + "title": "Usage limit reached", + "description": "View your usage.", + "ctas": [{"action": "view_usage", "label": "View usage"}] + }); let response_body = json!({ + "account_id": "account-123", + "user_id": "user-123", + "rate_limit_upsell": banner, + "plan_type": plan_type, "rate_limit": { "allowed": true, @@ -237,6 +249,8 @@ async fn get_account_rate_limits_returns_snapshot( timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; let expected = GetAccountRateLimitsResponse { + account_id: Some("account-123".to_string()), + rate_limit_upsell: Some(banner), rate_limits: RateLimitSnapshot { limit_id: Some("codex".to_string()), limit_name: None, @@ -343,6 +357,85 @@ async fn get_account_rate_limits_returns_snapshot( Ok(()) } +#[test_case(Some("workspace-a"), Some("user-a"), /*restricted*/ false, /*permitted*/ true; "matching_identity")] +#[test_case(Some("workspace-b"), Some("user-a"), /*restricted*/ false, /*permitted*/ false; "account_mismatch")] +#[test_case(Some("workspace-a"), Some("user-b"), /*restricted*/ false, /*permitted*/ false; "user_mismatch")] +#[test_case(/*account*/ None, Some("user-a"), /*restricted*/ false, /*permitted*/ false; "missing_account")] +#[test_case(Some("workspace-a"), /*user*/ None, /*restricted*/ false, /*permitted*/ false; "missing_user")] +#[test_case(Some("workspace-a"), Some("user-a"), /*restricted*/ true, /*permitted*/ false; "restricted_account")] +#[tokio::test] +async fn get_account_rate_limits_filters_banner_by_identity( + account: Option<&str>, + user: Option<&str>, + restricted: bool, + permitted: bool, +) -> Result<()> { + let codex_home = TempDir::new()?; + let mut claims = ChatGptIdTokenClaims::new() + .plan_type("team") + .chatgpt_user_id("user-a"); + claims.chatgpt_account_is_fedramp = restricted; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("workspace-a") + .claims(claims), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + write_chatgpt_base_url(codex_home.path(), &server.uri())?; + let banner = json!({ + "banner_type": "selected_model_limit", "model_slug": "test-model-a", + "presentation": "inline", "title": "Usage limit reached", + "description": "Contact your owner.", + "ctas": [{"action": "notify_owner", "label": "Notify owner"}] + }); + Mock::given(method("GET")) + .and(path("/api/codex/usage")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "workspace-a")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "account_id": account, "user_id": user, "plan_type": "team", + "rate_limit": {"allowed": true, "limit_reached": false, + "primary_window": {"used_percent": 42, "limit_window_seconds": 3600, + "reset_after_seconds": 120, "reset_at": 2000000000}}, + "rate_limit_upsell": banner, "rate_limit_reset_credits": {"available_count": 0} + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/codex/rate-limit-reset-credits")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "available_count": 0, "credits": [] + }))) + .expect(1) + .mount(&server) + .await; + + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) + .await?; + let request_id = mcp.send_get_account_rate_limits_request().await?; + let received: GetAccountRateLimitsResponse = + timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; + let snapshot = json!({ + "limitId": "codex", "planType": "team", + "primary": {"usedPercent": 42, "windowDurationMins": 60, "resetsAt": 2000000000} + }); + let expected: GetAccountRateLimitsResponse = serde_json::from_value(json!({ + "accountId": account, "rateLimitUpsell": if permitted { Some(banner) } else { None }, + "rateLimits": snapshot, "rateLimitsByLimitId": {"codex": snapshot}, + "rateLimitResetCredits": {"availableCount": 0, "credits": []} + }))?; + assert_eq!(received, expected); + server.verify().await; + Ok(()) +} + #[tokio::test] async fn get_account_rate_limits_preserves_count_when_reset_credit_details_fail() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/backend-client/src/client/rate_limit_resets.rs b/codex-rs/backend-client/src/client/rate_limit_resets.rs index 90bedfb24a..5ff565f4be 100644 --- a/codex-rs/backend-client/src/client/rate_limit_resets.rs +++ b/codex-rs/backend-client/src/client/rate_limit_resets.rs @@ -25,6 +25,9 @@ impl Client { Ok(RateLimitsWithResetCredits { rate_limits: Self::rate_limit_snapshots_from_payload(payload.rate_limits), rate_limit_reset_credits: payload.rate_limit_reset_credits, + account_id: payload.account_id, + user_id: payload.user_id, + rate_limit_upsell: payload.rate_limit_upsell, }) } diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 69185887ba..b5a2e960b5 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -45,6 +45,9 @@ pub struct RateLimitResetCreditDetails { pub struct RateLimitsWithResetCredits { pub rate_limits: Vec, pub rate_limit_reset_credits: Option, + pub account_id: Option, + pub user_id: Option, + pub rate_limit_upsell: Option, } #[derive(Clone, Debug, Deserialize, PartialEq)] @@ -52,6 +55,10 @@ pub(crate) struct RateLimitStatusWithResetCredits { #[serde(flatten)] pub rate_limits: RateLimitStatusPayload, pub rate_limit_reset_credits: Option, + pub account_id: Option, + pub user_id: Option, + // Preserve the backend-owned banner contract without making optional UI data break usage. + pub rate_limit_upsell: Option, } #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6621bcab65..f064fd018e 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -211,6 +211,7 @@ pub(crate) use agents_overview::AGENTS_OVERVIEW_VIEW_ID; mod app_server_event_targets; mod app_server_events; pub(crate) mod app_server_requests; +mod backend_banner_fallback; mod background_requests; mod config_persistence; mod connector_mentions; @@ -226,6 +227,7 @@ mod permission_shortcuts; mod pets; mod platform_actions; mod plugin_mentions; +mod rate_limit_refresh; mod recap; mod replay_filter; mod resize_reflow; @@ -627,6 +629,7 @@ pub(crate) struct App { startup_pending_protected_request: bool, /// Invalidates in-flight full rate-limit reads when a newer rolling hard stop arrives. rate_limit_hard_stop_generation: u64, + rate_limit_refresh_state: rate_limit_refresh::RateLimitRefreshState, // Serialize plugin enablement writes per plugin so stale completions cannot // overwrite a newer toggle, even if the plugin is toggled from different // cwd contexts. diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 624768803c..aa5d89e2bc 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -7,6 +7,7 @@ use super::app_server_event_targets::server_notification_thread_target; use super::app_server_event_targets::server_request_thread_id; use crate::app_command::AppCommand; use crate::app_event::AppEvent; +use crate::app_event::RateLimitRefreshOrigin; use crate::app_info::app_info_from_api; use crate::app_server_session::AppServerSession; use crate::app_server_session::status_account_display_from_auth_mode; @@ -178,7 +179,7 @@ impl App { self.refresh_mcp_startup_expected_servers_from_config(); } ServerNotification::AccountRateLimitsUpdated(notification) => { - if matches!( + let workspace_hard_stop = matches!( notification.rate_limits.rate_limit_reached_type, Some( RateLimitReachedType::WorkspaceOwnerCreditsDepleted @@ -186,16 +187,24 @@ impl App { | RateLimitReachedType::WorkspaceOwnerUsageLimitReached | RateLimitReachedType::WorkspaceMemberUsageLimitReached ) - ) || notification.rate_limits.spend_control_reached == Some(true) - { + ) || notification.rate_limits.spend_control_reached + == Some(true); + if workspace_hard_stop { self.rate_limit_hard_stop_generation = self.rate_limit_hard_stop_generation.wrapping_add(1); } self.chat_widget .on_rolling_rate_limit_snapshot(notification.rate_limits.clone()); + if workspace_hard_stop && self.chat_widget.has_chatgpt_account() { + // Background inference may publish a hard stop without a foreground Error. + self.refresh_rate_limits(app_server_client, RateLimitRefreshOrigin::Recovery); + } return; } ServerNotification::AccountUpdated(notification) => { + self.rate_limit_hard_stop_generation = + self.rate_limit_hard_stop_generation.wrapping_add(1); + self.rate_limit_refresh_state.invalidate_recovery(); // Deferred terminal writes must never carry the previous account's billing into // the newly authenticated identity, even when both accounts share one thread. self.last_thread_usage_status_cell = None; diff --git a/codex-rs/tui/src/app/backend_banner_fallback.rs b/codex-rs/tui/src/app/backend_banner_fallback.rs new file mode 100644 index 0000000000..c173bb07d7 --- /dev/null +++ b/codex-rs/tui/src/app/backend_banner_fallback.rs @@ -0,0 +1,64 @@ +//! Apply an accepted account usage response to the currently selected task and model. + +use super::App; +use crate::app_server_session::AppServerSession; +use crate::service_tier_resolution; +use codex_app_server_protocol::ThreadSettingsUpdateParams; + +impl App { + pub(super) async fn apply_backend_banner_fallback( + &mut self, + app_server: &mut AppServerSession, + ) { + let Some(thread_id) = self.active_thread_id else { + return; + }; + if Some(thread_id) != self.chat_widget.thread_id() { + return; + } + let Some(target) = self.chat_widget.backend_banner_fallback() else { + return; + }; + let effort = self + .chat_widget + .current_reasoning_effort() + .filter(|effort| { + target + .supported_reasoning_efforts + .iter() + .any(|option| option.effort == *effort) + }) + .unwrap_or(target.default_reasoning_effort); + let mut mode = self.chat_widget.effective_collaboration_mode(); + mode.settings.model = target.model.clone(); + mode.settings.reasoning_effort = Some(effort.clone()); + // Automatic recovery must not apply the permission defaults used by manual model selection. + let params = ThreadSettingsUpdateParams { + thread_id: thread_id.to_string(), + model: Some(target.model.clone()), + effort: Some(effort.clone()), + collaboration_mode: Some(mode.clone()), + service_tier: service_tier_resolution::service_tier_update_for_core( + self.chat_widget.config_ref(), + &target.model, + &self.model_catalog.try_list_models().unwrap_or_default(), + ), + ..ThreadSettingsUpdateParams::default() + }; + // Older remote servers can decline this method. Keep the existing recovery UI in that + // case or on failure, rather than changing local selection before the task accepts it. + // Event dispatch awaits this operation; a queued manual model selection runs afterward. + if self.send_thread_settings_update(app_server, params).await { + self.chat_widget.finish_backend_banner_fallback(mode); + self.sync_active_thread_service_tier_to_cached_session() + .await; + let mut message = format!("Automatically switched to {}", target.model); + if let Some(label) = Self::reasoning_label_for(&target.model, Some(&effort)) { + message.push(' '); + message.push_str(&label); + } + message.push_str(" due to usage limits."); + self.chat_widget.add_info_message(message, /*hint*/ None); + } + } +} diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index b27c39e498..c39f5c6375 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -73,22 +73,33 @@ impl App { /// Spawns a background task to fetch account rate limits and deliver the /// result as a `RateLimitsLoaded` event. /// - /// The `origin` is forwarded to the completion handler so it can distinguish - /// 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). + /// Recovery requests are coalesced and bounded by the reset-request timeout. The origin + /// also identifies command-specific completion work, such as finalizing a `/status` card, + /// without confusing sparse inference notifications with authoritative usage responses. pub(super) fn refresh_rate_limits( &mut self, app_server: &AppServerSession, origin: RateLimitRefreshOrigin, ) { + if matches!( + origin, + RateLimitRefreshOrigin::Recovery | RateLimitRefreshOrigin::ResetConsume { .. } + ) { + self.chat_widget.hold_rate_limit_recovery(); + } + let Some((request_id, hard_stop_generation)) = self + .rate_limit_refresh_state + .start(origin, &mut self.rate_limit_hard_stop_generation) + else { + return; + }; let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); - let hard_stop_generation = self.rate_limit_hard_stop_generation; tokio::spawn(async move { let request = fetch_account_rate_limits(request_handle); let result = match origin { - RateLimitRefreshOrigin::ResetConsume { .. } + RateLimitRefreshOrigin::Recovery + | RateLimitRefreshOrigin::ResetConsume { .. } | RateLimitRefreshOrigin::ResetPicker { .. } => { tokio::time::timeout(RATE_LIMIT_RESET_REQUEST_TIMEOUT, request) .await @@ -102,6 +113,7 @@ impl App { } }; app_event_tx.send(AppEvent::RateLimitsLoaded { + request_id, origin, hard_stop_generation, result, @@ -208,6 +220,7 @@ impl App { pub(super) fn send_add_credits_nudge_email( &mut self, app_server: &AppServerSession, + request_id: Uuid, credit_type: AddCreditsNudgeCreditType, ) { let request_handle = app_server.request_handle(); @@ -216,7 +229,7 @@ impl App { 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 }); + app_event_tx.send(AppEvent::AddCreditsNudgeEmailFinished { request_id, result }); }); } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 84840b0332..055612ef0b 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -3,6 +3,8 @@ //! This module contains the exhaustive `AppEvent` dispatcher and exit-mode handling. Large domain //! actions are delegated to focused app submodules so the central match remains the routing layer. +use super::rate_limit_refresh::RateLimitReadStatus; +use super::rate_limit_refresh::RateLimitRefreshOutcome; use super::resize_reflow::trailing_run_start; use super::session_lifecycle::ThreadAttachPresentation; use super::*; @@ -1234,31 +1236,58 @@ impl App { self.clear_thread_goal(app_server, thread_id).await; } AppEvent::SendAddCreditsNudgeEmail { credit_type } => { - if self + if let Some(request_id) = self .chat_widget .start_add_credits_nudge_email_request(credit_type) { - self.send_add_credits_nudge_email(app_server, credit_type); + self.send_add_credits_nudge_email(app_server, request_id, credit_type); } } - AppEvent::AddCreditsNudgeEmailFinished { result } => { + AppEvent::AddCreditsNudgeEmailFinished { request_id, result } => { self.chat_widget - .finish_add_credits_nudge_email_request(result); + .finish_add_credits_nudge_email_request(request_id, result); } AppEvent::RateLimitsLoaded { + request_id, origin, hard_stop_generation, result, - } => match result { + } => { + let accepted = match self.rate_limit_refresh_state.finish( + request_id, + hard_stop_generation, + self.rate_limit_hard_stop_generation, + if result.is_ok() { + RateLimitReadStatus::Succeeded + } else { + RateLimitReadStatus::Failed + }, + ) { + RateLimitRefreshOutcome::Apply => true, + RateLimitRefreshOutcome::Ignore => false, + RateLimitRefreshOutcome::RefreshRecovery => { + // Start in this account's event turn; a queued refresh could cross an account change. + self.refresh_rate_limits(app_server, RateLimitRefreshOrigin::Recovery); + false + } + }; + match result { Ok(response) => { let rate_limit_reset_credits = response.rate_limit_reset_credits.clone(); - let snapshots = if hard_stop_generation == self.rate_limit_hard_stop_generation + let snapshots = if accepted { + self.chat_widget.update_backend_banner(&response); + self.apply_backend_banner_fallback(app_server).await; app_server_rate_limit_snapshots(response) } else { Vec::new() }; match origin { + RateLimitRefreshOrigin::Recovery => { + for snapshot in snapshots { + self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); + } + } RateLimitRefreshOrigin::StartupPrefetch { reset_hint_request_id, } => { @@ -1312,8 +1341,10 @@ impl App { } } Err(err) => { + // A failed read is not authoritative recovery. Keep the last valid banner. tracing::warn!("account/rateLimits/read failed during TUI refresh: {err}"); match origin { + RateLimitRefreshOrigin::Recovery => {}, RateLimitRefreshOrigin::StartupPrefetch { reset_hint_request_id, } => { @@ -1350,6 +1381,14 @@ impl App { } } } + } + if matches!( + origin, + RateLimitRefreshOrigin::Recovery | RateLimitRefreshOrigin::ResetConsume { .. } + ) && !self.rate_limit_refresh_state.has_pending_recovery() + { + self.chat_widget.finish_rate_limit_recovery(); + } }, AppEvent::OpenTokenActivity => { self.chat_widget @@ -1412,6 +1451,11 @@ impl App { credit_id, result, ) { + // Reads started before redemption must not restore the pre-reset banner. + self.rate_limit_hard_stop_generation = + self.rate_limit_hard_stop_generation.wrapping_add(1); + self.rate_limit_refresh_state.invalidate_recovery(); + self.chat_widget.clear_backend_banner(); self.refresh_rate_limits( app_server, RateLimitRefreshOrigin::ResetConsume { request_id }, diff --git a/codex-rs/tui/src/app/rate_limit_refresh.rs b/codex-rs/tui/src/app/rate_limit_refresh.rs new file mode 100644 index 0000000000..51828e8d95 --- /dev/null +++ b/codex-rs/tui/src/app/rate_limit_refresh.rs @@ -0,0 +1,96 @@ +//! Orders full usage reads and coalesces recovery refreshes within an account/limit generation. + +use crate::app_event::RateLimitRefreshOrigin; + +#[derive(Default)] +pub(super) struct RateLimitRefreshState { + next_id: u64, + applied_id: u64, + recovery: Option, +} + +struct PendingRecovery { + request: (u64, u64), + origin: RateLimitRefreshOrigin, +} + +#[derive(Debug, PartialEq, Eq)] +pub(super) enum RateLimitRefreshOutcome { + Apply, + Ignore, + RefreshRecovery, +} + +#[derive(PartialEq, Eq)] +pub(super) enum RateLimitReadStatus { + Succeeded, + Failed, +} + +impl RateLimitRefreshState { + pub(super) fn has_pending_recovery(&self) -> bool { + self.recovery.is_some() + } + + pub(super) fn start( + &mut self, + origin: RateLimitRefreshOrigin, + generation: &mut u64, + ) -> Option<(u64, u64)> { + if matches!(origin, RateLimitRefreshOrigin::Recovery) { + // A new hard error supersedes a post-reset read that started before the error. + if self + .recovery + .as_ref() + .is_some_and(|pending| pending.origin == RateLimitRefreshOrigin::Recovery) + { + return None; + } + // A hard error invalidates even an older read without a preceding rolling notification. + *generation = generation.wrapping_add(1); + } + self.next_id = self.next_id.wrapping_add(1); + let request = (self.next_id, *generation); + // The post-reset read takes over the hold from the invalidated recovery request. + if matches!( + origin, + RateLimitRefreshOrigin::Recovery | RateLimitRefreshOrigin::ResetConsume { .. } + ) { + self.recovery = Some(PendingRecovery { request, origin }); + } + Some(request) + } + + // Account changes and successful resets invalidate the need as well as the old response. + pub(super) fn invalidate_recovery(&mut self) { + self.recovery = None; + } + + pub(super) fn finish( + &mut self, + request_id: u64, + generation: u64, + current_generation: u64, + status: RateLimitReadStatus, + ) -> RateLimitRefreshOutcome { + if self + .recovery + .as_ref() + .is_some_and(|pending| pending.request == (request_id, generation)) + { + self.recovery = None; + // A rolling hard stop can arrive after the error, without another error notification. + if generation != current_generation { + return RateLimitRefreshOutcome::RefreshRecovery; + } + } + if generation != current_generation + || request_id < self.applied_id + || status == RateLimitReadStatus::Failed + { + return RateLimitRefreshOutcome::Ignore; + } + self.applied_id = request_id; + RateLimitRefreshOutcome::Apply + } +} diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 4f5622f8f6..9cbeee0883 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -449,6 +449,7 @@ impl App { chat_widget.last_terminal_title = previous_terminal_title; } chat_widget.remote_connection = self.chat_widget.remote_connection.clone(); + chat_widget.inherit_backend_banner_state(&mut self.chat_widget); for (thread_id, entry) in self.agent_navigation.ordered_threads() { chat_widget.set_collab_agent_metadata( thread_id, @@ -706,8 +707,15 @@ impl App { if started.blocks_direct_input { self.mark_primary_thread_parent_owned(thread_id); } + // A full usage read can finish before thread/start. Apply its cached fallback + // after attachment but before the initial prompt or queued draft is submitted. + let recovery_was_pending = self.chat_widget.hold_rate_limit_recovery(); self.enqueue_primary_thread_session(started.session, started.turns) .await?; + self.apply_backend_banner_fallback(app_server).await; + if !recovery_was_pending { + self.chat_widget.finish_rate_limit_recovery(); + } self.chat_widget.maybe_send_next_queued_input(); } Err(err) => { diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 3f9e665a6c..6756b20294 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -537,6 +537,7 @@ See the Codex keymap documentation for supported actions and examples." startup_protected_input_boundary: true, startup_pending_protected_request: false, rate_limit_hard_stop_generation: 0, + rate_limit_refresh_state: Default::default(), pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), recap: recap::RecapState::default(), diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 82b833b33e..c8a891e1a6 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -79,6 +79,7 @@ pub(super) async fn make_test_app() -> App { startup_protected_input_boundary: false, startup_pending_protected_request: false, rate_limit_hard_stop_generation: 0, + rate_limit_refresh_state: Default::default(), pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), recap: recap::RecapState::default(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index a360285632..adeca53331 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2,6 +2,12 @@ #[path = "tests/advanced_reasoning_tests.rs"] mod advanced_reasoning_tests; +#[path = "tests/backend_banner_fallback_tests.rs"] +mod backend_banner_fallback_tests; +#[path = "tests/backend_banner_recovery_tests.rs"] +mod backend_banner_recovery_tests; +#[path = "tests/backend_banner_startup_tests.rs"] +mod backend_banner_startup_tests; #[path = "tests/background_exit_tests.rs"] mod background_exit_tests; #[path = "tests/connector_policy.rs"] @@ -5470,6 +5476,7 @@ async fn make_test_app() -> App { startup_protected_input_boundary: false, startup_pending_protected_request: false, rate_limit_hard_stop_generation: 0, + rate_limit_refresh_state: Default::default(), pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), recap: recap::RecapState::default(), @@ -5551,6 +5558,7 @@ async fn make_test_app_with_channels() -> ( startup_protected_input_boundary: false, startup_pending_protected_request: false, rate_limit_hard_stop_generation: 0, + rate_limit_refresh_state: Default::default(), pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), recap: recap::RecapState::default(), diff --git a/codex-rs/tui/src/app/tests/backend_banner_fallback_tests.rs b/codex-rs/tui/src/app/tests/backend_banner_fallback_tests.rs new file mode 100644 index 0000000000..c353384fb0 --- /dev/null +++ b/codex-rs/tui/src/app/tests/backend_banner_fallback_tests.rs @@ -0,0 +1,427 @@ +use super::*; +use crate::chatwidget::UserMessage; +use crate::chatwidget::tests::helpers::normalize_snapshot_paths; +use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_app_server_protocol::ThreadSettingsUpdateParams; +use pretty_assertions::assert_eq; +use serde_json::json; + +pub(super) fn fallback_response() -> GetAccountRateLimitsResponse { + serde_json::from_value(json!({ + "accountId": "workspace-a", "rateLimits": {}, + "rateLimitUpsell": { + "banner_type": "model_recovery", "model_slug": "gpt-5.4", + "blocked_model_slug": "gpt-5.4", + "fallback_model_slugs": ["unavailable-model", "gpt-5.4", "gpt-5.2"], + "title": "Selected model usage exhausted", + "description": "Add credits to use this model again.", + "presentation": "dismissible", + "ctas": [{"action": "view_usage", "label": "View usage"}] + } + })) + .unwrap() +} + +fn configure_fallback_model(app: &mut App) { + set_chatgpt_auth(&mut app.chat_widget); + set_fast_mode_test_catalog(&mut app.chat_widget); + app.model_catalog = app.chat_widget.model_catalog(); + app.chat_widget.set_model("gpt-5.4"); +} + +async fn start_fallback_thread( + app: &mut App, +) -> Result { + let (mut server, requests, proxy) = session_lifecycle_requests::start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + let started = server.start_thread(&app.config).await?; + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + configure_fallback_model(app); + Ok((server, requests, proxy)) +} + +#[tokio::test] +async fn backend_banner_fallback_updates_task_settings_and_keeps_notice() -> Result<()> { + for mode_kind in [ModeKind::Default, ModeKind::Plan] { + let (mut app, mut events, mut ops) = make_test_app_with_channels().await; + let (mut server, requests, proxy) = start_fallback_thread(&mut app).await?; + let thread_id = app.active_thread_id.expect("active fallback thread"); + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); + if mode_kind == ModeKind::Plan { + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::BackTab)); + app.chat_widget + .set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::High)); + } + app.chat_widget + .set_feature_enabled(Feature::FastMode, /*enabled*/ true); + app.chat_widget + .set_service_tier(Some(ServiceTier::Fast.request_value().to_string())); + let mut expected_mode = app.chat_widget.effective_collaboration_mode(); + assert_eq!(expected_mode.mode, mode_kind); + expected_mode.settings.model = "gpt-5.2".into(); + expected_mode.settings.reasoning_effort = Some(ReasoningEffortConfig::Medium); + let default_model = app.config.model.clone(); + let default_plan_effort = app.config.plan_mode_reasoning_effort.clone(); + let config_path = app.config.codex_home.join("config.toml"); + let saved_config = std::fs::read(&config_path).ok(); + while events.try_recv().is_ok() {} + while ops.try_recv().is_ok() {} + requests.lock().unwrap().clear(); + let response = fallback_response(); + let generation = app.rate_limit_hard_stop_generation; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.handle_event( + &mut tui, + &mut server, + AppEvent::RateLimitsLoaded { + request_id: 1, + origin: RateLimitRefreshOrigin::StatusCommand { request_id: 0 }, + hard_stop_generation: generation, + result: Ok(response.clone()), + }, + ) + .await?; + + let actual_mode = app.chat_widget.effective_collaboration_mode(); + assert_eq!(actual_mode, expected_mode); + assert_eq!(app.chat_widget.current_service_tier(), None); + let sent = requests.lock().unwrap().clone(); + let methods: Vec<_> = sent.iter().map(|request| request.method.as_str()).collect(); + assert_eq!(methods, ["thread/settings/update"]); + assert_eq!( + serde_json::from_value::(sent[0].params.clone().unwrap())?, + ThreadSettingsUpdateParams { + thread_id: thread_id.to_string(), + model: Some("gpt-5.2".into()), + effort: Some(ReasoningEffortConfig::Medium), + collaboration_mode: Some(expected_mode.clone()), + service_tier: Some(Some("default".into())), + ..Default::default() + } + ); + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 72); + assert!(rendered.contains("Selected model usage exhausted")); + if mode_kind == ModeKind::Default { + insta::assert_snapshot!( + "backend_banner_after_fallback", + normalize_snapshot_paths(rendered) + ); + } + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + let queued = std::iter::from_fn(|| events.try_recv().ok()).collect::>(); + assert!(queued.iter().any(|event| matches!(event, AppEvent::OpenUrlInBrowser { url } if url == "https://chatgpt.com/codex/settings/usage"))); + let notices = queued + .iter() + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.display_lines(/*width*/ 80))) + } + _ => None, + }) + .collect::>(); + assert_eq!(notices.len(), 1); + insta::assert_snapshot!("backend_banner_fallback_notice", notices[0]); + assert!(!queued.iter().any(|event| matches!( + event, + AppEvent::PersistModelSelection { .. } | AppEvent::CodexOp(_) + ))); + assert!(ops.try_recv().is_err(), "switch must not replay a turn"); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.chat_widget.update_backend_banner(&response); + app.apply_backend_banner_fallback(&mut server).await; + assert!( + !render_bottom_popup(&app.chat_widget, /*width*/ 72) + .contains("Selected model usage exhausted") + ); + let mut healthy = response; + healthy.rate_limit_upsell = None; + app.chat_widget.update_backend_banner(&healthy); + app.apply_backend_banner_fallback(&mut server).await; + assert_eq!(app.chat_widget.current_model(), "gpt-5.2"); + assert_eq!(requests.lock().unwrap().len(), 1); + assert!( + !std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::InsertHistoryCell(_))) + ); + assert_eq!(app.config.model, default_model); + assert_eq!(app.config.plan_mode_reasoning_effort, default_plan_effort); + assert_eq!(std::fs::read(&config_path).ok(), saved_config); + if mode_kind == ModeKind::Plan { + let chat = &mut app.chat_widget; + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Default); + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + } + app.chat_widget + .restore_user_message_to_composer(UserMessage::from("continue")); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(next_user_turn_op(&mut ops), AppCommand::UserTurn { model, collaboration_mode: Some(mode), .. } + if model == "gpt-5.2" && mode == expected_mode); + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + +#[tokio::test] +async fn backend_banner_fallback_uses_current_task_and_accepted_generation() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let (mut server, requests, proxy) = start_fallback_thread(&mut app).await?; + let read_generation = app.rate_limit_hard_stop_generation; + let second = server.start_thread(&app.config).await?; + let active_id = second.session.thread_id; + let mut tui = crate::tui::test_support::make_test_tui()?; + let init = app.chatwidget_init_for_forked_or_resumed_thread( + &mut tui, + app.config.clone(), + /*initial_user_message*/ None, + ); + app.replace_chat_widget(ChatWidget::new_with_app_event(init)); + app.store_active_thread_receiver().await; + app.active_thread_id = None; + app.enqueue_primary_thread_session(second.session, second.turns) + .await?; + configure_fallback_model(&mut app); + requests.lock().unwrap().clear(); + app.rate_limit_hard_stop_generation = read_generation.wrapping_add(1); + for generation in [read_generation, app.rate_limit_hard_stop_generation] { + app.handle_event( + &mut tui, + &mut server, + AppEvent::RateLimitsLoaded { + request_id: 1, + origin: RateLimitRefreshOrigin::StatusCommand { request_id: 0 }, + hard_stop_generation: generation, + result: Ok(fallback_response()), + }, + ) + .await?; + if generation == read_generation { + assert_eq!(app.chat_widget.current_model(), "gpt-5.4"); + assert!(requests.lock().unwrap().is_empty()); + } + } + let sent = requests.lock().unwrap().clone(); + assert_eq!(sent.len(), 1); + assert_eq!( + sent[0].params.as_ref().unwrap()["threadId"], + active_id.to_string() + ); + assert_eq!(app.chat_widget.current_model(), "gpt-5.2"); + server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn backend_banner_fallback_keeps_existing_recovery_without_candidate() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let (mut server, requests, proxy) = start_fallback_thread(&mut app).await?; + requests.lock().unwrap().clear(); + for (replacement, expect_legacy_banner) in [ + (json!([]), true), + (json!(["unavailable-model"]), false), + (json!(["gpt-5.4"]), false), + ] { + let mut response = fallback_response(); + response.rate_limit_upsell.as_mut().unwrap()["fallback_model_slugs"] = replacement; + app.chat_widget.update_backend_banner(&response); + app.apply_backend_banner_fallback(&mut server).await; + assert_eq!(app.chat_widget.current_model(), "gpt-5.4"); + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 72); + assert_eq!(rendered.contains("View usage"), expect_legacy_banner); + if !expect_legacy_banner { + insta::assert_snapshot!( + "backend_banner_fallback_unavailable", + normalize_snapshot_paths(rendered) + ); + } + } + app.chat_widget.set_model("gpt-5.2"); + app.chat_widget.update_backend_banner(&fallback_response()); + app.apply_backend_banner_fallback(&mut server).await; + assert_eq!(app.chat_widget.current_model(), "gpt-5.2"); + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 72) + .contains("Selected model usage exhausted") + ); + assert!(requests.lock().unwrap().is_empty()); + server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn backend_banner_fallback_handles_settings_failure_and_queued_manual_selection() -> Result<()> +{ + use futures::SinkExt; + use futures::StreamExt; + use tokio_tungstenite::tungstenite::Message; + + for error_code in [Some(-32601), Some(-32603), None] { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let id = ThreadId::new(); + app.active_thread_id = Some(id); + app.chat_widget + .handle_thread_session(test_thread_session(id, app.config.cwd.to_path_buf())); + configure_fallback_model(&mut app); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let websocket_url = format!("ws://{}", listener.local_addr()?); + let codex_home = app.config.codex_home.display().to_string(); + let manual_selection_tx = app.app_event_tx.clone(); + let fake = tokio::spawn(async move { + let (stream, _) = listener.accept().await?; + let mut socket = tokio_tungstenite::accept_async(stream).await?; + let mut settings_requests = 0; + while let Some(frame) = socket.next().await { + let Message::Text(text) = frame? else { + continue; + }; + let request: serde_json::Value = serde_json::from_str(&text)?; + if request["id"].is_null() { + continue; + } + let response = if request["method"] == "initialize" { + json!({"id": request["id"], "result": {"userAgent": "test", "codexHome": codex_home}}) + } else { + assert_eq!(request["method"], "thread/settings/update"); + settings_requests += 1; + if let Some(error_code) = error_code { + json!({"id": request["id"], "error": {"code": error_code, "message": "settings unavailable"}}) + } else { + if settings_requests == 1 { + // A manual selection arriving while the RPC is pending must win afterward. + manual_selection_tx.send(AppEvent::UpdateModel("gpt-5.4".into())); + } + json!({"id": request["id"], "result": {}}) + } + }; + socket + .send(Message::Text(response.to_string().into())) + .await?; + } + Result::<()>::Ok(()) + }); + let client = crate::connect_remote_app_server(crate::RemoteAppServerEndpoint::WebSocket { + websocket_url, + auth_token: None, + }) + .await?; + let mut server = AppServerSession::new( + client, + crate::app_server_session::ThreadParamsMode::Embedded, + ); + while events.try_recv().is_ok() {} + app.chat_widget.update_backend_banner(&fallback_response()); + app.apply_backend_banner_fallback(&mut server).await; + let queued = std::iter::from_fn(|| events.try_recv().ok()).collect::>(); + let switch_notices = queued + .iter() + .filter(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + lines_to_single_string(&cell.display_lines(/*width*/ 80)) + .contains("Automatically switched") + } + _ => false, + }) + .count(); + assert_eq!(switch_notices, usize::from(error_code.is_none())); + if error_code.is_none() { + assert_eq!(app.chat_widget.current_model(), "gpt-5.2"); + let manual_selection = queued + .into_iter() + .find(|event| matches!(event, AppEvent::UpdateModel(_))) + .unwrap(); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.handle_event(&mut tui, &mut server, manual_selection) + .await?; + } + assert_eq!(app.chat_widget.current_model(), "gpt-5.4"); + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 72).contains("View usage")); + server.shutdown().await?; + fake.await??; + } + Ok(()) +} + +#[tokio::test] +async fn backend_banner_fallback_preserves_permissions_for_first_eligible_cyber_model() -> Result<()> +{ + let mut app = make_test_app().await; + assert!( + app.apply_permission_profile_selection(PermissionProfileSelection { + profile_id: codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY.into(), + approval_policy: Some(AskForApproval::UnlessTrusted), + approvals_reviewer: Some(ApprovalsReviewer::User), + display_label: "Read Only".into(), + }) + .await + ); + let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let started = server.start_thread(&app.config).await?; + let thread_id = started.session.thread_id; + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + configure_fallback_model(&mut app); + let mut models = app.model_catalog.try_list_models()?; + let mut cyber_model = models + .iter() + .find(|model| model.model == "gpt-5.2") + .unwrap() + .clone(); + // Like the manual-selection tests, classify a fixture model through catalog metadata. + cyber_model.model = "cyber-test-model".into(); + cyber_model.model_specialty = Some(MODEL_SPECIALTY_CYBER.into()); + models.push(cyber_model.clone()); + app.model_catalog = Arc::new(ModelCatalog::new(models)); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut init = app.chatwidget_init_for_forked_or_resumed_thread( + &mut tui, + app.config.clone(), + /*initial_user_message*/ None, + ); + init.model_catalog = app.model_catalog.clone(); + app.replace_chat_widget(ChatWidget::new_with_app_event(init)); + app.chat_widget + .handle_thread_session(app.primary_session_configured.clone().unwrap()); + app.chat_widget.set_model("gpt-5.4"); + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); + let mut response = fallback_response(); + response.rate_limit_upsell.as_mut().unwrap()["fallback_model_slugs"] = + json!(["unavailable-model", "gpt-5.4", cyber_model.model, "gpt-5.2"]); + app.chat_widget.update_backend_banner(&response); + app.apply_backend_banner_fallback(&mut server).await; + assert_eq!(app.chat_widget.current_model(), cyber_model.model); + let settings = next_thread_settings_updated(&mut server, thread_id) + .await + .thread_settings; + assert_eq!( + ( + settings.model, + settings.effort, + settings.approval_policy, + settings.approvals_reviewer.to_core(), + settings.active_permission_profile.unwrap().id + ), + ( + cyber_model.model, + Some(ReasoningEffortConfig::Medium), + AskForApproval::UnlessTrusted, + ApprovalsReviewer::User, + codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string() + ), + ); + server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/backend_banner_recovery_tests.rs b/codex-rs/tui/src/app/tests/backend_banner_recovery_tests.rs new file mode 100644 index 0000000000..cf5f7113f6 --- /dev/null +++ b/codex-rs/tui/src/app/tests/backend_banner_recovery_tests.rs @@ -0,0 +1,488 @@ +use super::*; +use crate::app::rate_limit_refresh::RateLimitReadStatus; +use crate::app::rate_limit_refresh::RateLimitRefreshOutcome; +use app_test_support::ChatGptAuthFixture; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::ErrorNotification; +use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_config::types::AuthCredentialsStoreMode; +use pretty_assertions::assert_eq; +use serde_json::json; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +fn response_with_banner() -> GetAccountRateLimitsResponse { + serde_json::from_value(json!({ + "accountId":"workspace-a", "rateLimitUpsell":{ + "banner_type":"selected_model_limit", "model_slug":"test-model-a", "presentation":"inline", + "title":"Selected model usage exhausted", "description":"Switch to another model or contact your owner.", + "ctas":[{"action":"notify_owner","label":"Notify owner"}] + }, + "rateLimits":{}, "rateLimitsByLimitId":null,"rateLimitResetCredits":null + })).unwrap() +} + +async fn next_rate_limits_loaded( + events: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> Result { + Ok( + tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 10), async { + loop { + let event = events.recv().await.expect("app event channel"); + if matches!(event, AppEvent::RateLimitsLoaded { .. }) { + break event; + } + } + }) + .await?, + ) +} + +#[tokio::test] +async fn backend_banner_limit_error_refreshes_again_after_intervening_rolling_hard_stop() +-> Result<()> { + let server = MockServer::start().await; + let home = tempdir()?; + write_chatgpt_auth( + home.path(), + ChatGptAuthFixture::new("local-test-token") + .account_id("workspace-a") + .chatgpt_user_id("user-a") + .plan_type("team"), + AuthCredentialsStoreMode::File, + ) + .expect("write synthetic auth"); + std::fs::write( + home.path().join("config.toml"), + format!( + "chatgpt_base_url = {:?}\ncli_auth_credentials_store = \"file\"\n", + server.uri() + ), + )?; + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + app.config.codex_home = home.path().to_path_buf().abs(); + app.config.chatgpt_base_url = server.uri(); + app.config.sqlite = codex_state::SqliteConfig::new_for_testing(home.path().abs()); + set_chatgpt_auth(&mut app.chat_widget); + app.chat_widget.set_model("test-model-a"); + let mut healthy = response_with_banner(); + healthy.rate_limit_upsell = None; + app.chat_widget.update_backend_banner(&healthy); + assert!( + !render_bottom_popup(&app.chat_widget, /*width*/ 90) + .contains("Selected model usage exhausted") + ); + Mock::given(method("GET")) + .and(path("/api/codex/usage")) + .respond_with( + ResponseTemplate::new(200) + .set_delay(std::time::Duration::from_millis(/*millis*/ 100)) + .set_body_json(json!({ + "account_id":"workspace-a","user_id":"user-a","plan_type":"team", + "rate_limit":{"allowed":true,"limit_reached":false}, + "rate_limit_upsell":response_with_banner().rate_limit_upsell, + "rate_limit_reset_credits":{"available_count":0} + })), + ) + .expect(3) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/codex/rate-limit-reset-credits")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({"available_count":0,"credits":[]})), + ) + .expect(3) + .mount(&server) + .await; + let mut session = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + while events.try_recv().is_ok() {} + app.chat_widget.handle_server_notification( + ServerNotification::Error(ErrorNotification { + error: AppServerTurnError { + misalignment: None, + message: "credits exhausted".into(), + codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded), + additional_details: None, + }, + will_retry: false, + thread_id: "thread-a".into(), + turn_id: "turn-a".into(), + }), + /*replay_kind*/ None, + ); + let recovery = loop { + let event = events + .try_recv() + .expect("limit error should request recovery without a command"); + if matches!( + event, + AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::Recovery + } + ) { + break event; + } + }; + app.handle_event(&mut tui, &mut session, recovery).await?; + app.handle_event( + &mut tui, + &mut session, + AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::Recovery, + }, + ) + .await?; + let mut rolling = response_with_banner().rate_limits; + rolling.rate_limit_reached_type = + Some(codex_app_server_protocol::RateLimitReachedType::WorkspaceMemberCreditsDepleted); + app.handle_app_server_event( + &session, + codex_app_server_client::AppServerEvent::ServerNotification(Box::new( + ServerNotification::AccountRateLimitsUpdated( + codex_app_server_protocol::AccountRateLimitsUpdatedNotification { + rate_limits: rolling.clone(), + }, + ), + )), + ) + .await; + let loaded = next_rate_limits_loaded(&mut events).await?; + assert!( + matches!(&loaded, AppEvent::RateLimitsLoaded { result: Ok(_), .. }), + "{loaded:?}" + ); + app.handle_event(&mut tui, &mut session, loaded).await?; + assert!( + !render_bottom_popup(&app.chat_widget, /*width*/ 90) + .contains("Selected model usage exhausted") + ); + // No second inference Error or explicit refresh: the obsolete read starts its own replacement. + let loaded = next_rate_limits_loaded(&mut events).await?; + app.handle_event(&mut tui, &mut session, loaded).await?; + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 90); + assert!(rendered.contains("Selected model usage exhausted")); + assert!(rendered.contains("Notify owner")); + // A background operation can report the same hard stop after the full response omitted + // aggregate reached_type: included usage for other models is still available. + app.handle_app_server_event( + &session, + codex_app_server_client::AppServerEvent::ServerNotification(Box::new( + ServerNotification::AccountRateLimitsUpdated( + codex_app_server_protocol::AccountRateLimitsUpdatedNotification { + rate_limits: rolling, + }, + ), + )), + ) + .await; + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 90) + .contains("Selected model usage exhausted") + ); + let loaded = next_rate_limits_loaded(&mut events).await?; + app.handle_event(&mut tui, &mut session, loaded).await?; + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 90) + .contains("Selected model usage exhausted") + ); + app.chat_widget.set_model("test-model-b"); + assert!( + !render_bottom_popup(&app.chat_widget, /*width*/ 90) + .contains("Selected model usage exhausted") + ); + session.shutdown().await?; + server.verify().await; + Ok(()) +} + +#[test] +fn backend_banner_recovery_follows_rolling_invalidation_without_another_error() { + let mut refresh = crate::app::rate_limit_refresh::RateLimitRefreshState::default(); + let mut epoch = 0; + let first = refresh + .start(RateLimitRefreshOrigin::Recovery, &mut epoch) + .unwrap(); + epoch += 1; + assert_eq!( + refresh.finish(first.0, first.1, epoch, RateLimitReadStatus::Succeeded), + RateLimitRefreshOutcome::RefreshRecovery + ); +} + +#[tokio::test] +async fn backend_banner_rolling_only_recovery_holds_new_input() -> Result<()> { + let (mut app, _events, mut ops) = make_test_app_with_channels().await; + let mut session = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.chat_widget.handle_thread_session(test_thread_session( + ThreadId::new(), + app.config.cwd.to_path_buf(), + )); + set_chatgpt_auth(&mut app.chat_widget); + app.chat_widget.set_model("test-model-a"); + let mut rolling = response_with_banner().rate_limits; + rolling.spend_control_reached = Some(true); + app.handle_app_server_event( + &session, + codex_app_server_client::AppServerEvent::ServerNotification(Box::new( + ServerNotification::AccountRateLimitsUpdated( + codex_app_server_protocol::AccountRateLimitsUpdatedNotification { + rate_limits: rolling, + }, + ), + )), + ) + .await; + app.chat_widget.apply_external_edit("follow-up".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["follow-up"] + ); + assert!( + !std::iter::from_fn(|| ops.try_recv().ok()).any(|op| matches!(op, Op::UserTurn { .. })) + ); + app.handle_event( + &mut tui, + &mut session, + AppEvent::RateLimitsLoaded { + request_id: 1, + origin: RateLimitRefreshOrigin::Recovery, + hard_stop_generation: app.rate_limit_hard_stop_generation, + result: Ok(response_with_banner()), + }, + ) + .await?; + assert!( + matches!(next_user_turn_op(&mut ops), Op::UserTurn { items, .. } + if items == vec![UserInput::Text { text: "follow-up".into(), text_elements: Vec::new() }]) + ); + session.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn backend_banner_account_changes_invalidate_pending_recovery() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let mut session = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.chat_widget.set_model("test-model-a"); + let current = response_with_banner(); + app.chat_widget.update_backend_banner(¤t); + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 90) + .contains("Selected model usage exhausted") + ); + let pending = app + .rate_limit_refresh_state + .start( + RateLimitRefreshOrigin::Recovery, + &mut app.rate_limit_hard_stop_generation, + ) + .unwrap(); + app.handle_app_server_event( + &session, + codex_app_server_client::AppServerEvent::ServerNotification(Box::new( + ServerNotification::AccountUpdated( + codex_app_server_protocol::AccountUpdatedNotification { + auth_mode: None, + plan_type: None, + }, + ), + )), + ) + .await; + app.handle_event( + &mut tui, + &mut session, + AppEvent::RateLimitsLoaded { + request_id: pending.0, + origin: RateLimitRefreshOrigin::Recovery, + hard_stop_generation: pending.1, + result: Ok(current), + }, + ) + .await?; + assert!( + !render_bottom_popup(&app.chat_widget, /*width*/ 90) + .contains("Selected model usage exhausted") + ); + assert!( + app.rate_limit_refresh_state + .start( + RateLimitRefreshOrigin::Recovery, + &mut app.rate_limit_hard_stop_generation + ) + .is_some() + ); + session.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn backend_banner_reset_redemption_rejects_pre_reset_content() -> Result<()> { + let (mut app, _events, mut ops) = make_test_app_with_channels().await; + let mut session = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.chat_widget.handle_thread_session(test_thread_session( + ThreadId::new(), + app.config.cwd.to_path_buf(), + )); + app.chat_widget.set_model("test-model-a"); + let banner = response_with_banner(); + app.chat_widget.update_backend_banner(&banner); + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 90) + .contains("Selected model usage exhausted") + ); + let pending = app + .rate_limit_refresh_state + .start( + RateLimitRefreshOrigin::Recovery, + &mut app.rate_limit_hard_stop_generation, + ) + .unwrap(); + app.chat_widget.hold_rate_limit_recovery(); + app.chat_widget.apply_external_edit("after reset".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["after reset"] + ); + let request_id = app.chat_widget.show_rate_limit_reset_consuming_popup(); + app.handle_event( + &mut tui, + &mut session, + AppEvent::RateLimitResetCreditConsumed { + request_id, + idempotency_key: "mock-reset".into(), + credit_id: None, + result: Ok( + codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse { + outcome: + codex_app_server_protocol::ConsumeAccountRateLimitResetCreditOutcome::Reset, + }, + ), + }, + ) + .await?; + let mut recovered = banner.clone(); + recovered.rate_limit_upsell = None; + recovered.rate_limit_reset_credits = + Some(codex_app_server_protocol::RateLimitResetCreditsSummary { + available_count: 0, + credits: None, + }); + app.handle_event( + &mut tui, + &mut session, + AppEvent::RateLimitsLoaded { + request_id: 2, + origin: RateLimitRefreshOrigin::ResetConsume { request_id }, + hard_stop_generation: app.rate_limit_hard_stop_generation, + result: Ok(recovered), + }, + ) + .await?; + assert!( + matches!(next_user_turn_op(&mut ops), Op::UserTurn { items, .. } + if items == vec![UserInput::Text { text: "after reset".into(), text_elements: Vec::new() }]) + ); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.handle_event( + &mut tui, + &mut session, + AppEvent::RateLimitsLoaded { + request_id: pending.0, + origin: RateLimitRefreshOrigin::Recovery, + hard_stop_generation: pending.1, + result: Ok(banner), + }, + ) + .await?; + assert!( + !render_bottom_popup(&app.chat_widget, /*width*/ 90) + .contains("Selected model usage exhausted") + ); + assert!( + app.rate_limit_refresh_state + .start( + RateLimitRefreshOrigin::Recovery, + &mut app.rate_limit_hard_stop_generation + ) + .is_some() + ); + session.shutdown().await?; + Ok(()) +} + +#[test] +fn backend_banner_post_reset_read_preserves_newer_recovery_hold() { + let mut refresh = crate::app::rate_limit_refresh::RateLimitRefreshState::default(); + let mut epoch = 0; + let old = refresh + .start(RateLimitRefreshOrigin::Recovery, &mut epoch) + .unwrap(); + epoch += 1; + refresh.invalidate_recovery(); + let reset = refresh + .start( + RateLimitRefreshOrigin::ResetConsume { request_id: 1 }, + &mut epoch, + ) + .unwrap(); + assert_eq!( + refresh.finish(old.0, old.1, epoch, RateLimitReadStatus::Succeeded), + RateLimitRefreshOutcome::Ignore + ); + assert!(refresh.has_pending_recovery()); + let newer = refresh + .start(RateLimitRefreshOrigin::Recovery, &mut epoch) + .unwrap(); + assert_eq!( + refresh.finish(reset.0, reset.1, epoch, RateLimitReadStatus::Succeeded), + RateLimitRefreshOutcome::Ignore + ); + assert!(refresh.has_pending_recovery()); + assert_eq!( + refresh.finish(newer.0, newer.1, epoch, RateLimitReadStatus::Failed), + RateLimitRefreshOutcome::Ignore + ); + assert!(!refresh.has_pending_recovery()); +} + +#[test] +fn backend_banner_recovery_coalesces_until_newer_epoch_can_be_read() { + let mut refresh = crate::app::rate_limit_refresh::RateLimitRefreshState::default(); + let mut epoch = 0; + let first = refresh + .start(RateLimitRefreshOrigin::Recovery, &mut epoch) + .unwrap(); + epoch += 1; // A newer rolling hard stop makes the in-flight response obsolete. + for _ in 0..10 { + assert_eq!( + refresh.start(RateLimitRefreshOrigin::Recovery, &mut epoch), + None + ); + } + assert_eq!( + refresh.finish(first.0, first.1, epoch, RateLimitReadStatus::Succeeded), + RateLimitRefreshOutcome::RefreshRecovery + ); + let next = refresh + .start(RateLimitRefreshOrigin::Recovery, &mut epoch) + .unwrap(); + assert_eq!( + refresh.finish(next.0, next.1, epoch, RateLimitReadStatus::Succeeded), + RateLimitRefreshOutcome::Apply + ); +} diff --git a/codex-rs/tui/src/app/tests/backend_banner_startup_tests.rs b/codex-rs/tui/src/app/tests/backend_banner_startup_tests.rs new file mode 100644 index 0000000000..47cc5ce572 --- /dev/null +++ b/codex-rs/tui/src/app/tests/backend_banner_startup_tests.rs @@ -0,0 +1,94 @@ +use super::backend_banner_fallback_tests::fallback_response; +use super::*; +use crate::chatwidget::UserMessage; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn backend_banner_fallback_applies_before_initial_and_queued_startup_prompts() -> Result<()> { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let mut config = app.config.clone(); + config.model = Some("gpt-5.4".into()); + let mut tui = crate::tui::test_support::make_test_tui()?; + let init = app.chatwidget_init_for_forked_or_resumed_thread( + &mut tui, + config.clone(), + Some(UserMessage::from("initial prompt")), + ); + app.replace_chat_widget(ChatWidget::new_with_app_event(init)); + set_chatgpt_auth(&mut app.chat_widget); + set_fast_mode_test_catalog(&mut app.chat_widget); + app.model_catalog = app.chat_widget.model_catalog(); + app.pending_startup_thread_start = true; + app.chat_widget.set_model("gpt-5.4"); + app.chat_widget + .set_queue_submissions_until_session_configured(/*queue*/ true); + app.chat_widget + .apply_external_edit("queued follow-up".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let (mut server, requests, proxy) = session_lifecycle_requests::start_recording_app_server( + &config, /*blocked_thread_list*/ None, /*failed_thread_name*/ None, + ) + .await?; + let generation = app.rate_limit_hard_stop_generation; + app.handle_event( + &mut tui, + &mut server, + AppEvent::RateLimitsLoaded { + request_id: 1, + origin: RateLimitRefreshOrigin::StartupPrefetch { + reset_hint_request_id: 0, + }, + hard_stop_generation: generation, + result: Ok(fallback_response()), + }, + ) + .await?; + assert!(requests.lock().unwrap().is_empty()); + assert!( + !render_bottom_popup(&app.chat_widget, /*width*/ 72) + .contains("Selected model usage exhausted") + ); + let started = server.start_thread(&config).await?; + let thread_id = started.session.thread_id; + requests.lock().unwrap().clear(); + while events.try_recv().is_ok() {} + app.handle_startup_thread_started(&mut server, Ok(started)) + .await?; + assert_eq!(app.active_thread_id, Some(thread_id)); + let submissions = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| { + if let AppEvent::CodexOp(AppCommand::UserTurn { model, items, .. }) = event { + Some((model, items)) + } else { + None + } + }) + .collect::>(); + assert_eq!( + submissions, + vec![( + "gpt-5.2".into(), + vec![UserInput::Text { + text: "initial prompt".into(), + text_elements: Vec::new(), + }] + )] + ); + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["queued follow-up"] + ); + assert_eq!( + requests + .lock() + .unwrap() + .iter() + .map(|request| request.method.as_str()) + .collect::>(), + vec!["thread/settings/update"] + ); + server.shutdown().await?; + proxy.await??; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/rate_limits.rs b/codex-rs/tui/src/app/tests/rate_limits.rs index 13b4fe7617..171c6d9eef 100644 --- a/codex-rs/tui/src/app/tests/rate_limits.rs +++ b/codex-rs/tui/src/app/tests/rate_limits.rs @@ -40,6 +40,8 @@ fn rate_limit_snapshot( fn account_rate_limits_response(snapshot: RateLimitSnapshot) -> GetAccountRateLimitsResponse { GetAccountRateLimitsResponse { + account_id: None, + rate_limit_upsell: None, rate_limits: snapshot, rate_limits_by_limit_id: None, rate_limit_reset_credits: Some(RateLimitResetCreditsSummary { @@ -101,6 +103,50 @@ fn deliver_usage_limit_error(app: &mut App) { ); } +#[tokio::test] +async fn backend_banner_state_survives_widget_replacement() -> Result<()> { + for dismiss in [false, true] { + let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; + set_chatgpt_auth(&mut app.chat_widget); + let mut response = account_rate_limits_response(rate_limit_snapshot( + /*used_percent*/ 100, /*rate_limit_reached_type*/ None, + /*spend_control_reached*/ None, + )); + response.rate_limit_upsell = Some(serde_json::json!({ + "banner_type": "plus_rate_limit_reached", "title": "Usage limit reached", + "presentation": "dismissible", + "description": "Choose how to continue.", + "ctas": [{"action": "view_usage", "label": "View usage"}] + })); + app.chat_widget.update_backend_banner(&response); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 90).contains("View usage")); + if dismiss { + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + } + let before = render_bottom_popup(&app.chat_widget, /*width*/ 90); + let mut tui = crate::tui::test_support::make_test_tui()?; + let init = app.chatwidget_init_for_forked_or_resumed_thread( + &mut tui, + app.config.clone(), + /*initial_user_message*/ None, + ); + app.replace_chat_widget(ChatWidget::new_with_app_event(init)); + set_active_cell( + &mut app.chat_widget, + Box::new(PlainHistoryCell::new(Vec::new())), + ); + app.chat_widget.pre_draw_tick(); + assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 90), before); + app.chat_widget.update_backend_banner(&response); + assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 90), before); + response.rate_limit_upsell = None; + app.chat_widget.update_backend_banner(&response); + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 90).contains("View usage")); + } + Ok(()) +} + #[tokio::test] async fn rolling_workspace_hard_stops_invalidate_older_rate_limit_reads() -> Result<()> { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -234,6 +280,7 @@ async fn stale_rate_limit_reads_preserve_newer_workspace_hard_stop_for_every_ori &mut tui, &mut app_server, AppEvent::RateLimitsLoaded { + request_id: 0, origin, hard_stop_generation: read_generation, result: Ok(account_rate_limits_response(rate_limit_snapshot( @@ -313,6 +360,7 @@ async fn stale_rate_limit_read_does_not_dismiss_visible_workspace_advisory() -> &mut tui, &mut app_server, AppEvent::RateLimitsLoaded { + request_id: 0, origin: RateLimitRefreshOrigin::StatusCommand { request_id }, hard_stop_generation: read_generation, result: Ok(account_rate_limits_response(rate_limit_snapshot( @@ -359,6 +407,7 @@ async fn post_hard_stop_rate_limit_read_clears_recovered_workspace_limit() -> Re &mut tui, &mut app_server, AppEvent::RateLimitsLoaded { + request_id: 0, origin: RateLimitRefreshOrigin::StatusCommand { request_id }, hard_stop_generation: read_generation, result: Ok(account_rate_limits_response(rate_limit_snapshot( @@ -386,3 +435,113 @@ async fn post_hard_stop_rate_limit_read_clears_recovered_workspace_limit() -> Re app_server.shutdown().await?; Ok(()) } + +#[tokio::test] +async fn failed_rate_limit_read_preserves_visible_backend_banner() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await?; + let mut response = account_rate_limits_response(rate_limit_snapshot( + /*used_percent*/ 25, + /*rate_limit_reached_type*/ None, + Some(false), + )); + response.account_id = Some("workspace-a".into()); + response.rate_limit_upsell = Some(serde_json::json!({ + "banner_type": "workspace_recovery", "presentation": "inline", + "title": "Workspace needs credits", "description": "Ask your owner for credits.", + "ctas": [] + })); + app.chat_widget.update_backend_banner(&response); + let before = render_bottom_popup(&app.chat_widget, /*width*/ 90); + assert!(before.contains("Workspace needs credits")); + let request_id = 7; + app.chat_widget + .add_status_output(/*refreshing_rate_limits*/ true, Some(request_id)); + let generation = app.rate_limit_hard_stop_generation; + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::RateLimitsLoaded { + request_id: 0, + origin: RateLimitRefreshOrigin::StatusCommand { request_id }, + hard_stop_generation: generation, + result: Err("transient test failure".into()), + }, + ) + .await?; + assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 90), before); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn backend_banner_reads_ignore_older_completions() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let mut session = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.chat_widget.set_model("test-model-a"); + let mut current = account_rate_limits_response(rate_limit_snapshot( + /*used_percent*/ 100, /*rate_limit_reached_type*/ None, + /*spend_control_reached*/ None, + )); + current.rate_limit_upsell = Some(serde_json::json!({ + "banner_type":"selected_model_limit", "model_slug":"test-model-a", "presentation":"inline", + "title":"Selected model usage exhausted", "description":"Contact your owner.", "ctas":[] + })); + for request_id in 1..=5 { + app.rate_limit_refresh_state + .start( + RateLimitRefreshOrigin::StatusCommand { request_id }, + &mut app.rate_limit_hard_stop_generation, + ) + .unwrap(); + } + for (id, generation, result, expect_visible) in [ + (2, 0, Ok(current.clone()), true), + ( + 1, + 0, + Ok({ + let mut absent = current.clone(); + absent.rate_limit_upsell = None; + absent + }), + true, + ), + (4, 0, Err("transient failure".into()), true), + ( + 3, + 0, + Ok({ + let mut absent = current.clone(); + absent.rate_limit_upsell = None; + absent + }), + false, + ), + (5, 0, Ok(current.clone()), true), + ] { + app.handle_event( + &mut tui, + &mut session, + AppEvent::RateLimitsLoaded { + request_id: id, + origin: RateLimitRefreshOrigin::StatusCommand { request_id: id }, + hard_stop_generation: generation, + result, + }, + ) + .await?; + assert_eq!( + render_bottom_popup(&app.chat_widget, /*width*/ 90) + .contains("Selected model usage exhausted"), + expect_visible + ); + } + session.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_after_fallback.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_after_fallback.snap new file mode 100644 index 0000000000..a39f4fd236 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_after_fallback.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/app/tests/backend_banner_fallback_tests.rs +assertion_line: 76 +expression: rendered +--- + Selected model usage exhausted + Add credits to use this model again. + +› 1. View usage + + Press a number to choose · esc to dismiss · type to continue + +› Ask Codex to do anything + + gpt-5.2 medium · /tmp/project diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_fallback_notice.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_fallback_notice.snap new file mode 100644 index 0000000000..da8d0ecb7b --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_fallback_notice.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/tests/backend_banner_fallback_tests.rs +assertion_line: 124 +expression: "notices[0]" +--- +• Automatically switched to gpt-5.2 medium due to usage limits. diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_fallback_unavailable.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_fallback_unavailable.snap new file mode 100644 index 0000000000..b90f77cb51 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_fallback_unavailable.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/app/tests/backend_banner_fallback_tests.rs +assertion_line: 234 +expression: rendered +--- +› Ask Codex to do anything + + gpt-5.4 default · /tmp/project diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 23d7928cfe..bb5b57aa3d 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -173,6 +173,8 @@ pub(crate) enum RateLimitRefreshOrigin { ResetPicker { request_id: u64 }, /// Refresh requested after a reset credit was successfully consumed. ResetConsume { request_id: u64 }, + /// Refresh backend recovery after an inference limit error. + Recovery, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -524,6 +526,7 @@ pub(crate) enum AppEvent { /// Result of refreshing rate limits. RateLimitsLoaded { + request_id: u64, origin: RateLimitRefreshOrigin, hard_stop_generation: u64, result: Result, @@ -601,6 +604,7 @@ pub(crate) enum AppEvent { /// Result of notifying the workspace owner. AddCreditsNudgeEmailFinished { + request_id: Uuid, result: Result, }, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index c7908ca637..4bd7a56ef9 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -2461,6 +2461,8 @@ mod tests { #[test] fn app_server_rate_limit_snapshots_deduplicates_top_level_limit_from_map() { let response = GetAccountRateLimitsResponse { + account_id: None, + rate_limit_upsell: None, rate_limits: rate_limit_snapshot("codex"), rate_limits_by_limit_id: Some(HashMap::from([ ("codex".to_string(), rate_limit_snapshot("codex")), diff --git a/codex-rs/tui/src/backend_banners.rs b/codex-rs/tui/src/backend_banners.rs new file mode 100644 index 0000000000..18c3a17c4f --- /dev/null +++ b/codex-rs/tui/src/backend_banners.rs @@ -0,0 +1,74 @@ +//! Backend banner payload and CTA mapping, supplied by the existing account usage read. +//! +//! The backend owns eligibility, copy, actions, and ordered model fallback instructions. + +use codex_protocol::account::PlanType; +use serde::Deserialize; + +mod actions; +mod render; + +#[cfg(test)] +#[path = "backend_banners_tests.rs"] +mod tests; + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub(crate) struct BackendBanner { + pub(crate) banner_type: String, + pub(crate) title: String, + pub(crate) description: String, + pub(crate) ctas: Vec, + pub(crate) reset_at: Option, + pub(crate) model_slug: Option, + pub(crate) blocked_model_slug: Option, + #[serde(default)] + pub(crate) fallback_model_slugs: Vec, + #[serde(default)] + pub(crate) presentation: BannerPresentation, + request_url: Option, + #[serde(skip)] + pub(crate) account_id: String, + #[serde(skip)] + pub(crate) plan_type: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum BannerPresentation { + #[default] + Inline, + Dismissible, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub(crate) struct BackendBannerCta { + action: String, + label: String, +} + +impl BackendBanner { + /// Parse supported, bounded content before constructing rendered copy or CTA closures. + pub(crate) fn parse(raw: &serde_json::Value) -> Option { + serde_json::from_value::(raw.clone()) + .ok() + .filter(|banner| { + let valid_slug = |slug: &str| { + !slug.trim().is_empty() + && slug.len() <= 256 + && !slug.chars().any(char::is_control) + }; + banner.title.len() <= 1024 + && banner.description.len() <= 4096 + && banner.ctas.len() <= 8 + && !banner.title.trim().is_empty() + && banner.title.lines().count() <= 3 + && banner.description.lines().count() <= 12 + && banner.blocked_model_slug.as_deref().is_none_or(valid_slug) + && banner.fallback_model_slugs.len() <= 16 + && banner + .fallback_model_slugs + .iter() + .all(|slug| valid_slug(slug)) + }) + } +} diff --git a/codex-rs/tui/src/backend_banners/actions.rs b/codex-rs/tui/src/backend_banners/actions.rs new file mode 100644 index 0000000000..0b3d99968e --- /dev/null +++ b/codex-rs/tui/src/backend_banners/actions.rs @@ -0,0 +1,87 @@ +//! Map backend CTA names to existing CLI actions and desktop-equivalent browser destinations. + +use super::BackendBanner; +use codex_app_server_protocol::AddCreditsNudgeCreditType; +use codex_protocol::account::PlanType; + +const USAGE_URL: &str = "https://chatgpt.com/codex/settings/usage"; +const WORKSPACE_USAGE_URL: &str = "https://chatgpt.com/admin/usage-limits/workspace"; + +pub(super) enum BannerAction { + OpenUrl(String), + NotifyOwner(AddCreditsNudgeCreditType), + ResetUsage, +} + +impl BackendBanner { + pub(super) fn resolve_action(&self, action: &str) -> Option { + match action { + "notify_owner" | "contact_owner" => { + return Some(BannerAction::NotifyOwner( + AddCreditsNudgeCreditType::Credits, + )); + } + "request_increase" => { + let Some(destination) = self.request_url.as_deref() else { + return Some(BannerAction::NotifyOwner( + AddCreditsNudgeCreditType::UsageLimit, + )); + }; + let url = url::Url::parse(destination).ok()?; + if !matches!(url.scheme(), "https" | "http") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + { + return None; + } + return Some(BannerAction::OpenUrl(url.to_string())); + } + // Keep the existing picker and explicit confirmation before consuming a reset. + "reset_usage" => return Some(BannerAction::ResetUsage), + _ => {} + } + let destination = match action { + "add_credits" | "buy_credits" + if self.plan_type.is_some_and(PlanType::is_workspace_account) => + { + "https://chatgpt.com/admin/billing?codex_credit_action=add_credits".to_string() + } + "add_credits" | "buy_credits" => format!("{USAGE_URL}?credits_modal=true"), + "buy_reset" => "https://chatgpt.com/codex/purchase/reset".to_string(), + "view_usage" | "request_increase_usage_settings" => USAGE_URL.to_string(), + "view_workspace_usage" | "increase_spend_cap" => WORKSPACE_USAGE_URL.to_string(), + "open_plus_pricing_web" => "https://chatgpt.com/explore/plus".to_string(), + "open_pro_pricing_web" => "https://chatgpt.com/explore/pro".to_string(), + "open_pricing_dialog" => { + let mut url = url::Url::parse("https://chatgpt.com/").ok()?; + let target = if matches!(self.plan_type, Some(PlanType::Plus | PlanType::ProLite)) { + "pro" + } else { + "plus" + }; + url.query_pairs_mut() + .append_pair("cta_tab", "personal") + .append_pair("highlight_plan", target); + if self.plan_type == Some(PlanType::ProLite) { + url.query_pairs_mut().append_pair("pro_variant", "2x"); + } + url.set_fragment(Some("pricing")); + url.to_string() + } + // Desktop-only referral and Premium dialogs need their own CLI flow. + _ => return None, + }; + let mut destination = url::Url::parse(&destination).ok()?; + if destination.path().starts_with("/admin/") { + if self.account_id.is_empty() { + return None; + } + // The existing admin route selects the requested workspace before opening the modal. + destination + .query_pairs_mut() + .append_pair("account_id", &self.account_id); + } + Some(BannerAction::OpenUrl(destination.to_string())) + } +} diff --git a/codex-rs/tui/src/backend_banners/render.rs b/codex-rs/tui/src/backend_banners/render.rs new file mode 100644 index 0000000000..e4eeeb3e50 --- /dev/null +++ b/codex-rs/tui/src/backend_banners/render.rs @@ -0,0 +1,74 @@ +//! Render backend-owned copy and ordered CTA labels with the shared inline banner UI. + +use super::BackendBanner; +use super::BannerPresentation; +use super::actions::BannerAction; +use crate::app_event::AppEvent; +use crate::bottom_pane::ActionableBanner; +use crate::bottom_pane::BannerDismissal; +use crate::bottom_pane::SelectionItem; +use chrono::DateTime; +use chrono::Local; + +impl BackendBanner { + pub(crate) fn actionable_banner(&self) -> ActionableBanner { + let reset_time = self + .reset_at + .and_then(|timestamp| DateTime::from_timestamp(timestamp, /*nsecs*/ 0)) + .map(|time| { + crate::status::format_reset_timestamp(time.with_timezone(&Local), Local::now()) + }); + let copy = |text: &str| { + let text: String = text + .chars() + .filter(|c| !c.is_control() || *c == '\n') + .collect(); + match reset_time.as_deref() { + Some(reset_time) => text.replace("{time}", reset_time), + None => text, + } + }; + let actions = self + .ctas + .iter() + .filter_map(|cta| { + if cta.label.trim().is_empty() + || cta.label.len() > 256 + || cta.label.chars().any(char::is_control) + { + return None; + } + let action = self.resolve_action(&cta.action)?; + Some(SelectionItem { + name: cta.label.clone(), + actions: vec![Box::new(move |tx| { + tx.send(match &action { + BannerAction::OpenUrl(url) => { + AppEvent::OpenUrlInBrowser { url: url.clone() } + } + BannerAction::NotifyOwner(credit_type) => { + AppEvent::SendAddCreditsNudgeEmail { + credit_type: *credit_type, + } + } + BannerAction::ResetUsage => AppEvent::OpenRateLimitResetCredits, + }) + })], + dismiss_on_select: false, + ..Default::default() + }) + }) + .collect::>(); + ActionableBanner { + title: copy(&self.title), + description: copy(&self.description), + actions, + dismissal: if self.presentation == BannerPresentation::Dismissible { + BannerDismissal::Dismissible + } else { + BannerDismissal::Persistent + }, + ..Default::default() + } + } +} diff --git a/codex-rs/tui/src/backend_banners_tests.rs b/codex-rs/tui/src/backend_banners_tests.rs new file mode 100644 index 0000000000..aec08e98a7 --- /dev/null +++ b/codex-rs/tui/src/backend_banners_tests.rs @@ -0,0 +1,57 @@ +use super::BackendBanner; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn parse_rejects_more_actions_than_the_inline_banner_can_display() { + let labels: Vec<_> = (1..=8).map(|index| format!("Action {index}")).collect(); + let mut raw = json!({ + "banner_type": "usage_limit", "title": "Usage limit reached", + "description": "Choose how to continue.", + "ctas": labels.iter().map(|label| json!({ + "action": "view_usage", "label": label, + })).collect::>(), + }); + let banner = BackendBanner::parse(&raw).expect("eight actions fit"); + assert_eq!( + banner + .actionable_banner() + .actions + .into_iter() + .map(|action| action.name) + .collect::>(), + labels + ); + + raw["ctas"].as_array_mut().unwrap().push(json!({ + "action": "view_usage", "label": "Hidden ninth action", + })); + assert_eq!(BackendBanner::parse(&raw), None); +} + +#[test] +fn parse_rejects_unsupported_or_unrenderable_content() { + let valid = json!({ + "banner_type": "usage_limit", "title": "Usage limit reached", + "description": "Choose how to continue.", "ctas": [], + }); + for invalid_fields in [ + json!({"presentation":"future_mode"}), + json!({"presentation":null}), + json!({"title":" "}), + json!({"title":"x".repeat(1025)}), + json!({"title":"line\n".repeat(4)}), + json!({"description":"x".repeat(4097)}), + json!({"description":"line\n".repeat(13)}), + json!({"blocked_model_slug":""}), + json!({"blocked_model_slug":"bad\nslug"}), + json!({"fallback_model_slugs":["x".repeat(257)]}), + json!({"fallback_model_slugs":vec!["model"; 17]}), + ] { + let mut raw = valid.clone(); + raw.as_object_mut() + .unwrap() + .extend(invalid_fields.as_object().unwrap().clone()); + assert_eq!(BackendBanner::parse(&raw), None, "{invalid_fields}"); + } +} diff --git a/codex-rs/tui/src/bottom_pane/actionable_banner.rs b/codex-rs/tui/src/bottom_pane/actionable_banner.rs new file mode 100644 index 0000000000..d1e12a6257 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/actionable_banner.rs @@ -0,0 +1,259 @@ +//! Shared title, description, and CTA content for banners in the TUI bottom pane. +//! +//! Workspace notices and backend banners share the existing selection list for CTA dispatch. +//! The inline wrapper owns presentation and dismissal while preserving normal composer input. + +use super::BottomPane; +use super::BottomPaneView; +use super::SelectionItem; +use super::SelectionViewParams; +use super::list_selection_view::ListSelectionView; +use crate::render::Insets; +use crate::render::RectExt; +use crate::render::renderable::Renderable; +use crate::wrapping::word_wrap_lines; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use std::cell::Cell; + +#[derive(Default)] +pub(crate) struct ActionableBanner { + pub(crate) title: String, + pub(crate) description: String, + pub(crate) actions: Vec, + pub(crate) initial_selected_idx: Option, + pub(crate) dismissal: BannerDismissal, + pub(crate) view_id: Option<&'static str>, +} + +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub(crate) enum BannerDismissal { + #[default] + Dismissible, + Persistent, +} + +pub(super) struct InlineBanner { + content: InlineBannerContent, + dismissal: BannerDismissal, + shown: Cell, + // Current visibility is separate from whether this banner has ever been shown. + pub(super) visible: Cell, + dismissed: bool, + visible_action_count: Cell, +} + +enum InlineBannerContent { + Actions(Box), + Information { + header: Box, + hint: Line<'static>, + }, +} + +impl Renderable for InlineBanner { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.visible_action_count.set(0); + self.visible.set(!self.dismissed && !area.is_empty()); + if !self.visible.get() { + return; + } + self.shown.set(true); + match &self.content { + InlineBannerContent::Actions(view) => { + view.render(area, buf); + self.visible_action_count.set(view.rendered_item_count()); + } + InlineBannerContent::Information { header, hint } => { + let content = area.inset(Insets::tlbr( + /*top*/ 1, /*left*/ 2, /*bottom*/ 1, /*right*/ 2, + )); + header.render(content, buf); + let y = content + .y + .saturating_add(header.desired_height(content.width) + 1); + if y < area.bottom() { + Renderable::render( + hint, + Rect::new(content.x, y, content.width, /*height*/ 1), + buf, + ); + } + } + } + } + + fn desired_height(&self, width: u16) -> u16 { + if self.dismissed { + return 0; + } + match &self.content { + InlineBannerContent::Actions(view) => view.desired_height(width), + InlineBannerContent::Information { header, hint } => { + header.desired_height(width.saturating_sub(4)) + + if hint.width() == 0 { 2 } else { 4 } + } + } + } +} + +/// Multiline banner copy uses the same word wrapping for measurement and rendering. +struct BannerContent(Vec>); + +impl BannerContent { + fn wrapped_lines(&self, width: u16) -> Vec> { + let mut lines = word_wrap_lines(&self.0, width as usize); + if lines.len() > 8 { + lines.truncate(7); + lines.push("…".dim().into()); + } + lines + } +} + +impl Renderable for BannerContent { + fn render(&self, area: Rect, buf: &mut Buffer) { + let lines = self.wrapped_lines(area.width); + Widget::render(Paragraph::new(lines), area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.wrapped_lines(width).len() as u16 + } +} + +impl From for SelectionViewParams { + fn from(banner: ActionableBanner) -> Self { + let lines = banner + .title + .lines() + .map(|line| Line::from(line.to_owned().bold())) + .chain( + banner + .description + .lines() + .map(|line| Line::from(line.to_owned().dim())), + ) + .collect(); + Self { + view_id: banner.view_id, + header: Box::new(BannerContent(lines)), + items: banner.actions, + initial_selected_idx: banner.initial_selected_idx, + ..Default::default() + } + } +} + +impl BottomPane { + pub(crate) fn show_actionable_banner(&mut self, banner: ActionableBanner) { + self.show_selection_view(banner.into()); + } + + /// Display a banner above the composer without interrupting active dialogs or losing a draft. + pub(crate) fn set_inline_banner(&mut self, banner: Option) { + self.inline_banner = banner.map(|banner| { + let dismissal = banner.dismissal; + let has_actions = !banner.actions.is_empty(); + let mut params: SelectionViewParams = banner.into(); + params.allow_cancel = dismissal == BannerDismissal::Dismissible; + for item in &mut params.items { + item.dismiss_on_select = false; + } + let hint = match (dismissal, has_actions) { + (BannerDismissal::Persistent, true) => "Press a number to choose", + (BannerDismissal::Persistent, false) => "", + (BannerDismissal::Dismissible, true) => { + "Press a number to choose · esc to dismiss · type to continue" + } + (BannerDismissal::Dismissible, false) => "esc to dismiss · type to continue", + }; + let hint: Line<'static> = hint.dim().into(); + params.footer_hint = Some(hint.clone()); + InlineBanner { + content: if has_actions { + InlineBannerContent::Actions(Box::new(ListSelectionView::new( + params, + self.app_event_tx.clone(), + self.keymap.list.clone(), + ))) + } else { + InlineBannerContent::Information { + header: params.header, + hint, + } + }, + dismissal, + shown: Cell::new(false), + visible: Cell::new(false), + dismissed: false, + visible_action_count: Cell::new(0), + } + }); + self.request_redraw(); + } + + /// Observation for the owning controller; model hiding is not a dismissal. + pub(crate) fn inline_banner_lifecycle(&self) -> (bool, bool) { + self.inline_banner + .as_ref() + .map_or((false, false), |banner| { + (banner.shown.get(), banner.dismissed) + }) + } + + pub(super) fn inline_banner_accepts_dismissal(&self) -> bool { + self.inline_banner.as_ref().is_some_and(|banner| { + banner.visible.get() + && !banner.dismissed + && banner.dismissal == BannerDismissal::Dismissible + }) + } + + pub(super) fn handle_inline_banner_key(&mut self, key: KeyEvent) -> bool { + // Draft input, completion menus, and paste bursts retain all of their normal keys. + if !self.composer_is_empty() + || self.composer.popup_active() + || self.composer.is_in_paste_burst() + || self.composer_should_handle_vim_insert_escape(key) + || self.is_task_running + || key.kind != KeyEventKind::Press + || key.modifiers != KeyModifiers::NONE + { + return false; + } + let Some(banner) = self.inline_banner.as_mut() else { + return false; + }; + if !banner.visible.get() || banner.dismissed { + return false; + } + match key.code { + KeyCode::Esc if banner.dismissal == BannerDismissal::Persistent => return false, + KeyCode::Esc => { + banner.dismissed = true; + } + KeyCode::Char(digit @ '1'..='9') => { + if digit as usize - '1' as usize >= banner.visible_action_count.get() { + return false; + } + let InlineBannerContent::Actions(view) = &mut banner.content else { + return false; + }; + view.handle_key_event(key); + let _ = view.take_last_selected_index(); + } + _ => return false, + } + self.request_redraw(); + true + } +} 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 7895bfd73b..2993540424 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -273,6 +273,7 @@ pub(crate) struct ListSelectionView { name_column_width: Option, filtered_indices: Vec, last_selected_actual_idx: Option, + rendered_item_count: std::cell::Cell, header: Box, initial_selected_idx: Option, side_content: Box, @@ -408,6 +409,7 @@ impl ListSelectionView { name_column_width: params.name_column_width, filtered_indices: Vec::new(), last_selected_actual_idx: None, + rendered_item_count: std::cell::Cell::new(0), header, initial_selected_idx: params.initial_selected_idx, side_content: params.side_content, @@ -1197,6 +1199,7 @@ impl Renderable for ListSelectionView { } fn render(&self, area: Rect, buf: &mut Buffer) { + self.rendered_item_count.set(0); if area.height == 0 || area.width == 0 { return; } @@ -1316,7 +1319,7 @@ impl Renderable for ListSelectionView { width: effective_rows_width.max(1), height: list_area.height, }; - match self.row_display { + let rendered_rows = match self.row_display { SelectionRowDisplay::Wrapped => render_rows_with_col_width_mode( render_area, buf, @@ -1336,6 +1339,7 @@ impl Renderable for ListSelectionView { column_width, ), }; + self.rendered_item_count.set(rendered_rows.items); } // -- Side content (preview panel) -- @@ -1432,6 +1436,12 @@ impl Renderable for ListSelectionView { } } +impl ListSelectionView { + pub(crate) fn rendered_item_count(&self) -> usize { + self.rendered_item_count.get() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index a5ab19a640..fd2fd4a104 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -13,6 +13,8 @@ //! //! Some UI is time-based rather than input-based, such as the transient "press again to quit" //! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle. +//! Inline banners sit above the composer. Number shortcuts apply only to an empty, idle composer; +//! drafts, paste bursts, and active dialogs keep their normal input routing. use std::collections::VecDeque; use std::path::PathBuf; @@ -55,6 +57,7 @@ use std::time::Duration; use std::time::Instant; mod action_required_title; +mod actionable_banner; mod app_link_view; mod apply_patch_header; mod approval_overlay; @@ -67,6 +70,8 @@ mod status_surface_preview; mod title_setup; pub(crate) use action_required_title::ACTION_REQUIRED_PREVIEW_PREFIX; pub(crate) use action_required_title::build_action_required_title_text; +pub(crate) use actionable_banner::ActionableBanner; +pub(crate) use actionable_banner::BannerDismissal; pub(crate) use app_link_view::AppLinkElicitationTarget; pub(crate) use app_link_view::AppLinkSuggestionType; pub(crate) use app_link_view::AppLinkView; @@ -243,6 +248,7 @@ pub(crate) struct BottomPane { /// Inline status indicator shown above the composer while a task is running. status: Option, + inline_banner: Option, /// Unified exec session summary source. /// /// When a status row exists, this summary is mirrored inline in that row; @@ -313,6 +319,7 @@ impl BottomPane { disable_paste_burst, is_task_running: false, status: None, + inline_banner: None, unified_exec_footer: UnifiedExecFooter::new(), pending_input_preview: PendingInputPreview::new(), pending_thread_approvals: PendingThreadApprovals::new(), @@ -691,6 +698,9 @@ impl BottomPane { self.request_redraw(); InputResult::None } else { + if self.handle_inline_banner_key(key_event) { + return InputResult::None; + } // If a task is running and a status line is visible, allow the // configured action to interrupt even while the composer has focus. // When a popup is active, prefer dismissing it over interrupting the task. @@ -1461,7 +1471,10 @@ impl BottomPane { /// overlays or popups and not running a task. This is the safe context to /// use Esc-Esc for backtracking from the main view. pub(crate) fn is_normal_backtrack_mode(&self) -> bool { - !self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active() + !self.is_task_running + && self.view_stack.is_empty() + && !self.composer.popup_active() + && !self.inline_banner_accepts_dismissal() } /// Return true when no popups or modal views are active, regardless of task state. @@ -1815,10 +1828,22 @@ impl BottomPane { &'_ self, composer_right_reserve: u16, ) -> RenderableItem<'_> { + if (self.is_task_running || !self.view_stack.is_empty()) + && let Some(banner) = &self.inline_banner + { + banner.visible.set(false); + } if let Some(view) = self.active_view() { RenderableItem::Borrowed(view) } else { let mut flex = FlexRenderable::new(); + if let Some(banner) = self + .inline_banner + .as_ref() + .filter(|_| !self.is_task_running) + { + flex.push(/*flex*/ 0, RenderableItem::Borrowed(banner)); + } if let Some(status) = &self.status { flex.push(/*flex*/ 0, RenderableItem::Borrowed(status)); } @@ -1952,6 +1977,9 @@ impl Renderable for BottomPane { #[cfg(test)] mod tests { + #[path = "actionable_banner_tests.rs"] + mod actionable_banner_tests; + use super::*; use crate::app::app_server_requests::ResolvedAppServerRequest; use crate::app_command::AppCommand as Op; @@ -2030,6 +2058,93 @@ mod tests { }) } + #[test] + fn inline_banner_snapshot() { + let (tx, _rx) = unbounded_channel(); + let mut pane = test_pane(AppEventSender::new(tx)); + pane.set_inline_banner(Some(ActionableBanner { + title: "Choose how to continue working".to_string(), + description: "A long description wraps to fit the available terminal width.\n" + .repeat(/*n*/ 8), + actions: vec![ + SelectionItem { + name: "Open usage settings".to_string(), + ..Default::default() + }, + SelectionItem { + name: "Notify owner".to_string(), + ..Default::default() + }, + ], + ..Default::default() + })); + let width = 44; + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + pane.desired_height(width), + ); + assert_snapshot!( + "inline_banner_wrapped_and_truncated", + render_snapshot(&pane, area) + ); + assert!(!pane.is_normal_backtrack_mode()); + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(pane.is_normal_backtrack_mode()); + } + + #[test] + fn backend_banner_snapshots_and_numbered_actions() { + for (kind, action, label) in [ + ("personal_limit", "view_usage", "View usage"), + ("workspace_member_credits", "notify_owner", "Notify owner"), + ] { + let banner = crate::backend_banners::BackendBanner::parse(&serde_json::json!({ + "banner_type": kind, + "presentation": "dismissible", + "title": "Usage limit\u{7} reached", + "description": "Your included usage is depleted.\nChoose an action to continue.", + "ctas": [ + {"action": "unsupported", "label": "Hidden action"}, + {"action": "view_usage", "label": "Open usage settings"}, + {"action": action, "label": label}, + {"action": "view_usage", "label": "Extra action"} + ] + })) + .expect("valid optional banner"); + let (tx, mut rx) = unbounded_channel(); + let mut pane = test_pane(AppEventSender::new(tx)); + pane.set_inline_banner(Some(banner.actionable_banner())); + let width = 44; + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + pane.desired_height(width), + ); + assert_snapshot!( + format!("backend_banner_{kind}"), + render_snapshot(&pane, area) + ); + pane.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + let selected = match rx.try_recv().expect("numbered CTA dispatch") { + AppEvent::OpenUrlInBrowser { url } => url, + AppEvent::SendAddCreditsNudgeEmail { credit_type } => format!("{credit_type:?}"), + other => panic!("unexpected banner action: {other:?}"), + }; + assert_eq!( + selected, + if action == "view_usage" { + "https://chatgpt.com/codex/settings/usage" + } else { + "Credits" + } + ); + assert!(pane.inline_banner.is_some()); + } + } + #[derive(Default)] struct DismissibleView { id: Option<&'static str>, diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index 9481006400..aceecb92ed 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -452,6 +452,12 @@ fn adjust_start_for_wrapped_selection_visibility( /// and behavior for selection popups. /// Returns the number of terminal lines actually rendered (including the /// single-line empty placeholder when shown). +#[derive(Clone, Copy, Default)] +pub(crate) struct RenderedRows { + pub(crate) lines: u16, + pub(crate) items: usize, +} + fn render_rows_inner( area: Rect, buf: &mut Buffer, @@ -460,18 +466,21 @@ fn render_rows_inner( max_results: usize, empty_message: &str, column_width: ColumnWidthConfig, -) -> u16 { +) -> RenderedRows { if rows_all.is_empty() { if area.height > 0 { Line::from(empty_message.dim().italic()).render(area, buf); } // Count the placeholder line only when there is vertical space to draw it. - return u16::from(area.height > 0); + return RenderedRows { + lines: u16::from(area.height > 0), + items: 0, + }; } let max_items = max_results.min(rows_all.len()); if max_items == 0 { - return 0; + return RenderedRows::default(); } let desc_measure_items = max_items.min(area.height.max(1) as usize); @@ -499,6 +508,7 @@ fn render_rows_inner( // shared description column. Stop when we run out of vertical space. let mut cur_y = area.y; let mut rendered_lines: u16 = 0; + let mut rendered_items = 0; for (i, row) in rows_all.iter().enumerate().skip(start_idx).take(max_items) { if cur_y >= area.y + area.height { break; @@ -513,6 +523,7 @@ fn render_rows_inner( ); // Render the wrapped lines. + let mut rendered_item = false; for line in wrapped { if cur_y >= area.y + area.height { break; @@ -528,10 +539,17 @@ fn render_rows_inner( ); cur_y = cur_y.saturating_add(1); rendered_lines = rendered_lines.saturating_add(1); + rendered_item = true; + } + if rendered_item { + rendered_items += 1; } } - rendered_lines + RenderedRows { + lines: rendered_lines, + items: rendered_items, + } } /// Render a list of rows using the provided ScrollState, with shared styling @@ -559,6 +577,7 @@ pub(crate) fn render_rows( empty_message, ColumnWidthConfig::default(), ) + .lines } /// Render a list of rows using the provided ScrollState and explicit @@ -566,7 +585,7 @@ pub(crate) fn render_rows( /// /// This is the low-level entry point for callers that need to thread a mode /// through higher-level configuration. -/// Returns the number of terminal lines actually rendered. +/// Returns the terminal lines and selectable items actually rendered. pub(crate) fn render_rows_with_col_width_mode( area: Rect, buf: &mut Buffer, @@ -575,7 +594,7 @@ pub(crate) fn render_rows_with_col_width_mode( max_results: usize, empty_message: &str, column_width: ColumnWidthConfig, -) -> u16 { +) -> RenderedRows { render_rows_inner( area, buf, @@ -609,10 +628,12 @@ pub(crate) fn render_rows_single_line( empty_message, ColumnWidthConfig::default(), ) + .lines } /// Render a list of rows as a single line each (no wrapping), truncating overflow with an /// ellipsis while honoring the configured column width behavior. +/// Returns the terminal lines and selectable items actually rendered. pub(crate) fn render_rows_single_line_with_col_width_mode( area: Rect, buf: &mut Buffer, @@ -621,13 +642,16 @@ pub(crate) fn render_rows_single_line_with_col_width_mode( max_results: usize, empty_message: &str, column_width: ColumnWidthConfig, -) -> u16 { +) -> RenderedRows { if rows_all.is_empty() { if area.height > 0 { Line::from(empty_message.dim().italic()).render(area, buf); } // Count the placeholder line only when there is vertical space to draw it. - return u16::from(area.height > 0); + return RenderedRows { + lines: u16::from(area.height > 0), + items: 0, + }; } let visible_items = max_results @@ -686,7 +710,10 @@ pub(crate) fn render_rows_single_line_with_col_width_mode( rendered_lines = rendered_lines.saturating_add(1); } - rendered_lines + RenderedRows { + lines: rendered_lines, + items: rendered_lines as usize, + } } /// Compute the number of terminal rows required to render up to `max_results` diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__backend_banner_personal_limit.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__backend_banner_personal_limit.snap new file mode 100644 index 0000000000..c0eed8e3c8 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__backend_banner_personal_limit.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- + + Usage limit reached + Your included usage is depleted. + Choose an action to continue. + +› 1. Open usage settings + 2. View usage + 3. Extra action + + Press a number to choose · esc to dismiss + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__backend_banner_workspace_member_credits.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__backend_banner_workspace_member_credits.snap new file mode 100644 index 0000000000..5a94128004 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__backend_banner_workspace_member_credits.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- + + Usage limit reached + Your included usage is depleted. + Choose an action to continue. + +› 1. Open usage settings + 2. Notify owner + 3. Extra action + + Press a number to choose · esc to dismiss + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__inline_banner_wrapped_and_truncated.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__inline_banner_wrapped_and_truncated.snap new file mode 100644 index 0000000000..9f72caad92 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__inline_banner_wrapped_and_truncated.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- + + Choose how to continue working + A long description wraps to fit the + available terminal width. + A long description wraps to fit the + available terminal width. + A long description wraps to fit the + available terminal width. + … + +› 1. Open usage settings + 2. Notify owner + + Press a number to choose · esc to dismiss + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/tests/actionable_banner_tests.rs b/codex-rs/tui/src/bottom_pane/tests/actionable_banner_tests.rs new file mode 100644 index 0000000000..98646682b4 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/tests/actionable_banner_tests.rs @@ -0,0 +1,170 @@ +use super::super::actionable_banner::BannerDismissal; +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn hidden_banner_preserves_input_before_render_and_after_resize() { + let (tx, mut rx) = unbounded_channel(); + let mut pane = test_pane_with_disable_paste_burst( + AppEventSender::new(tx), + /*disable_paste_burst*/ true, + ); + pane.set_inline_banner(Some(ActionableBanner { + title: "Usage limit reached".into(), + actions: vec![SelectionItem { + name: "View usage".into(), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenUrlInBrowser { + url: "https://example.com/usage".into(), + }); + })], + ..Default::default() + }], + ..Default::default() + })); + let width = 70; + let hidden_area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + pane.composer.desired_height(width), + ); + let visible_area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + pane.desired_height(width), + ); + + // Keys remain composer input before the first frame and after resizing a + // previously visible banner out of view. + for render_hidden in [false, true] { + if render_hidden { + let rendered = render_snapshot(&pane, hidden_area); + assert!(!rendered.contains("View usage")); + } + pane.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + assert_eq!(pane.composer_text(), "1"); + assert!(rx.try_recv().is_err()); + pane.set_composer_text(String::new(), Vec::new(), Vec::new()); + assert!(pane.is_normal_backtrack_mode()); + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + let rendered = render_snapshot(&pane, visible_area); + assert!(rendered.contains("View usage")); + assert!(!pane.is_normal_backtrack_mode()); + pane.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + assert_eq!(pane.composer_text(), ""); + assert!( + matches!(rx.try_recv(), Ok(AppEvent::OpenUrlInBrowser { url }) + if url == "https://example.com/usage") + ); + } + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(pane.is_normal_backtrack_mode()); + assert!(!render_snapshot(&pane, visible_area).contains("View usage")); +} + +#[test] +fn partially_clipped_banner_preserves_hidden_action_shortcuts() { + let (tx, mut rx) = unbounded_channel(); + let mut pane = test_pane_with_disable_paste_burst( + AppEventSender::new(tx), + /*disable_paste_burst*/ true, + ); + pane.set_inline_banner(Some(ActionableBanner { + title: "Usage limit reached".into(), + actions: vec![SelectionItem { + name: "View usage".into(), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenUrlInBrowser { + url: "https://example.com/usage".into(), + }); + })], + ..Default::default() + }], + ..Default::default() + })); + let width = 70; + let footer_only_area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + pane.composer.desired_height(width) + 1, + ); + + let rendered = render_snapshot(&pane, footer_only_area); + assert!(!rendered.contains("View usage")); + pane.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + assert_eq!(pane.composer_text(), "1"); + assert!(rx.try_recv().is_err()); +} + +#[test] +fn banner_hidden_during_task_waits_for_redraw_before_accepting_dismissal() { + let (tx, _rx) = unbounded_channel(); + let mut pane = test_pane(AppEventSender::new(tx)); + pane.set_inline_banner(Some(ActionableBanner { + title: "Usage limit reached".into(), + ..Default::default() + })); + let width = 70; + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + pane.desired_height(width), + ); + assert!(render_snapshot(&pane, area).contains("Usage limit reached")); + + pane.set_task_running(/*running*/ true); + assert!(!render_snapshot(&pane, area).contains("Usage limit reached")); + pane.set_task_running(/*running*/ false); + assert!(pane.is_normal_backtrack_mode()); + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(render_snapshot(&pane, area).contains("Usage limit reached")); + assert!(!pane.is_normal_backtrack_mode()); +} + +#[test] +fn information_banner_preserves_input_and_honors_dismissal() { + for dismissal in [BannerDismissal::Persistent, BannerDismissal::Dismissible] { + let (tx, _rx) = unbounded_channel(); + let mut pane = test_pane_with_disable_paste_burst( + AppEventSender::new(tx), + /*disable_paste_burst*/ true, + ); + pane.set_inline_banner(Some(ActionableBanner { + title: "Usage limit reached".into(), + description: "You can change models or request more usage.".into(), + dismissal, + ..Default::default() + })); + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + /*width*/ 70, + pane.desired_height(/*width*/ 70), + ); + let rendered = render_snapshot(&pane, area); + assert!(!rendered.contains("no matches")); + assert!(!rendered.contains("Press a number")); + assert_eq!( + rendered.contains("esc to dismiss"), + dismissal == BannerDismissal::Dismissible + ); + if dismissal == BannerDismissal::Persistent { + assert_snapshot!("information_banner_persistent", rendered); + } else { + assert_snapshot!("information_banner_dismissible", rendered); + } + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + render_snapshot(&pane, area).contains("Usage limit reached"), + dismissal == BannerDismissal::Persistent + ); + pane.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + assert_eq!(pane.composer_text(), "1"); + } +} diff --git a/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__actionable_banner_tests__information_banner_dismissible.snap b/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__actionable_banner_tests__information_banner_dismissible.snap new file mode 100644 index 0000000000..9c624f0467 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__actionable_banner_tests__information_banner_dismissible.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/bottom_pane/tests/actionable_banner_tests.rs +assertion_line: 34 +expression: rendered +--- + + Usage limit reached + You can change models or request more usage. + + esc to dismiss · type to continue + + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__actionable_banner_tests__information_banner_persistent.snap b/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__actionable_banner_tests__information_banner_persistent.snap new file mode 100644 index 0000000000..f717e24265 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__actionable_banner_tests__information_banner_persistent.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/tests/actionable_banner_tests.rs +assertion_line: 32 +expression: rendered +--- + + Usage limit reached + You can change models or request more usage. + + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ab529a0fd6..4b601e5728 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -393,6 +393,7 @@ mod permission_shortcuts; mod permissions_menu; pub(crate) use self::permissions_menu::auto_review_available; pub(crate) use self::permissions_menu::cyber_model_approval_reviewer; +mod backend_banners; mod protocol; mod protocol_requests; mod rate_limits; @@ -594,9 +595,11 @@ pub(crate) struct ChatWidget { codex_rate_limit_reached_type: Option, codex_spend_control_reached: Option, rate_limit_warnings: RateLimitWarningState, + backend_banner_state: backend_banners::BackendBannerState, + backend_banner_notice_model: Option, warning_display_state: WarningDisplayState, rate_limit_switch_prompt: RateLimitSwitchPromptState, - add_credits_nudge_email_in_flight: Option, + add_credits_nudge_email_in_flight: Option, adaptive_chunking: AdaptiveChunkingPolicy, // Stream lifecycle controller stream_controller: Option, diff --git a/codex-rs/tui/src/chatwidget/backend_banners.rs b/codex-rs/tui/src/chatwidget/backend_banners.rs new file mode 100644 index 0000000000..c362658030 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/backend_banners.rs @@ -0,0 +1,180 @@ +//! Account banner state, independent of the current model and the lifetime of its rendered view. +//! +//! Only explicitly dismissible, previously shown occurrences can be dismissed. Model changes hide +//! content without dismissing it; an authoritative replacement/absence starts a new occurrence. + +use super::ChatWidget; +use crate::backend_banners::BackendBanner; +use crate::backend_banners::BannerPresentation; +use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::openai_models::ModelPreset; + +#[derive(Default)] +pub(super) struct BackendBannerState { + banner: Option, + presented: Option, + shown: bool, + dismissed: bool, +} + +impl ChatWidget { + pub(crate) fn backend_banner_fallback(&self) -> Option { + let banner = self.backend_banner_state.banner.as_ref()?; + if !self.has_chatgpt_account + || !self.config.model_provider.requires_openai_auth + || banner.blocked_model_slug.as_deref() != Some(self.current_model()) + { + return None; + } + let models = self.model_catalog.try_list_models().ok()?; + banner.fallback_model_slugs.iter().find_map(|candidate| { + models + .iter() + .find(|model| { + model.show_in_picker + && model.model == *candidate + && model.model != self.current_model() + }) + .cloned() + }) + } + + /// Commit a successfully applied task setting and keep its recovery notice visible together. + pub(crate) fn finish_backend_banner_fallback(&mut self, mode: CollaborationMode) { + self.set_model(mode.model()); + self.set_reasoning_effort(mode.reasoning_effort()); + if mode.mode == ModeKind::Plan { + self.set_plan_mode_reasoning_effort(mode.reasoning_effort()); + } + self.set_effective_collaboration_mode(mode); + // Showing the post-switch notice is distinct from having seen the original blocked state. + self.backend_banner_state.shown = false; + self.backend_banner_state.dismissed = false; + self.backend_banner_notice_model = Some(self.current_model().to_string()); + self.refresh_backend_banner_visibility(); + } + + pub(crate) fn inherit_backend_banner_state(&mut self, previous: &mut ChatWidget) { + previous.observe_backend_banner_view(); + self.backend_banner_state = std::mem::take(&mut previous.backend_banner_state); + self.backend_banner_state.presented = None; + self.refresh_backend_banner_visibility(); + } + + pub(crate) fn update_backend_banner(&mut self, response: &GetAccountRateLimitsResponse) { + self.observe_backend_banner_view(); + let banner = response + .rate_limit_upsell + .as_ref() + .and_then(BackendBanner::parse) + .map(|mut banner| { + banner.account_id = response.account_id.clone().unwrap_or_default(); + banner.plan_type = response.rate_limits.plan_type; + banner + }); + let same_occurrence = self + .backend_banner_state + .banner + .as_ref() + .zip(banner.as_ref()) + .is_some_and(|(old, new)| { + old.account_id == new.account_id + && old.banner_type == new.banner_type + && old.reset_at == new.reset_at + && old.presentation == new.presentation + && old.blocked_model_slug.as_ref().or(old.model_slug.as_ref()) + == new.blocked_model_slug.as_ref().or(new.model_slug.as_ref()) + && old.fallback_model_slugs == new.fallback_model_slugs + }); + if !same_occurrence { + self.backend_banner_state.shown = false; + self.backend_banner_state.dismissed = false; + self.backend_banner_notice_model = None; + } + self.backend_banner_state.banner = banner; + self.refresh_backend_banner_visibility(); + } + + fn observe_backend_banner_view(&mut self) { + let (shown, dismissed) = self.bottom_pane.inline_banner_lifecycle(); + self.backend_banner_state.shown |= shown; + self.backend_banner_state.dismissed |= dismissed; + } + + pub(super) fn refresh_backend_banner_visibility(&mut self) { + let banner = self.backend_banner_state.banner.as_ref().filter(|banner| { + // Explicit fallback payloads describe the selected replacement, not a pending switch. + let matches_selected_model = match banner.blocked_model_slug.as_deref() { + Some(blocked) if !banner.fallback_model_slugs.is_empty() => { + blocked != self.current_model() + && banner + .fallback_model_slugs + .iter() + .any(|model| model == self.current_model()) + } + Some(_) | None => { + banner + .model_slug + .as_deref() + .is_none_or(|model| model == self.current_model()) + || self.backend_banner_notice_model.as_deref() == Some(self.current_model()) + } + }; + !self.backend_banner_state.dismissed && matches_selected_model + }); + if banner == self.backend_banner_state.presented.as_ref() { + return; + } + let content = banner.map(BackendBanner::actionable_banner); + self.backend_banner_state.presented = banner.cloned(); + if content.is_some() { + self.bottom_pane + .dismiss_view_by_id(super::rate_limits::WORKSPACE_NUDGE_VIEW_ID); + if self + .bottom_pane + .dismiss_view_by_id(super::rate_limits::RATE_LIMIT_SWITCH_PROMPT_VIEW_ID) + { + // Replacing the view is not the user's decision to dismiss the prompt. + self.rate_limit_switch_prompt = super::RateLimitSwitchPromptState::Pending; + } + } + self.bottom_pane.set_inline_banner(content); + if self.backend_banner_state.presented.is_none() + && !self.bottom_pane.is_task_running() + && self.bottom_pane.no_modal_or_popup_active() + { + self.maybe_show_pending_rate_limit_prompt(); + } + } + + pub(super) fn sync_backend_banner_view(&mut self) { + self.observe_backend_banner_view(); + self.refresh_backend_banner_visibility(); + } + + pub(super) fn dismiss_backend_banner_for_new_turn(&mut self) { + self.observe_backend_banner_view(); + if self.backend_banner_state.shown + && self + .backend_banner_state + .presented + .as_ref() + .is_some_and(|banner| banner.presentation == BannerPresentation::Dismissible) + { + self.backend_banner_state.dismissed = true; + self.refresh_backend_banner_visibility(); + } + } + + pub(super) fn has_applicable_backend_banner(&self) -> bool { + !self.backend_banner_state.dismissed && self.backend_banner_state.presented.is_some() + } + + pub(crate) fn clear_backend_banner(&mut self) { + self.backend_banner_state = BackendBannerState::default(); + self.backend_banner_notice_model = None; + self.bottom_pane.set_inline_banner(/*banner*/ None); + } +} diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 7c3dec2ae6..24e84747ee 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -147,6 +147,8 @@ impl ChatWidget { codex_rate_limit_reached_type: None, codex_spend_control_reached: None, rate_limit_warnings: RateLimitWarningState::default(), + backend_banner_state: backend_banners::BackendBannerState::default(), + backend_banner_notice_model: None, warning_display_state: WarningDisplayState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), add_credits_nudge_email_in_flight: None, diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index 5daab24c48..2e056558fa 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -32,6 +32,7 @@ impl ChatWidget { let should_submit_now = self.is_session_configured() && !self.is_plan_streaming_in_tui() && !self.input_queue.suppress_queue_autosend + && !self.input_queue.rate_limit_recovery_pending && (!self.input_queue.user_turn_pending_start || self.turn_lifecycle.agent_turn_running); if should_submit_now { @@ -114,7 +115,8 @@ impl ChatWidget { } let should_run_now = self.is_session_configured() && !self.is_user_turn_pending_or_running() - && !self.input_queue.suppress_queue_autosend; + && !self.input_queue.suppress_queue_autosend + && !self.input_queue.rate_limit_recovery_pending; if !should_run_now || action != QueuedInputAction::Plain { self.input_queue .queued_user_messages @@ -140,6 +142,7 @@ impl ChatWidget { if !self.is_session_configured() || self.misalignment_policy_violation || self.input_queue.suppress_queue_autosend + || self.input_queue.rate_limit_recovery_pending { return false; } diff --git a/codex-rs/tui/src/chatwidget/input_queue.rs b/codex-rs/tui/src/chatwidget/input_queue.rs index 67a306af22..f14059e3ca 100644 --- a/codex-rs/tui/src/chatwidget/input_queue.rs +++ b/codex-rs/tui/src/chatwidget/input_queue.rs @@ -42,6 +42,8 @@ pub(super) struct InputQueueState { /// fresh user turn instead of restoring them into the composer. pub(super) submit_pending_steers_after_interrupt: bool, pub(super) suppress_queue_autosend: bool, + /// Hold submissions while a usage failure or backend-directed model fallback is resolved. + pub(super) rate_limit_recovery_pending: bool, } impl InputQueueState { @@ -57,6 +59,7 @@ impl InputQueueState { self.rejected_steer_history_records.clear(); self.pending_steers.clear(); self.submit_pending_steers_after_interrupt = false; + self.rate_limit_recovery_pending = false; } pub(super) fn preview(&self) -> PendingInputPreview { diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index 28b88f89d1..89d93a6cb0 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -118,7 +118,8 @@ impl ChatWidget { } pub(crate) fn submit_initial_user_message_if_pending(&mut self) { - if self.suppress_initial_user_message_submit { + if self.suppress_initial_user_message_submit || self.input_queue.rate_limit_recovery_pending + { return; } #[cfg(any(target_os = "windows", test))] diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs index 1fc8b6ba04..8571519c7f 100644 --- a/codex-rs/tui/src/chatwidget/input_submission.rs +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -114,6 +114,16 @@ impl ChatWidget { if self.misalignment_policy_violation { return (false, None); } + if self.input_queue.rate_limit_recovery_pending { + self.input_queue + .queued_user_messages + .push_back(QueuedUserMessage::from(user_message)); + self.input_queue + .queued_user_message_history_records + .push_back(history_record); + self.refresh_pending_input_preview(); + return (true, None); + } if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.input_queue @@ -394,6 +404,7 @@ impl ChatWidget { if !self.submit_op(op.clone()) { return (false, None); } + self.dismiss_backend_banner_for_new_turn(); if render_in_history { self.input_queue.user_turn_pending_start = true; } diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index b38e33b56f..e36dcb582d 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -180,6 +180,7 @@ impl ChatWidget { self.flush_completed_command_activity(); } let input_result = self.bottom_pane.handle_key_event(key_event); + self.sync_backend_banner_view(); if should_pause_active_goal { self.pause_active_goal_for_interrupt(); } diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index d8cd725a32..255b8e2897 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -1,7 +1,15 @@ //! Rate-limit warning, prompt, and notice surfaces for `ChatWidget`. use super::*; +pub(super) const WORKSPACE_NUDGE_VIEW_ID: &str = "workspace-usage-nudge"; +use crate::bottom_pane::ActionableBanner; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use uuid::Uuid; + +pub(super) struct PendingCreditsNudge { + request_id: Uuid, + credit_type: AddCreditsNudgeCreditType, +} pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.6-luna"; pub(super) const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; @@ -162,11 +170,31 @@ fn has_usable_workspace_credits(credits: &CreditsSnapshot) -> bool { } impl ChatWidget { + pub(crate) fn hold_rate_limit_recovery(&mut self) -> bool { + std::mem::replace(&mut self.input_queue.rate_limit_recovery_pending, true) + } + + pub(crate) fn finish_rate_limit_recovery(&mut self) { + if std::mem::take(&mut self.input_queue.rate_limit_recovery_pending) { + self.submit_initial_user_message_if_pending(); + self.maybe_send_next_queued_input(); + } + } + pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option) { self.on_rate_limit_snapshot_from(snapshot, RateLimitSnapshotSource::AccountUsage); } pub(crate) fn on_rolling_rate_limit_snapshot(&mut self, snapshot: RateLimitSnapshot) { + if let (Some(previous), Some(current)) = ( + self.codex_rate_limit_reached_type, + snapshot.rate_limit_reached_type, + ) && previous != current + { + // Do not keep a credits CTA when a newer notification requires a different remedy. + // An absent aggregate limit type is not a contradiction of model-scoped recovery. + self.clear_backend_banner(); + } // Rolling app-server notifications are sparse. Preserve metadata learned from the full read. self.on_rate_limit_snapshot_from(Some(snapshot), RateLimitSnapshotSource::RollingUpdate); } @@ -347,7 +375,10 @@ impl ChatWidget { } pub(super) fn maybe_show_pending_rate_limit_prompt(&mut self) { - if self.rate_limit_switch_prompt_hidden() { + if self.has_applicable_backend_banner() { + return; + } + if self.rate_limit_switch_prompt_hidden() || self.current_model() == NUDGE_MODEL_SLUG { self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; return; } @@ -482,11 +513,11 @@ impl ChatWidget { }, ]; - 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, + self.bottom_pane.show_actionable_banner(ActionableBanner { + view_id: Some(WORKSPACE_NUDGE_VIEW_ID), + title: title.to_string(), + description: prompt.to_string(), + actions: items, initial_selected_idx: Some(1), ..Default::default() }); @@ -495,19 +526,32 @@ impl ChatWidget { pub(crate) fn start_add_credits_nudge_email_request( &mut self, credit_type: AddCreditsNudgeCreditType, - ) -> bool { - self.add_credits_nudge_email_in_flight = Some(credit_type); - true + ) -> Option { + if self.add_credits_nudge_email_in_flight.is_some() { + return None; + } + let request_id = Uuid::new_v4(); + self.add_credits_nudge_email_in_flight = Some(PendingCreditsNudge { + request_id, + credit_type, + }); + Some(request_id) } pub(crate) fn finish_add_credits_nudge_email_request( &mut self, + request_id: Uuid, result: Result, ) { - let credit_type = self + let Some(pending) = self .add_credits_nudge_email_in_flight - .take() - .unwrap_or(AddCreditsNudgeCreditType::Credits); + .as_ref() + .filter(|pending| pending.request_id == request_id) + else { + return; + }; + let credit_type = pending.credit_type; + self.add_credits_nudge_email_in_flight = None; let message = match (credit_type, result) { (AddCreditsNudgeCreditType::Credits, Ok(AddCreditsNudgeEmailStatus::Sent)) => { "Workspace owner notified." diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index e4e910523b..ce996c784a 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -25,6 +25,7 @@ impl ChatWidget { self.bottom_pane .set_queue_submissions(/*queue_submissions*/ false); if previous_thread_id != self.thread_id { + self.backend_banner_notice_model = None; self.pending_automatic_thread_names.clear(); self.review.recent_auto_review_denials = RecentAutoReviewDenials::default(); self.clear_thread_usage_state(); diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 75f3e8fba4..74d3e7f15d 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -220,6 +220,9 @@ impl ChatWidget { self.invalidate_connector_scope(); self.clear_pending_token_activity_refreshes(); self.clear_pending_rate_limit_reset_requests(); + self.clear_backend_banner(); + self.input_queue.rate_limit_recovery_pending = false; + self.add_credits_nudge_email_in_flight = None; self.codex_rate_limit_reached_type = None; self.codex_spend_control_reached = None; self.rate_limit_warnings = RateLimitWarningState::default(); @@ -446,6 +449,7 @@ impl ChatWidget { /// header/title (`refresh_model_display`) but forget the footer status line /// (`refresh_status_line`). pub(super) fn refresh_model_dependent_surfaces(&mut self) { + self.sync_backend_banner_view(); self.refresh_model_display(); self.refresh_status_line(); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 2de6b97774..d59533eb29 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -233,6 +233,8 @@ fn next_goal_draft( mod app_server; mod approval_requests; +#[path = "tests/backend_banners_tests.rs"] +mod backend_banners_tests; mod composer_submission; #[path = "tests/config_errors_tests.rs"] mod config_errors; @@ -252,6 +254,8 @@ mod plan_mode; #[path = "tests/plugin_catalog_tests.rs"] mod plugin_catalog; mod popups_and_settings; +#[path = "tests/rate_limit_recovery_tests.rs"] +mod rate_limit_recovery_tests; mod review_mode; mod side; mod slash_commands; diff --git a/codex-rs/tui/src/chatwidget/tests/backend_banners_tests.rs b/codex-rs/tui/src/chatwidget/tests/backend_banners_tests.rs new file mode 100644 index 0000000000..b3ed272fb9 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/backend_banners_tests.rs @@ -0,0 +1,419 @@ +use super::*; +use codex_app_server_protocol::GetAccountRateLimitsResponse; +use pretty_assertions::assert_eq; +use serde_json::json; + +fn banner_response( + presentation: Option<&str>, + ctas: serde_json::Value, +) -> GetAccountRateLimitsResponse { + let mut banner = json!({ + "banner_type": "selected_model_limit", "model_slug": "test-model-a", + "title": "Selected model usage exhausted", + "description": "Ask your owner for credits or switch to another model.", "ctas": ctas, + }); + if let Some(presentation) = presentation { + banner["presentation"] = json!(presentation); + } + GetAccountRateLimitsResponse { + account_id: Some("workspace-a".into()), + rate_limit_upsell: Some(banner), + rate_limits: snapshot(/*percent*/ 25.0), + rate_limits_by_limit_id: None, + rate_limit_reset_credits: None, + } +} + +#[tokio::test] +async fn backend_banner_presentation_and_cta_do_not_imply_recovery() { + for presentation in [None, Some("inline"), Some("dismissible")] { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(Some("test-model-a")).await; + let response = banner_response( + presentation, + json!([{"action":"notify_owner","label":"Notify owner"}]), + ); + chat.update_backend_banner(&response); + let initial = render_bottom_popup(&chat, /*width*/ 70); + assert!(initial.contains("Selected model usage exhausted")); + assert_eq!( + initial.contains("esc to dismiss"), + presentation == Some("dismissible") + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + assert!( + rx.try_recv() + .is_ok_and(|event| matches!(event, AppEvent::SendAddCreditsNudgeEmail { .. })) + ); + assert_eq!(render_bottom_popup(&chat, /*width*/ 70), initial); + let request_id = chat + .start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::Credits) + .expect("start notification"); + if presentation == Some("inline") { + insta::assert_snapshot!( + "backend_banner_notification", + normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 70)) + ); + } + chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + assert!( + rx.try_recv() + .is_ok_and(|event| matches!(event, AppEvent::SendAddCreditsNudgeEmail { .. })) + ); + assert_eq!(chat.composer_text_with_pending(), ""); + assert!( + chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::Credits) + .is_none() + ); + chat.finish_add_credits_nudge_email_request(request_id, Err("local failure".into())); + chat.update_backend_banner(&response); + assert_eq!(render_bottom_popup(&chat, /*width*/ 70), initial); + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted"), + presentation != Some("dismissible") + ); + chat.set_model("test-model-b"); + assert!(!chat.has_applicable_backend_banner()); + assert!( + !render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted") + ); + chat.set_model("test-model-a"); + assert_eq!( + render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted"), + presentation != Some("dismissible") + ); + } +} + +#[tokio::test] +async fn backend_banner_zero_actions_preserve_guidance_and_composer_input() { + for presentation in [None, Some("inline"), Some("dismissible")] { + for ctas in [ + json!([]), + json!([{"action":"future_action","label":"Unsupported"}]), + ] { + let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("test-model-a")).await; + chat.update_backend_banner(&banner_response(presentation, ctas)); + let rendered = render_bottom_popup(&chat, /*width*/ 70); + assert!(rendered.contains("Selected model usage exhausted")); + assert!(rendered.contains("switch to another model")); + assert!(!rendered.to_lowercase().contains("no matches")); + assert!(!rendered.contains("Press a number")); + assert_eq!( + rendered.contains("esc to dismiss"), + presentation == Some("dismissible") + ); + if presentation == Some("dismissible") { + insta::assert_snapshot!( + "backend_banner_information_dismissible", + normalize_snapshot_paths(rendered) + ); + } else { + insta::assert_snapshot!( + "backend_banner_information_only", + normalize_snapshot_paths(rendered) + ); + } + chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + // An ordinary navigation key flushes the composer's pending first-character buffer. + chat.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!(chat.composer_text_with_pending(), "1"); + } + } +} + +#[tokio::test] +async fn backend_banner_dismissal_tracks_occurrence_not_copy_or_model() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("test-model-a")).await; + let mut response = banner_response(Some("dismissible"), json!([])); + chat.update_backend_banner(&response); + assert!(render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted")); + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + chat.set_model("test-model-b"); + response.rate_limit_upsell.as_mut().unwrap()["description"] = json!("Updated backend copy"); + chat.update_backend_banner(&response); + chat.set_model("test-model-a"); + assert!(!render_bottom_popup(&chat, /*width*/ 70).contains("Updated backend copy")); + response.rate_limit_upsell.as_mut().unwrap()["reset_at"] = json!(2000000000); + chat.update_backend_banner(&response); + assert!(render_bottom_popup(&chat, /*width*/ 70).contains("Updated backend copy")); + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let mut absent = response.clone(); + absent.rate_limit_upsell = None; + chat.update_backend_banner(&absent); + chat.update_backend_banner(&response); + assert!(render_bottom_popup(&chat, /*width*/ 70).contains("Updated backend copy")); +} + +#[tokio::test] +async fn backend_banner_occurrence_uses_explicit_or_legacy_source_model() { + for explicit_source in [false, true] { + let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("test-model-a")).await; + let mut response = banner_response(Some("dismissible"), json!([])); + if explicit_source { + response.rate_limit_upsell.as_mut().unwrap()["blocked_model_slug"] = + json!("test-model-a"); + } + chat.update_backend_banner(&response); + assert!( + render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted") + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + response.rate_limit_upsell.as_mut().unwrap()["model_slug"] = json!("test-model-b"); + chat.update_backend_banner(&response); + chat.set_model("test-model-b"); + assert_eq!( + render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted"), + !explicit_source + ); + } +} + +#[tokio::test] +async fn backend_banner_turn_on_another_model_preserves_hidden_occurrence() { + let (mut chat, _rx, mut ops) = make_chatwidget_manual(Some("test-model-a")).await; + chat.thread_id = Some(ThreadId::new()); + chat.update_backend_banner(&banner_response(Some("dismissible"), json!([]))); + assert!(render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted")); + chat.set_model("test-model-b"); + assert!(!render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted")); + chat.submit_user_message(UserMessage::from("hello")); + assert!( + ops.try_recv() + .is_ok_and(|op| matches!(op, Op::UserTurn { .. })) + ); + handle_turn_started(&mut chat, "turn-1"); + handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None); + chat.set_model("test-model-a"); + assert!(render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted")); +} + +#[tokio::test] +async fn backend_banner_restores_only_programmatically_displaced_switch_prompt() { + for user_dismissed in [false, true] { + let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("gpt-5")).await; + chat.has_chatgpt_account = true; + chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 95.0))); + chat.maybe_show_pending_rate_limit_prompt(); + assert!(render_bottom_popup(&chat, /*width*/ 90).contains("Approaching rate limits")); + if user_dismissed { + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + } + let mut response = banner_response(Some("inline"), json!([])); + response.rate_limit_upsell.as_mut().unwrap()["model_slug"] = json!("gpt-5"); + chat.update_backend_banner(&response); + assert!( + render_bottom_popup(&chat, /*width*/ 90).contains("Selected model usage exhausted") + ); + response.rate_limit_upsell = None; + chat.update_backend_banner(&response); + assert_eq!( + render_bottom_popup(&chat, /*width*/ 90).contains("Approaching rate limits"), + !user_dismissed + ); + } +} + +#[tokio::test] +async fn owner_notification_completion_cannot_cross_account_change() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + let previous = chat + .start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::Credits) + .expect("start previous account request"); + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + let current = chat + .start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::UsageLimit) + .expect("new account must be actionable"); + drain_insert_history(&mut rx); + chat.finish_add_credits_nudge_email_request(previous, Ok(AddCreditsNudgeEmailStatus::Sent)); + assert!(drain_insert_history(&mut rx).is_empty()); + assert!( + chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::UsageLimit) + .is_none() + ); + chat.finish_add_credits_nudge_email_request(current, Ok(AddCreditsNudgeEmailStatus::Sent)); + let rendered = drain_insert_history(&mut rx) + .into_iter() + .map(|lines| lines_to_single_string(&lines)) + .collect::(); + insta::assert_snapshot!("owner_notification_after_account_change", rendered); +} + +#[tokio::test] +async fn backend_banner_new_turn_dismisses_only_shown_dismissible_content() { + for (presentation, show_before_submit) in [ + (None, true), + (Some("inline"), true), + (Some("dismissible"), true), + (Some("dismissible"), false), + ] { + let (mut chat, _rx, mut ops) = make_chatwidget_manual(Some("test-model-a")).await; + chat.thread_id = Some(ThreadId::new()); + chat.update_backend_banner(&banner_response(presentation, json!([]))); + if show_before_submit { + assert!( + render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted") + ); + } + chat.submit_user_message(UserMessage::from("hello")); + assert!( + ops.try_recv() + .is_ok_and(|op| matches!(op, Op::UserTurn { .. })) + ); + handle_turn_started(&mut chat, "turn-1"); + handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None); + assert_eq!( + render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted"), + !(presentation == Some("dismissible") && show_before_submit) + ); + } +} + +#[tokio::test] +async fn backend_banner_invalid_content_and_absence_restore_fallback() { + for replacement in [ + serde_json::Value::Null, + json!({"presentation":"future_mode"}), + json!({"presentation":null}), + json!({"title":" "}), + ] { + let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("test-model-a")).await; + let mut response = banner_response(Some("inline"), json!([])); + chat.update_backend_banner(&response); + assert!( + render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted") + ); + if replacement.is_null() { + response.rate_limit_upsell = None; + } else { + for (key, value) in replacement.as_object().unwrap() { + response.rate_limit_upsell.as_mut().unwrap()[key.as_str()] = value.clone(); + } + } + chat.update_backend_banner(&response); + assert!(!chat.has_applicable_backend_banner()); + chat.codex_rate_limit_reached_type = + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted); + chat.on_rate_limit_error(RateLimitErrorKind::Generic, "limit".into()); + assert!(render_bottom_popup(&chat, /*width*/ 90).contains("workspace owner")); + } +} + +#[tokio::test] +async fn backend_banner_fallback_candidates_and_notice_follow_selected_model() { + let (mut chat, _events, _ops) = make_chatwidget_manual(Some("test-model-a")).await; + chat.has_chatgpt_account = true; + let template = chat.model_catalog.try_list_models().unwrap()[0].clone(); + let models = ["hidden-model", "test-model-b", "test-model-c"].map(|model| ModelPreset { + model: model.into(), + show_in_picker: model != "hidden-model", + ..template.clone() + }); + chat.model_catalog = Arc::new(ModelCatalog::new(models.to_vec())); + let mut response = banner_response(Some("inline"), json!([])); + response.rate_limit_upsell.as_mut().unwrap()["blocked_model_slug"] = json!("test-model-a"); + response.rate_limit_upsell.as_mut().unwrap()["fallback_model_slugs"] = + json!(["hidden-model", "test-model-c", "test-model-b"]); + chat.update_backend_banner(&response); + assert_eq!(chat.backend_banner_fallback(), Some(models[2].clone())); + // A retained ChatGPT login must not change a task using a custom provider. + chat.config.model_provider.requires_openai_auth = false; + assert_eq!(chat.backend_banner_fallback(), None); + chat.config.model_provider.requires_openai_auth = true; + let mut mode = chat.effective_collaboration_mode(); + mode.settings.model = "test-model-c".into(); + chat.finish_backend_banner_fallback(mode); + let before = render_bottom_popup(&chat, /*width*/ 70); + assert!(before.contains("Selected model usage exhausted")); + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted")); + assert_eq!(chat.backend_banner_fallback(), None); + let (mut other_task, _events, _ops) = make_chatwidget_manual(Some("test-model-c")).await; + other_task.inherit_backend_banner_state(&mut chat); + assert!( + render_bottom_popup(&other_task, /*width*/ 70).contains("Selected model usage exhausted") + ); + other_task.set_model("test-model-a"); + assert!( + !render_bottom_popup(&other_task, /*width*/ 70).contains("Selected model usage exhausted") + ); + other_task.set_model("unrelated-model"); + assert!( + !render_bottom_popup(&other_task, /*width*/ 70).contains("Selected model usage exhausted") + ); + other_task.set_model("test-model-c"); + assert!( + render_bottom_popup(&other_task, /*width*/ 70).contains("Selected model usage exhausted") + ); +} + +#[tokio::test] +async fn backend_banner_sparse_updates_preserve_visible_and_dismissed_occurrences() { + for dismiss in [false, true] { + let (mut chat, _events, _ops) = make_chatwidget_manual(Some("test-model-a")).await; + chat.update_backend_banner(&banner_response(Some("dismissible"), json!([]))); + assert!( + render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted") + ); + if dismiss { + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + } + let before = render_bottom_popup(&chat, /*width*/ 70); + chat.on_rolling_rate_limit_snapshot(snapshot(/*percent*/ 60.0)); + assert_eq!(render_bottom_popup(&chat, /*width*/ 70), before); + // A model-scoped banner can coexist with an absent aggregate reached type. + let mut exhausted = snapshot(/*percent*/ 100.0); + exhausted.rate_limit_reached_type = + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted); + chat.on_rolling_rate_limit_snapshot(exhausted); + assert_eq!(render_bottom_popup(&chat, /*width*/ 70), before); + } +} + +#[tokio::test] +async fn backend_banner_changed_remedy_keeps_fallback_until_applicable_replacement() { + for rolling_update in [false, true] { + let (mut chat, _events, _ops) = make_chatwidget_manual(Some("test-model-a")).await; + let mut credits = snapshot(/*percent*/ 100.0); + credits.rate_limit_reached_type = + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted); + chat.on_rate_limit_snapshot(Some(credits)); + let mut response = banner_response( + Some("inline"), + json!([{"action":"notify_owner","label":"Notify owner"}]), + ); + chat.update_backend_banner(&response); + assert!(render_bottom_popup(&chat, /*width*/ 90).contains("Notify owner")); + let mut cap = snapshot(/*percent*/ 100.0); + cap.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); + if rolling_update { + chat.on_rolling_rate_limit_snapshot(cap); + } + chat.on_rate_limit_error(RateLimitErrorKind::UsageLimit, "Usage cap reached".into()); + assert!( + render_bottom_popup(&chat, /*width*/ 90) + .contains("Request a limit increase from your owner") + ); + response.rate_limit_upsell.as_mut().unwrap()["title"] = + json!("Workspace spending cap reached"); + response.rate_limit_upsell.as_mut().unwrap()["ctas"] = + json!([{"action":"request_increase","label":"Request more usage"}]); + chat.update_backend_banner(&response); + let rendered = render_bottom_popup(&chat, /*width*/ 90); + assert!( + rendered.contains("Workspace spending cap reached"), + "{rendered}" + ); + assert!(rendered.contains("Request more usage")); + assert!(!rendered.contains("Request a limit increase from your owner")); + chat.set_model("test-model-b"); + chat.on_rate_limit_error(RateLimitErrorKind::UsageLimit, "Usage cap reached".into()); + assert!( + render_bottom_popup(&chat, /*width*/ 90) + .contains("Request a limit increase from your owner") + ); + } +} diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 73a33d83a7..33c46c33de 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -35,7 +35,7 @@ pub(super) fn truncated_path_variants(path: &str) -> Vec { .collect() } -pub(super) fn normalize_snapshot_paths(text: impl Into) -> String { +pub(crate) fn normalize_snapshot_paths(text: impl Into) -> String { let mut text = text.into(); for unix_path in ["/tmp/project", "/tmp/hooks.json"] { diff --git a/codex-rs/tui/src/chatwidget/tests/rate_limit_recovery_tests.rs b/codex-rs/tui/src/chatwidget/tests/rate_limit_recovery_tests.rs new file mode 100644 index 0000000000..53d61827d5 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/rate_limit_recovery_tests.rs @@ -0,0 +1,65 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn rate_limit_recovery_holds_submissions_until_model_change() { + let (mut chat, mut events, mut ops) = make_chatwidget_manual(Some("test-model-a")).await; + set_chatgpt_auth(&mut chat); + chat.thread_id = Some(ThreadId::new()); + handle_turn_started(&mut chat, "failed-turn"); + chat.queue_user_message(UserMessage::from("queued follow-up")); + chat.on_rate_limit_error(RateLimitErrorKind::UsageLimit, "Usage exhausted".into()); + assert!( + std::iter::from_fn(|| events.try_recv().ok()).any(|event| matches!( + event, + AppEvent::RefreshRateLimits { + origin: crate::app_event::RateLimitRefreshOrigin::Recovery + } + )) + ); + chat.submit_user_message(UserMessage::from("submitted during recovery")); + assert_no_submit_op(&mut ops); + assert_eq!( + chat.queued_user_message_texts(), + vec!["queued follow-up", "submitted during recovery"] + ); + + chat.set_model("test-model-b"); + chat.finish_rate_limit_recovery(); + let Op::UserTurn { model, items, .. } = next_submit_op(&mut ops) else { + panic!("expected queued follow-up on the fallback model"); + }; + assert_eq!(model, "test-model-b"); + assert!( + matches!(items.as_slice(), [UserInput::Text { text, .. }] if text == "queued follow-up") + ); + assert_eq!( + chat.queued_user_message_texts(), + vec!["submitted during recovery"] + ); + chat.finish_rate_limit_recovery(); + assert_no_submit_op(&mut ops); +} + +#[tokio::test] +async fn rate_limit_recovery_preserves_settings_hold_and_clears_on_account_change() { + let (mut chat, _events, mut ops) = make_chatwidget_manual(Some("test-model-a")).await; + set_chatgpt_auth(&mut chat); + chat.thread_id = Some(ThreadId::new()); + chat.set_queue_autosend_suppressed(/*suppressed*/ true); + chat.on_rate_limit_error(RateLimitErrorKind::UsageLimit, "Usage exhausted".into()); + chat.queue_user_message(UserMessage::from("queued follow-up")); + chat.finish_rate_limit_recovery(); + assert_no_submit_op(&mut ops); + assert!(chat.input_queue.suppress_queue_autosend); + assert!(!chat.input_queue.rate_limit_recovery_pending); + + chat.on_rate_limit_error(RateLimitErrorKind::UsageLimit, "Usage exhausted".into()); + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false, + ); + assert!(!chat.input_queue.rate_limit_recovery_pending); + assert_eq!(chat.queued_user_message_texts(), vec!["queued follow-up"]); + assert_no_submit_op(&mut ops); +} diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__backend_banners_tests__backend_banner_information_dismissible.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__backend_banners_tests__backend_banner_information_dismissible.snap new file mode 100644 index 0000000000..dd73e1cfe1 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__backend_banners_tests__backend_banner_information_dismissible.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/chatwidget/tests/backend_banners_tests.rs +assertion_line: 66 +expression: rendered +--- + Selected model usage exhausted + Ask your owner for credits or switch to another model. + + esc to dismiss · type to continue + + +› Ask Codex to do anything + + test-model-a default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__backend_banners_tests__backend_banner_information_only.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__backend_banners_tests__backend_banner_information_only.snap new file mode 100644 index 0000000000..af451575ef --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__backend_banners_tests__backend_banner_information_only.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests/backend_banners_tests.rs +assertion_line: 62 +expression: rendered +--- + Selected model usage exhausted + Ask your owner for credits or switch to another model. + + +› Ask Codex to do anything + + test-model-a default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__backend_banners_tests__backend_banner_notification.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__backend_banners_tests__backend_banner_notification.snap new file mode 100644 index 0000000000..7f16786846 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__backend_banners_tests__backend_banner_notification.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/chatwidget/tests/backend_banners_tests.rs +assertion_line: 50 +expression: "render_bottom_popup(&chat, 70)" +--- + Selected model usage exhausted + Ask your owner for credits or switch to another model. + +› 1. Notify owner + + Press a number to choose + +› Ask Codex to do anything + + test-model-a default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__backend_banners_tests__owner_notification_after_account_change.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__backend_banners_tests__owner_notification_after_account_change.snap new file mode 100644 index 0000000000..94013f0814 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__backend_banners_tests__owner_notification_after_account_change.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests/backend_banners_tests.rs +expression: rendered +--- +• Limit increase requested. 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 a283433b1f..6b72898912 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1918,8 +1918,10 @@ async fn workspace_owner_credits_nudge_completion_renders_feedback() { 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; - chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::Credits); - chat.finish_add_credits_nudge_email_request(result); + let request_id = chat + .start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::Credits) + .expect("start notification"); + chat.finish_add_credits_nudge_email_request(request_id, result); let rendered = drain_insert_history(&mut rx) .into_iter() .map(|lines| lines_to_single_string(&lines)) @@ -1954,14 +1956,24 @@ async fn workspace_owner_usage_limit_nudge_completion_renders_feedback() { 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; - chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::UsageLimit); - chat.finish_add_credits_nudge_email_request(result); + let request_id = chat + .start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::UsageLimit) + .expect("start notification"); + assert!( + chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::Credits) + .is_none() + ); + chat.finish_add_credits_nudge_email_request(request_id, 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!( + chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::Credits) + .is_some() + ); } assert_chatwidget_snapshot!( diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index dcf29f8739..3d9e698e1b 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -398,6 +398,8 @@ impl ChatWidget { } pub(super) fn on_rate_limit_error(&mut self, error_kind: RateLimitErrorKind, message: String) { + // on_error can drain queued input, before the asynchronous recovery read completes. + self.input_queue.rate_limit_recovery_pending = self.has_chatgpt_account; let usage_limit_error = matches!(error_kind, RateLimitErrorKind::UsageLimit); let rate_limit_reached_type = self.codex_rate_limit_reached_type.map(|kind| { if usage_limit_error { @@ -414,31 +416,38 @@ impl ChatWidget { kind } }); + if self.codex_rate_limit_reached_type != rate_limit_reached_type { + self.clear_backend_banner(); + } self.codex_rate_limit_reached_type = rate_limit_reached_type; - match rate_limit_reached_type { - Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted) => { - self.on_error( + // Keep owner remediation in history even when the optional backend banner is unavailable. + let (message, nudge) = match rate_limit_reached_type { + Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted) => ( "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( + None, + ), + Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached) => ( "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); - } + None, + ), + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) => + (message, Some(AddCreditsNudgeCreditType::Credits)), + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) => + (message, Some(AddCreditsNudgeCreditType::UsageLimit)), + Some(RateLimitReachedType::RateLimitReached) | None => (message, None), + }; + self.on_error(message); + if !self.has_applicable_backend_banner() + && let Some(credit_type) = nudge + { + self.open_workspace_owner_nudge_prompt(credit_type); + } + if self.has_chatgpt_account { + self.app_event_tx.send(AppEvent::RefreshRateLimits { + origin: crate::app_event::RateLimitRefreshOrigin::Recovery, + }); } } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 9ca0d639bd..5cbf403654 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -106,6 +106,7 @@ mod app_server_approval_conversions; mod app_server_session; mod approval_events; mod ascii_animation; +mod backend_banners; mod bottom_pane; mod branch_summary; mod chatwidget; diff --git a/codex-rs/tui/src/status/mod.rs b/codex-rs/tui/src/status/mod.rs index 8dfced4d75..43959526fa 100644 --- a/codex-rs/tui/src/status/mod.rs +++ b/codex-rs/tui/src/status/mod.rs @@ -23,6 +23,7 @@ pub(crate) use card::new_status_output_with_rate_limits; pub(crate) use card::new_status_output_with_rate_limits_handle; pub(crate) use helpers::compose_agents_summary; pub(crate) use helpers::format_directory_display; +pub(crate) use helpers::format_reset_timestamp; pub(crate) use helpers::format_tokens_compact; pub(crate) use helpers::plan_type_display_name; pub(crate) use rate_limits::RateLimitSnapshotDisplay;