diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b15474ffc7..c37466943a 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3415,6 +3415,13 @@ "null" ] }, + "normalModelSlug": { + "description": "Normal model whose display name and reasoning options describe this quota alias.", + "type": [ + "string", + "null" + ] + }, "planType": { "anyOf": [ { 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 b6f11c36b5..b0867382e5 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 @@ -17482,6 +17482,13 @@ "null" ] }, + "normalModelSlug": { + "description": "Normal model whose display name and reasoning options describe this quota alias.", + "type": [ + "string", + "null" + ] + }, "planType": { "anyOf": [ { 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 90df043826..b5e819cb47 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 @@ -13554,6 +13554,13 @@ "null" ] }, + "normalModelSlug": { + "description": "Normal model whose display name and reasoning options describe this quota alias.", + "type": [ + "string", + "null" + ] + }, "planType": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json index 89466118d6..1f5c3ccabd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json @@ -88,6 +88,13 @@ "null" ] }, + "normalModelSlug": { + "description": "Normal model whose display name and reasoning options describe this quota alias.", + "type": [ + "string", + "null" + ] + }, "planType": { "anyOf": [ { 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 1448eaf545..b949ebc32d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json @@ -174,6 +174,13 @@ "null" ] }, + "normalModelSlug": { + "description": "Normal model whose display name and reasoning options describe this quota alias.", + "type": [ + "string", + "null" + ] + }, "planType": { "anyOf": [ { 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 3c223b2cc1..cedcdc1806 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 fc866601b4..719e533b6a 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/RateLimitSnapshot.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts index 13c1604b9e..4740f38da0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts @@ -7,7 +7,11 @@ import type { RateLimitReachedType } from "./RateLimitReachedType"; import type { RateLimitWindow } from "./RateLimitWindow"; import type { SpendControlLimitSnapshot } from "./SpendControlLimitSnapshot"; -export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, individualLimit: SpendControlLimitSnapshot | null, +export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, +/** + * Normal model whose display name and reasoning options describe this quota alias. + */ +normalModelSlug: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, individualLimit: SpendControlLimitSnapshot | null, /** * Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery. */ 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 cb60fded98..40965ed3be 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -585,6 +585,8 @@ pub struct AccountRateLimitsUpdatedNotification { pub struct RateLimitSnapshot { pub limit_id: Option, pub limit_name: Option, + /// Normal model whose display name and reasoning options describe this quota alias. + pub normal_model_slug: Option, pub primary: Option, pub secondary: Option, pub credits: Option, @@ -600,6 +602,7 @@ impl From for RateLimitSnapshot { Self { limit_id: value.limit_id, limit_name: value.limit_name, + normal_model_slug: value.normal_model_slug, primary: value.primary.map(RateLimitWindow::from), secondary: value.secondary.map(RateLimitWindow::from), credits: value.credits.map(CreditsSnapshot::from), diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index f067c2bbec..7c46d1b327 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -2798,6 +2798,7 @@ Field notes: - `usedPercent` is current usage within the OpenAI quota window. - `windowDurationMins` is the quota window length. - `resetsAt` is a Unix timestamp (seconds) for the next reset. +- `normalModelSlug` optionally identifies the normal model associated with an additional quota, forwarded from `/wham/usage`'s `normal_model_slug`. Clients can use that model's catalog name and reasoning choices without changing the quota alias used for requests. Missing metadata is `null`; it does not grant model access. - `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. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index e7017f4fb9..52ef949436 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3845,6 +3845,7 @@ mod tests { let rate_limits = RateLimitSnapshot { limit_id: Some("codex".to_string()), limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 42.5, window_minutes: Some(15), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 6624df8230..6904cfb389 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -836,6 +836,7 @@ mod tests { rate_limits: RateLimitSnapshot { limit_id: Some("codex".to_string()), limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 25, window_duration_mins: Some(15), @@ -857,6 +858,7 @@ mod tests { "rateLimits": { "limitId": "codex", "limitName": null, + "normalModelSlug": null, "primary": { "usedPercent": 25, "windowDurationMins": 15, 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 6a9d6064ac..ab29fe850f 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -255,6 +255,7 @@ async fn get_account_rate_limits_returns_snapshot( rate_limits: RateLimitSnapshot { limit_id: Some("codex".to_string()), limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 42, window_duration_mins: Some(60), @@ -283,6 +284,7 @@ async fn get_account_rate_limits_returns_snapshot( RateLimitSnapshot { limit_id: Some("codex".to_string()), limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 42, window_duration_mins: Some(60), @@ -312,6 +314,7 @@ async fn get_account_rate_limits_returns_snapshot( RateLimitSnapshot { limit_id: Some("codex_other".to_string()), limit_name: Some("codex_other".to_string()), + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 88, window_duration_mins: Some(30), diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 0c4f96395f..811bd45d43 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -565,21 +565,30 @@ impl Client { rate_limit_reached_type, )]; if let Some(additional) = payload.additional_rate_limits.flatten() { - snapshots.extend(additional.into_iter().map(|details| { - Self::make_rate_limit_snapshot( - Some(details.metered_feature), - Some(details.limit_name), - details.rate_limit.flatten().map(|rate_limit| *rate_limit), - /*credits*/ None, - /*spend_control*/ None, - plan_type, - /*rate_limit_reached_type*/ None, - ) - })); + snapshots.extend( + additional + .into_iter() + .map(|details| Self::make_additional_rate_limit_snapshot(details, plan_type)), + ); } snapshots } + fn make_additional_rate_limit_snapshot( + details: codex_backend_openapi_models::models::AdditionalRateLimitDetails, + plan_type: Option, + ) -> RateLimitSnapshot { + Self::make_rate_limit_snapshot( + Some(details.metered_feature), + Some(details.limit_name), + details.rate_limit.flatten().map(|rate_limit| *rate_limit), + /*credits*/ None, + /*spend_control*/ None, + plan_type, + /*rate_limit_reached_type*/ None, + ) + } + fn make_rate_limit_snapshot( limit_id: Option, limit_name: Option, @@ -603,6 +612,7 @@ impl Client { RateLimitSnapshot { limit_id, limit_name, + normal_model_slug: None, primary, secondary, credits: Self::map_credits(credits), @@ -953,6 +963,7 @@ mod tests { RateLimitSnapshot { limit_id: Some("codex_other".to_string()), limit_name: Some("codex_other".to_string()), + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 90.0, window_minutes: Some(60), @@ -968,6 +979,7 @@ mod tests { RateLimitSnapshot { limit_id: Some("codex".to_string()), limit_name: Some("codex".to_string()), + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 10.0, window_minutes: Some(60), 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 010fd744a4..07a6ecc5dc 100644 --- a/codex-rs/backend-client/src/client/rate_limit_resets.rs +++ b/codex-rs/backend-client/src/client/rate_limit_resets.rs @@ -42,8 +42,22 @@ impl Client { .as_ref() .and_then(|limit| limit.as_deref()) .map(|limit| limit.allowed); + let mut rate_limits = Self::rate_limit_snapshots_from_payload(payload.rate_limits); + let plan_type = rate_limits.first().and_then(|snapshot| snapshot.plan_type); + rate_limits.extend( + payload + .additional_rate_limits + .into_iter() + .flatten() + .map(|limit| { + let mut snapshot = + Self::make_additional_rate_limit_snapshot(limit.details, plan_type); + snapshot.normal_model_slug = limit.normal_model_slug; + snapshot + }), + ); Ok(RateLimitsWithResetCredits { - rate_limits: Self::rate_limit_snapshots_from_payload(payload.rate_limits), + rate_limits, ordinary_usage_allowed, rate_limit_reset_credits: payload.rate_limit_reset_credits, account_id: payload.account_id, diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 4e1511c3b3..791f9c4ace 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -56,6 +56,9 @@ pub struct RateLimitsWithResetCredits { pub(crate) struct RateLimitStatusWithResetCredits { #[serde(flatten)] pub rate_limits: RateLimitStatusPayload, + // Capture the new optional metadata alongside the generated quota fields until the + // backend OpenAPI export includes it. This field owns additional_rate_limits on the wire. + pub additional_rate_limits: Option>, pub rate_limit_reset_credits: Option, pub account_id: Option, pub user_id: Option, @@ -63,6 +66,14 @@ pub(crate) struct RateLimitStatusWithResetCredits { pub rate_limit_upsell: Option, } +/// Additional quota details plus the normal model whose picker metadata should be used. +#[derive(Clone, Debug, Deserialize, PartialEq)] +pub(crate) struct AdditionalRateLimitWithNormalModel { + #[serde(flatten)] + pub details: codex_backend_openapi_models::models::AdditionalRateLimitDetails, + pub normal_model_slug: Option, +} + #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] pub struct CodexWorkspaceMessagesResponse { #[serde(default)] diff --git a/codex-rs/codex-api/src/rate_limits.rs b/codex-rs/codex-api/src/rate_limits.rs index d0f936a81f..4fcfc4ccd6 100644 --- a/codex-rs/codex-api/src/rate_limits.rs +++ b/codex-rs/codex-api/src/rate_limits.rs @@ -90,6 +90,7 @@ pub fn parse_rate_limit_for_limit( Some(RateLimitSnapshot { limit_id: Some(normalized_limit_id), limit_name: parsed_limit_name, + normal_model_slug: None, primary, secondary, credits, @@ -156,6 +157,7 @@ pub fn parse_rate_limit_event(payload: &str) -> Option { Some(RateLimitSnapshot { limit_id: Some(limit_id.unwrap_or_else(|| "codex".to_string())), limit_name: None, + normal_model_slug: None, primary, secondary, credits, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 75dd964962..c1b00dfa26 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4506,6 +4506,7 @@ async fn set_rate_limits_retains_previous_credits() { let initial = RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 10.0, window_minutes: Some(15), @@ -4527,6 +4528,7 @@ async fn set_rate_limits_retains_previous_credits() { let update = RateLimitSnapshot { limit_id: Some("codex_other".to_string()), limit_name: Some("codex_other".to_string()), + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 40.0, window_minutes: Some(30), @@ -4550,6 +4552,7 @@ async fn set_rate_limits_retains_previous_credits() { Some(RateLimitSnapshot { limit_id: Some("codex_other".to_string()), limit_name: Some("codex_other".to_string()), + normal_model_slug: None, primary: update.primary.clone(), secondary: update.secondary, credits: initial.credits, @@ -4622,6 +4625,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { let initial = RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 15.0, window_minutes: Some(20), @@ -4647,6 +4651,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { let update = RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 35.0, window_minutes: Some(25), @@ -4666,6 +4671,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { Some(RateLimitSnapshot { limit_id: Some("codex".to_string()), limit_name: None, + normal_model_slug: None, primary: update.primary, secondary: update.secondary, credits: initial.credits, diff --git a/codex-rs/core/src/state/session_tests.rs b/codex-rs/core/src/state/session_tests.rs index 533bd2df26..5d3defeefe 100644 --- a/codex-rs/core/src/state/session_tests.rs +++ b/codex-rs/core/src/state/session_tests.rs @@ -89,6 +89,7 @@ async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() { state.set_rate_limits(RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 12.0, window_minutes: Some(60), @@ -135,6 +136,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc state.set_rate_limits(RateLimitSnapshot { limit_id: Some("codex_other".to_string()), limit_name: Some("codex_other".to_string()), + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 20.0, window_minutes: Some(60), @@ -150,6 +152,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc state.set_rate_limits(RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 30.0, window_minutes: Some(60), @@ -180,6 +183,7 @@ async fn set_rate_limits_carries_account_metadata_from_codex_to_codex_other() { state.set_rate_limits(RateLimitSnapshot { limit_id: Some("codex".to_string()), limit_name: Some("codex".to_string()), + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 10.0, window_minutes: Some(60), @@ -205,6 +209,7 @@ async fn set_rate_limits_carries_account_metadata_from_codex_to_codex_other() { state.set_rate_limits(RateLimitSnapshot { limit_id: Some("codex_other".to_string()), limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 30.0, window_minutes: Some(120), @@ -223,6 +228,7 @@ async fn set_rate_limits_carries_account_metadata_from_codex_to_codex_other() { Some(RateLimitSnapshot { limit_id: Some("codex_other".to_string()), limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 30.0, window_minutes: Some(120), @@ -249,6 +255,7 @@ async fn set_rate_limits_carries_account_metadata_from_codex_to_codex_other() { state.set_rate_limits(RateLimitSnapshot { limit_id: Some("codex_other".to_string()), limit_name: None, + normal_model_slug: None, primary: None, secondary: None, credits: None, diff --git a/codex-rs/memories/write/src/guard_tests.rs b/codex-rs/memories/write/src/guard_tests.rs index 551f36a52b..5271070cc6 100644 --- a/codex-rs/memories/write/src/guard_tests.rs +++ b/codex-rs/memories/write/src/guard_tests.rs @@ -8,6 +8,7 @@ fn snapshot( RateLimitSnapshot { limit_id: Some(crate::guard_limits::CODEX_LIMIT_ID.to_string()), limit_name: None, + normal_model_slug: None, primary: primary_used_percent.map(window), secondary: secondary_used_percent.map(window), credits: None, diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index 1b4cf067fc..be0192f61a 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -650,6 +650,8 @@ pub struct UsageLimitReachedError { impl std::fmt::Display for UsageLimitReachedError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Reserve is a fallback for exhausted ordinary usage, so keep the standard + // promo/plan recovery copy below instead of suggesting another model. if let Some(limit_name) = self .rate_limits .as_ref() @@ -657,6 +659,7 @@ impl std::fmt::Display for UsageLimitReachedError { .map(str::trim) .filter(|name| !name.is_empty()) && !limit_name.eq_ignore_ascii_case("codex") + && !limit_name.eq_ignore_ascii_case("gpt-reserve") { return write!( f, diff --git a/codex-rs/protocol/src/error_tests.rs b/codex-rs/protocol/src/error_tests.rs index 2590994653..ca0b7b2cff 100644 --- a/codex-rs/protocol/src/error_tests.rs +++ b/codex-rs/protocol/src/error_tests.rs @@ -88,6 +88,7 @@ fn rate_limit_snapshot() -> RateLimitSnapshot { RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 50.0, window_minutes: Some(60), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index ae2107b801..66e431551d 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2324,6 +2324,9 @@ pub struct TokenCountEvent { pub struct RateLimitSnapshot { pub limit_id: Option, pub limit_name: Option, + /// Normal model metadata for a quota alias; never a replacement for the request model. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub normal_model_slug: Option, pub primary: Option, pub secondary: Option, pub credits: Option, diff --git a/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs b/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs index 1a5a43d9b2..3ed9c360d9 100644 --- a/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs +++ b/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs @@ -1728,6 +1728,7 @@ async fn paginated_projection_accepts_float_rate_limits_and_later_final_answers( rate_limits: Some(RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: primary.map(|used_percent| RateLimitWindow { used_percent, window_minutes: Some(60), diff --git a/codex-rs/tui/src/app/backend_banner_fallback.rs b/codex-rs/tui/src/app/backend_banner_fallback.rs index 287112d0a1..96f355cdc8 100644 --- a/codex-rs/tui/src/app/backend_banner_fallback.rs +++ b/codex-rs/tui/src/app/backend_banner_fallback.rs @@ -1,27 +1,71 @@ -//! Apply an accepted account usage response to the currently selected task and model. +//! Apply backend-authorized fallback and task-local Reserve settings without saving defaults. use super::App; use crate::app_server_session::AppServerSession; +use crate::chatwidget::AutomaticModelSwitchReason; +use crate::model_catalog::LUNA_RESERVE_MODEL; +use crate::model_catalog::model_display_name; use crate::service_tier_resolution; use codex_app_server_protocol::ThreadSettingsUpdateParams; +use codex_protocol::ThreadId; +use codex_protocol::config_types::ModeKind; +use codex_protocol::openai_models::ReasoningEffort; impl App { + pub(super) async fn update_luna_reserve_reasoning( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + effort: Option, + ) { + // A picker left open during recovery or a task switch must not affect the new model. + if self.active_thread_id != Some(thread_id) + || self.chat_widget.thread_id() != Some(thread_id) + || self.chat_widget.current_model() != LUNA_RESERVE_MODEL + { + return; + } + let mut mode = self.chat_widget.effective_collaboration_mode(); + mode.settings.reasoning_effort = effort.clone(); + let params = ThreadSettingsUpdateParams { + thread_id: thread_id.to_string(), + effort: effort.clone(), + collaboration_mode: Some(mode.clone()), + ..ThreadSettingsUpdateParams::default() + }; + if self.send_thread_settings_update(app_server, params).await { + self.chat_widget.set_reasoning_effort(effort.clone()); + if mode.mode == ModeKind::Plan { + self.chat_widget.set_plan_mode_reasoning_effort(effort); + } + } + } + pub(super) async fn apply_backend_banner_fallback( &mut self, app_server: &mut AppServerSession, ) { + // Reattached tasks must wait for the read that supersedes the latest hard stop. + if self.rate_limit_refresh_state.has_pending_recovery() { + return; + } 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 { + let Some(switch) = self.chat_widget.backend_banner_fallback() else { return; }; - let effort = self - .chat_widget - .current_reasoning_effort() + let target = switch.model; + let entering_reserve = target.model == crate::model_catalog::LUNA_RESERVE_MODEL; + if entering_reserve && !self.chat_widget.prepare_luna_reserve_return() { + self.chat_widget.show_unavailable_reserve_recovery(); + return; + } + let effort = switch + .effort .filter(|effort| { target .supported_reasoning_efforts @@ -53,13 +97,24 @@ impl App { 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); + let (prefix, suffix) = match switch.reason { + AutomaticModelSwitchReason::UsageLimit => { + ("Automatically switched to", " due to usage limits.") + } + AutomaticModelSwitchReason::UsageRecovered => ( + "Automatically switched back to", + " because ordinary usage is available again.", + ), + }; + let mut message = format!("{prefix} {}", model_display_name(&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."); + message.push_str(suffix); self.chat_widget.add_info_message(message, /*hint*/ None); + } else if entering_reserve { + self.chat_widget.show_unavailable_reserve_recovery(); } } } diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index c39f5c6375..6ce76cebb1 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -14,6 +14,7 @@ use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditParams; use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse; +use codex_app_server_protocol::GetAccountRateLimitsParams; use codex_app_server_protocol::GetAccountTokenUsageParams; use codex_app_server_protocol::GetAccountTokenUsageResponse; use codex_app_server_protocol::MarketplaceAddParams; @@ -85,6 +86,7 @@ impl App { origin, RateLimitRefreshOrigin::Recovery | RateLimitRefreshOrigin::ResetConsume { .. } ) { + self.chat_widget.invalidate_ordinary_usage_recovery(); self.chat_widget.hold_rate_limit_recovery(); } let Some((request_id, hard_stop_generation)) = self @@ -96,9 +98,10 @@ impl App { let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { - let request = fetch_account_rate_limits(request_handle); + let request = fetch_account_rate_limits(request_handle, origin); let result = match origin { RateLimitRefreshOrigin::Recovery + | RateLimitRefreshOrigin::Periodic | RateLimitRefreshOrigin::ResetConsume { .. } | RateLimitRefreshOrigin::ResetPicker { .. } => { tokio::time::timeout(RATE_LIMIT_RESET_REQUEST_TIMEOUT, request) @@ -798,15 +801,34 @@ pub(super) async fn fetch_all_mcp_server_statuses( pub(super) async fn fetch_account_rate_limits( request_handle: AppServerRequestHandle, + origin: RateLimitRefreshOrigin, ) -> Result { let request_id = RequestId::String(format!("account-rate-limits-{}", Uuid::new_v4())); - request_handle + let result = request_handle .request_typed(ClientRequest::GetAccountRateLimits { - request_id, - params: None, + request_id: request_id.clone(), + params: Some(GetAccountRateLimitsParams { + supports_luna_reserve: true, + exclude_reset_credit_details: origin == RateLimitRefreshOrigin::Periodic, + }), }) - .await - .wrap_err("account/rateLimits/read failed in TUI") + .await; + // Older remote app servers accept only null params. Keep their usage reads working + // without opting them into exposure or pretending that they support the new capability. + if matches!( + &result, + Err(codex_app_server_client::TypedRequestError::Server { source, .. }) + if matches!(source.code, -32600 | -32602) + ) { + return request_handle + .request_typed(ClientRequest::GetAccountRateLimits { + request_id, + params: None, + }) + .await + .wrap_err("account/rateLimits/read failed in TUI"); + } + result.wrap_err("account/rateLimits/read failed in TUI") } pub(super) async fn fetch_account_token_activity( diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 7e4c8cf877..4e94ec6819 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -721,7 +721,16 @@ impl App { AppEvent::FatalExitRequest(message) => { return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); } - AppEvent::CodexOp(op) => { + AppEvent::CodexOp(mut op) => { + if self.active_thread_id == self.chat_widget.thread_id() { + if matches!(&op, AppCommand::UserTurn { .. }) + && self.chat_widget.defer_pending_turn_for_luna_reserve() + { + return Ok(AppRunControl::Continue); + } + self.chat_widget + .apply_reserve_fallback_to_pending_turn(&mut op); + } let is_user_turn = matches!(&op, AppCommand::UserTurn { .. }); if is_user_turn { let screen_size = tui.terminal.last_known_screen_size; @@ -1219,6 +1228,17 @@ impl App { AppEvent::RefreshRateLimits { origin } => { self.refresh_rate_limits(app_server, origin); } + AppEvent::ApplyBackendBannerFallback { thread_id } => { + if self.active_thread_id == Some(thread_id) + && self.chat_widget.thread_id() == Some(thread_id) + { + self.apply_backend_banner_fallback(app_server).await; + if !self.rate_limit_refresh_state.has_pending_recovery() { + self.chat_widget.finish_rate_limit_recovery(); + } + self.refresh_rate_limits(app_server, RateLimitRefreshOrigin::Periodic); + } + } AppEvent::RefreshTokenActivity { request_id } => { self.refresh_token_activity(app_server, request_id); } @@ -1300,7 +1320,7 @@ impl App { Vec::new() }; match origin { - RateLimitRefreshOrigin::Recovery => { + RateLimitRefreshOrigin::Recovery | RateLimitRefreshOrigin::Periodic => { for snapshot in snapshots { self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); } @@ -1361,7 +1381,11 @@ impl App { // 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::Recovery | RateLimitRefreshOrigin::Periodic => { + // Re-evaluate snapshot age even when the backend cannot refresh it. + // This updates display freshness without authorizing model recovery. + self.chat_widget.refresh_status_surfaces(); + }, RateLimitRefreshOrigin::StartupPrefetch { reset_hint_request_id, } => { @@ -1399,10 +1423,10 @@ impl App { } } } - if matches!( + if (accepted || matches!( origin, RateLimitRefreshOrigin::Recovery | RateLimitRefreshOrigin::ResetConsume { .. } - ) && !self.rate_limit_refresh_state.has_pending_recovery() + )) && !self.rate_limit_refresh_state.has_pending_recovery() { self.chat_widget.finish_rate_limit_recovery(); } @@ -1541,6 +1565,10 @@ impl App { self.sync_active_thread_reasoning_setting(app_server, effort) .await; } + AppEvent::UpdateLunaReserveReasoning { thread_id, effort } => { + self.update_luna_reserve_reasoning(app_server, thread_id, effort) + .await; + } AppEvent::UpdateModel(model) => { let model_changed = self.chat_widget.current_model() != model || self.chat_widget.current_collaboration_mode().model() != model; diff --git a/codex-rs/tui/src/app/rate_limit_refresh.rs b/codex-rs/tui/src/app/rate_limit_refresh.rs index 51828e8d95..29a478a013 100644 --- a/codex-rs/tui/src/app/rate_limit_refresh.rs +++ b/codex-rs/tui/src/app/rate_limit_refresh.rs @@ -1,12 +1,17 @@ //! Orders full usage reads and coalesces recovery refreshes within an account/limit generation. +//! Periodic reads share that ordering and wait while another periodic read or recovery is pending. use crate::app_event::RateLimitRefreshOrigin; +use std::time::Duration; +use std::time::Instant; #[derive(Default)] pub(super) struct RateLimitRefreshState { next_id: u64, applied_id: u64, recovery: Option, + periodic: Option<(u64, u64)>, + last_requested_at: Option, } struct PendingRecovery { @@ -28,6 +33,17 @@ pub(super) enum RateLimitReadStatus { } impl RateLimitRefreshState { + pub(super) fn poll_deadline(&self, interval: Duration) -> Option { + if self.periodic.is_some() || self.recovery.is_some() { + return None; + } + Some( + self.last_requested_at + .map(|last| last + interval) + .unwrap_or_else(Instant::now), + ) + } + pub(super) fn has_pending_recovery(&self) -> bool { self.recovery.is_some() } @@ -37,6 +53,11 @@ impl RateLimitRefreshState { origin: RateLimitRefreshOrigin, generation: &mut u64, ) -> Option<(u64, u64)> { + if origin == RateLimitRefreshOrigin::Periodic + && (self.periodic.is_some() || self.recovery.is_some()) + { + return None; + } if matches!(origin, RateLimitRefreshOrigin::Recovery) { // A new hard error supersedes a post-reset read that started before the error. if self @@ -51,6 +72,10 @@ impl RateLimitRefreshState { } self.next_id = self.next_id.wrapping_add(1); let request = (self.next_id, *generation); + self.last_requested_at = Some(Instant::now()); + if origin == RateLimitRefreshOrigin::Periodic { + self.periodic = Some(request); + } // The post-reset read takes over the hold from the invalidated recovery request. if matches!( origin, @@ -64,6 +89,8 @@ impl RateLimitRefreshState { // Account changes and successful resets invalidate the need as well as the old response. pub(super) fn invalidate_recovery(&mut self) { self.recovery = None; + self.periodic = None; + self.last_requested_at = None; } pub(super) fn finish( @@ -73,6 +100,14 @@ impl RateLimitRefreshState { current_generation: u64, status: RateLimitReadStatus, ) -> RateLimitRefreshOutcome { + if self.periodic == Some((request_id, generation)) { + self.periodic = None; + } + if generation == current_generation && request_id == self.next_id { + // Completion starts the next wait, including after failures/timeouts, so errors + // cannot create a tight retry loop while the account is nearly exhausted. + self.last_requested_at = Some(Instant::now()); + } if self .recovery .as_ref() diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 29bcd42b41..e08e3fad6f 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -765,6 +765,10 @@ See the Codex keymap documentation for supported actions and examples." && has_pending_app_events || (!waiting_for_initial_session_configured && app.has_queued_startup_protected_request()); + let rate_limit_poll_deadline = app + .chat_widget + .rate_limit_refresh_interval() + .and_then(|interval| app.rate_limit_refresh_state.poll_deadline(interval)); let control = select! { Some(event) = app_event_rx.recv() => { let is_initial_session_header = matches!( @@ -873,6 +877,17 @@ See the Codex keymap documentation for supported actions and examples." } AppRunControl::Continue } + () = async { + match rate_limit_poll_deadline { + Some(deadline) => { + tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await; + } + None => std::future::pending().await, + } + }, if listen_for_app_server_events => { + app.refresh_rate_limits(&app_server, RateLimitRefreshOrigin::Periodic); + AppRunControl::Continue + } () = async { match app.chat_widget.terminal_title_next_refresh { Some(deadline) => { diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index e57a84f6e0..41fb39c9a1 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -20,6 +20,8 @@ mod connector_policy; mod disconnect; #[path = "tests/key_chords.rs"] mod key_chords; +#[path = "tests/luna_reserve_recovery_tests.rs"] +mod luna_reserve_recovery_tests; #[path = "tests/mcp_startup.rs"] mod mcp_startup; mod model_catalog; 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 index c353384fb0..72f8550f71 100644 --- a/codex-rs/tui/src/app/tests/backend_banner_fallback_tests.rs +++ b/codex-rs/tui/src/app/tests/backend_banner_fallback_tests.rs @@ -29,7 +29,7 @@ fn configure_fallback_model(app: &mut App) { app.chat_widget.set_model("gpt-5.4"); } -async fn start_fallback_thread( +pub(super) async fn start_fallback_thread( app: &mut App, ) -> Result { let (mut server, requests, proxy) = session_lifecycle_requests::start_recording_app_server( @@ -268,13 +268,22 @@ async fn backend_banner_fallback_handles_settings_failure_and_queued_manual_sele use futures::StreamExt; use tokio_tungstenite::tungstenite::Message; - for error_code in [Some(-32601), Some(-32603), None] { + for (use_reserve, error_code) in [false, true] + .into_iter() + .flat_map(|reserve| [Some(-32601), Some(-32603), None].map(|error| (reserve, error))) + { 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); + if use_reserve { + app.primary_session_configured = + Some(test_thread_session(id, app.config.cwd.to_path_buf())); + super::luna_reserve_recovery_tests::configure_reserve_catalog(&mut app); + } else { + 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(); @@ -322,7 +331,25 @@ async fn backend_banner_fallback_handles_settings_failure_and_queued_manual_sele crate::app_server_session::ThreadParamsMode::Embedded, ); while events.try_recv().is_ok() {} - app.chat_widget.update_backend_banner(&fallback_response()); + let pending_turn = if use_reserve && error_code.is_some() { + app.chat_widget + .restore_user_message_to_composer(UserMessage::from("queued before usage reply")); + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::Enter)); + Some( + std::iter::from_fn(|| events.try_recv().ok()) + .find(|event| matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. }))) + .expect("turn already queued before the failed switch"), + ) + } else { + None + }; + let response = if use_reserve { + super::luna_reserve_recovery_tests::reserve_response() + } else { + fallback_response() + }; + app.chat_widget.update_backend_banner(&response); app.apply_backend_banner_fallback(&mut server).await; let queued = std::iter::from_fn(|| events.try_recv().ok()).collect::>(); let switch_notices = queued @@ -337,7 +364,14 @@ async fn backend_banner_fallback_handles_settings_failure_and_queued_manual_sele .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"); + assert_eq!( + app.chat_widget.current_model(), + if use_reserve { + "gpt-reserve" + } else { + "gpt-5.2" + } + ); let manual_selection = queued .into_iter() .find(|event| matches!(event, AppEvent::UpdateModel(_))) @@ -348,6 +382,43 @@ async fn backend_banner_fallback_handles_settings_failure_and_queued_manual_sele } assert_eq!(app.chat_widget.current_model(), "gpt-5.4"); assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 72).contains("View usage")); + if use_reserve && error_code.is_some() { + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 90); + assert!(rendered.contains("Add credits")); + assert!(!rendered.contains("You’re now using Luna")); + if error_code == Some(-32601) { + insta::assert_snapshot!( + "luna_reserve_settings_unavailable", + normalize_snapshot_paths(rendered) + ); + } + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + assert!( + std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::OpenUrlInBrowser { .. })) + ); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.handle_event(&mut tui, &mut server, pending_turn.unwrap()) + .await?; + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["queued before usage reply"] + ); + app.chat_widget.finish_rate_limit_recovery(); + app.chat_widget + .restore_user_message_to_composer(UserMessage::from("after recovery")); + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["queued before usage reply", "after recovery"] + ); + assert!( + !std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. }))) + ); + } server.shutdown().await?; fake.await??; } 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 index cf5f7113f6..60d8639af6 100644 --- a/codex-rs/tui/src/app/tests/backend_banner_recovery_tests.rs +++ b/codex-rs/tui/src/app/tests/backend_banner_recovery_tests.rs @@ -42,6 +42,85 @@ async fn next_rate_limits_loaded( ) } +#[tokio::test] +async fn luna_reserve_periodic_refresh_adapts_without_an_experiment_banner() -> Result<()> { + let backend = 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("plus"), + AuthCredentialsStoreMode::File, + ) + .expect("write synthetic auth"); + std::fs::write( + home.path().join("config.toml"), + format!( + "chatgpt_base_url = {:?}\ncli_auth_credentials_store = \"file\"\n", + backend.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 = backend.uri(); + app.config.sqlite = codex_state::SqliteConfig::new_for_testing(home.path().abs()); + set_chatgpt_auth(&mut app.chat_widget); + 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()?; + for (used, seconds) in [(10, 60), (75, 30), (90, 15), (99, 5), (20, 60)] { + backend.reset().await; + Mock::given(method("GET")) + .and(path("/api/codex/usage")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "account_id": "workspace-a", "user_id": "user-a", "plan_type": "plus", + "rate_limit": {"allowed": true, "limit_reached": false, + "primary_window": {"used_percent": used, "limit_window_seconds": 18000, + "reset_after_seconds": 1800, "reset_at": 2000000000}}, + "rate_limit_reset_credits": {"available_count": 1} + }))) + .expect(/*r*/ 1) + .mount(&backend) + .await; + Mock::given(method("GET")) + .and(path("/api/codex/rate-limit-reset-credits")) + .respond_with(ResponseTemplate::new(/*s*/ 200)) + .expect(/*r*/ 0) + .mount(&backend) + .await; + app.refresh_rate_limits(&session, RateLimitRefreshOrigin::Periodic); + // A second timer tick while this RPC is pending must not add another request. + app.refresh_rate_limits(&session, RateLimitRefreshOrigin::Periodic); + assert!( + app.rate_limit_refresh_state + .poll_deadline(Duration::from_secs(/*secs*/ 60)) + .is_none() + ); + let loaded = next_rate_limits_loaded(&mut events).await?; + assert_matches!(&loaded, AppEvent::RateLimitsLoaded { result: Ok(_), .. }); + let before = std::time::Instant::now(); + app.handle_event(&mut tui, &mut session, loaded).await?; + let after = std::time::Instant::now(); + let interval = app.chat_widget.rate_limit_refresh_interval().unwrap(); + assert_eq!(interval, Duration::from_secs(seconds)); + let deadline = app + .rate_limit_refresh_state + .poll_deadline(interval) + .unwrap(); + assert!(deadline >= before + interval && deadline <= after + interval); + let requests = backend.received_requests().await.unwrap(); + let usage = requests + .iter() + .find(|request| request.url.path() == "/api/codex/usage") + .unwrap(); + assert!(usage.headers.contains_key("x-openai-codex-luna-reserve")); + backend.verify().await; + } + session.shutdown().await?; + Ok(()) +} + #[tokio::test] async fn backend_banner_limit_error_refreshes_again_after_intervening_rolling_hard_stop() -> Result<()> { diff --git a/codex-rs/tui/src/app/tests/luna_reserve_recovery_tests.rs b/codex-rs/tui/src/app/tests/luna_reserve_recovery_tests.rs new file mode 100644 index 0000000000..9545a95469 --- /dev/null +++ b/codex-rs/tui/src/app/tests/luna_reserve_recovery_tests.rs @@ -0,0 +1,382 @@ +//! Verify Reserve recovery through accepted usage reads and the task settings RPC. + +use super::*; +use crate::chatwidget::UserMessage; +use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_app_server_protocol::ThreadSettingsUpdateParams; +use pretty_assertions::assert_eq; +use serde_json::json; + +pub(super) fn reserve_response() -> GetAccountRateLimitsResponse { + serde_json::from_value(json!({ + "accountId": "workspace-a", "ordinaryUsageAllowed": false, + "rateLimits": {"limitId": "codex", "primary": {"usedPercent": 100}}, + "rateLimitsByLimitId": {"base_model_inference": { + "limitId": "base_model_inference", "limitName": "gpt-reserve", + "primary": {"usedPercent": 48, "windowDurationMins": 10080} + }}, + "rateLimitUpsell": { + "banner_type": "luna_reserve", "presentation": "dismissible", + "title": "You’re now using Luna, a faster model for simpler tasks.", + "description": "Add credits or upgrade to continue using the most advanced models.", + "ctas": [{"action": "add_credits", "label": "Add credits"}] + } + })) + .unwrap() +} + +pub(super) fn configure_reserve_catalog(app: &mut App) { + let mut models = app.model_catalog.try_list_models().unwrap(); + let mut reserve = models + .iter() + .find(|model| model.model == "gpt-5.4") + .unwrap() + .clone(); + reserve.model = "gpt-reserve".into(); + reserve.show_in_picker = false; + models.push(reserve); + let original = models + .iter_mut() + .find(|model| model.model == "gpt-5.4") + .unwrap(); + let mut high = original.supported_reasoning_efforts[0].clone(); + high.effort = ReasoningEffortConfig::High; + original.supported_reasoning_efforts.push(high); + app.model_catalog = Arc::new(ModelCatalog::new(models)); + let mut tui = crate::tui::test_support::make_test_tui().unwrap(); + let mut init = app.chatwidget_init_for_forked_or_resumed_thread( + &mut tui, + app.config.clone(), + /*initial_user_message*/ None, + ); + init.has_chatgpt_account = true; + init.has_codex_backend_auth = true; + 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"); +} + +#[tokio::test] +async fn luna_reserve_recovery_survives_task_reconstruction() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let (mut server, requests, proxy) = + backend_banner_fallback_tests::start_fallback_thread(&mut app).await?; + configure_reserve_catalog(&mut app); + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::High)); + app.chat_widget.update_backend_banner(&reserve_response()); + app.apply_backend_banner_fallback(&mut server).await; + let mut resumed = app.primary_session_configured.clone().unwrap(); + resumed.model = "gpt-reserve".into(); + resumed.collaboration_mode = Some(Box::new(app.chat_widget.effective_collaboration_mode())); + 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.has_chatgpt_account = true; + init.has_codex_backend_auth = true; + // No in-memory transition state is inherited: resume must recover the persisted target. + app.replace_chat_widget(ChatWidget::new_with_app_event(init)); + app.chat_widget.handle_thread_session(resumed); + assert_eq!(app.chat_widget.current_model(), "gpt-reserve"); + let mut recovered = reserve_response(); + recovered.ordinary_usage_allowed = Some(true); + recovered.rate_limit_upsell = None; + app.chat_widget.update_backend_banner(&recovered); + app.apply_backend_banner_fallback(&mut server).await; + assert_eq!(app.chat_widget.current_model(), "gpt-5.4"); + assert_eq!( + app.chat_widget.current_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); + assert_eq!( + requests + .lock() + .unwrap() + .iter() + .filter(|request| request.method == "thread/settings/update") + .count(), + 2 + ); + assert!( + !app.config + .codex_home + .join("tui-luna-reserve") + .join(format!("{}.json", app.active_thread_id.unwrap())) + .exists() + ); + server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn luna_reserve_entry_dispatches_an_already_queued_turn_with_accepted_settings() -> Result<()> +{ + for mode in [ModeKind::Default, ModeKind::Plan] { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let (mut server, requests, proxy) = + backend_banner_fallback_tests::start_fallback_thread(&mut app).await?; + configure_reserve_catalog(&mut app); + app.chat_widget + .set_feature_enabled(Feature::FastMode, /*enabled*/ true); + app.chat_widget.set_service_tier(Some("fast".into())); + if mode == ModeKind::Plan { + app.chat_widget + .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::from(KeyCode::Enter)); + let pending = std::iter::from_fn(|| events.try_recv().ok()) + .find(|event| matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. }))) + .expect("queued turn before the usage response"); + requests.lock().unwrap().clear(); + let mut tui = crate::tui::test_support::make_test_tui()?; + let generation = app.rate_limit_hard_stop_generation; + app.handle_event( + &mut tui, + &mut server, + AppEvent::RateLimitsLoaded { + request_id: 1, + origin: RateLimitRefreshOrigin::Periodic, + hard_stop_generation: generation, + result: Ok(reserve_response()), + }, + ) + .await?; + app.handle_event(&mut tui, &mut server, pending).await?; + let sent = requests.lock().unwrap().clone(); + let methods: Vec<_> = sent.iter().map(|request| request.method.as_str()).collect(); + assert_eq!(methods, ["thread/settings/update", "turn/start"]); + let settings: ThreadSettingsUpdateParams = + serde_json::from_value(sent[0].params.clone().unwrap())?; + let turn: codex_app_server_protocol::TurnStartParams = + serde_json::from_value(sent[1].params.clone().unwrap())?; + assert_eq!( + ( + turn.model, + turn.effort, + turn.service_tier, + turn.collaboration_mode + ), + ( + settings.model, + settings.effort, + settings.service_tier, + settings.collaboration_mode + ) + ); + assert_eq!(app.chat_widget.effective_collaboration_mode().mode, mode); + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + +#[tokio::test] +async fn luna_reserve_recovery_restores_task_and_pending_turn_after_fresh_backend_read() +-> Result<()> { + for has_credits in [false, true] { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let (mut server, requests, proxy) = + backend_banner_fallback_tests::start_fallback_thread(&mut app).await?; + configure_reserve_catalog(&mut app); + app.chat_widget + .set_feature_enabled(Feature::FastMode, /*enabled*/ false); + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::High)); + let original_mode = app.chat_widget.effective_collaboration_mode(); + let config_path = app.config.codex_home.join("config.toml"); + let saved_config = std::fs::read(&config_path).ok(); + let mut tui = crate::tui::test_support::make_test_tui()?; + requests.lock().unwrap().clear(); + let generation = app.rate_limit_hard_stop_generation; + app.handle_event( + &mut tui, + &mut server, + AppEvent::RateLimitsLoaded { + request_id: 1, + origin: RateLimitRefreshOrigin::Periodic, + hard_stop_generation: generation, + result: Ok(reserve_response()), + }, + ) + .await?; + assert_eq!(app.chat_widget.current_model(), "gpt-reserve"); + + // A prompt composed before the next accepted read must use the recovered model, once. + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::Esc)); + app.chat_widget + .restore_user_message_to_composer(UserMessage::from("continue")); + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::Enter)); + let mut pending = std::iter::from_fn(|| events.try_recv().ok()) + .find_map(|event| match event { + AppEvent::CodexOp(op @ AppCommand::UserTurn { .. }) => Some(op), + _ => None, + }) + .expect("queued user turn"); + assert_matches!(&pending, AppCommand::UserTurn {model, ..} if model == "gpt-reserve"); + while events.try_recv().is_ok() {} + + let mut recovered = reserve_response(); + recovered.ordinary_usage_allowed = Some(!has_credits); + recovered.rate_limits.credits = Some(codex_app_server_protocol::CreditsSnapshot { + has_credits, + unlimited: false, + balance: None, + }); + recovered.rate_limit_upsell = None; + // A rounded percentage is not an authorization decision; backend allowed=true wins. + for (request_id, hard_stop_generation, expected) in [ + (2, generation.wrapping_sub(1), "gpt-reserve"), + (3, generation, "gpt-5.4"), + (4, generation, "gpt-5.4"), + ] { + app.handle_event( + &mut tui, + &mut server, + AppEvent::RateLimitsLoaded { + request_id, + origin: RateLimitRefreshOrigin::Periodic, + hard_stop_generation, + result: Ok(recovered.clone()), + }, + ) + .await?; + assert_eq!(app.chat_widget.current_model(), expected); + } + assert_eq!( + app.chat_widget.effective_collaboration_mode(), + original_mode + ); + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 100).contains("Luna Reserve")); + app.chat_widget + .apply_reserve_fallback_to_pending_turn(&mut pending); + assert_matches!(pending, AppCommand::UserTurn { model, effort, collaboration_mode, .. } + if model == "gpt-5.4" && effort == original_mode.reasoning_effort() + && collaboration_mode == Some(original_mode.clone())); + let sent = requests.lock().unwrap().clone(); + assert_eq!(sent.len(), 2); + let restored: ThreadSettingsUpdateParams = + serde_json::from_value(sent[1].params.clone().unwrap())?; + assert_eq!( + restored, + ThreadSettingsUpdateParams { + thread_id: app.active_thread_id.unwrap().to_string(), + model: Some("gpt-5.4".into()), + effort: Some(ReasoningEffortConfig::High), + collaboration_mode: Some(original_mode), + service_tier: None, + ..Default::default() + } + ); + assert_eq!(std::fs::read(&config_path).ok(), saved_config); + let queued = std::iter::from_fn(|| events.try_recv().ok()).collect::>(); + assert!( + !queued.iter().any(|event| matches!( + event, + AppEvent::CodexOp(_) | AppEvent::PersistModelSelection { .. } + )), + "recovery must not replay a turn or change global defaults" + ); + let notice = queued + .iter() + .find_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.display_lines(/*width*/ 100))) + } + _ => None, + }) + .expect("recovery notice"); + insta::assert_snapshot!("luna_reserve_recovery_notice", notice); + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + +#[tokio::test] +async fn luna_reserve_recovery_requires_permission_and_no_remaining_blocker() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let (mut server, requests, proxy) = + backend_banner_fallback_tests::start_fallback_thread(&mut app).await?; + configure_reserve_catalog(&mut app); + app.chat_widget.update_backend_banner(&reserve_response()); + app.apply_backend_banner_fallback(&mut server).await; + requests.lock().unwrap().clear(); + let mut recovered = reserve_response(); + recovered.rate_limit_upsell = None; + recovered.rate_limits.primary.as_mut().unwrap().used_percent = 0; + recovered.rate_limits.primary.as_mut().unwrap().resets_at = Some(0); + for allowed in [None, Some(false)] { + recovered.ordinary_usage_allowed = allowed; + app.chat_widget.update_backend_banner(&recovered); + app.apply_backend_banner_fallback(&mut server).await; + assert_eq!(app.chat_widget.current_model(), "gpt-reserve"); + } + recovered.ordinary_usage_allowed = Some(true); + for blocker in [ + json!({"accountId": null, "ordinaryUsageAllowed": null}), + json!({"rateLimitUpsell": {"unsupported": true}}), + json!({"rateLimits": {"spendControlReached": true}}), + json!({"rateLimits": {"rateLimitReachedType": "workspace_owner_usage_limit_reached"}}), + ] { + let mut raw = serde_json::to_value(&recovered)?; + for (key, value) in blocker.as_object().unwrap() { + raw[key] = value.clone(); + } + app.chat_widget + .update_backend_banner(&serde_json::from_value(raw)?); + app.apply_backend_banner_fallback(&mut server).await; + assert_eq!(app.chat_widget.current_model(), "gpt-reserve"); + } + // Reset redemption clears presentation before the authoritative follow-up read. + app.chat_widget.clear_backend_banner(); + app.chat_widget.update_backend_banner(&recovered); + app.apply_backend_banner_fallback(&mut server).await; + assert_eq!(app.chat_widget.current_model(), "gpt-5.4"); + assert_eq!(requests.lock().unwrap().len(), 1); + server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn luna_reserve_recovery_does_not_override_manual_choice_or_account_change() -> Result<()> { + for change_account in [false, true] { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let (mut server, requests, proxy) = + backend_banner_fallback_tests::start_fallback_thread(&mut app).await?; + configure_reserve_catalog(&mut app); + app.chat_widget.update_backend_banner(&reserve_response()); + app.apply_backend_banner_fallback(&mut server).await; + requests.lock().unwrap().clear(); + let mut recovered = reserve_response(); + recovered.ordinary_usage_allowed = Some(true); + recovered.rate_limit_upsell = None; + let expected = if change_account { + recovered.account_id = Some("workspace-b".into()); + "gpt-reserve" + } else { + app.chat_widget.set_model("gpt-5.2"); + "gpt-5.2" + }; + app.chat_widget.update_backend_banner(&recovered); + app.apply_backend_banner_fallback(&mut server).await; + recovered.account_id = Some("workspace-a".into()); + app.chat_widget.update_backend_banner(&recovered); + app.apply_backend_banner_fallback(&mut server).await; + assert_eq!(app.chat_widget.current_model(), expected); + assert!(requests.lock().unwrap().is_empty()); + 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 d3bfd7a49b..344c703aea 100644 --- a/codex-rs/tui/src/app/tests/rate_limits.rs +++ b/codex-rs/tui/src/app/tests/rate_limits.rs @@ -20,6 +20,7 @@ fn rate_limit_snapshot( RateLimitSnapshot { limit_id: Some("codex".to_string()), limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent, window_duration_mins: Some(300), @@ -224,6 +225,12 @@ async fn stale_rate_limit_reads_preserve_newer_workspace_hard_stop_for_every_ori )) .await?; + app.chat_widget + .on_rate_limit_snapshot(Some(rate_limit_snapshot( + /*used_percent*/ 20, + /*rate_limit_reached_type*/ None, + Some(false), + ))); let origin = match origin_name { "startup" => RateLimitRefreshOrigin::StartupPrefetch { reset_hint_request_id: app.chat_widget.start_rate_limit_reset_startup_check(), @@ -309,8 +316,8 @@ async fn stale_rate_limit_reads_preserve_newer_workspace_hard_stop_for_every_ori let status = render_status_output(&mut app, &mut app_event_rx); assert!( - status.contains("5% left"), - "expected {origin_name} to preserve rolling limits, got: {status}" + status.contains("80% left"), + "expected {origin_name} to preserve the last account usage snapshot, got: {status}" ); deliver_usage_limit_error(&mut app); let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); 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 index a39f4fd236..a94c50ce49 100644 --- 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 @@ -1,11 +1,9 @@ --- source: tui/src/app/tests/backend_banner_fallback_tests.rs -assertion_line: 76 -expression: rendered +expression: normalize_snapshot_paths(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 diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__luna_reserve_settings_unavailable.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__luna_reserve_settings_unavailable.snap new file mode 100644 index 0000000000..bae8c11321 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__luna_reserve_settings_unavailable.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/app/tests/backend_banner_fallback_tests.rs +expression: normalize_snapshot_paths(rendered) +--- + Usage limit reached + Your included usage is exhausted. Choose an option below to continue. + +› 1. Add credits + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__luna_reserve_recovery_tests__luna_reserve_recovery_notice.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__luna_reserve_recovery_tests__luna_reserve_recovery_notice.snap new file mode 100644 index 0000000000..a61bfc5b10 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__luna_reserve_recovery_tests__luna_reserve_recovery_notice.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app/tests/luna_reserve_recovery_tests.rs +expression: notice +--- +• Automatically switched back to gpt-5.4 high because ordinary usage is available again. diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 1592aef9af..89270a947a 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -175,6 +175,8 @@ pub(crate) enum RateLimitRefreshOrigin { ResetConsume { request_id: u64 }, /// Refresh backend recovery after an inference limit error. Recovery, + /// Background account usage read, scheduled more frequently near exhaustion. + Periodic, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -502,6 +504,11 @@ pub(crate) enum AppEvent { origin: RateLimitRefreshOrigin, }, + /// Reconcile inherited account usage with an attached task before its queued input runs. + ApplyBackendBannerFallback { + thread_id: ThreadId, + }, + /// Open the current thread goal summary/action menu. OpenThreadGoalMenu { thread_id: ThreadId, @@ -962,6 +969,12 @@ pub(crate) enum AppEvent { /// Update the current reasoning effort in the running app and widget. UpdateReasoningEffort(Option), + /// Change Reserve effort only on the task that opened the picker, without saving defaults. + UpdateLunaReserveReasoning { + thread_id: ThreadId, + effort: Option, + }, + /// Update the current model slug in the running app and widget. UpdateModel(String), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 693b4c735b..cd596e030a 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -2420,6 +2420,7 @@ mod tests { RateLimitSnapshot { limit_id: Some(limit_id.to_string()), limit_name: None, + normal_model_slug: None, primary: Some(codex_app_server_protocol::RateLimitWindow { used_percent: 0, window_duration_mins: Some(10_080), diff --git a/codex-rs/tui/src/backend_banners.rs b/codex-rs/tui/src/backend_banners.rs index 18c3a17c4f..4109b8965e 100644 --- a/codex-rs/tui/src/backend_banners.rs +++ b/codex-rs/tui/src/backend_banners.rs @@ -8,6 +8,9 @@ use serde::Deserialize; mod actions; mod render; +pub(crate) const LUNA_RESERVE_BANNER: &str = "luna_reserve"; +pub(crate) const LUNA_RESERVE_RECOVERY_VIEW_ID: &str = "luna-reserve-recovery"; + #[cfg(test)] #[path = "backend_banners_tests.rs"] mod tests; diff --git a/codex-rs/tui/src/bottom_pane/actionable_banner.rs b/codex-rs/tui/src/bottom_pane/actionable_banner.rs index d1e12a6257..65750afcfa 100644 --- a/codex-rs/tui/src/bottom_pane/actionable_banner.rs +++ b/codex-rs/tui/src/bottom_pane/actionable_banner.rs @@ -11,6 +11,8 @@ use super::list_selection_view::ListSelectionView; use crate::render::Insets; use crate::render::RectExt; use crate::render::renderable::Renderable; +use crate::terminal_palette::best_color; +use crate::terminal_palette::default_fg; use crate::wrapping::word_wrap_lines; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -18,6 +20,7 @@ use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use ratatui::style::Color; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; @@ -122,7 +125,9 @@ impl BannerContent { 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); + // Explicit foreground avoids a terminal's separate bold color washing out the title. + let foreground = default_fg().map(best_color).unwrap_or(Color::Reset); + Widget::render(Paragraph::new(lines).fg(foreground), area, buf); } fn desired_height(&self, width: u16) -> u16 { @@ -140,7 +145,7 @@ impl From for SelectionViewParams { banner .description .lines() - .map(|line| Line::from(line.to_owned().dim())), + .map(|line| Line::from(line.to_owned())), ) .collect(); Self { @@ -164,6 +169,7 @@ impl BottomPane { let dismissal = banner.dismissal; let has_actions = !banner.actions.is_empty(); let mut params: SelectionViewParams = banner.into(); + params.header_gap = 0; params.allow_cancel = dismissal == BannerDismissal::Dismissible; for item in &mut params.items { item.dismiss_on_select = false; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 2735840aae..1f67d2a593 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -6,6 +6,7 @@ //! - Routing keys to the active popup (slash commands, file search, skill/apps/task mentions). //! - Promoting typed slash commands into atomic elements when the command name is completed. //! - Handling submit vs newline on Enter. +//! - Showing a single yellow prompt arrow while the active model is Luna Reserve. //! - Turning raw key streams into explicit paste operations on platforms where terminals //! don't provide reliable bracketed paste (notably Windows). //! @@ -518,6 +519,7 @@ pub(crate) struct ChatComposer { effort_ignition: Option, effort_status_line_transition: Option, effort_observed: bool, + luna_reserve_active: bool, attachments: AttachmentState, placeholder_text: String, blocks_direct_input: bool, @@ -697,6 +699,7 @@ impl ChatComposer { effort_ignition: None, effort_status_line_transition: None, effort_observed: false, + luna_reserve_active: false, attachments: AttachmentState::default(), placeholder_text, blocks_direct_input: false, @@ -4626,6 +4629,14 @@ impl Renderable for ChatComposer { } impl ChatComposer { + pub(crate) fn set_luna_reserve_active(&mut self, active: bool) -> bool { + if self.luna_reserve_active == active { + return false; + } + self.luna_reserve_active = active; + true + } + pub(crate) fn desired_height_with_textarea_right_reserve( &self, width: u16, @@ -4949,6 +4960,11 @@ impl ChatComposer { let prompt = if self.draft.input_enabled { if self.draft.is_bash_mode { Span::from("!").light_red().bold() + } else if self.luna_reserve_active { + // Reserve keeps one arrow at every reasoning effort; only its foreground changes. + "›" + .fg(crate::terminal_palette::best_color((246, 197, 67))) + .bold() } else if let Some(tier) = self.effort_tier { let charge = self .effort_ignition 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 2993540424..a908b00c2b 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -183,6 +183,8 @@ pub(crate) struct SelectionViewParams { /// Rendered left-column width to use for auto-sized rows. pub name_column_width: Option, pub header: Box, + /// Blank rows after the header; defaults to one. Inline banners use zero. + pub header_gap: u16, pub initial_selected_idx: Option, /// Rich content rendered beside (wide terminals) or below (narrow terminals) @@ -234,6 +236,7 @@ impl Default for SelectionViewParams { description_layout: SelectionDescriptionLayout::Columns, name_column_width: None, header: Box::new(()), + header_gap: 1, initial_selected_idx: None, side_content: Box::new(()), side_content_width: SideContentWidth::default(), @@ -275,6 +278,7 @@ pub(crate) struct ListSelectionView { last_selected_actual_idx: Option, rendered_item_count: std::cell::Cell, header: Box, + header_gap: u16, initial_selected_idx: Option, side_content: Box, side_content_width: SideContentWidth, @@ -411,6 +415,7 @@ impl ListSelectionView { last_selected_actual_idx: None, rendered_item_count: std::cell::Cell::new(0), header, + header_gap: params.header_gap, initial_selected_idx: params.initial_selected_idx, side_content: params.side_content, side_content_width: params.side_content_width, @@ -1170,7 +1175,7 @@ impl Renderable for ListSelectionView { let tab_height = tab_bar_height(&self.tabs, self.active_tab_idx.unwrap_or(0), inner_width); let mut height = header.desired_height(inner_width); height = height.saturating_add(tab_height + u16::from(tab_height > 0)); - height = height.saturating_add(rows_height + 3); + height = height.saturating_add(rows_height + 2 + self.header_gap); if self.is_searchable { height = height.saturating_add(1); } @@ -1265,7 +1270,7 @@ impl Renderable for ListSelectionView { stacked_side_area, ] = Layout::vertical([ Constraint::Max(header_height), - Constraint::Max(1), + Constraint::Max(self.header_gap), Constraint::Length(tab_height), Constraint::Length(u16::from(tab_height > 0)), Constraint::Length(if self.is_searchable { 1 } else { 0 }), diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 6c8deb17fe..c6c86e74b0 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1967,6 +1967,12 @@ impl BottomPane { } } + pub(crate) fn set_luna_reserve_active(&mut self, active: bool) { + if self.composer.set_luna_reserve_active(active) { + self.request_redraw(); + } + } + pub(crate) fn set_status_line_hyperlink(&mut self, url: Option) { if self.composer.set_status_line_hyperlink(url) { self.request_redraw(); 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 index c0eed8e3c8..1b90357356 100644 --- 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 @@ -6,7 +6,6 @@ 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 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 index 5a94128004..faf38fd585 100644 --- 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 @@ -6,7 +6,6 @@ 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 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 index 9f72caad92..04967785ba 100644 --- 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 @@ -11,7 +11,6 @@ expression: "render_snapshot(&pane, area)" A long description wraps to fit the available terminal width. … - › 1. Open usage settings 2. Notify owner diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0780e64a68..8737e6f1b6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -396,6 +396,9 @@ pub(crate) use self::permissions_menu::auto_review_available; pub(crate) use self::permissions_menu::cyber_model_approval_reviewer; mod backend_banners; mod compaction; +mod luna_reserve_model; +mod luna_reserve_return; +pub(crate) use backend_banners::AutomaticModelSwitchReason; mod protocol; mod protocol_requests; mod rate_limits; @@ -583,6 +586,7 @@ pub(crate) struct ChatWidget { pub(crate) remote_connection: Option, token_info: Option, token_usage_pending: bool, + // Status and polling use account usage reads; response streams may identify meters differently. rate_limit_snapshots_by_limit_id: BTreeMap, refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>, next_status_refresh_request_id: u64, @@ -602,7 +606,10 @@ pub(crate) struct ChatWidget { codex_spend_control_reached: Option, rate_limit_warnings: RateLimitWarningState, backend_banner_state: backend_banners::BackendBannerState, + automatic_model_switch_state: backend_banners::AutomaticModelSwitchState, backend_banner_notice_model: Option, + // Remember the account's Reserve entry notice across chats and transient banner refreshes. + luna_reserve_notice_account_id: Option, warning_display_state: WarningDisplayState, rate_limit_switch_prompt: RateLimitSwitchPromptState, add_credits_nudge_email_in_flight: Option, diff --git a/codex-rs/tui/src/chatwidget/backend_banners.rs b/codex-rs/tui/src/chatwidget/backend_banners.rs index a989f477b9..98192e8d57 100644 --- a/codex-rs/tui/src/chatwidget/backend_banners.rs +++ b/codex-rs/tui/src/chatwidget/backend_banners.rs @@ -1,70 +1,328 @@ -//! Account banner state, independent of the current model and the lifetime of its rendered view. +//! Account banners and response-driven model transitions for the selected task. //! //! Only explicitly dismissible, previously shown occurrences can be dismissed. Model changes hide //! content without dismissing it; an authoritative replacement/absence starts a new occurrence. +//! Reserve recovery uses a focused picker and a persisted task-local return model. +//! Its entry notice is shared across chats until the backend confirms ordinary usage has recovered. use super::ChatWidget; +use super::luna_reserve_return::ReserveReturnModel; +use crate::app_command::AppCommand; use crate::backend_banners::BackendBanner; use crate::backend_banners::BannerPresentation; +use crate::backend_banners::LUNA_RESERVE_BANNER; +use crate::backend_banners::LUNA_RESERVE_RECOVERY_VIEW_ID; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::accept_cancel_hint_line; +use crate::keymap::ListAction; +use crate::model_catalog::LUNA_RESERVE_MODEL; use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ReasoningEffort; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; + +#[derive(Debug, PartialEq)] +pub(crate) enum AutomaticModelSwitchReason { + UsageLimit, + UsageRecovered, +} + +#[derive(Debug, PartialEq)] +pub(crate) struct AutomaticModelSwitch { + pub model: ModelPreset, + pub effort: Option, + pub reason: AutomaticModelSwitchReason, +} + +/// Live transition state; the return target is also persisted per task for reconstruction. +#[derive(Default)] +pub(super) struct AutomaticModelSwitchState { + reserve_return: Option, + replaced_model: Option, +} #[derive(Default)] pub(super) struct BackendBannerState { + account_id: Option, + ordinary_usage_recovered: bool, banner: Option, presented: Option, shown: bool, dismissed: bool, + picker_dismissed: Arc, } 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.requires_openai_auth - || banner.blocked_model_slug.as_deref() != Some(self.current_model()) - { + pub(super) fn restrict_model_picker_to_luna_reserve(&self) -> bool { + // A fresh account read can allow manual recovery even without a valid saved return model. + self.current_model() == LUNA_RESERVE_MODEL + && !self.backend_banner_state.ordinary_usage_recovered + } + + pub(crate) fn invalidate_ordinary_usage_recovery(&mut self) { + // Keep the current banner, but never reuse permission from before a new hard stop. + self.backend_banner_state.ordinary_usage_recovered = false; + } + + pub(super) fn waiting_for_luna_reserve(&self) -> bool { + self.current_model() != LUNA_RESERVE_MODEL + && self + .backend_banner_state + .banner + .as_ref() + .is_some_and(|banner| banner.banner_type == LUNA_RESERVE_BANNER) + } + + pub(crate) fn backend_banner_fallback(&mut self) -> Option { + if !self.has_chatgpt_account || !self.requires_openai_auth { return None; } + if self.current_model() == LUNA_RESERVE_MODEL + && self.automatic_model_switch_state.reserve_return.is_none() + { + self.automatic_model_switch_state.reserve_return = + self.thread_id().and_then(|thread_id| { + ReserveReturnModel::load(self.config.codex_home.as_path(), thread_id).or_else( + || { + // Forks inherit Reserve settings, but need their own return target: + // the parent's recovery will delete the parent's saved target. + let previous = ReserveReturnModel::load( + self.config.codex_home.as_path(), + self.forked_from?, + )?; + if self.backend_banner_state.account_id.as_deref() + != Some(previous.account_id.as_str()) + { + return None; + } + previous + .save(self.config.codex_home.as_path(), thread_id) + .ok()?; + Some(previous) + }, + ) + }); + } + if self + .automatic_model_switch_state + .reserve_return + .as_ref() + .is_some_and(|previous| { + self.backend_banner_state + .account_id + .as_ref() + .is_some_and(|account_id| account_id != &previous.account_id) + }) + { + self.clear_reserve_return(); + } 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() + if self.current_model() == LUNA_RESERVE_MODEL + && self.backend_banner_state.ordinary_usage_recovered + { + let previous = self.automatic_model_switch_state.reserve_return.as_ref()?; + if self.backend_banner_state.account_id.as_deref() != Some(previous.account_id.as_str()) + { + return None; + } + let model = models + .into_iter() + .find(|model| model.show_in_picker && model.model == previous.model)?; + return Some(AutomaticModelSwitch { + model, + effort: previous.effort.clone(), + reason: AutomaticModelSwitchReason::UsageRecovered, + }); + } + let banner = self.backend_banner_state.banner.as_ref()?; + // The backend emits this banner only after ordinary usage is exhausted and Reserve + // is available. Reserve is deliberately hidden from manual model selection. + let model = if banner.banner_type == LUNA_RESERVE_BANNER { + models.into_iter().find(|model| { + model.model == LUNA_RESERVE_MODEL + && model.model != self.current_model() + && banner + .blocked_model_slug + .as_deref() + .is_none_or(|blocked| blocked == self.current_model()) + }) + } else { + if banner.blocked_model_slug.as_deref() != Some(self.current_model()) { + return None; + } + 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() + }) + }?; + Some(AutomaticModelSwitch { + model, + effort: self.current_reasoning_effort(), + reason: AutomaticModelSwitchReason::UsageLimit, }) } + /// Save the return target before changing server state, including before any turn is sent. + pub(crate) fn prepare_luna_reserve_return(&mut self) -> bool { + let Some((account_id, thread_id)) = self + .backend_banner_state + .account_id + .clone() + .zip(self.thread_id()) + else { + return false; + }; + let previous = ReserveReturnModel { + account_id, + model: self.current_model().to_string(), + effort: self.current_reasoning_effort(), + }; + previous + .save(self.config.codex_home.as_path(), thread_id) + .is_ok() + } + + pub(super) fn clear_reserve_return(&mut self) { + if let Some(thread_id) = self.thread_id() { + ReserveReturnModel::clear(self.config.codex_home.as_path(), thread_id); + } + self.automatic_model_switch_state = AutomaticModelSwitchState::default(); + } + + pub(crate) fn show_unavailable_reserve_recovery(&mut self) { + self.clear_reserve_return(); + // A failed switch still needs recovery actions, even if another chat used Reserve. + self.backend_banner_state.dismissed = false; + self.backend_banner_state.picker_dismissed = Arc::default(); + self.backend_banner_state.presented = None; + self.refresh_backend_banner_visibility(); + } + + /// A composer submission can already be in the app queue when a Reserve switch fails. + /// Reuse the retained prompt/steer rather than sending it on the blocked model. + pub(crate) fn defer_pending_turn_for_luna_reserve(&mut self) -> bool { + if !self.waiting_for_luna_reserve() { + return false; + } + if self.input_queue.user_turn_pending_start + && let Some(prompt) = self.safety_buffering_prompt.take() + { + self.finalize_turn(); + self.input_queue + .queued_user_messages + .push_front(prompt.into()); + self.input_queue + .queued_user_message_history_records + .push_front(super::UserMessageHistoryRecord::UserMessageText); + self.refresh_pending_input_preview(); + return true; + } + !self.input_queue.pending_steers.is_empty() && self.enqueue_rejected_steer() + } + /// Commit a successfully applied task setting and keep its recovery notice visible together. pub(crate) fn finish_backend_banner_fallback(&mut self, mode: CollaborationMode) { + let previous_model = self.current_model().to_string(); + let reserve_return = (mode.model() == LUNA_RESERVE_MODEL) + .then(|| self.backend_banner_state.account_id.clone()) + .flatten() + .map(|account_id| ReserveReturnModel { + account_id, + model: previous_model.clone(), + effort: self.current_reasoning_effort(), + }); + let involves_reserve = + mode.model() == LUNA_RESERVE_MODEL || previous_model == LUNA_RESERVE_MODEL; 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); + self.automatic_model_switch_state = AutomaticModelSwitchState { + reserve_return, + replaced_model: involves_reserve.then_some(previous_model), + }; // 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_state.dismissed = self.reserve_notice_already_shown(); + self.backend_banner_state.picker_dismissed = Arc::default(); + self.bottom_pane + .dismiss_view_by_id(LUNA_RESERVE_RECOVERY_VIEW_ID); + self.backend_banner_state.presented = None; self.backend_banner_notice_model = Some(self.current_model().to_string()); self.refresh_backend_banner_visibility(); } + /// A queued command may have been composed before the account read switched this task. + pub(crate) fn apply_reserve_fallback_to_pending_turn(&self, op: &mut AppCommand) { + let AppCommand::UserTurn { + model, + effort, + service_tier, + collaboration_mode, + .. + } = op + else { + return; + }; + if self.automatic_model_switch_state.replaced_model.as_deref() != Some(model.as_str()) { + return; + } + *model = self.current_model().to_string(); + *effort = self.current_reasoning_effort(); + *service_tier = self.service_tier_update_for_core(); + if let Some(mode) = collaboration_mode { + mode.settings.model = model.clone(); + mode.settings.reasoning_effort = effort.clone(); + } + } + 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.luna_reserve_notice_account_id = previous.luna_reserve_notice_account_id.take(); + self.backend_banner_state.dismissed |= self.reserve_notice_already_shown(); + // Usage belongs to the account, so switching tasks must not reset the polling cadence + // or discard the already-known limits shown by /status. + self.rate_limit_snapshots_by_limit_id = + std::mem::take(&mut previous.rate_limit_snapshots_by_limit_id); + self.codex_rate_limit_reached_type = previous.codex_rate_limit_reached_type; + self.codex_spend_control_reached = previous.codex_spend_control_reached; 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(); + self.backend_banner_state.account_id = response.account_id.clone(); + // Only a full, identity-validated backend read can authorize recovery. Unknown banners + // still block it; percentages, sparse notifications and reset timestamps cannot prove it. + let has_usable_credits = response + .rate_limits + .credits + .as_ref() + .is_some_and(|credits| credits.unlimited || credits.has_credits); + self.backend_banner_state.ordinary_usage_recovered = + response.ordinary_usage_allowed.is_some() + && (response.ordinary_usage_allowed == Some(true) || has_usable_credits) + && response.rate_limit_upsell.is_none() + && response.rate_limits.spend_control_reached != Some(true) + && response.rate_limits.rate_limit_reached_type.is_none(); + if self.backend_banner_state.ordinary_usage_recovered { + self.luna_reserve_notice_account_id = None; + } let banner = response .rate_limit_upsell .as_ref() @@ -88,23 +346,47 @@ impl ChatWidget { == new.blocked_model_slug.as_ref().or(new.model_slug.as_ref()) && old.fallback_model_slugs == new.fallback_model_slugs }); + self.backend_banner_state.banner = banner; if !same_occurrence { self.backend_banner_state.shown = false; - self.backend_banner_state.dismissed = false; + self.backend_banner_state.dismissed = self.reserve_notice_already_shown(); + self.backend_banner_state.picker_dismissed = Arc::default(); self.backend_banner_notice_model = None; } - self.backend_banner_state.banner = banner; + if self.waiting_for_luna_reserve() { + self.hold_rate_limit_recovery(); + } self.refresh_backend_banner_visibility(); } + fn reserve_notice_already_shown(&self) -> bool { + self.backend_banner_state + .banner + .as_ref() + .is_some_and(|banner| { + banner.banner_type == LUNA_RESERVE_BANNER + && self.luna_reserve_notice_account_id.as_deref() + == Some(banner.account_id.as_str()) + }) + } + 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; + self.backend_banner_state.dismissed |= self + .backend_banner_state + .picker_dismissed + .load(Ordering::Relaxed); } pub(super) fn refresh_backend_banner_visibility(&mut self) { let banner = self.backend_banner_state.banner.as_ref().filter(|banner| { + // Keep recovery actions available while switching, including on older servers + // without settings/update. The copy below describes the accepted model only. + if banner.banner_type == LUNA_RESERVE_BANNER { + return !self.backend_banner_state.dismissed; + } // 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() => { @@ -127,7 +409,19 @@ impl ChatWidget { if banner == self.backend_banner_state.presented.as_ref() { return; } - let content = banner.map(BackendBanner::actionable_banner); + let is_reserve = banner.is_some_and(|banner| banner.banner_type == LUNA_RESERVE_BANNER); + let content = banner.map(|banner| { + let mut content = banner.actionable_banner(); + if banner.banner_type == LUNA_RESERVE_BANNER + && self.current_model() != LUNA_RESERVE_MODEL + { + content.title = "Usage limit reached".to_string(); + content.description = + "Your included usage is exhausted. Choose an option below to continue." + .to_string(); + } + content + }); self.backend_banner_state.presented = banner.cloned(); if content.is_some() { self.bottom_pane @@ -140,7 +434,45 @@ impl ChatWidget { self.rate_limit_switch_prompt = super::RateLimitSwitchPromptState::Pending; } } - self.bottom_pane.set_inline_banner(content); + self.bottom_pane + .dismiss_view_by_id(LUNA_RESERVE_RECOVERY_VIEW_ID); + match (is_reserve, content) { + (true, Some(content)) => { + self.bottom_pane.set_inline_banner(/*banner*/ None); + let mut params: SelectionViewParams = content.into(); + params.view_id = Some(LUNA_RESERVE_RECOVERY_VIEW_ID); + if self.current_model() == LUNA_RESERVE_MODEL { + self.luna_reserve_notice_account_id = + self.backend_banner_state.account_id.clone(); + // Continuing is a local dismissal, separate from the backend's purchase CTAs. + params.items.push(SelectionItem { + name: "Continue with Luna Reserve".to_string(), + ..Default::default() + }); + let list_keymap = self.bottom_pane.list_keymap(); + params.footer_hint = Some(accept_cancel_hint_line( + list_keymap.primary_hint(ListAction::Accept), + "to confirm", + list_keymap.primary_hint(ListAction::Cancel), + "to continue working", + )); + } + // Use the standard focused picker: arrows/Enter and numeric shortcuts select, + // Escape returns to the unchanged composer and its saved draft. + let dismissed = Arc::clone(&self.backend_banner_state.picker_dismissed); + params.on_cancel = + Some(Box::new(move |_| dismissed.store(true, Ordering::Relaxed))); + for item in &mut params.items { + item.dismiss_on_select = true; + let dismissed = Arc::clone(&self.backend_banner_state.picker_dismissed); + item.actions + .push(Box::new(move |_| dismissed.store(true, Ordering::Relaxed))); + } + self.backend_banner_state.shown = true; + self.bottom_pane.show_selection_view(params); + } + (_, content) => 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() @@ -175,6 +507,8 @@ impl ChatWidget { pub(crate) fn clear_backend_banner(&mut self) { self.backend_banner_state = BackendBannerState::default(); self.backend_banner_notice_model = None; + self.bottom_pane + .dismiss_view_by_id(LUNA_RESERVE_RECOVERY_VIEW_ID); 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 c04a210d94..fcdf83a9e2 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -153,7 +153,9 @@ impl ChatWidget { codex_spend_control_reached: None, rate_limit_warnings: RateLimitWarningState::default(), backend_banner_state: backend_banners::BackendBannerState::default(), + automatic_model_switch_state: backend_banners::AutomaticModelSwitchState::default(), backend_banner_notice_model: None, + luna_reserve_notice_account_id: 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/luna_reserve_model.rs b/codex-rs/tui/src/chatwidget/luna_reserve_model.rs new file mode 100644 index 0000000000..8634932b72 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/luna_reserve_model.rs @@ -0,0 +1,62 @@ +//! The Reserve-only /model picker uses normal-model metadata while keeping Reserve routing. + +use super::*; +use crate::model_catalog::LUNA_MODEL; +use crate::model_catalog::LUNA_RESERVE_MODEL; + +impl ChatWidget { + pub(super) fn open_luna_reserve_model_popup( + &mut self, + presets: Vec, + view_id: &'static str, + ) { + let normal_model_slug = self + .rate_limit_snapshots_by_limit_id + .values() + .find(|snapshot| snapshot.limit_name == LUNA_RESERVE_MODEL) + .and_then(|snapshot| snapshot.normal_model_slug.as_deref()); + let preset = normal_model_slug + .and_then(|slug| presets.iter().find(|preset| preset.model == slug)) + .or_else(|| presets.iter().find(|preset| preset.model == LUNA_MODEL)); + let Some(mut preset) = preset.cloned() else { + self.bottom_pane.dismiss_view_by_id(view_id); + self.add_info_message( + "Luna model settings are unavailable; please try /model again in a moment." + .to_string(), + /*hint*/ None, + ); + return; + }; + + // Only borrow the normal model's presentation and supported efforts. Selecting an + // effort must keep the authorized Reserve model and the saved ordinary return target. + preset.model = LUNA_RESERVE_MODEL.to_string(); + let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; + let name = preset.display_name.clone(); + let description = (!preset.description.is_empty()).then_some(preset.description.clone()); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenReasoningPopup { + model: preset.clone(), + }); + })]; + let header = self.model_menu_header( + "Select Model", + "Other models return when ordinary usage is available again.", + ); + self.show_model_selection_view(SelectionViewParams { + view_id: Some(view_id), + header, + footer_hint: Some(self.bottom_pane.standard_popup_hint_line()), + items: vec![SelectionItem { + name, + description, + is_current: true, + actions, + dismiss_on_select: single_supported_effort, + dismiss_parent_on_child_accept: !single_supported_effort, + ..Default::default() + }], + ..Default::default() + }); + } +} diff --git a/codex-rs/tui/src/chatwidget/luna_reserve_return.rs b/codex-rs/tui/src/chatwidget/luna_reserve_return.rs new file mode 100644 index 0000000000..36a7da02a3 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/luna_reserve_return.rs @@ -0,0 +1,48 @@ +//! Persist the account-bound return model for a task before automatically entering Reserve. +//! Task IDs scope the cache across reconnects/resumes without changing global model defaults. + +use codex_protocol::ThreadId; +use codex_protocol::openai_models::ReasoningEffort; +use serde::Deserialize; +use serde::Serialize; +use std::io; +use std::io::Read; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Clone, Serialize, Deserialize)] +pub(super) struct ReserveReturnModel { + pub account_id: String, + pub model: String, + pub effort: Option, +} + +impl ReserveReturnModel { + fn path(codex_home: &Path, thread_id: ThreadId) -> PathBuf { + codex_home + .join("tui-luna-reserve") + .join(format!("{thread_id}.json")) + } + + pub(super) fn load(codex_home: &Path, thread_id: ThreadId) -> Option { + let file = std::fs::File::open(Self::path(codex_home, thread_id)).ok()?; + // A corrupt or unrelated cache file must not cause an unbounded read. + serde_json::from_reader(file.take(/*limit*/ 4096)).ok() + } + + pub(super) fn save(&self, codex_home: &Path, thread_id: ThreadId) -> io::Result<()> { + let path = Self::path(codex_home, thread_id); + let directory = path.parent().ok_or(io::ErrorKind::InvalidInput)?; + std::fs::create_dir_all(directory)?; + let mut file = tempfile::NamedTempFile::new_in(directory)?; + serde_json::to_writer(&mut file, self)?; + file.as_file().sync_all()?; + file.persist(path).map_err(|error| error.error)?; + Ok(()) + } + + pub(super) fn clear(codex_home: &Path, thread_id: ThreadId) { + // A missing cache needs no cleanup; the live model is authoritative after leaving Reserve. + let _ = std::fs::remove_file(Self::path(codex_home, thread_id)); + } +} diff --git a/codex-rs/tui/src/chatwidget/model_popup_state.rs b/codex-rs/tui/src/chatwidget/model_popup_state.rs index ebc6a4111e..a85480e1bb 100644 --- a/codex-rs/tui/src/chatwidget/model_popup_state.rs +++ b/codex-rs/tui/src/chatwidget/model_popup_state.rs @@ -36,16 +36,22 @@ impl ChatWidget { { return false; } - self.model_catalog = Arc::new(ModelCatalog::new(presets.clone())); + self.model_catalog = Arc::new(ModelCatalog::new(presets)); self.refresh_effective_service_tier(); self.refresh_model_dependent_surfaces(); + true + } + + /// Keep a present model picker aligned with the active task, including Reserve entry/exit. + pub(super) fn refresh_open_model_picker(&mut self) { // Refresh an existing parent without reopening it or interrupting its reasoning child. match self.model_popup_view_id() { - Some(MODEL_SELECTION_VIEW_ID) => self.open_model_popup_with_presets(presets), + Some(MODEL_SELECTION_VIEW_ID) => self.open_model_popup_with_presets( + self.model_catalog.try_list_models().unwrap_or_default(), + ), Some(ALL_MODELS_SELECTION_VIEW_ID) => self.open_all_models_popup(), _ => {} } - true } pub(super) fn show_model_selection_view(&mut self, mut params: SelectionViewParams) { diff --git a/codex-rs/tui/src/chatwidget/model_popups.rs b/codex-rs/tui/src/chatwidget/model_popups.rs index 0210428f4f..2155103740 100644 --- a/codex-rs/tui/src/chatwidget/model_popups.rs +++ b/codex-rs/tui/src/chatwidget/model_popups.rs @@ -4,6 +4,7 @@ //! into another, especially while Plan mode is active. use super::*; +use crate::model_catalog::LUNA_RESERVE_MODEL; const ULTRA_REASONING_CONCURRENCY_WARNING_THRESHOLD: usize = 8; pub(super) const MODEL_SELECTION_VIEW_ID: &str = "model-selection"; @@ -38,7 +39,7 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::FetchModels { request_id }); } - fn model_menu_header(&self, title: &str, subtitle: &str) -> Box { + pub(super) fn model_menu_header(&self, title: &str, subtitle: &str) -> Box { let title = title.to_string(); let subtitle = subtitle.to_string(); let mut header = ColumnRenderable::new(); @@ -78,6 +79,10 @@ impl ChatWidget { } pub(crate) fn open_model_popup_with_presets(&mut self, presets: Vec) { + if self.restrict_model_picker_to_luna_reserve() { + self.open_luna_reserve_model_popup(presets, MODEL_SELECTION_VIEW_ID); + return; + } let presets: Vec = presets .into_iter() .filter(|preset| preset.show_in_picker) @@ -191,6 +196,13 @@ impl ChatWidget { } pub(crate) fn open_all_models_popup(&mut self) { + if self.restrict_model_picker_to_luna_reserve() { + self.open_luna_reserve_model_popup( + self.model_catalog.try_list_models().unwrap_or_default(), + ALL_MODELS_SELECTION_VIEW_ID, + ); + return; + } let presets = self .model_catalog .try_list_models() @@ -262,8 +274,17 @@ impl ChatWidget { let warning = effort_for_action .as_ref() .and_then(|effort| self.ultra_reasoning_concurrency_warning(effort)); + let thread_id = self.thread_id(); vec![Box::new(move |tx| { - if effort_for_action == Some(ReasoningEffortConfig::Ultra) { + if model_for_action == LUNA_RESERVE_MODEL { + // Reserve is temporary: update the active task without persisting a model default. + if let Some(thread_id) = thread_id { + tx.send(AppEvent::UpdateLunaReserveReasoning { + thread_id, + effort: effort_for_action.clone(), + }); + } + } else if effort_for_action == Some(ReasoningEffortConfig::Ultra) { tx.send(AppEvent::ApplyAdvancedReasoning { model: model_for_action.clone(), effort: ReasoningEffortConfig::Ultra, @@ -295,6 +316,7 @@ impl ChatWidget { selected_effort: Option, ) -> bool { if !self.collaboration_modes_enabled() + || selected_model == LUNA_RESERVE_MODEL || self.active_mode_kind() != ModeKind::Plan || selected_model != self.current_model() { @@ -478,6 +500,11 @@ impl ChatWidget { .then(|| default_effort.clone()); let model_slug = preset.model.to_string(); + let model_label = if model_slug == LUNA_RESERVE_MODEL { + preset.display_name.clone() + } else { + model_slug.clone() + }; let is_current_model = self.current_model() == preset.model.as_str(); let highlight_choice = if is_current_model { if in_plan_mode { @@ -575,7 +602,7 @@ impl ChatWidget { let mut header = ColumnRenderable::new(); header.push(Line::from( - format!("Select Reasoning Level for {model_slug}").bold(), + format!("Select Reasoning Level for {model_label}").bold(), )); self.bottom_pane.show_selection_view(SelectionViewParams { @@ -704,27 +731,11 @@ impl ChatWidget { )) } - pub(super) fn apply_model_and_effort_without_persist( - &self, - model: String, - effort: Option, - ) { - let warning = effort - .as_ref() - .and_then(|effort| self.ultra_reasoning_concurrency_warning(effort)); - self.app_event_tx.send(AppEvent::UpdateModel(model)); - self.app_event_tx - .send(AppEvent::UpdateReasoningEffort(effort)); - if let Some(warning) = warning { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_warning_event(warning), - ))); + fn apply_model_and_effort(&self, model: String, effort: Option) { + for action in self + .model_selection_actions(model, effort, /*should_prompt_plan_mode_scope*/ false) + { + action(&self.app_event_tx); } } - - fn apply_model_and_effort(&self, model: String, effort: Option) { - self.apply_model_and_effort_without_persist(model.clone(), effort.clone()); - self.app_event_tx - .send(AppEvent::PersistModelSelection { model, effort }); - } } diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index 70c54e1a72..cd88e565aa 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -1,8 +1,9 @@ -//! Rate-limit warning, prompt, and notice surfaces for `ChatWidget`. +//! Rate-limit warning, prompt, and notice surfaces, plus quota-aware account refresh cadence. use super::*; pub(super) const WORKSPACE_NUDGE_VIEW_ID: &str = "workspace-usage-nudge"; use crate::bottom_pane::ActionableBanner; +use crate::model_catalog::LUNA_RESERVE_MODEL; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use uuid::Uuid; @@ -11,7 +12,7 @@ pub(super) struct PendingCreditsNudge { credit_type: AddCreditsNudgeCreditType, } -pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.6-luna"; +pub(super) const NUDGE_MODEL_SLUG: &str = crate::model_catalog::LUNA_MODEL; pub(super) const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; pub(super) const RATE_LIMIT_SWITCH_PROMPT_VIEW_ID: &str = "rate-limit-switch-prompt"; @@ -185,11 +186,43 @@ fn has_usable_workspace_credits(credits: &CreditsSnapshot) -> bool { } impl ChatWidget { + /// Poll more often near exhaustion for every ChatGPT account, independently of experiments. + pub(crate) fn rate_limit_refresh_interval(&self) -> Option { + if !self.should_prefetch_rate_limits() { + return None; + } + // Ignore unrelated model buckets; watch ordinary usage and the selected model's bucket. + let used_percent = self + .rate_limit_snapshots_by_limit_id + .iter() + .filter(|(limit_id, snapshot)| { + limit_id.as_str() == "codex" || snapshot.limit_name == self.current_model() + }) + .flat_map(|(_, snapshot)| snapshot.primary.iter().chain(snapshot.secondary.iter())) + .map(|window| window.used_percent) + .filter(|percent| percent.is_finite()) + .max_by(f64::total_cmp) + .unwrap_or_default(); + let seconds = if used_percent >= 99.0 { + 5 + } else if used_percent >= 90.0 { + 15 + } else if used_percent >= 75.0 { + 30 + } else { + 60 + }; + Some(std::time::Duration::from_secs(seconds)) + } + 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 self.waiting_for_luna_reserve() { + return; + } 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(); @@ -224,10 +257,6 @@ impl ChatWidget { .limit_id .clone() .unwrap_or_else(|| "codex".to_string()); - let limit_label = snapshot - .limit_name - .clone() - .unwrap_or_else(|| limit_id.clone()); if matches!(source, RateLimitSnapshotSource::RollingUpdate) && snapshot.credits.is_none() { @@ -241,16 +270,6 @@ impl ChatWidget { balance: credits.balance.clone(), }); } - let preserved_individual_limit = - if matches!(source, RateLimitSnapshotSource::RollingUpdate) - && snapshot.individual_limit.is_none() - { - self.rate_limit_snapshots_by_limit_id - .get(&limit_id) - .and_then(|display| display.individual_limit.clone()) - } else { - None - }; self.plan_type = snapshot.plan_type.or(self.plan_type); let is_codex_limit = limit_id.eq_ignore_ascii_case("codex"); @@ -341,13 +360,18 @@ impl ChatWidget { self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending; } - let mut display = - rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now()); - if display.individual_limit.is_none() { - display.individual_limit = preserved_individual_limit; + // /wham/usage identifies ordinary and additional model limits separately. Streamed + // updates still drive warnings/recovery above, but must not overwrite status data. + if matches!(source, RateLimitSnapshotSource::AccountUsage) { + let limit_label = snapshot + .limit_name + .clone() + .unwrap_or_else(|| limit_id.clone()); + let display = + rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now()); + self.rate_limit_snapshots_by_limit_id + .insert(limit_id, display); } - self.rate_limit_snapshots_by_limit_id - .insert(limit_id, display); if !warnings.is_empty() { for warning in warnings { @@ -394,7 +418,9 @@ impl ChatWidget { if self.has_applicable_backend_banner() { return; } - if self.rate_limit_switch_prompt_hidden() || self.current_model() == NUDGE_MODEL_SLUG { + if self.rate_limit_switch_prompt_hidden() + || matches!(self.current_model(), NUDGE_MODEL_SLUG | LUNA_RESERVE_MODEL) + { self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; return; } diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index f45df6992e..adfbf5625d 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -26,6 +26,8 @@ impl ChatWidget { .set_queue_submissions(/*queue_submissions*/ false); if previous_thread_id != self.thread_id { self.backend_banner_notice_model = None; + self.automatic_model_switch_state = + backend_banners::AutomaticModelSwitchState::default(); self.pending_automatic_thread_names.clear(); self.review.recent_auto_review_denials = RecentAutoReviewDenials::default(); self.clear_thread_usage_state(); @@ -114,6 +116,19 @@ impl ChatWidget { .set_active_reasoning_effort_baseline(effort.as_ref()); self.refresh_model_display(); self.refresh_status_surfaces(); + if previous_thread_id != self.thread_id + && self.should_prefetch_rate_limits() + && (self.current_model() == crate::model_catalog::LUNA_RESERVE_MODEL + || self.backend_banner_fallback().is_some()) + { + // Reconcile this task with retained account state before sending its initial/queued + // prompt. Do not wait for the next usage poll after /new, /resume or a thread switch. + self.hold_rate_limit_recovery(); + self.app_event_tx + .send(AppEvent::ApplyBackendBannerFallback { + thread_id: session.thread_id, + }); + } self.sync_service_tier_commands(); self.sync_personality_command_enabled(); self.sync_plugins_command_enabled(); diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 6422f6f7c8..89461c5051 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -221,6 +221,8 @@ impl ChatWidget { self.clear_pending_token_activity_refreshes(); self.clear_pending_rate_limit_reset_requests(); self.clear_backend_banner(); + self.luna_reserve_notice_account_id = None; + self.automatic_model_switch_state = backend_banners::AutomaticModelSwitchState::default(); self.input_queue.rate_limit_recovery_pending = false; self.add_credits_nudge_email_in_flight = None; self.codex_rate_limit_reached_type = None; @@ -261,6 +263,14 @@ impl ChatWidget { /// Set the model in the widget's config copy and stored collaboration mode. pub(crate) fn set_model(&mut self, model: &str) { + if model != self.current_model() { + if self.current_model() == crate::model_catalog::LUNA_RESERVE_MODEL { + self.clear_reserve_return(); + } else { + self.automatic_model_switch_state = + backend_banners::AutomaticModelSwitchState::default(); + } + } self.current_collaboration_mode = self.current_collaboration_mode.with_updates( Some(model.to_string()), /*effort*/ None, @@ -452,6 +462,7 @@ impl ChatWidget { self.sync_backend_banner_view(); self.refresh_model_display(); self.refresh_status_line(); + self.refresh_open_model_picker(); } fn apply_thread_settings(&mut self, mut settings: ThreadSettings) { @@ -551,7 +562,7 @@ impl ChatWidget { if model.is_empty() { DEFAULT_MODEL_DISPLAY_NAME } else { - model + crate::model_catalog::model_display_name(model) } } diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 1b5402c4cf..3cf584cffb 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -9,6 +9,7 @@ use crate::branch_summary; use crate::chatwidget::limit_label_for_window; use crate::chatwidget::rate_limits::get_limits_duration; use crate::legacy_core::config::Config; +use crate::model_catalog::LUNA_RESERVE_MODEL; use crate::status::format_credit_micros; use crate::status::format_estimated_usd_micros; use crate::status::format_tokens_compact; @@ -293,6 +294,8 @@ impl ChatWidget { /// warnings once, synchronizes shared cached state (such as git-branch /// lookup), then renders each surface from that shared snapshot. pub(crate) fn refresh_status_surfaces(&mut self) { + self.bottom_pane + .set_luna_reserve_active(self.current_model() == LUNA_RESERVE_MODEL); let selections = self.status_surface_selections(); self.warn_invalid_status_line_items_once(&selections.invalid_status_line_items); self.warn_invalid_terminal_title_items_once(&selections.invalid_terminal_title_items); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 33efb56033..af379dc148 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -246,6 +246,8 @@ mod goal_validation; mod guardian; pub(crate) mod helpers; mod history_replay; +#[path = "tests/luna_reserve_usage_tests.rs"] +mod luna_reserve_usage_tests; mod mcp_startup; #[path = "tests/misalignment_policy_tests.rs"] mod misalignment_policy; diff --git a/codex-rs/tui/src/chatwidget/tests/backend_banners_tests.rs b/codex-rs/tui/src/chatwidget/tests/backend_banners_tests.rs index bdb3ff9067..5244de15a4 100644 --- a/codex-rs/tui/src/chatwidget/tests/backend_banners_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/backend_banners_tests.rs @@ -319,7 +319,10 @@ async fn backend_banner_fallback_candidates_and_notice_follow_selected_model() { 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())); + assert_eq!( + chat.backend_banner_fallback().map(|switch| switch.model), + Some(models[2].clone()) + ); // A retained ChatGPT login must not change a task whose server does not require auth. chat.requires_openai_auth = false; assert_eq!(chat.backend_banner_fallback(), None); diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index e0dc255ae4..9ac2070bec 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -107,6 +107,7 @@ pub(super) fn snapshot(percent: f64) -> RateLimitSnapshot { RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: percent.round() as i32, window_duration_mins: Some(60), diff --git a/codex-rs/tui/src/chatwidget/tests/luna_reserve_usage_tests.rs b/codex-rs/tui/src/chatwidget/tests/luna_reserve_usage_tests.rs new file mode 100644 index 0000000000..fb042b7640 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/luna_reserve_usage_tests.rs @@ -0,0 +1,254 @@ +//! Exercise Reserve picker focus, its status label, and the composer palette across entry and exit. + +use super::helpers::normalize_snapshot_paths; +use super::*; +use crate::terminal_palette::with_test_default_colors; +use crate::terminal_probe::DefaultColors; +use pretty_assertions::assert_eq; +use serde_json::json; + +fn reserve_snapshot(primary_used: i32, weekly_used: i32) -> RateLimitSnapshot { + RateLimitSnapshot { + limit_id: Some("base_model_inference".into()), + limit_name: Some("gpt-reserve".into()), + normal_model_slug: None, + primary: Some(RateLimitWindow { + used_percent: primary_used, + window_duration_mins: Some(300), + resets_at: None, + }), + secondary: Some(RateLimitWindow { + used_percent: weekly_used, + window_duration_mins: Some(10080), + resets_at: None, + }), + ..snapshot(/*percent*/ 0.0) + } +} + +#[tokio::test] +async fn luna_reserve_selector_supports_arrows_enter_shortcuts_and_escape_without_losing_draft() { + for (keys, destination) in [ + ( + vec![KeyCode::Down, KeyCode::Enter], + Some("https://chatgpt.com/?cta_tab=personal&highlight_plan=plus#pricing"), + ), + ( + vec![KeyCode::Down, KeyCode::Up, KeyCode::Enter], + Some("https://chatgpt.com/codex/settings/usage?credits_modal=true"), + ), + ( + vec![KeyCode::Char('1')], + Some("https://chatgpt.com/codex/settings/usage?credits_modal=true"), + ), + ( + vec![KeyCode::Char('2')], + Some("https://chatgpt.com/?cta_tab=personal&highlight_plan=plus#pricing"), + ), + (vec![KeyCode::Down, KeyCode::Esc], None), + ] { + let (mut chat, mut events, _ops) = make_chatwidget_manual(Some("gpt-reserve")).await; + chat.has_chatgpt_account = true; + chat.apply_external_edit("saved draft".into()); + chat.on_rate_limit_snapshot(Some(reserve_snapshot( + /*primary_used*/ 48, /*weekly_used*/ 20, + ))); + let response = serde_json::from_value(json!({ + "accountId": "account-preview", "rateLimits": {}, + "rateLimitUpsell": { + "banner_type": "luna_reserve", "presentation": "dismissible", + "title": "You’re now using Luna, a faster model for simpler tasks.", + "description": "Add credits or upgrade to continue using the most advanced models.", + "ctas": [{"action": "add_credits", "label": "Add credits"}, + {"action": "open_pricing_dialog", "label": "Upgrade"}] + } + })) + .unwrap(); + chat.update_backend_banner(&response); + let rendered = normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 90)); + assert!( + !rendered.contains("saved draft"), + "selector owns input focus" + ); + assert!(rendered.contains("esc to continue working")); + while events.try_recv().is_ok() {} + for key in keys { + chat.handle_key_event(KeyEvent::from(key)); + if key == KeyCode::Down { + insta::assert_snapshot!( + "luna_reserve_selector_second_choice", + normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 90)) + ); + } + } + let queued = std::iter::from_fn(|| events.try_recv().ok()).collect::>(); + let urls: Vec<_> = queued + .iter() + .filter_map(|event| match event { + AppEvent::OpenUrlInBrowser { url } => Some(url.as_str()), + _ => None, + }) + .collect(); + assert_eq!(urls, destination.into_iter().collect::>()); + assert!( + !queued + .iter() + .any(|event| matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. }))) + ); + assert_eq!(chat.composer_text_with_pending(), "saved draft"); + // A periodic usage reply must not reopen a selector the user just closed. + chat.update_backend_banner(&response); + let rendered = normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 90)); + assert!(!rendered.contains("You’re now using Luna")); + assert!(rendered.contains("saved draft")); + assert!(rendered.contains("Luna Reserve")); + } +} + +#[tokio::test] +async fn luna_reserve_status_tracks_the_active_model() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("gpt-5.6-sol")).await; + chat.has_chatgpt_account = true; + chat.on_rate_limit_snapshot(Some(reserve_snapshot( + /*primary_used*/ 25, /*weekly_used*/ 60, + ))); + assert!( + !normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80)) + .contains("Luna Reserve") + ); + + chat.set_model("gpt-reserve"); + let rendered = normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80)); + assert!(rendered.contains("Luna Reserve default")); + insta::assert_snapshot!("luna_reserve_usage_wide", rendered); + insta::assert_snapshot!( + "luna_reserve_usage_narrow", + normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 34)) + ); + + chat.set_model("gpt-5.6-sol"); + assert!( + !normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80)) + .contains("Luna Reserve") + ); + chat.set_model("gpt-reserve"); + assert!( + normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80)).contains("Luna Reserve") + ); +} + +#[tokio::test] +async fn luna_reserve_usage_survives_banner_dismissal_and_typing_during_a_turn() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("gpt-reserve")).await; + chat.has_chatgpt_account = true; + chat.on_rate_limit_snapshot(Some(reserve_snapshot( + /*primary_used*/ 48, /*weekly_used*/ 20, + ))); + let response = codex_app_server_protocol::GetAccountRateLimitsResponse { + ordinary_usage_allowed: Some(false), + account_id: Some("account-preview".into()), + rate_limits: snapshot(/*percent*/ 100.0), + rate_limits_by_limit_id: None, + rate_limit_reset_credits: None, + rate_limit_upsell: Some(json!({ + "banner_type": "luna_reserve", "presentation": "dismissible", + "title": "You’re now using Luna, a faster model for simpler tasks.", + "description": "Add credits or upgrade to continue using the most advanced models.", + "ctas": [ + {"action": "add_credits", "label": "Add credits"}, + {"action": "open_pricing_dialog", "label": "Upgrade"} + ] + })), + }; + chat.update_backend_banner(&response); + for colors in [ + DefaultColors { + fg: (230, 230, 230), + bg: (20, 20, 20), + }, + DefaultColors { + fg: (25, 25, 25), + bg: (255, 255, 255), + }, + ] { + with_test_default_colors(colors, || { + let area = Rect::new(0, 0, 80, chat.bottom_pane.desired_height(/*width*/ 80)); + let mut buffer = Buffer::empty(area); + chat.bottom_pane.render(area, &mut buffer); + // Do not rely on the terminal's bold default foreground or dim important copy. + for (y, text) in buffer + .content + .chunks(80) + .enumerate() + .filter_map(|(y, row)| { + let text: String = row.iter().map(ratatui::buffer::Cell::symbol).collect(); + (text.contains("You’re now using Luna") + || text.contains("Add credits or upgrade")) + .then_some((y as u16, text)) + }) + { + let copy = &buffer[(2, y)]; + assert_eq!(copy.fg, crate::terminal_palette::rgb_color(colors.fg)); + assert!( + !copy.modifier.contains(ratatui::style::Modifier::DIM), + "{text}" + ); + } + }); + } + insta::assert_snapshot!( + "luna_reserve_usage_with_upsell", + normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80)) + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!( + !normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80)) + .contains("You’re now using Luna") + ); + chat.bottom_pane.set_task_running(/*running*/ true); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + let rendered = normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80)); + assert_eq!(chat.composer_text_with_pending(), "x"); + insta::assert_snapshot!("luna_reserve_usage_running", rendered); +} + +#[tokio::test] +async fn luna_reserve_prompt_preserves_the_composer_palette_on_exit() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("gpt-reserve")).await; + chat.has_chatgpt_account = true; + chat.on_rate_limit_snapshot(Some(reserve_snapshot( + /*primary_used*/ 25, /*weekly_used*/ 60, + ))); + for colors in [ + DefaultColors { + fg: (230, 230, 230), + bg: (20, 20, 20), + }, + DefaultColors { + fg: (25, 25, 25), + bg: (255, 255, 255), + }, + ] { + with_test_default_colors(colors, || { + chat.set_model("gpt-reserve"); + let area = Rect::new(0, 0, 80, chat.bottom_pane.desired_height(/*width*/ 80)); + let mut active = Buffer::empty(area); + chat.bottom_pane.render(area, &mut active); + let cursor = chat.bottom_pane.cursor_pos(area).expect("composer cursor"); + let ordinary_style = crate::style::user_message_style(); + assert_eq!(active[(0, cursor.1)].bg, ordinary_style.bg.unwrap()); + assert_eq!(active[(0, cursor.1)].bg, active[(0, cursor.1 - 1)].bg); + chat.set_model("gpt-5.6-sol"); + let area = Rect::new(0, 0, 80, chat.bottom_pane.desired_height(/*width*/ 80)); + let mut inactive = Buffer::empty(area); + chat.bottom_pane.render(area, &mut inactive); + let restored_cursor = chat.bottom_pane.cursor_pos(area).expect("composer cursor"); + assert_eq!( + inactive[(0, restored_cursor.1)].bg, + ordinary_style.bg.unwrap() + ); + assert_eq!(restored_cursor.1, cursor.1); + }); + } +} 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 index 7f16786846..49046392aa 100644 --- 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 @@ -1,11 +1,9 @@ --- source: tui/src/chatwidget/tests/backend_banners_tests.rs -assertion_line: 50 -expression: "render_bottom_popup(&chat, 70)" +expression: "normalize_snapshot_paths(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 diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_selector_second_choice.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_selector_second_choice.snap new file mode 100644 index 0000000000..6c12866001 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_selector_second_choice.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests/luna_reserve_usage_tests.rs +expression: "normalize_snapshot_paths(render_bottom_popup(&chat, 90))" +--- + You’re now using Luna, a faster model for simpler tasks. + Add credits or upgrade to continue using the most advanced models. + + 1. Add credits +› 2. Upgrade + 3. Continue with Luna Reserve + + Press enter to confirm or esc to continue working diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_usage_narrow.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_usage_narrow.snap new file mode 100644 index 0000000000..0644d12b6f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_usage_narrow.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/chatwidget/tests/luna_reserve_usage_tests.rs +expression: "render_bottom_popup(&chat, 34)" +--- + +› Ask Codex to do anything + + Luna Reserve default · /tmp/pro… diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_usage_running.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_usage_running.snap new file mode 100644 index 0000000000..4f4664d17a --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_usage_running.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/chatwidget/tests/luna_reserve_usage_tests.rs +expression: rendered +--- +• Working (0s • esc to interrupt) + + +› x + + tab to queue message 100% context left diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_usage_wide.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_usage_wide.snap new file mode 100644 index 0000000000..d6537e354d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_usage_wide.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/chatwidget/tests/luna_reserve_usage_tests.rs +expression: rendered +--- + +› Ask Codex to do anything + + Luna Reserve default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_usage_with_upsell.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_usage_with_upsell.snap new file mode 100644 index 0000000000..2159b29177 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__luna_reserve_usage_tests__luna_reserve_usage_with_upsell.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests/luna_reserve_usage_tests.rs +expression: "normalize_snapshot_paths(render_bottom_popup(&chat, 80))" +--- + You’re now using Luna, a faster model for simpler tasks. + Add credits or upgrade to continue using the most advanced models. + +› 1. Add credits + 2. Upgrade + 3. Continue with Luna Reserve + + Press enter to confirm or esc to continue working 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 300dcb55e2..19fcf63bf8 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -706,6 +706,7 @@ async fn status_line_uses_secondary_fallback_for_unsupported_window() { chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: None, secondary: Some(RateLimitWindow { used_percent: 50, @@ -732,6 +733,7 @@ async fn status_line_legacy_limit_items_prefer_matching_windows() { chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 94, window_duration_mins: Some(7 * 24 * 60), @@ -766,6 +768,7 @@ async fn status_line_shows_secondary_non_weekly_when_primary_is_weekly() { chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 94, window_duration_mins: Some(7 * 24 * 60), @@ -800,6 +803,7 @@ async fn status_line_five_hour_item_omits_weekly_only_limit() { chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 9, window_duration_mins: Some(7 * 24 * 60), @@ -830,6 +834,7 @@ async fn status_line_single_monthly_primary_omits_weekly_limit_item() { chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 35, window_duration_mins: Some(30 * 24 * 60), @@ -860,6 +865,7 @@ async fn status_line_secondary_only_non_weekly_limit_omits_primary_limit_item() chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: None, secondary: Some(RateLimitWindow { used_percent: 35, @@ -890,7 +896,12 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { limit_id: None, limit_name: None, - primary: None, + normal_model_slug: None, + primary: Some(RateLimitWindow { + used_percent: 10, + window_duration_mins: Some(60), + resets_at: Some(123), + }), secondary: None, credits: Some(CreditsSnapshot { has_credits: true, @@ -912,6 +923,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { chat.on_rolling_rate_limit_snapshot(RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 80, window_duration_mins: Some(60), @@ -938,7 +950,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { assert!(!credits.unlimited); assert_eq!( display.primary.as_ref().map(|window| window.used_percent), - Some(80.0) + Some(10.0) ); } @@ -983,6 +995,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 10, window_duration_mins: Some(60), @@ -1004,6 +1017,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 25, window_duration_mins: Some(30), @@ -1025,6 +1039,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 30, window_duration_mins: Some(60), @@ -1051,6 +1066,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() { chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { limit_id: Some("codex".to_string()), limit_name: Some("codex".to_string()), + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 20, window_duration_mins: Some(300), @@ -1071,6 +1087,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() { chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { limit_id: Some("codex_other".to_string()), limit_name: Some("codex_other".to_string()), + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 90, window_duration_mins: Some(60), @@ -1126,6 +1143,7 @@ async fn rate_limit_switch_prompt_skips_non_codex_limit() { chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { limit_id: Some("codex_other".to_string()), limit_name: Some("codex_other".to_string()), + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 95, window_duration_mins: Some(60), diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index ef7e122ff8..2e8a8a5b29 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -67,6 +67,7 @@ fn cache_rate_limit_snapshot(chat: &mut ChatWidget) { chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 35, window_duration_mins: Some(30 * 24 * 60), @@ -353,6 +354,7 @@ async fn status_surface_preview_omits_unavailable_rate_limit_items() { chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 9, window_duration_mins: Some(7 * 24 * 60), diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index 5f2ea9ed28..d57546436e 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -401,6 +401,7 @@ impl ChatWidget { } pub(super) fn on_rate_limit_error(&mut self, error_kind: RateLimitErrorKind, message: String) { + self.invalidate_ordinary_usage_recovery(); // 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); diff --git a/codex-rs/tui/src/history_cell/session.rs b/codex-rs/tui/src/history_cell/session.rs index 87e0c7326c..4af9a17fee 100644 --- a/codex-rs/tui/src/history_cell/session.rs +++ b/codex-rs/tui/src/history_cell/session.rs @@ -266,7 +266,7 @@ impl SessionHeaderHistoryCell { ) -> Self { Self { version, - model, + model: crate::model_catalog::model_display_name(&model).to_string(), model_style, reasoning_effort, show_fast_status, diff --git a/codex-rs/tui/src/model_catalog.rs b/codex-rs/tui/src/model_catalog.rs index 69c9ded566..bbef579e81 100644 --- a/codex-rs/tui/src/model_catalog.rs +++ b/codex-rs/tui/src/model_catalog.rs @@ -1,6 +1,19 @@ +//! TUI model inventory and user-facing labels; wire requests retain their original model slugs. + use codex_protocol::openai_models::ModelPreset; use std::convert::Infallible; +pub(crate) const LUNA_RESERVE_MODEL: &str = "gpt-reserve"; +pub(crate) const LUNA_MODEL: &str = "gpt-5.6-luna"; + +pub(crate) fn model_display_name(model: &str) -> &str { + if model.eq_ignore_ascii_case(LUNA_RESERVE_MODEL) { + "Luna Reserve" + } else { + model + } +} + #[derive(Debug, Clone)] pub(crate) struct ModelCatalog { models: Vec, diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index aaf54df448..2099647d7b 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -95,6 +95,8 @@ impl RateLimitWindowDisplay { pub(crate) struct RateLimitSnapshotDisplay { /// Canonical limit identifier (for example: `codex` or `codex_other`). pub limit_name: String, + /// Optional normal model associated with this account-read quota alias. + pub normal_model_slug: Option, /// Local timestamp representing when this display snapshot was captured. pub captured_at: DateTime, /// Primary usage window. @@ -147,6 +149,7 @@ pub(crate) fn rate_limit_snapshot_display_for_limit( captured_at: DateTime, ) -> RateLimitSnapshotDisplay { RateLimitSnapshotDisplay { + normal_model_slug: snapshot.normal_model_slug.clone(), limit_name, captured_at, primary: snapshot @@ -216,7 +219,19 @@ pub(crate) fn compose_rate_limit_data_many( let mut rows = Vec::with_capacity(snapshots.len().saturating_mul(3)); let mut stale = false; - for snapshot in snapshots { + // Show ordinary plan usage before model-specific quotas, regardless of backend bucket IDs. + let mut ordered_snapshots = snapshots.iter().collect::>(); + ordered_snapshots.sort_by_key(|snapshot| !snapshot.limit_name.eq_ignore_ascii_case("codex")); + for snapshot in ordered_snapshots { + let credit_row = snapshot.credits.as_ref().and_then(credit_status_row); + // Metadata-only buckets have no visible values and should not leave an empty heading. + if snapshot.primary.is_none() + && snapshot.secondary.is_none() + && credit_row.is_none() + && snapshot.individual_limit.is_none() + { + continue; + } stale |= now.signed_duration_since(snapshot.captured_at) > ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES); stale |= snapshot @@ -228,7 +243,7 @@ pub(crate) fn compose_rate_limit_data_many( }) .unwrap_or(false); - let limit_bucket_label = snapshot.limit_name.clone(); + let limit_bucket_label = crate::model_catalog::model_display_name(&snapshot.limit_name); let show_limit_prefix = !limit_bucket_label.eq_ignore_ascii_case("codex"); let primary_label = snapshot .primary @@ -307,9 +322,7 @@ pub(crate) fn compose_rate_limit_data_many( }); } - if let Some(credits) = snapshot.credits.as_ref() - && let Some(row) = credit_status_row(credits) - { + if let Some(row) = credit_row { rows.push(row); } if let Some(individual_limit) = snapshot.individual_limit.as_ref() { @@ -341,10 +354,15 @@ pub(crate) fn compose_rate_limit_data_many( /// This function expects a remaining value in the `0..=100` range and clamps out-of-range input. /// Passing a used percentage by mistake will invert the bar and mislead users. pub(crate) fn render_status_limit_progress_bar(percent_remaining: f64) -> String { + render_limit_progress_bar(percent_remaining, STATUS_LIMIT_BAR_SEGMENTS) +} + +/// Shared bar geometry for the status card and compact composer usage indicators. +pub(crate) fn render_limit_progress_bar(percent_remaining: f64, segments: usize) -> String { let ratio = (percent_remaining / 100.0).clamp(0.0, 1.0); - let filled = (ratio * STATUS_LIMIT_BAR_SEGMENTS as f64).round() as usize; - let filled = filled.min(STATUS_LIMIT_BAR_SEGMENTS); - let empty = STATUS_LIMIT_BAR_SEGMENTS.saturating_sub(filled); + let filled = (ratio * segments as f64).round() as usize; + let filled = filled.min(segments); + let empty = segments.saturating_sub(filled); format!( "[{}{}]", STATUS_LIMIT_BAR_FILLED.repeat(filled), @@ -438,6 +456,7 @@ mod tests { let now = Local::now(); let codex = RateLimitSnapshotDisplay { limit_name: "codex".to_string(), + normal_model_slug: None, captured_at: now, primary: Some(window(/*used_percent*/ 10.0)), secondary: None, @@ -450,6 +469,7 @@ mod tests { }; let other = RateLimitSnapshotDisplay { limit_name: "codex-other".to_string(), + normal_model_slug: None, captured_at: now, primary: Some(window(/*used_percent*/ 20.0)), secondary: None, @@ -484,6 +504,7 @@ mod tests { let now = Local::now(); let other = RateLimitSnapshotDisplay { limit_name: "codex-other".to_string(), + normal_model_slug: None, captured_at: now, primary: Some(RateLimitWindowDisplay { used_percent: 20.0, diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index f8e23fd730..6a408c1434 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -64,6 +64,7 @@ fn stale_monthly_limit_marks_fresh_rolling_snapshot_stale() { let now = Local::now(); let snapshot = RateLimitSnapshotDisplay { limit_name: "codex".to_string(), + normal_model_slug: None, captured_at: now, primary: Some(RateLimitWindowDisplay { used_percent: 20.0, @@ -306,6 +307,7 @@ async fn status_snapshot_includes_reasoning_details() { let snapshot = RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 72, window_duration_mins: Some(300), @@ -999,6 +1001,7 @@ async fn status_snapshot_includes_monthly_limit() { let snapshot = RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 12, window_duration_mins: Some(43_200), @@ -1063,6 +1066,7 @@ async fn status_snapshot_includes_enterprise_monthly_credit_limit() { let snapshot = RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: None, secondary: None, credits: None, @@ -1141,6 +1145,7 @@ async fn status_snapshot_uses_generic_limit_labels_for_unsupported_windows() { let snapshot = RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 35, window_duration_mins: Some(2 * 60), @@ -1199,6 +1204,7 @@ async fn status_snapshot_shows_unlimited_credits() { let snapshot = RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: None, secondary: None, credits: Some(CreditsSnapshot { @@ -1251,6 +1257,7 @@ async fn status_snapshot_shows_positive_credits() { let snapshot = RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: None, secondary: None, credits: Some(CreditsSnapshot { @@ -1312,6 +1319,7 @@ async fn status_snapshot_shows_available_credits_without_display_balance() { let snapshot = RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: None, secondary: None, credits: Some(CreditsSnapshot { @@ -1363,6 +1371,7 @@ async fn status_snapshot_respects_unlimited_without_has_credits_flag() { let snapshot = RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: None, secondary: None, credits: Some(CreditsSnapshot { @@ -1473,6 +1482,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { let snapshot = RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 72, window_duration_mins: Some(300), @@ -1678,6 +1688,7 @@ async fn status_snapshot_shows_refreshing_limits_notice() { let snapshot = RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 45, window_duration_mins: Some(300), @@ -1769,6 +1780,7 @@ async fn transcript_overlay_remeasures_status_after_rate_limit_refresh() { handle.finish_rate_limit_refresh( &[RateLimitSnapshotDisplay { limit_name: "spark".to_string(), + normal_model_slug: None, captured_at: now, primary: Some(RateLimitWindowDisplay { used_percent: 45.0, @@ -1831,6 +1843,7 @@ async fn status_snapshot_includes_credits_and_limits() { let snapshot = RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 45, window_duration_mins: Some(300), @@ -1899,6 +1912,7 @@ async fn status_snapshot_shows_unavailable_limits_message() { let snapshot = RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: None, secondary: None, credits: None, @@ -1958,6 +1972,7 @@ async fn status_snapshot_treats_refreshing_empty_limits_as_unavailable() { let snapshot = RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: None, secondary: None, credits: None, @@ -2023,6 +2038,7 @@ async fn status_snapshot_shows_stale_limits_message() { let snapshot = RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 72, window_duration_mins: Some(300), @@ -2092,6 +2108,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { let snapshot = RateLimitSnapshot { limit_id: None, limit_name: None, + normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 60, window_duration_mins: Some(300), diff --git a/codex-rs/tui/src/status/thread_usage.rs b/codex-rs/tui/src/status/thread_usage.rs index 49fd4c6c3d..60948689fd 100644 --- a/codex-rs/tui/src/status/thread_usage.rs +++ b/codex-rs/tui/src/status/thread_usage.rs @@ -263,6 +263,7 @@ fn grouped_usage( } fn format_model_display_name(model: &str) -> String { + let model = crate::model_catalog::model_display_name(model); let Some((prefix, remainder)) = model.split_once('-') else { return model.to_string(); };