mirror of
https://github.com/openai/codex.git
synced 2026-10-21 12:10:06 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
+5
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
)));
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user