Allow session-only model and reasoning selection in the TUI (#45831)

## Why

Users need to change the active session's model and reasoning effort without replacing saved defaults for future threads.

## What changed

- Add an `s` shortcut to final model and reasoning choices, with footer hints and a session-only confirmation.
- Apply the selection to the active thread while preserving saved configuration, including separate Plan mode defaults.
- Restore Plan mode reasoning effort with thread input state and suppress the shortcut when it conflicts with configured list bindings.

## Testing

Add coverage for picker shortcuts, active-thread updates, unchanged configuration and fresh-thread defaults, Plan mode restoration, and shortcut conflicts. Update picker snapshots to show the new hints.

GitOrigin-RevId: 07fd129021cc076e3a9bdee04b71979212788eb4
This commit is contained in:
Eric Traut
2026-09-16 01:50:50 +00:00
committed by copyberry
parent ca99b271d4
commit 0dfb28edb9
22 changed files with 465 additions and 29 deletions
+3
View File
@@ -2131,6 +2131,9 @@ impl App {
}
}
}
AppEvent::SelectSessionModel { model, effort } => {
self.select_session_model(app_server, model, effort).await;
}
AppEvent::CyberModelAutoReviewNotice => {
self.chat_widget.add_warning_message(
"Cyber models default to \"Approve for me\" for safety reasons.".to_string(),
+59 -1
View File
@@ -1,15 +1,73 @@
//! Saved model-default feedback, independent of active thread settings.
//! Apply session model choices separately from saved model defaults.
//!
//! A successful config write can still be overridden. Report that distinction without
//! replacing the active task's explicit selection with launch-time configuration.
use super::App;
use crate::app_server_session::AppServerSession;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_protocol::ConfigEdit;
use codex_app_server_protocol::WriteStatus;
use codex_protocol::config_types::ModeKind;
use codex_protocol::openai_models::ReasoningEffort;
use color_eyre::eyre::Result;
impl App {
pub(super) async fn select_session_model(
&mut self,
app_server: &mut AppServerSession,
model: String,
effort: Option<ReasoningEffort>,
) {
let model_changed = self.chat_widget.current_model() != model
|| self.chat_widget.current_collaboration_mode().model() != model;
if model_changed
&& self
.active_thread_model_setting_update_params(model.clone())
.is_some_and(|params| params.permissions.is_some())
&& self.reject_pending_permission_change()
{
return;
}
let in_plan_mode = self.chat_widget.effective_collaboration_mode().mode == ModeKind::Plan;
let ultra = effort == Some(ReasoningEffort::Ultra);
let clear_default_ultra = self
.chat_widget
.current_collaboration_mode()
.reasoning_effort()
== Some(ReasoningEffort::Ultra)
&& self.config.model_reasoning_effort != Some(ReasoningEffort::Ultra);
let clear_plan_ultra = self.chat_widget.config_ref().plan_mode_reasoning_effort
== Some(ReasoningEffort::Ultra)
&& self.config.plan_mode_reasoning_effort != Some(ReasoningEffort::Ultra);
self.chat_widget.set_model(&model);
if !in_plan_mode || ultra || clear_default_ultra {
self.chat_widget.set_reasoning_effort(effort.clone());
}
if in_plan_mode || ultra || clear_plan_ultra {
self.chat_widget
.set_plan_mode_reasoning_effort(effort.clone());
}
if model_changed {
self.sync_active_thread_model_setting(app_server, model.clone(), effort.clone())
.await;
} else if let Some(mut params) =
self.active_thread_reasoning_setting_update_params(effort.clone())
{
params.collaboration_mode = Some(self.chat_widget.effective_collaboration_mode());
self.send_thread_settings_update(app_server, params).await;
}
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);
}
message.push_str(" for this session only");
self.chat_widget.add_info_message(message, /*hint*/ None);
}
pub(super) async fn persist_model_defaults(
&mut self,
request_handle: AppServerRequestHandle,
+31 -2
View File
@@ -1789,6 +1789,8 @@ async fn replay_thread_snapshot_restores_collaboration_mode_without_input() {
reasoning_effort: Some(Some(ReasoningEffortConfig::High)),
developer_instructions: None,
});
app.chat_widget
.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::High));
let input_state = app
.chat_widget
.capture_thread_input_state()
@@ -1796,6 +1798,8 @@ async fn replay_thread_snapshot_restores_collaboration_mode_without_input() {
let (chat_widget, _app_event_tx, _rx, _new_op_rx) = make_chatwidget_manual_with_sender().await;
app.chat_widget = chat_widget;
app.chat_widget
.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::Low));
app.chat_widget.handle_thread_session(session.clone());
app.chat_widget
.set_reasoning_effort(Some(ReasoningEffortConfig::Low));
@@ -1829,6 +1833,14 @@ async fn replay_thread_snapshot_restores_collaboration_mode_without_input() {
app.chat_widget.current_reasoning_effort(),
Some(ReasoningEffortConfig::High)
);
for _ in 0..2 {
app.chat_widget
.handle_key_event(KeyEvent::from(KeyCode::BackTab));
}
assert_eq!(
app.chat_widget.current_reasoning_effort(),
Some(ReasoningEffortConfig::High)
);
}
#[tokio::test]
@@ -8949,8 +8961,25 @@ async fn changing_cyber_model_reasoning_preserves_selected_permissions() {
.expect("primary thread should be registered");
let mut tui = crate::tui::test_support::make_test_tui().expect("test tui");
for effort in [ReasoningEffortConfig::High, ReasoningEffortConfig::Ultra] {
if effort == ReasoningEffortConfig::Ultra {
for (effort, session_only) in [
(ReasoningEffortConfig::High, false),
(ReasoningEffortConfig::Medium, true),
(ReasoningEffortConfig::Ultra, false),
(ReasoningEffortConfig::High, true),
(ReasoningEffortConfig::Ultra, true),
] {
if session_only {
app.handle_event(
&mut tui,
&mut app_server,
AppEvent::SelectSessionModel {
model: model_name.clone(),
effort: Some(effort.clone()),
},
)
.await
.expect("session-only reasoning selection should succeed");
} else if effort == ReasoningEffortConfig::Ultra {
app.chat_widget
.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
app.chat_widget
@@ -1,8 +1,88 @@
use super::*;
use crate::collaboration_modes;
use codex_app_server_client::AppServerClient;
use codex_config::LoaderOverrides;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn session_model_selection_preserves_defaults_and_updates_active_thread() -> Result<()> {
for mode in [ModeKind::Default, ModeKind::Plan] {
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
app.config.model = Some("gpt-5.5".into());
app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium);
app.config.plan_mode_reasoning_effort = Some(ReasoningEffortConfig::Low);
let config_path = app.config.codex_home.join("config.toml");
let original = "# My defaults\nmodel = 'gpt-5.5'\nmodel_reasoning_effort = 'medium'\nplan_mode_reasoning_effort = 'low'\n";
std::fs::write(&config_path, original)?;
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let started = server.start_thread(&app.config).await?;
let thread_id = started.session.thread_id;
app.enqueue_primary_thread_session(started.session, started.turns)
.await?;
app.chat_widget
.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
if mode == ModeKind::Plan {
let mask = collaboration_modes::plan_mask(app.model_catalog.as_ref()).unwrap();
app.chat_widget.set_collaboration_mask(mask);
}
let mut tui = crate::tui::test_support::make_test_tui()?;
for effort in [
ReasoningEffortConfig::High,
ReasoningEffortConfig::Ultra,
ReasoningEffortConfig::Max,
] {
while events.try_recv().is_ok() {}
Box::pin(app.handle_event(
&mut tui,
&mut server,
AppEvent::SelectSessionModel {
model: "gpt-5.6-terra".into(),
effort: Some(effort.clone()),
},
))
.await?;
let settings = next_thread_settings_updated(&mut server, thread_id).await;
assert_eq!(
(
settings.thread_settings.model.as_str(),
settings.thread_settings.effort.clone()
),
("gpt-5.6-terra", Some(effort.clone()))
);
if mode == ModeKind::Default && effort == ReasoningEffortConfig::High {
insta::assert_snapshot!(
"session_only_model_confirmation",
next_history_message(&mut events)
);
}
app.enqueue_thread_notification(
thread_id,
ServerNotification::ThreadSettingsUpdated(settings),
)
.await?;
assert_eq!(
(
app.chat_widget.current_model(),
app.chat_widget.current_reasoning_effort()
),
("gpt-5.6-terra", Some(effort))
);
assert_eq!(std::fs::read(&config_path)?, original.as_bytes());
}
let fresh = server.start_thread(&app.config).await?;
assert_eq!(
(fresh.session.model.as_str(), fresh.session.reasoning_effort),
("gpt-5.5", Some(ReasoningEffortConfig::Medium))
);
assert_eq!(
app.config.plan_mode_reasoning_effort,
Some(ReasoningEffortConfig::Low)
);
server.shutdown().await?;
}
Ok(())
}
#[tokio::test]
async fn model_default_saves_report_server_outcomes_and_target_server_profile() -> Result<()> {
for outcome in ["saved", "overridden", "rejected"] {
@@ -0,0 +1,5 @@
---
source: tui/src/app/tests/model_defaults_tests.rs
expression: next_history_message(&mut events)
---
• Model changed to gpt-5.6-terra high for this session only
+6
View File
@@ -1141,6 +1141,12 @@ pub(crate) enum AppEvent {
effort: Option<ReasoningEffort>,
},
/// Apply a model and effort only to the active session, preserving saved defaults.
SelectSessionModel {
model: String,
effort: Option<ReasoningEffort>,
},
/// Show the cyber auto-review notice after the model selection confirmation.
CyberModelAutoReviewNotice,
@@ -16,6 +16,7 @@ use super::selection_popup_common::render_menu_surface;
use super::selection_popup_common::wrap_styled_line;
use crate::app_event_sender::AppEventSender;
use crate::clipboard_paste::normalize_pasted_search_query;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::ShortcutHint;
use crate::key_hint::is_plain_text_key_event;
@@ -106,6 +107,18 @@ pub(crate) enum SelectionRowDisplay {
/// One selectable item in the generic selection list.
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
/// A second way to accept the highlighted row, sharing its dismissal behavior.
pub(crate) struct SelectionSecondaryAction {
pub key: KeyBinding,
pub action: SelectionAction,
pub footer_hint: Line<'static>,
}
enum SelectionActionKind {
Primary,
Secondary,
}
pub(crate) type SelectionToggleAction = dyn Fn(bool, &AppEventSender) + Send + Sync;
pub(crate) struct SelectionToggle {
@@ -142,6 +155,7 @@ pub(crate) struct SelectionItem {
pub is_default: bool,
pub is_disabled: bool,
pub actions: Vec<SelectionAction>,
pub secondary_action: Option<SelectionSecondaryAction>,
pub dismiss_on_select: bool,
/// Require an explicit accept key after a direct shortcut highlights this sensitive item.
pub require_explicit_confirmation: bool,
@@ -466,6 +480,13 @@ impl ListSelectionView {
}
fn active_footer_hint(&self) -> Option<&Line<'static>> {
if let Some(action) = self
.selected_actual_idx()
.and_then(|idx| self.active_items().get(idx))
.and_then(|item| item.secondary_action.as_ref())
{
return Some(&action.footer_hint);
}
self.active_tab_id()
.and_then(|active_tab_id| {
self.tab_footer_hints
@@ -784,30 +805,31 @@ impl ListSelectionView {
}
}
fn accept(&mut self) {
let selected_actual_idx = self
.state
.selected_idx
.and_then(|idx| self.filtered_indices.get(idx).copied());
let selected_is_enabled = selected_actual_idx
.and_then(|actual_idx| self.active_items().get(actual_idx))
.is_some_and(|item| item.disabled_reason.is_none() && !item.is_disabled);
if selected_is_enabled {
self.last_selected_actual_idx = selected_actual_idx;
let Some(actual_idx) = selected_actual_idx else {
fn accept(&mut self, kind: SelectionActionKind) {
let selected_actual_idx = self.selected_actual_idx();
if let Some(item) = selected_actual_idx.and_then(|idx| self.active_items().get(idx)) {
if item.is_disabled || item.disabled_reason.is_some() {
return;
};
let Some(item) = self.active_items().get(actual_idx) else {
return;
};
for act in &item.actions {
act(&self.app_event_tx);
}
match kind {
SelectionActionKind::Primary => {
for act in &item.actions {
act(&self.app_event_tx);
}
}
SelectionActionKind::Secondary => {
let Some(action) = &item.secondary_action else {
return;
};
(action.action)(&self.app_event_tx);
}
}
if item.dismiss_on_select {
self.completion = Some(ViewCompletion::Accepted);
} else if item.dismiss_parent_on_child_accept {
self.dismiss_after_child_accept = true;
}
self.last_selected_actual_idx = selected_actual_idx;
} else if selected_actual_idx.is_none() {
if let Some(cb) = &self.on_cancel {
cb(&self.app_event_tx);
@@ -828,7 +850,7 @@ impl ListSelectionView {
self.fire_selection_changed();
}
} else {
self.accept();
self.accept(SelectionActionKind::Primary);
}
}
@@ -1043,7 +1065,18 @@ impl BottomPaneView for ListSelectionView {
_ if self.allow_cancel && self.keymap.cancel.is_pressed(key_event) => {
self.on_ctrl_c();
}
_ if self.keymap.accept.is_pressed(key_event) => self.accept(),
_ if self.keymap.accept.is_pressed(key_event) => {
self.accept(SelectionActionKind::Primary)
}
_ if allow_plain_char_navigation
&& self
.selected_actual_idx()
.and_then(|idx| self.active_items().get(idx))
.and_then(|item| item.secondary_action.as_ref())
.is_some_and(|action| action.key.is_press(key_event)) =>
{
self.accept(SelectionActionKind::Secondary);
}
KeyEvent {
code: KeyCode::Char(c),
..
@@ -1510,6 +1543,49 @@ mod tests {
ListSelectionView::new(params, tx, crate::keymap::RuntimeKeymap::defaults().list)
}
#[test]
fn secondary_action_respects_search_disabled_rows_and_dismissal() {
for (is_searchable, is_disabled, key, expected) in [
(false, false, KeyCode::Char('s'), Some("secondary")),
(false, false, KeyCode::Enter, Some("primary")),
(false, true, KeyCode::Char('s'), None),
(true, false, KeyCode::Char('s'), None),
] {
let (tx, mut rx) = unbounded_channel();
let mut view = new_view(
SelectionViewParams {
is_searchable,
items: vec![SelectionItem {
name: "Selection".into(),
is_disabled,
dismiss_on_select: true,
actions: vec![Box::new(|tx| {
tx.send(AppEvent::UpdateModel("primary".into()))
})],
secondary_action: Some(SelectionSecondaryAction {
key: crate::key_hint::plain(KeyCode::Char('s')),
action: Box::new(|tx| {
tx.send(AppEvent::UpdateModel("secondary".into()))
}),
footer_hint: "s for secondary".into(),
}),
..Default::default()
}],
..Default::default()
},
AppEventSender::new(tx),
);
view.handle_key_event(KeyEvent::from(key));
let actual = rx.try_recv().ok().map(|event| match event {
AppEvent::UpdateModel(model) => model,
event => panic!("unexpected event: {event:?}"),
});
assert_eq!(actual.as_deref(), expected);
assert_eq!(view.completion, expected.map(|_| ViewCompletion::Accepted));
assert_eq!(view.search_query, if is_searchable { "s" } else { "" });
}
}
fn make_selection_view(subtitle: Option<&str>) -> ListSelectionView {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
+13
View File
@@ -233,6 +233,7 @@ pub(crate) use list_selection_view::SELECTION_TOGGLE_BLOCKED_PREFIX;
pub(crate) use list_selection_view::SELECTION_TOGGLE_UNAVAILABLE_PREFIX;
pub(crate) use list_selection_view::SelectionAction;
pub(crate) use list_selection_view::SelectionItem;
pub(crate) use list_selection_view::SelectionSecondaryAction;
struct DelayedApprovalRequest {
request: ApprovalRequest,
@@ -1301,6 +1302,18 @@ impl BottomPane {
}
fn apply_standard_popup_hint(&self, params: &mut list_selection_view::SelectionViewParams) {
// Configured list actions take precedence over optional row shortcuts.
for item in &mut params.items {
if item.secondary_action.as_ref().is_some_and(|secondary| {
let (code, modifiers) = secondary.key.parts();
self.keymap
.list
.action_for(KeyEvent::new(code, modifiers))
.is_some()
}) {
item.secondary_action = None;
}
}
if !params.allow_cancel {
if params.footer_hint.is_none()
|| params.footer_hint.as_ref() == Some(&popup_consts::standard_popup_hint_line())
+1
View File
@@ -380,6 +380,7 @@ use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE;
mod model_popup_state;
mod model_popups;
mod notifications;
mod session_model_selection;
use self::notifications::Notification;
mod permission_discovery;
mod permission_popups;
@@ -549,6 +549,7 @@ impl ChatWidget {
.submit_pending_steers_after_interrupt,
current_collaboration_mode: self.current_collaboration_mode.clone(),
active_collaboration_mask: self.active_collaboration_mask.clone(),
plan_mode_reasoning_effort: self.config.plan_mode_reasoning_effort.clone(),
task_running: self.bottom_pane.is_task_running(),
agent_turn_running: self.turn_lifecycle.agent_turn_running,
})
@@ -567,6 +568,7 @@ impl ChatWidget {
self.input_queue.recovered_queue = input_state.recovered_queue;
self.current_collaboration_mode = input_state.current_collaboration_mode;
self.active_collaboration_mask = input_state.active_collaboration_mask;
self.config.plan_mode_reasoning_effort = input_state.plan_mode_reasoning_effort;
self.safety_buffering_prompt = input_state.safety_buffering_prompt;
self.safety_buffering_source = input_state.safety_buffering_source;
self.turn_lifecycle.restore_running(
+23 -2
View File
@@ -141,6 +141,14 @@ impl ChatWidget {
description,
is_current: model.as_str() == current_model,
is_default: preset.is_default,
secondary_action: if requires_advanced_selection {
None
} else {
self.session_model_selection_action(
model.clone(),
Some(preset.default_reasoning_effort),
)
},
actions,
dismiss_on_select: !requires_advanced_selection,
dismiss_parent_on_child_accept: requires_advanced_selection,
@@ -232,7 +240,13 @@ impl ChatWidget {
let description =
(!preset.description.is_empty()).then_some(preset.description.to_string());
let is_current = preset.model.as_str() == self.current_model();
let single_supported_effort = preset.supported_reasoning_efforts.len() == 1;
let direct_effort = match preset.supported_reasoning_efforts.as_slice() {
[] => Some(preset.default_reasoning_effort.clone()),
[option] => Some(option.effort.clone()),
_ => None,
}
.filter(|effort| !Self::is_advanced_reasoning_effort(effort));
let single_supported_effort = direct_effort.is_some();
let preset_for_action = preset.clone();
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
let preset_for_event = preset_for_action.clone();
@@ -245,6 +259,9 @@ impl ChatWidget {
description,
is_current,
is_default: preset.is_default,
secondary_action: direct_effort.and_then(|effort| {
self.session_model_selection_action(preset.model.clone(), Some(effort))
}),
actions,
dismiss_on_select: single_supported_effort,
dismiss_parent_on_child_accept: !single_supported_effort,
@@ -555,7 +572,7 @@ impl ChatWidget {
);
let actions = self.model_selection_actions(
model_slug.clone(),
choice_effort,
choice_effort.clone(),
should_prompt_plan_mode_scope,
);
@@ -564,6 +581,8 @@ impl ChatWidget {
description,
selected_description,
is_current: is_current_model && Some(choice) == highlight_choice.as_ref(),
secondary_action: self
.session_model_selection_action(model_slug.clone(), choice_effort),
actions,
dismiss_on_select: true,
..Default::default()
@@ -660,6 +679,8 @@ impl ChatWidget {
name: Self::reasoning_effort_label(&effort),
description: Some(description.to_string()),
is_current: is_current_model && Some(&effort) == highlight_choice.as_ref(),
secondary_action: self
.session_model_selection_action(model_slug.clone(), Some(effort.clone())),
actions,
dismiss_on_select: true,
..Default::default()
@@ -0,0 +1,52 @@
//! Session-only model picker actions leave durable defaults untouched.
use super::*;
use crate::bottom_pane::SelectionSecondaryAction;
use crate::keymap::ListAction;
use crate::model_catalog::LUNA_RESERVE_MODEL;
impl ChatWidget {
pub(super) fn session_model_selection_action(
&self,
model: String,
effort: Option<ReasoningEffortConfig>,
) -> Option<SelectionSecondaryAction> {
// Reserve selections are already session-only.
if model == LUNA_RESERVE_MODEL {
return None;
}
let key = key_hint::plain(KeyCode::Char('s'));
let keymap = self.bottom_pane.list_keymap();
let mut hints = Vec::new();
if let Some(accept) = keymap.primary_hint(ListAction::Accept) {
let label = if effort == Some(ReasoningEffortConfig::Ultra) {
" to apply · "
} else {
" to set as default · "
};
hints.extend([accept.into(), label.into()]);
}
hints.extend([key.into(), " for this session".into()]);
if let Some(cancel) = keymap.primary_hint(ListAction::Cancel) {
hints.extend([" · ".into(), cancel.into(), " to go back".into()]);
}
let warning = effort
.as_ref()
.and_then(|effort| self.ultra_reasoning_concurrency_warning(effort));
Some(SelectionSecondaryAction {
key,
footer_hint: hints.into(),
action: Box::new(move |tx| {
tx.send(AppEvent::SelectSessionModel {
model: model.clone(),
effort: effort.clone(),
});
if let Some(warning) = warning.clone() {
tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_warning_event(warning),
)));
}
}),
})
}
}
@@ -10,4 +10,4 @@ expression: "render_bottom_popup(&chat, 100)"
4. Extra high Extra high reasoning depth for complex problems
5. More reasoning… Max consumes usage limits faster
Press enter to confirm or esc to go back
enter to set as default · s for this session · esc to go back
@@ -10,4 +10,4 @@ expression: "render_bottom_popup(&chat, 100)"
4. Extra high Extra high reasoning depth for complex problems
5. More reasoning… Max consumes usage limits faster
Press enter to confirm or esc to go back
enter to set as default · s for this session · esc to go back
@@ -9,4 +9,4 @@ expression: popup
speed · higher usage
› 2. Ultra (current) For demanding work using multiple agents · highest usage
Press enter to confirm or esc to go back
enter to apply · s for this session · esc to go back
@@ -7,4 +7,4 @@ expression: popup
› 1. test-visible-model (current) test-visible-model description
Press enter to confirm or esc to go back
enter to set as default · s for this session · esc to go back
@@ -11,4 +11,4 @@ expression: popup
5. Persistent Continue working until put to sleep
6. More reasoning… Max and Ultra consume usage limits faster
Press enter to confirm or esc to go back
enter to set as default · s for this session · esc to go back
+2
View File
@@ -272,6 +272,8 @@ mod reasoning_status_tests;
#[path = "tests/replay_render_tests.rs"]
mod replay_render_tests;
mod review_mode;
#[path = "tests/session_model_selection_tests.rs"]
mod session_model_selection_tests;
mod side;
mod slash_commands;
mod status_and_layout;
@@ -1793,6 +1793,7 @@ async fn restore_thread_input_state_applies_running_state_policy() {
submit_pending_steers_after_interrupt: true,
current_collaboration_mode: chat.current_collaboration_mode.clone(),
active_collaboration_mask: chat.active_collaboration_mask.clone(),
plan_mode_reasoning_effort: chat.config.plan_mode_reasoning_effort.clone(),
task_running: true,
agent_turn_running: true,
};
@@ -392,6 +392,7 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_
submit_pending_steers_after_interrupt: false,
current_collaboration_mode: chat.current_collaboration_mode.clone(),
active_collaboration_mask: chat.active_collaboration_mask.clone(),
plan_mode_reasoning_effort: chat.config.plan_mode_reasoning_effort.clone(),
task_running: false,
agent_turn_running: false,
}),
@@ -0,0 +1,84 @@
//! Exercise session-only model selection through picker key events.
use super::*;
#[tokio::test]
async fn session_model_selection_accepts_final_choices_without_saving() {
for picker in [
"auto",
"single",
"default_only",
"reasoning",
"max",
"ultra",
] {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.5")).await;
let effort = match picker {
"max" => ReasoningEffortConfig::Max,
"ultra" => ReasoningEffortConfig::Ultra,
_ => ReasoningEffortConfig::High,
};
let mut preset = get_available_model(&chat, "gpt-5.5");
preset.default_reasoning_effort = effort.clone();
preset.supported_reasoning_efforts = vec![ReasoningEffortPreset {
effort: effort.clone(),
description: "Selected effort".into(),
}];
if picker == "auto" {
preset.model = "codex-auto-test".into();
}
let expected_model = preset.model.clone();
if picker == "default_only" {
preset.supported_reasoning_efforts.clear();
}
match picker {
"auto" | "single" | "default_only" => chat.open_model_popup_with_presets(vec![preset]),
"max" | "ultra" => chat.open_advanced_reasoning_popup(preset),
"reasoning" => {
chat.set_reasoning_effort(Some(effort.clone()));
preset
.supported_reasoning_efforts
.push(ReasoningEffortPreset {
effort: ReasoningEffortConfig::Low,
description: "Low effort".into(),
});
chat.open_reasoning_popup(preset);
}
_ => unreachable!(),
}
while rx.try_recv().is_ok() {}
chat.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
let selected = rx.try_recv().expect("session-only selection");
assert_matches!(selected, AppEvent::SelectSessionModel { model, effort: selected_effort }
if model == expected_model && selected_effort.as_ref() == Some(&effort));
assert!(chat.bottom_pane.no_modal_or_popup_active(), "{picker}");
assert!(
std::iter::from_fn(|| rx.try_recv().ok()).all(|event| matches!(
event,
AppEvent::InsertHistoryCell(_) | AppEvent::SettingsSelectionClosed
)),
"{picker}"
);
}
}
#[tokio::test]
async fn session_model_selection_hides_conflicting_shortcut() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.5")).await;
let mut config = codex_config::types::TuiKeymap::default();
config.list.accept = Some(codex_config::types::KeybindingsSpec::One(
codex_config::types::KeybindingSpec("s".into()),
));
let keymap = RuntimeKeymap::from_config(&config).expect("valid list keymap");
chat.bottom_pane.set_keymap_bindings(&keymap);
let preset = get_available_model(&chat, "gpt-5.5");
chat.open_reasoning_popup(preset);
let popup = render_bottom_popup(&chat, /*width*/ 100);
assert!(!popup.contains("for this session"), "{popup}");
while rx.try_recv().is_ok() {}
chat.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
assert!(
std::iter::from_fn(|| rx.try_recv().ok())
.any(|event| matches!(event, AppEvent::PersistModelSelection { .. }))
);
}
@@ -20,6 +20,7 @@ use codex_app_server_protocol::UserInput;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::models::local_image_label_text;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use codex_utils_plugins::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL;
@@ -146,6 +147,7 @@ pub(crate) struct ThreadInputState {
pub(super) submit_pending_steers_after_interrupt: bool,
pub(super) current_collaboration_mode: CollaborationMode,
pub(super) active_collaboration_mask: Option<CollaborationModeMask>,
pub(super) plan_mode_reasoning_effort: Option<ReasoningEffortConfig>,
pub(super) task_running: bool,
pub(super) agent_turn_running: bool,
}