From f8580db4725c6a51a5ee2de0980a8b8b0659e143 Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Fri, 13 Nov 2026 15:57:11 +0000 Subject: [PATCH] Reflect accepted cloud model selections in TUI status (#61350) ## Why After a cloud model selection was accepted for future turns, status displays still showed the session's serving model and reasoning effort. A reasoning-only `/model` command also resolved against that model instead of the accepted selection. ## What changed - Show the accepted model's display name and reasoning effort in the status line, terminal title, and `/status`. - Resolve reasoning-only `/model` commands against the selected model. - Share the session-only model change confirmation between cloud and local selections, including reasoning effort and service tier when present. ## Testing Extend cloud model admission coverage to check accepted selections in status output, reasoning-only commands, and unchanged status before acceptance and after session metadata clears the selection. GitOrigin-RevId: cc50c2b0a98e884138ad2c2339c79c28ee3d346a --- codex-rs/tui/src/app/model_defaults.rs | 45 ++++++++++------- .../tui/src/app/tests/model_defaults_tests.rs | 49 ++++++++++++++++++- codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/model_command.rs | 2 +- codex-rs/tui/src/chatwidget/service_tiers.rs | 6 +-- .../tui/src/chatwidget/status_controls.rs | 13 +++-- .../tui/src/chatwidget/status_surfaces.rs | 25 ++++++++-- 7 files changed, 108 insertions(+), 33 deletions(-) diff --git a/codex-rs/tui/src/app/model_defaults.rs b/codex-rs/tui/src/app/model_defaults.rs index 1e204d922c..7a8af3ac2c 100644 --- a/codex-rs/tui/src/app/model_defaults.rs +++ b/codex-rs/tui/src/app/model_defaults.rs @@ -122,10 +122,8 @@ impl App { ); return; }; - let label = Self::reasoning_label_for(&model, effort.as_ref()) - .map_or_else(String::new, |label| format!(" {label}")); let message = - format!("Selected {model}{label} for the next new turn in this conversation"); + session_model_confirmation(&model, effort.as_ref(), service_tier.as_deref()); match app_server.stage_cloud_model_selection(thread_id, preset, effort) { Ok(dropped_tier) => { self.chat_widget.sync_service_tier_commands(); @@ -227,22 +225,7 @@ impl App { } self.sync_active_thread_service_tier_to_cached_session() .await; - let mut message = format!("Model changed to {model}"); - if let Some(label) = Self::reasoning_label_for(&model, effort.as_ref()) { - message.push(' '); - message.push_str(&label); - } - if let Some(service_tier) = service_tier { - message.push(' '); - message.push_str(if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE { - "standard" - } else if service_tier == ServiceTier::Fast.request_value() { - "fast" - } else { - &service_tier - }); - } - message.push_str(" for this session only"); + let message = session_model_confirmation(&model, effort.as_ref(), service_tier.as_deref()); self.chat_widget.add_info_message(message, /*hint*/ None); } @@ -261,3 +244,27 @@ impl App { Ok(()) } } + +fn session_model_confirmation( + model: &str, + effort: Option<&ReasoningEffort>, + service_tier: Option<&str>, +) -> String { + let mut message = format!("Model changed to {model}"); + if let Some(label) = App::reasoning_label_for(model, effort) { + message.push(' '); + message.push_str(&label); + } + if let Some(service_tier) = service_tier { + message.push(' '); + message.push_str(if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE { + "standard" + } else if service_tier == ServiceTier::Fast.request_value() { + "fast" + } else { + service_tier + }); + } + message.push_str(" for this session only"); + message +} diff --git a/codex-rs/tui/src/app/tests/model_defaults_tests.rs b/codex-rs/tui/src/app/tests/model_defaults_tests.rs index 5a83087498..37a2a8332d 100644 --- a/codex-rs/tui/src/app/tests/model_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/model_defaults_tests.rs @@ -442,6 +442,10 @@ async fn cloud_model_selection_waits_for_native_admission() -> Result<()> { app.app_server_target = crate::AppServerTarget::Cloud; app.bind_backend_workspace(&server, app.config.cwd.to_path_buf()); + app.chat_widget.setup_status_line( + vec![crate::bottom_pane::StatusLineItem::ModelWithReasoning], + /*use_theme_colors*/ false, + ); app.chat_widget .set_feature_enabled(Feature::UltrafastMode, /*enabled*/ true); app.chat_widget.open_model_popup(); @@ -456,6 +460,7 @@ async fn cloud_model_selection_waits_for_native_admission() -> Result<()> { .iter_mut() .find(|model| model.model == "gpt-5.6-terra") .expect("Cloud model"); + selected.display_name = "Cloud Terra".into(); selected.default_service_tier = Some("ultrafast".into()); selected.service_tiers = vec![codex_protocol::openai_models::ModelServiceTier { id: "ultrafast".into(), @@ -465,6 +470,7 @@ async fn cloud_model_selection_waits_for_native_admission() -> Result<()> { assert!(app.chat_widget.on_models_loaded(request_id, Ok(models))); app.chat_widget.handle_key_event(KeyCode::Esc.into()); while events.try_recv().is_ok() {} + let original_status_line = app.chat_widget.status_line_text(); let event = AppEvent::ApplyModelSelection { model: "gpt-5.6-terra".into(), @@ -473,6 +479,7 @@ async fn cloud_model_selection_waits_for_native_admission() -> Result<()> { Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; assert!(commands.try_recv().is_err()); assert!(app.chat_widget.cloud_picker_selection.is_none()); + assert_eq!(app.chat_widget.status_line_text(), original_status_line); let messages = std::iter::from_fn(|| events.try_recv().ok()) .filter_map(|event| match event { AppEvent::InsertHistoryCell(cell) => { @@ -484,7 +491,7 @@ async fn cloud_model_selection_waits_for_native_admission() -> Result<()> { insta::allow_duplicates! { insta::assert_snapshot!( messages.join("\n"), - @"• Selected gpt-5.6-terra high for the next new turn in this conversation" + @"• Model changed to gpt-5.6-terra high for this session only" ); } app.chat_widget.apply_external_edit("continue".into()); @@ -542,6 +549,45 @@ async fn cloud_model_selection_waits_for_native_admission() -> Result<()> { admitted.then_some(("gpt-5.6-terra", Some(ReasoningEffortConfig::High))), ); if accepted.is_some() { + let status_line = app.chat_widget.status_line_text().expect("status line"); + while events.try_recv().is_ok() {} + app.chat_widget + .dispatch_command(crate::slash_command::SlashCommand::Status); + let AppEvent::InsertHistoryCell(status) = events.try_recv()? else { + panic!("expected /status output"); + }; + let status = lines_to_single_string(&status.display_lines(/*width*/ 120)); + let model = status + .lines() + .find_map(|line| line.split_once("Model:")) + .expect("model row in /status") + .1 + .trim() + .trim_end_matches('│') + .trim_end(); + insta::allow_duplicates! { + insta::assert_snapshot!(format!("{status_line}\nModel: {model}"), @r" + Cloud Terra high + Model: Cloud Terra (reasoning high) + "); + } + app.chat_widget + .apply_external_edit("/model --reasoning=medium".into()); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + let mut messages = Vec::new(); + while let Ok(event) = events.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = &event { + messages.push(lines_to_single_string(&cell.transcript_lines(/*width*/ 80))); + } + Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; + } + insta::allow_duplicates! { + insta::assert_snapshot!( + messages.join("\n"), + @"• Model changed to gpt-5.6-terra medium for this session only" + ); + } app.chat_widget.open_model_popup(); let popup = render_bottom_popup(&app.chat_widget, /*width*/ 80); let label = popup.lines().find(|line| line.contains("Accepted for")); @@ -554,6 +600,7 @@ async fn cloud_model_selection_waits_for_native_admission() -> Result<()> { )); assert!(app.chat_widget.cloud_picker_selection.is_none()); } + assert_eq!(app.chat_widget.status_line_text(), original_status_line); } Ok(()) } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index dccbfb3e48..2e7b7115b6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -552,6 +552,7 @@ pub(crate) struct ChatWidget { has_codex_backend_auth: bool, model_catalog: Arc, model_popup_request_id: Option, + /// Cloud settings accepted for future turns. Serving model metadata comes from the session. pub(crate) cloud_picker_selection: Option, pub(crate) pending_cloud_turn_settings: diff --git a/codex-rs/tui/src/chatwidget/model_command.rs b/codex-rs/tui/src/chatwidget/model_command.rs index a4fa682896..6cf922450a 100644 --- a/codex-rs/tui/src/chatwidget/model_command.rs +++ b/codex-rs/tui/src/chatwidget/model_command.rs @@ -132,7 +132,7 @@ fn resolve_model_command(chat: &ChatWidget, args: &str) -> Result &str { + pub(super) fn selected_model(&self) -> &str { self.cloud_picker_selection.as_ref().map_or_else( || self.current_model(), |selection| selection.model.as_str(), @@ -152,7 +152,7 @@ impl ChatWidget { self.effective_service_tier = service_tier_resolution::effective_service_tier( &self.config, &self.local_settings.notices, - self.service_tier_model(), + self.selected_model(), &self.model_catalog.try_list_models().unwrap_or_default(), ); } diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs index 7fb8adb577..53df874062 100644 --- a/codex-rs/tui/src/chatwidget/status_controls.rs +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -220,7 +220,7 @@ impl ChatWidget { .map(|ti| &ti.total_token_usage) .unwrap_or(&default_usage); let collaboration_mode = self.collaboration_mode_label(); - let model = self.current_model().to_string(); + let model = self.selected_model().to_string(); let model_default_reasoning_effort = self.model_catalog .try_list_models() @@ -231,9 +231,14 @@ impl ChatWidget { .find(|preset| preset.model == model) .map(|preset| preset.default_reasoning_effort) }); + let configured_reasoning_effort = if self.cloud_picker_selection.is_none() { + self.config.model_reasoning_effort.clone() + } else { + None + }; let reasoning_effort_override = Some( - self.effective_reasoning_effort() - .or_else(|| self.config.model_reasoning_effort.clone()) + self.status_reasoning_effort() + .or(configured_reasoning_effort) .or(model_default_reasoning_effort), ); let rate_limit_snapshots: Vec = self @@ -258,7 +263,7 @@ impl ChatWidget { rate_limit_snapshots.as_slice(), self.plan_type, Local::now(), - self.model_display_name(), + self.status_model_display_name(), collaboration_mode, reasoning_effort_override, agents_summary, diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 79f1392f01..32f558f033 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -725,7 +725,7 @@ impl ChatWidget { /// git metadata. pub(super) fn status_line_value_for_item(&mut self, item: StatusLineItem) -> Option { match item { - StatusLineItem::ModelName => Some(self.model_display_name().to_string()), + StatusLineItem::ModelName => Some(self.status_model_display_name().to_string()), StatusLineItem::ModelWithReasoning => { let service_tier = self.processing_tier_display_name().map(str::to_lowercase); Some(self.model_with_reasoning_display_name(service_tier.as_deref())) @@ -972,7 +972,7 @@ impl ChatWidget { self.status_line_value_for_item(StatusLineItem::Daybreak) } TerminalTitleItem::Model => Some(Self::truncate_terminal_title_part( - self.model_display_name().to_string(), + self.status_model_display_name().to_string(), /*max_chars*/ 32, )), TerminalTitleItem::ModelWithReasoning => Some(Self::truncate_terminal_title_part( @@ -987,17 +987,32 @@ impl ChatWidget { } } + pub(super) fn status_model_display_name(&self) -> &str { + self.cloud_picker_selection.as_ref().map_or_else( + || self.model_display_name(), + |selection| self.model_catalog.display_name(&selection.model), + ) + } + + pub(super) fn status_reasoning_effort(&self) -> Option { + self.cloud_picker_selection.as_ref().map_or_else( + || self.effective_reasoning_effort(), + |selection| selection.effort.clone(), + ) + } + fn reasoning_display_name(&self) -> String { - let effort = self.effective_reasoning_effort(); + let effort = self.status_reasoning_effort(); Self::status_line_reasoning_effort_label(effort.as_ref()) } fn model_with_reasoning_display_name(&self, service_tier: Option<&str>) -> String { + let model = self.status_model_display_name(); let label = self.reasoning_display_name(); let service_tier_label = service_tier .map(|tier| format!(" {tier}")) .unwrap_or_default(); - format!("{} {label}{service_tier_label}", self.model_display_name()) + format!("{model} {label}{service_tier_label}") } fn processing_tier_display_name(&self) -> Option<&str> { @@ -1005,7 +1020,7 @@ impl ChatWidget { self.model_catalog .models .iter() - .find(|preset| preset.model == self.service_tier_model()) + .find(|preset| preset.model == self.selected_model()) .and_then(|preset| preset.service_tiers.iter().find(|tier| tier.id == id)) .map(|tier| tier.name.as_str()) .or_else(|| {