From 5fc7840cf6d085a7a7b3438d69a2beb934a2a5f4 Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Sat, 29 Aug 2026 04:13:27 +0000 Subject: [PATCH] Refresh the TUI model picker from the app server (#41467) ## Why The model picker can open from a cached startup catalog that no longer reflects the models available to the current account. ## What changed - Fetch the current model list asynchronously whenever the picker opens, while showing cached choices immediately. - Refresh an open model picker in place, preserving its highlighted model, dismissal behavior, and reasoning submenu. - Apply accepted catalog updates to model-dependent UI, service-tier controls, and new-thread defaults. Ignore stale, failed, empty, unchanged, or account-invalidated responses. ## Testing - Added coverage for catalog refreshes, stale and unusable responses, popup state preservation, empty choices, service tiers, and new-thread defaults. GitOrigin-RevId: 45abe1d309ff7f90701fffe29c6544ca4157f836 --- codex-rs/tui/src/app/event_dispatch.rs | 16 +- codex-rs/tui/src/app/tests/model_catalog.rs | 111 +++++++ codex-rs/tui/src/app_event.rs | 13 +- codex-rs/tui/src/app_server_session.rs | 1 + codex-rs/tui/src/app_server_session/models.rs | 50 ++++ .../src/bottom_pane/list_selection_view.rs | 2 +- codex-rs/tui/src/bottom_pane/mod.rs | 7 +- codex-rs/tui/src/chatwidget.rs | 3 + codex-rs/tui/src/chatwidget/constructor.rs | 2 + .../tui/src/chatwidget/model_popup_state.rs | 68 +++++ codex-rs/tui/src/chatwidget/model_popups.rs | 41 ++- codex-rs/tui/src/chatwidget/settings.rs | 1 + ...el_picker_refresh_preserves_highlight.snap | 13 + ...s__model_picker_refreshes_auto_models.snap | 12 + ...icker_refreshes_service_tier_controls.snap | 7 + .../chatwidget/tests/popups_and_settings.rs | 280 ++++++++++++++++++ 16 files changed, 609 insertions(+), 18 deletions(-) create mode 100644 codex-rs/tui/src/app_server_session/models.rs create mode 100644 codex-rs/tui/src/chatwidget/model_popup_state.rs create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refresh_preserves_highlight.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refreshes_auto_models.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refreshes_service_tier_controls.snap diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 84c22011bf..84840b0332 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1518,6 +1518,18 @@ impl App { } } } + AppEvent::FetchModels { request_id } => { + if self.chat_widget.model_popup_request_is_current(request_id) { + app_server.fetch_models(request_id, self.app_event_tx.clone()); + } + } + AppEvent::ModelsLoaded { request_id, result } => { + if self.chat_widget.on_models_loaded(request_id, result) { + self.model_catalog = self.chat_widget.model_catalog(); + app_server.set_available_models(self.model_catalog.try_list_models()?); + self.sync_active_thread_service_tier_to_cached_session().await; + } + } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); } @@ -1571,8 +1583,8 @@ impl App { self.chat_widget .open_plan_reasoning_scope_prompt(model, effort); } - AppEvent::OpenAllModelsPopup { models } => { - self.chat_widget.open_all_models_popup(models); + AppEvent::OpenAllModelsPopup => { + self.chat_widget.open_all_models_popup(); } AppEvent::OpenFullAccessConfirmation { preset, diff --git a/codex-rs/tui/src/app/tests/model_catalog.rs b/codex-rs/tui/src/app/tests/model_catalog.rs index b0eff0056c..88e1c125f4 100644 --- a/codex-rs/tui/src/app/tests/model_catalog.rs +++ b/codex-rs/tui/src/app/tests/model_catalog.rs @@ -1,6 +1,7 @@ use super::*; use assert_matches::assert_matches; use codex_config::types::ModelAvailabilityNuxConfig; +use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ModelAvailabilityNux; use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; @@ -9,6 +10,116 @@ fn all_model_presets() -> Vec { crate::test_support::TEST_MODEL_PRESETS.clone() } +#[tokio::test] +async fn model_picker_refresh_updates_app_catalog_from_app_server() -> Result<()> { + let (mut app, mut rx, _op_rx) = make_test_app_with_channels().await; + let mut app_server = start_config_write_test_app_server(&app).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let fast = Some(ServiceTier::Fast.request_value()); + let thread_id = ThreadId::new(); + let mut session = test_thread_session(thread_id, test_path_buf("/tmp/project")); + session.model = "gpt-5.4".to_string(); + app.primary_thread_id = Some(thread_id); + app.active_thread_id = Some(thread_id); + app.primary_session_configured = Some(session.clone()); + app.thread_event_channels.insert( + thread_id, + ThreadEventChannel::new_with_session(/*capacity*/ 4, session.clone(), Vec::new()), + ); + app.chat_widget.handle_thread_session(session.clone()); + app.chat_widget + .set_feature_enabled(Feature::FastMode, /*enabled*/ true); + app.chat_widget.open_model_popup(); + let request_id = std::iter::from_fn(|| rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::FetchModels { request_id } => Some(request_id), + _ => None, + }) + .expect("initial model request"); + let mut hidden = all_model_presets(); + for preset in &mut hidden { + preset.show_in_picker = false; + } + Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::ModelsLoaded { + request_id, + result: Ok(hidden), + }, + )) + .await?; + app.chat_widget.open_model_popup(); + assert!(app.chat_widget.no_modal_or_popup_active()); + let request = std::iter::from_fn(|| rx.try_recv().ok()) + .find(|event| matches!(event, AppEvent::FetchModels { .. })) + .expect("catalog request"); + + Box::pin(app.handle_event(&mut tui, &mut app_server, request)).await?; + let mut loaded = tokio::time::timeout(Duration::from_secs(/*secs*/ 10), async { + loop { + if let Some(event @ AppEvent::ModelsLoaded { .. }) = rx.recv().await { + break event; + } + } + }) + .await?; + let AppEvent::ModelsLoaded { + result: Ok(models), .. + } = &mut loaded + else { + panic!("model/list should return models"); + }; + assert!(!models.is_empty()); + for model in models.iter_mut() { + model.is_default = model.model == "gpt-5.4"; + model.default_service_tier = model + .is_default + .then(|| ServiceTier::Fast.request_value().to_string()); + } + let expected = models.clone(); + Box::pin(app.handle_event(&mut tui, &mut app_server, loaded)).await?; + assert!(app.chat_widget.no_modal_or_popup_active()); + app.chat_widget.open_model_popup(); + assert!(!app.chat_widget.no_modal_or_popup_active()); + + session.service_tier = fast.map(str::to_string); + let channel = &app.thread_event_channels[&thread_id]; + let cached = channel.store.lock().await.session.clone(); + assert_eq!( + (&app.primary_session_configured, &cached), + (&Some(session.clone()), &Some(session.clone())) + ); + app.chat_widget.handle_thread_session(test_thread_session( + ThreadId::new(), + test_path_buf("/tmp/other"), + )); + app.chat_widget.handle_thread_session(cached.unwrap()); + assert_eq!(app.chat_widget.current_service_tier(), fast); + let stale = AppEvent::ModelsLoaded { + request_id: uuid::Uuid::new_v4(), + result: Ok(all_model_presets()), + }; + Box::pin(app.handle_event(&mut tui, &mut app_server, stale)).await?; + assert_eq!( + ( + app.model_catalog.try_list_models().unwrap(), + app.chat_widget.model_catalog().try_list_models().unwrap(), + ), + (expected.clone(), expected) + ); + let mut config = app.config.clone(); + config.service_tier = None; + config.features.enable(Feature::FastMode)?; + for (model, expected_tier) in [(None, fast), (Some("gpt-5.2"), Some("default"))] { + config.model = model.map(str::to_string); + let started = app_server.start_thread(&config).await?; + assert_eq!(started.session.service_tier.as_deref(), expected_tier); + } + app_server.shutdown().await?; + Ok(()) +} + fn model_presets_with_test_upgrades() -> Vec { let mut presets = all_model_presets(); for model in ["gpt-5.2", "gpt-5.4"] { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index a8126066d9..23d7928cfe 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -982,6 +982,15 @@ pub(crate) enum AppEvent { service_tier: Option, }, + /// Fetch the current catalog even when cached models produce no picker. + FetchModels { + request_id: uuid::Uuid, + }, + ModelsLoaded { + request_id: uuid::Uuid, + result: Result, String>, + }, + /// Open the reasoning selection popup after picking a model. OpenReasoningPopup { model: ModelPreset, @@ -1005,9 +1014,7 @@ pub(crate) enum AppEvent { }, /// Open the full model picker (non-auto models). - OpenAllModelsPopup { - models: Vec, - }, + OpenAllModelsPopup, /// Open the confirmation prompt before enabling full access mode. OpenFullAccessConfirmation { diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 551ed157c2..c7908ca637 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -5,6 +5,7 @@ mod fs; mod history; +mod models; mod rollout_history; pub(crate) use history::HISTORY_ITEM_PAGE_LIMIT; diff --git a/codex-rs/tui/src/app_server_session/models.rs b/codex-rs/tui/src/app_server_session/models.rs new file mode 100644 index 0000000000..ca945f854f --- /dev/null +++ b/codex-rs/tui/src/app_server_session/models.rs @@ -0,0 +1,50 @@ +//! Fetch picker models without blocking the event loop and keep new-thread defaults in sync. + +use super::AppServerSession; +use super::model_preset_from_api_model; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ModelListParams; +use codex_app_server_protocol::ModelListResponse; +use codex_app_server_protocol::RequestId; +use codex_protocol::openai_models::ModelPreset; +use uuid::Uuid; + +impl AppServerSession { + pub(crate) fn set_available_models(&mut self, models: Vec) { + if let Some(default) = models + .iter() + .find(|model| model.is_default) + .or(models.first()) + { + self.default_model = Some(default.model.clone()); + } + self.available_models = models; + } + + pub(crate) fn fetch_models(&self, request_id: Uuid, app_event_tx: AppEventSender) { + let request_handle = self.request_handle(); + tokio::spawn(async move { + let result = request_handle + .request_typed::(ClientRequest::ModelList { + request_id: RequestId::String(format!("model-list-{request_id}")), + params: ModelListParams { + cursor: None, + limit: None, + include_hidden: Some(true), + }, + }) + .await + .map(|response| { + response + .data + .into_iter() + .map(model_preset_from_api_model) + .collect() + }) + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::ModelsLoaded { request_id, result }); + }); + } +} diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 7c01406612..7895bfd73b 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -262,7 +262,7 @@ pub(crate) struct ListSelectionView { active_tab_idx: Option, state: ScrollState, completion: Option, - dismiss_after_child_accept: bool, + pub(super) dismiss_after_child_accept: bool, app_event_tx: AppEventSender, is_searchable: bool, search_query: String, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 592c835e20..67cdb0a23d 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1198,6 +1198,7 @@ impl BottomPane { } /// Replace the newest matching selection view without disturbing views stacked above it. + /// Preserve pending parent cleanup when an already-open child is accepted. pub(crate) fn replace_selection_view_if_present( &mut self, view_id: &'static str, @@ -1213,11 +1214,13 @@ impl BottomPane { let replaces_active_view = index + 1 == self.view_stack.len(); self.apply_standard_popup_hint(&mut params); - self.view_stack[index] = Box::new(list_selection_view::ListSelectionView::new( + let mut view = list_selection_view::ListSelectionView::new( params, self.app_event_tx.clone(), self.keymap.list.clone(), - )); + ); + view.dismiss_after_child_accept = self.view_stack[index].dismiss_after_child_accept(); + self.view_stack[index] = Box::new(view); if replaces_active_view { self.schedule_active_view_frame(); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b5215116e6..ab529a0fd6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -384,6 +384,7 @@ use self::plugins::PluginListFetchState; use self::plugins::PluginsCacheState; mod plan_implementation; use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE; +mod model_popup_state; mod model_popups; mod notifications; use self::notifications::Notification; @@ -565,6 +566,8 @@ pub(crate) struct ChatWidget { has_chatgpt_account: bool, has_codex_backend_auth: bool, model_catalog: Arc, + model_popup_request_id: Option, + model_popup_model_ids: Vec, session_telemetry: SessionTelemetry, session_header: SessionHeader, initial_user_message: Option, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index c4fa4c9c77..7c3dec2ae6 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -119,6 +119,8 @@ impl ChatWidget { has_chatgpt_account, has_codex_backend_auth, model_catalog, + model_popup_request_id: None, + model_popup_model_ids: Vec::new(), session_telemetry, session_header: SessionHeader::new(header_model), initial_user_message, diff --git a/codex-rs/tui/src/chatwidget/model_popup_state.rs b/codex-rs/tui/src/chatwidget/model_popup_state.rs new file mode 100644 index 0000000000..ebc6a4111e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/model_popup_state.rs @@ -0,0 +1,68 @@ +//! Accept current account-scoped model replies and refresh present pickers without reopening them. + +use super::model_popups::ALL_MODELS_SELECTION_VIEW_ID; +use super::model_popups::MODEL_SELECTION_VIEW_ID; +use super::*; + +impl ChatWidget { + fn model_popup_view_id(&self) -> Option<&'static str> { + [MODEL_SELECTION_VIEW_ID, ALL_MODELS_SELECTION_VIEW_ID] + .into_iter() + .find(|view_id| { + self.bottom_pane + .selected_index_for_present_view(view_id) + .is_some() + }) + } + + pub(crate) fn model_popup_request_is_current(&self, request_id: uuid::Uuid) -> bool { + self.model_popup_request_id == Some(request_id) + } + + pub(crate) fn on_models_loaded( + &mut self, + request_id: uuid::Uuid, + result: Result, String>, + ) -> bool { + if !self.model_popup_request_is_current(request_id) { + return false; + } + self.model_popup_request_id = None; + let Ok(presets) = result else { + return false; + }; + if presets.is_empty() + || self.model_catalog.try_list_models().ok().as_ref() == Some(&presets) + { + return false; + } + self.model_catalog = Arc::new(ModelCatalog::new(presets.clone())); + self.refresh_effective_service_tier(); + self.refresh_model_dependent_surfaces(); + // 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(ALL_MODELS_SELECTION_VIEW_ID) => self.open_all_models_popup(), + _ => {} + } + true + } + + pub(super) fn show_model_selection_view(&mut self, mut params: SelectionViewParams) { + let selected_index = params + .view_id + .and_then(|view_id| self.bottom_pane.selected_index_for_present_view(view_id)); + let selected_model = selected_index.and_then(|index| self.model_popup_model_ids.get(index)); + params.initial_selected_idx = params + .items + .iter() + .position(|item| Some(&item.name) == selected_model); + self.model_popup_model_ids = params.items.iter().map(|item| item.name.clone()).collect(); + if let Some(view_id) = params.view_id.filter(|_| selected_index.is_some()) { + self.bottom_pane + .replace_selection_view_if_present(view_id, params); + } else { + self.bottom_pane.show_selection_view(params); + } + } +} diff --git a/codex-rs/tui/src/chatwidget/model_popups.rs b/codex-rs/tui/src/chatwidget/model_popups.rs index 0fb20b5e7c..0210428f4f 100644 --- a/codex-rs/tui/src/chatwidget/model_popups.rs +++ b/codex-rs/tui/src/chatwidget/model_popups.rs @@ -6,6 +6,8 @@ use super::*; const ULTRA_REASONING_CONCURRENCY_WARNING_THRESHOLD: usize = 8; +pub(super) const MODEL_SELECTION_VIEW_ID: &str = "model-selection"; +pub(super) const ALL_MODELS_SELECTION_VIEW_ID: &str = "all-models-selection"; impl ChatWidget { /// Open a popup to choose a quick auto model. Selecting "All models" @@ -29,7 +31,11 @@ impl ChatWidget { return; } }; + let request_id = uuid::Uuid::new_v4(); + self.model_popup_request_id = Some(request_id); self.open_model_popup_with_presets(presets); + // Show cached choices immediately and update any still-present picker when the reply arrives. + self.app_event_tx.send(AppEvent::FetchModels { request_id }); } fn model_menu_header(&self, title: &str, subtitle: &str) -> Box { @@ -89,7 +95,7 @@ impl ChatWidget { .partition(|preset| Self::is_auto_model(&preset.model)); if auto_presets.is_empty() { - self.open_all_models_popup(other_presets); + self.open_all_models_popup_with_view_id(other_presets, MODEL_SELECTION_VIEW_ID); return; } @@ -139,11 +145,8 @@ impl ChatWidget { .collect(); if !other_presets.is_empty() { - let all_models = other_presets; - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::OpenAllModelsPopup { - models: all_models.clone(), - }); + let actions: Vec = vec![Box::new(|tx| { + tx.send(AppEvent::OpenAllModelsPopup); })]; let is_current = !items.iter().any(|item| item.is_current); @@ -165,7 +168,8 @@ impl ChatWidget { "Select Model", "Pick a quick auto mode or browse all models.", ); - self.bottom_pane.show_selection_view(SelectionViewParams { + self.show_model_selection_view(SelectionViewParams { + view_id: Some(MODEL_SELECTION_VIEW_ID), footer_hint: Some(standard_popup_hint_line()), items, header, @@ -173,7 +177,7 @@ impl ChatWidget { }); } - fn is_auto_model(model: &str) -> bool { + pub(super) fn is_auto_model(model: &str) -> bool { model.starts_with("codex-auto-") } @@ -186,8 +190,24 @@ impl ChatWidget { } } - pub(crate) fn open_all_models_popup(&mut self, presets: Vec) { + pub(crate) fn open_all_models_popup(&mut self) { + let presets = self + .model_catalog + .try_list_models() + .unwrap_or_default() + .into_iter() + .filter(|preset| preset.show_in_picker && !Self::is_auto_model(&preset.model)) + .collect(); + self.open_all_models_popup_with_view_id(presets, ALL_MODELS_SELECTION_VIEW_ID); + } + + fn open_all_models_popup_with_view_id( + &mut self, + presets: Vec, + view_id: &'static str, + ) { if presets.is_empty() { + self.bottom_pane.dismiss_view_by_id(view_id); self.add_info_message( "No additional models are available right now.".to_string(), /*hint*/ None, @@ -224,7 +244,8 @@ impl ChatWidget { "Select Model and Effort", "Access legacy models by running codex -m or in your config.toml", ); - self.bottom_pane.show_selection_view(SelectionViewParams { + self.show_model_selection_view(SelectionViewParams { + view_id: Some(view_id), footer_hint: Some(self.bottom_pane.standard_popup_hint_line()), items, header, diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index d11445895b..75f3e8fba4 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -216,6 +216,7 @@ impl ChatWidget { ) { // Account-update notifications are the identity boundary. The visible account fields can // be identical across two accounts, so always invalidate account-scoped requests and data. + self.model_popup_request_id = None; self.invalidate_connector_scope(); self.clear_pending_token_activity_refreshes(); self.clear_pending_rate_limit_reset_requests(); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refresh_preserves_highlight.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refresh_preserves_highlight.snap new file mode 100644 index 0000000000..f838568bed --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refresh_preserves_highlight.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Select Model and Effort + Access legacy models by running codex -m or in your config.toml + +› 1. gpt-5.5 Frontier model for complex coding, research, and real- + world work. + 2. gpt-5.2 (current) Optimized for professional work and long-running + agents. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refreshes_auto_models.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refreshes_auto_models.snap new file mode 100644 index 0000000000..de697ffd62 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refreshes_auto_models.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Select Model + Pick a quick auto mode or browse all models. + + 1. codex-auto-test Auto model +› 2. All models (current) Choose a specific model and reasoning level + (current: gpt-5.2) + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refreshes_service_tier_controls.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refreshes_service_tier_controls.snap new file mode 100644 index 0000000000..d6477eeace --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refreshes_service_tier_controls.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: "render_bottom_popup(&chat, 80)" +--- +› /fast + + gpt-5.4 default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 17a52f8c07..f0cb0be02a 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -3236,6 +3236,286 @@ async fn model_selection_popup_snapshot() { assert_chatwidget_snapshot!("model_selection_popup", popup); } +fn apply_model_list_response(chat: &mut ChatWidget, presets: Vec) { + let request_id = chat.model_popup_request_id.expect("pending model request"); + assert!(chat.on_models_loaded(request_id, Ok(presets))); +} + +#[tokio::test] +async fn model_picker_refresh_rejects_obsolete_and_unusable_replies() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; + chat.thread_id = Some(ThreadId::new()); + let initial = chat.model_catalog.try_list_models().unwrap(); + let mut refreshed = initial.clone(); + refreshed[0].description = "Updated model details".to_string(); + chat.open_model_popup(); + let old_request = chat.model_popup_request_id.unwrap(); + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + chat.open_model_popup(); + let current_request = chat.model_popup_request_id.unwrap(); + assert!(!chat.on_models_loaded(old_request, Ok(refreshed.clone()))); + assert!(chat.model_popup_request_is_current(current_request)); + // Identical visible account fields still mark an account boundary. + chat.update_account_state( + chat.status_account_display.clone(), + chat.plan_type, + chat.has_chatgpt_account, + chat.has_codex_backend_auth, + ); + assert!(!chat.on_models_loaded(current_request, Ok(refreshed.clone()))); + assert_eq!(chat.model_catalog.try_list_models().unwrap(), initial); + + for result in [ + Err("unavailable".to_string()), + Ok(Vec::new()), + Ok(initial.clone()), + ] { + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + chat.open_model_popup(); + let request_id = chat.model_popup_request_id.unwrap(); + let before = render_bottom_popup(&chat, /*width*/ 80); + assert!(!chat.on_models_loaded(request_id, result)); + assert!(!chat.model_popup_request_is_current(request_id)); + assert_eq!(chat.model_catalog.try_list_models().unwrap(), initial); + assert_eq!(render_bottom_popup(&chat, /*width*/ 80), before); + } + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + chat.open_model_popup(); + apply_model_list_response(&mut chat, refreshed.clone()); + assert_eq!(chat.model_catalog.try_list_models().unwrap(), refreshed); +} + +#[tokio::test] +async fn model_picker_refreshes_startup_catalog() { + for (explicit_all_models, hidden_startup) in [(false, false), (true, false), (false, true)] { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; + chat.thread_id = Some(ThreadId::new()); + let mut startup = vec![get_available_model(&chat, "gpt-5.2")]; + let mut refreshed = chat.model_catalog.try_list_models().unwrap(); + let mut auto = startup[0].clone(); + auto.model = "codex-auto-test".to_string(); + auto.id = auto.model.clone(); + auto.description = "Auto model".to_string(); + refreshed.push(auto); + startup[0].show_in_picker = !hidden_startup; + chat.model_catalog = Arc::new(ModelCatalog::new(startup.clone())); + chat.open_model_popup(); + assert!( + std::iter::from_fn(|| rx.try_recv().ok()) + .any(|event| matches!(event, AppEvent::FetchModels { .. })) + ); + if explicit_all_models { + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + chat.open_all_models_popup(); + } + + apply_model_list_response(&mut chat, refreshed.clone()); + if hidden_startup { + assert!(chat.no_modal_or_popup_active()); + chat.open_model_popup(); + } + + assert_eq!(chat.model_catalog.try_list_models().unwrap(), refreshed); + insta::allow_duplicates! { + assert_chatwidget_snapshot!( + if explicit_all_models { + "model_selection_popup" + } else { + "model_picker_refreshes_auto_models" + }, + render_bottom_popup(&chat, /*width*/ 80) + ); + } + } +} + +#[tokio::test] +async fn model_picker_queued_all_models_uses_refreshed_catalog() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; + chat.thread_id = Some(ThreadId::new()); + let refreshed = chat.model_catalog.try_list_models().unwrap(); + let preset = get_available_model(&chat, "gpt-5.2"); + let mut auto = preset.clone(); + auto.model = "codex-auto-test".to_string(); + auto.id = auto.model.clone(); + chat.model_catalog = Arc::new(ModelCatalog::new(vec![auto, preset])); + chat.open_model_popup(); + assert_matches!(rx.try_recv(), Ok(AppEvent::FetchModels { .. })); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_eq!(chat.bottom_pane.active_view_id(), None); + // Apply the model/list reply before the queued All models intent is handled. + apply_model_list_response(&mut chat, refreshed); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenAllModelsPopup)); + chat.open_all_models_popup(); + assert_chatwidget_snapshot!( + "model_selection_popup", + render_bottom_popup(&chat, /*width*/ 80) + ); +} + +#[tokio::test] +async fn model_picker_refresh_preserves_highlight() { + for (remove_selected, reasoning_submenu, expected) in [ + (false, false, "gpt-5.5"), + (true, false, "gpt-5.2"), + (false, true, "gpt-5.5"), + (true, true, "gpt-5.2"), + ] { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; + chat.thread_id = Some(ThreadId::new()); + let mut presets = vec![ + get_available_model(&chat, "gpt-5.2"), + get_available_model(&chat, "gpt-5.5"), + ]; + chat.model_catalog = Arc::new(ModelCatalog::new(presets.clone())); + chat.open_model_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + if reasoning_submenu { + chat.open_reasoning_popup(presets[1].clone()); + } + let before = render_bottom_popup(&chat, /*width*/ 80); + presets.reverse(); + if remove_selected { + presets.remove(/*index*/ 0); + } + apply_model_list_response(&mut chat, presets); + if reasoning_submenu { + assert_eq!(render_bottom_popup(&chat, /*width*/ 80), before); + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + } + if !remove_selected { + insta::allow_duplicates! { + assert_chatwidget_snapshot!( + "model_picker_refresh_preserves_highlight", + render_bottom_popup(&chat, /*width*/ 80) + ); + } + } + while rx.try_recv().is_ok() {} + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let selected = assert_matches!(rx.try_recv(), Ok(AppEvent::OpenReasoningPopup { model }) => model.model); + assert_eq!(selected, expected); + } +} + +#[tokio::test] +async fn model_picker_refreshes_service_tier_controls() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + chat.thread_id = Some(ThreadId::new()); + set_fast_mode_test_catalog(&mut chat); + chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); + chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string())); + let mut refreshed = chat.model_catalog.try_list_models().unwrap(); + refreshed + .iter_mut() + .for_each(|model| model.service_tiers.clear()); + chat.open_model_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + apply_model_list_response(&mut chat, refreshed); + + assert_eq!(chat.current_service_tier(), None); + chat.bottom_pane + .set_composer_text("/fast".to_string(), Vec::new(), Vec::new()); + assert_chatwidget_snapshot!( + "model_picker_refreshes_service_tier_controls", + normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80)) + ); +} + +#[tokio::test] +async fn model_picker_refresh_preserves_dismissal_and_reasoning_submenu() { + for (dismiss, explicit_all_models, accept_reasoning) in [ + (true, false, false), + (false, false, false), + (false, true, false), + (false, false, true), + (false, true, true), + ] { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; + chat.thread_id = Some(ThreadId::new()); + let preset = get_available_model(&chat, "gpt-5.2"); + let refreshed = chat.model_catalog.try_list_models().unwrap(); + chat.model_catalog = Arc::new(ModelCatalog::new(vec![preset.clone()])); + chat.open_model_popup(); + assert_matches!(rx.try_recv(), Ok(AppEvent::FetchModels { .. })); + if explicit_all_models { + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + chat.open_all_models_popup(); + } + if dismiss { + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + } else { + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let model = + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenReasoningPopup { model }) => model); + chat.open_reasoning_popup(model); + } + let before = render_bottom_popup(&chat, /*width*/ 80); + + apply_model_list_response(&mut chat, refreshed.clone()); + + assert_eq!(chat.model_catalog.try_list_models().unwrap(), refreshed); + assert_eq!(render_bottom_popup(&chat, /*width*/ 80), before); + if !dismiss { + chat.handle_key_event(KeyEvent::from(if accept_reasoning { + KeyCode::Enter + } else { + KeyCode::Esc + })); + } + if dismiss || accept_reasoning { + assert!(chat.no_modal_or_popup_active()); + } else { + insta::allow_duplicates! { + assert_chatwidget_snapshot!( + "model_selection_popup", + render_bottom_popup(&chat, /*width*/ 80) + ); + } + } + } +} + +#[tokio::test] +async fn model_picker_refresh_dismisses_empty_choices() { + for (explicit_all_models, reasoning_submenu) in + [(false, false), (true, false), (false, true), (true, true)] + { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; + chat.thread_id = Some(ThreadId::new()); + let mut preset = get_available_model(&chat, "gpt-5.2"); + chat.open_model_popup(); + if explicit_all_models { + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + chat.open_all_models_popup(); + } + if reasoning_submenu { + chat.open_reasoning_popup(preset.clone()); + } + let before = render_bottom_popup(&chat, /*width*/ 80); + if explicit_all_models { + preset.model = "codex-auto-test".to_string(); + } else { + preset.show_in_picker = false; + } + while rx.try_recv().is_ok() {} + apply_model_list_response(&mut chat, vec![preset]); + if reasoning_submenu { + assert_eq!(render_bottom_popup(&chat, /*width*/ 80), before); + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + } + assert_eq!(chat.bottom_pane.active_view_id(), None); + let cell = assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(cell)) => cell); + insta::allow_duplicates! { + insta::assert_snapshot!( + lines_to_single_string(&cell.display_lines(/*width*/ 80)), + @"• No additional models are available right now." + ); + } + } +} + #[tokio::test] async fn personality_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;