From 0dfb28edb9305fcae4ab006fb6b7b196cbdbac28 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 16 Sep 2026 01:40:58 +0000 Subject: [PATCH] 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 --- codex-rs/tui/src/app/event_dispatch.rs | 3 + codex-rs/tui/src/app/model_defaults.rs | 60 ++++++++- codex-rs/tui/src/app/tests.rs | 33 ++++- .../tui/src/app/tests/model_defaults_tests.rs | 80 ++++++++++++ ...ults__session_only_model_confirmation.snap | 5 + codex-rs/tui/src/app_event.rs | 6 + .../src/bottom_pane/list_selection_view.rs | 114 +++++++++++++++--- codex-rs/tui/src/bottom_pane/mod.rs | 13 ++ codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/input_restore.rs | 2 + codex-rs/tui/src/chatwidget/model_popups.rs | 25 +++- .../src/chatwidget/session_model_selection.rs | 52 ++++++++ ...tests__bedrock_mantle_astra_reasoning.snap | 2 +- ...ests__bedrock_runtime_astra_reasoning.snap | 2 +- ...el_advanced_reasoning_selection_popup.snap | 2 +- ...s__model_picker_filters_hidden_models.snap | 2 +- ...ests__model_reasoning_selection_popup.snap | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 2 + .../chatwidget/tests/composer_submission.rs | 1 + .../tui/src/chatwidget/tests/review_mode.rs | 1 + .../tests/session_model_selection_tests.rs | 84 +++++++++++++ codex-rs/tui/src/chatwidget/user_messages.rs | 2 + 22 files changed, 465 insertions(+), 29 deletions(-) create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__model_defaults__session_only_model_confirmation.snap create mode 100644 codex-rs/tui/src/chatwidget/session_model_selection.rs create mode 100644 codex-rs/tui/src/chatwidget/tests/session_model_selection_tests.rs diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index c897019843..c43699d375 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -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(), diff --git a/codex-rs/tui/src/app/model_defaults.rs b/codex-rs/tui/src/app/model_defaults.rs index 268c90344b..dca0c9da55 100644 --- a/codex-rs/tui/src/app/model_defaults.rs +++ b/codex-rs/tui/src/app/model_defaults.rs @@ -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, + ) { + 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, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 781f7e9690..b3344bc5a2 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -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 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 3d63979fbc..2b69291c2a 100644 --- a/codex-rs/tui/src/app/tests/model_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/model_defaults_tests.rs @@ -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"] { diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__model_defaults__session_only_model_confirmation.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__model_defaults__session_only_model_confirmation.snap new file mode 100644 index 0000000000..297c047e9c --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__model_defaults__session_only_model_confirmation.snap @@ -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 diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index a53a4ddf4b..0027716d86 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1141,6 +1141,12 @@ pub(crate) enum AppEvent { effort: Option, }, + /// Apply a model and effort only to the active session, preserving saved defaults. + SelectSessionModel { + model: String, + effort: Option, + }, + /// Show the cyber auto-review notice after the model selection confirmation. CyberModelAutoReviewNotice, 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 a908b00c2b..57c3e8793e 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -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; + +/// 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, + pub secondary_action: Option, 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::(); let tx = AppEventSender::new(tx_raw); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index fb24219149..3aedb4fc50 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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()) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e1123385b8..66a2d964db 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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; diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index 1d11bf04d9..c33af45117 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -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( diff --git a/codex-rs/tui/src/chatwidget/model_popups.rs b/codex-rs/tui/src/chatwidget/model_popups.rs index 2155103740..fbb8f40e6e 100644 --- a/codex-rs/tui/src/chatwidget/model_popups.rs +++ b/codex-rs/tui/src/chatwidget/model_popups.rs @@ -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 = 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() diff --git a/codex-rs/tui/src/chatwidget/session_model_selection.rs b/codex-rs/tui/src/chatwidget/session_model_selection.rs new file mode 100644 index 0000000000..6c4cee214f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/session_model_selection.rs @@ -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, + ) -> Option { + // 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), + ))); + } + }), + }) + } +} diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_mantle_astra_reasoning.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_mantle_astra_reasoning.snap index ea473fbada..4d4a43494a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_mantle_astra_reasoning.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_mantle_astra_reasoning.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_astra_reasoning.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_astra_reasoning.snap index d25285d3e1..a56c8ba016 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_astra_reasoning.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_astra_reasoning.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_advanced_reasoning_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_advanced_reasoning_selection_popup.snap index 926a0b1b71..487f30e956 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_advanced_reasoning_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_advanced_reasoning_selection_popup.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_filters_hidden_models.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_filters_hidden_models.snap index f3ab226535..2b1228c3f7 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_filters_hidden_models.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_filters_hidden_models.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap index 21e9b5e80b..e1589d1d3c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 5f70dee7a7..9a68c6e61a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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; diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 5566010a4a..facb10b58a 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -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, }; diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 7107273e6c..c452612ad3 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -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, }), diff --git a/codex-rs/tui/src/chatwidget/tests/session_model_selection_tests.rs b/codex-rs/tui/src/chatwidget/tests/session_model_selection_tests.rs new file mode 100644 index 0000000000..dd77afed7b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/session_model_selection_tests.rs @@ -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 { .. })) + ); +} diff --git a/codex-rs/tui/src/chatwidget/user_messages.rs b/codex-rs/tui/src/chatwidget/user_messages.rs index befc76f54a..21f5175b4a 100644 --- a/codex-rs/tui/src/chatwidget/user_messages.rs +++ b/codex-rs/tui/src/chatwidget/user_messages.rs @@ -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, + pub(super) plan_mode_reasoning_effort: Option, pub(super) task_running: bool, pub(super) agent_turn_running: bool, }