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
This commit is contained in:
Benjamin Carlsson
2026-11-13 18:48:50 +00:00
committed by copyberry
parent d87330d91e
commit f8580db472
7 changed files with 108 additions and 33 deletions
+26 -19
View File
@@ -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
}
@@ -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(())
}
+1
View File
@@ -552,6 +552,7 @@ pub(crate) struct ChatWidget {
has_codex_backend_auth: bool,
model_catalog: Arc<ModelCatalog>,
model_popup_request_id: Option<uuid::Uuid>,
/// Cloud settings accepted for future turns. Serving model metadata comes from the session.
pub(crate) cloud_picker_selection:
Option<crate::app_server_session::user_message_submission::CloudModelSelection>,
pub(crate) pending_cloud_turn_settings:
+1 -1
View File
@@ -132,7 +132,7 @@ fn resolve_model_command(chat: &ChatWidget, args: &str) -> Result<ModelCommand,
let model_name = args
.model
.clone()
.unwrap_or_else(|| chat.current_model().to_owned());
.unwrap_or_else(|| chat.selected_model().to_owned());
let Some(model) =
chat.model_catalog.models.iter().find(|model| {
model.model == model_name && (args.model.is_none() || model.show_in_picker)
+3 -3
View File
@@ -87,7 +87,7 @@ impl ChatWidget {
.and_then(|id| self.pending_cloud_turn_settings.as_ref()?.model(id));
let model = pending_model
.as_deref()
.unwrap_or_else(|| self.service_tier_model());
.unwrap_or_else(|| self.selected_model());
self.model_catalog
.try_list_models()
.ok()
@@ -136,7 +136,7 @@ impl ChatWidget {
.find(|tier| tier.name.eq_ignore_ascii_case(SPEED_TIER_FAST))
}
pub(super) fn service_tier_model(&self) -> &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(),
);
}
@@ -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<RateLimitSnapshotDisplay> = 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,
+20 -5
View File
@@ -725,7 +725,7 @@ impl ChatWidget {
/// git metadata.
pub(super) fn status_line_value_for_item(&mut self, item: StatusLineItem) -> Option<String> {
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<ReasoningEffortConfig> {
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(|| {