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
This commit is contained in:
Benjamin Carlsson
2026-08-29 04:30:58 +00:00
committed by copyberry
parent 0b45b171ca
commit 5fc7840cf6
16 changed files with 609 additions and 18 deletions
+14 -2
View File
@@ -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,
+111
View File
@@ -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<ModelPreset> {
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<ModelPreset> {
let mut presets = all_model_presets();
for model in ["gpt-5.2", "gpt-5.4"] {
+10 -3
View File
@@ -982,6 +982,15 @@ pub(crate) enum AppEvent {
service_tier: Option<String>,
},
/// Fetch the current catalog even when cached models produce no picker.
FetchModels {
request_id: uuid::Uuid,
},
ModelsLoaded {
request_id: uuid::Uuid,
result: Result<Vec<ModelPreset>, 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<ModelPreset>,
},
OpenAllModelsPopup,
/// Open the confirmation prompt before enabling full access mode.
OpenFullAccessConfirmation {
+1
View File
@@ -5,6 +5,7 @@
mod fs;
mod history;
mod models;
mod rollout_history;
pub(crate) use history::HISTORY_ITEM_PAGE_LIMIT;
@@ -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<ModelPreset>) {
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::<ModelListResponse>(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 });
});
}
}
@@ -262,7 +262,7 @@ pub(crate) struct ListSelectionView {
active_tab_idx: Option<usize>,
state: ScrollState,
completion: Option<ViewCompletion>,
dismiss_after_child_accept: bool,
pub(super) dismiss_after_child_accept: bool,
app_event_tx: AppEventSender,
is_searchable: bool,
search_query: String,
+5 -2
View File
@@ -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();
}
+3
View File
@@ -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<ModelCatalog>,
model_popup_request_id: Option<uuid::Uuid>,
model_popup_model_ids: Vec<String>,
session_telemetry: SessionTelemetry,
session_header: SessionHeader,
initial_user_message: Option<UserMessage>,
@@ -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,
@@ -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<Vec<ModelPreset>, 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);
}
}
}
+31 -10
View File
@@ -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<dyn Renderable> {
@@ -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<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::OpenAllModelsPopup {
models: all_models.clone(),
});
let actions: Vec<SelectionAction> = 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<ModelPreset>) {
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<ModelPreset>,
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 <model_name> 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,
+1
View File
@@ -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();
@@ -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 <model_name> 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
@@ -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
@@ -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
@@ -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<ModelPreset>) {
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;