From 0884c8047af8b02df31da8c622110a724c6b3cbc Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 15 Oct 2026 18:51:20 +0000 Subject: [PATCH] Support inline model, reasoning, and speed selection in `/model` (#54378) ## What changed Allow `/model [model] --reasoning --speed ` to update the current session without changing saved defaults. Both space-separated and `=` flag values are supported; omitting the model applies settings to the current model. Bare `/model` still opens the picker. Validate model availability, supported reasoning levels, and speed tiers before applying changes. Reject unknown flags, duplicate settings, and missing values. Apply model, reasoning, and speed together after server acceptance. Restore each thread's speed when switching threads, and use destination configuration defaults for new sessions instead of carrying over the active session's speed. ## Testing Extend regression coverage for inline commands, invalid arguments, unchanged saved defaults, settings-only updates to hidden current models, destination speed defaults, and speed restoration on the next turn after switching threads. GitOrigin-RevId: ad9cecbdb3554e34a6518367364978a37fb68439 --- codex-rs/tui/src/app/agents_overview.rs | 4 - codex-rs/tui/src/app/event_dispatch.rs | 4 +- codex-rs/tui/src/app/model_defaults.rs | 74 ++++-- codex-rs/tui/src/app/new_session.rs | 11 +- codex-rs/tui/src/app/tests.rs | 1 + .../src/app/tests/advanced_reasoning_tests.rs | 48 ++++ .../tests/background_task_defaults_tests.rs | 8 +- .../app/tests/local_command_scroll_tests.rs | 1 + .../tui/src/app/tests/model_defaults_tests.rs | 62 ++++-- ...ults__session_only_model_confirmation.snap | 11 +- codex-rs/tui/src/app/thread_settings.rs | 9 + codex-rs/tui/src/app/working_directory.rs | 4 +- codex-rs/tui/src/app_event.rs | 5 +- codex-rs/tui/src/app_server_session.rs | 2 +- .../bottom_pane/chat_composer/slash_input.rs | 6 +- .../chat_composer/sparkle_tests.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/model_command.rs | 210 ++++++++++++++++++ codex-rs/tui/src/chatwidget/session_flow.rs | 1 + .../src/chatwidget/session_model_selection.rs | 13 +- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 4 + .../tests/session_model_selection_tests.rs | 31 ++- codex-rs/tui/src/slash_command.rs | 1 + 23 files changed, 455 insertions(+), 58 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/model_command.rs diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index 8701235d2f..fc6d46921a 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -947,7 +947,6 @@ impl App { .as_ref() .filter(|_| remote) .map(AbsolutePathBuf::to_path_buf); - let preserve_service_tier = remote || cwd.is_none(); let local_cwd = if remote { self.config.cwd.to_path_buf() } else { @@ -969,9 +968,6 @@ impl App { return None; } }; - if preserve_service_tier { - config.service_tier = self.chat_widget.configured_service_tier(); - } let trust_cwd = config.cwd.to_path_buf(); if self .confirm_directory_trust( diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index b7f6a94f51..8efc33bdbc 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2536,9 +2536,9 @@ impl App { AppEvent::PersistDaybreakSelection { thread_id, enabled } => { self.persist_daybreak_selection(app_server, thread_id, enabled).await; } - AppEvent::SelectSessionModel { model, effort } => { + AppEvent::SelectSessionModel { model, effort, service_tier } => { self.app_event_tx.send(AppEvent::FollowTranscript); - self.select_session_model(app_server, model, effort).await; + self.select_session_model(app_server, model, effort, service_tier).await; } AppEvent::CyberModelAutoReviewNotice => { self.chat_widget.add_warning_message( diff --git a/codex-rs/tui/src/app/model_defaults.rs b/codex-rs/tui/src/app/model_defaults.rs index dca0c9da55..21373a1489 100644 --- a/codex-rs/tui/src/app/model_defaults.rs +++ b/codex-rs/tui/src/app/model_defaults.rs @@ -1,14 +1,17 @@ -//! Apply session model choices separately from saved model defaults. +//! Apply complete session model choices after server acceptance, separately from saved 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 crate::service_tier_resolution; 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::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; +use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use color_eyre::eyre::Result; @@ -18,16 +21,50 @@ impl App { app_server: &mut AppServerSession, model: String, effort: Option, + service_tier: 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 params = if model_changed { + self.active_thread_model_setting_update_params(model.clone()) + } else { + self.active_thread_reasoning_setting_update_params(effort.clone()) + }; + if let Some(mut params) = params { + if params.permissions.is_some() && self.reject_pending_permission_change() { + return; + } + params.effort = effort.clone(); + params.collaboration_mode = Some( + self.chat_widget + .effective_collaboration_mode() + .with_updates( + Some(model.clone()), + Some(effort.clone()), + /*developer_instructions*/ None, + ), + ); + params.service_tier = match service_tier.as_ref() { + Some(tier) => Some(Some(tier.clone())), + None => service_tier_resolution::service_tier_update_for_core( + self.chat_widget.config_ref(), + &self.local_settings.notices, + &model, + &self.model_catalog.models, + ), + }; + if !self.send_model_settings_update(app_server, params).await { + if app_server.thread_settings_update_supported { + return; + } + if service_tier.is_some() { + self.chat_widget.add_error_message( + "Model selection requires an app server with thread/settings/update support." + .into(), + ); + return; + } + } } let in_plan_mode = self.chat_widget.effective_collaboration_mode().mode == ModeKind::Plan; let ultra = effort == Some(ReasoningEffort::Ultra); @@ -48,14 +85,9 @@ impl App { 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; + if let Some(service_tier) = service_tier.as_ref() { + self.chat_widget + .set_service_tier(Some(service_tier.clone())); } self.sync_active_thread_service_tier_to_cached_session() .await; @@ -64,6 +96,16 @@ impl App { message.push(' '); message.push_str(&label); } + if let Some(service_tier) = service_tier { + message.push(' '); + message.push_str(if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE { + "standard" + } else if service_tier == ServiceTier::Fast.request_value() { + "fast" + } else { + &service_tier + }); + } message.push_str(" for this session only"); self.chat_widget.add_info_message(message, /*hint*/ None); } diff --git a/codex-rs/tui/src/app/new_session.rs b/codex-rs/tui/src/app/new_session.rs index ffc00b9754..73ce337a25 100644 --- a/codex-rs/tui/src/app/new_session.rs +++ b/codex-rs/tui/src/app/new_session.rs @@ -4,6 +4,7 @@ use super::*; use codex_config::ConfigLayerSource; +use codex_protocol::config_types::ServiceTier; pub(crate) fn has_launch_setting( config: &Config, @@ -40,6 +41,15 @@ pub(super) fn overlay_new_session_defaults( if !has_launch_setting(config, cli_kv_overrides, "model_reasoning_effort") { config.model_reasoning_effort = defaults.model_reasoning_effort.clone(); } + if harness_overrides.service_tier.is_none() + && !has_launch_setting(config, cli_kv_overrides, "service_tier") + { + config.service_tier = defaults.service_tier.as_deref().map(|service_tier| { + ServiceTier::from_request_value(service_tier) + .map(|tier| tier.request_value().to_string()) + .unwrap_or_else(|| service_tier.to_string()) + }); + } if !has_launch_setting(config, cli_kv_overrides, "daybreak") { config.daybreak_enabled = defaults .additional @@ -87,7 +97,6 @@ impl App { ); } self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::ExplicitOnly)?; - config.service_tier = self.chat_widget.configured_service_tier(); if let Some(defaults) = defaults.as_ref() { overlay_new_session_defaults( &mut config, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index ea58190284..5d85e384e5 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -9729,6 +9729,7 @@ async fn changing_cyber_model_reasoning_preserves_selected_permissions() { AppEvent::SelectSessionModel { model: model_name.clone(), effort: Some(effort.clone()), + service_tier: None, }, ) .await diff --git a/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs b/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs index 4bdfa6aa81..6b1551c61a 100644 --- a/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs +++ b/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs @@ -1,5 +1,6 @@ use super::*; use app_test_support::create_fake_rollout; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; @@ -111,3 +112,50 @@ async fn switching_from_ultra_thread_restores_configured_plan_effort() { Some(ReasoningEffortConfig::High) ); } + +#[tokio::test] +async fn switching_threads_restores_session_speed_for_the_next_turn() { + let (mut app, _events, mut ops) = make_test_app_with_channels().await; + app.config.service_tier = Some("flex".into()); + app.chat_widget.set_service_tier(Some("flex".into())); + let standard_thread = ThreadId::new(); + let flex_thread = ThreadId::new(); + let session = |thread_id, service_tier: &str| ThreadSessionState { + model: "gpt-5.5".into(), + service_tier: Some(service_tier.into()), + ..test_thread_session(thread_id, test_path_buf("/tmp/project")) + }; + let snapshot = |session| ThreadEventSnapshot { + delegated_turns: Vec::new(), + session: Some(session), + turns: Vec::new(), + events: Vec::new(), + active_reasoning_item: None, + input_state: None, + }; + + app.replay_thread_snapshot( + snapshot(session(standard_thread, SERVICE_TIER_DEFAULT_REQUEST_VALUE)), + /*resume_restored_queue*/ false, + ); + app.replay_thread_snapshot( + snapshot(session(flex_thread, "flex")), + /*resume_restored_queue*/ false, + ); + assert_eq!( + app.chat_widget.configured_service_tier().as_deref(), + Some("flex") + ); + app.replay_thread_snapshot( + snapshot(session(standard_thread, SERVICE_TIER_DEFAULT_REQUEST_VALUE)), + /*resume_restored_queue*/ false, + ); + app.chat_widget + .apply_external_edit("Check this task".into()); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + + assert_matches!(next_user_turn_op(&mut ops), Op::UserTurn { service_tier, .. } + if service_tier == Some(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.into()))); + assert_eq!(app.config.service_tier.as_deref(), Some("flex")); +} diff --git a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs index 72e79e241a..dd727a879f 100644 --- a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs @@ -245,7 +245,7 @@ async fn command_center_new_reads_server_defaults_for_actual_destination() -> Re std::fs::write( server_home.path().join("config.toml"), format!( - "{}model_reasoning_effort = \"high\"\n{}", + "{}model_reasoning_effort = \"high\"\nservice_tier = \"fast\"\n{}", if mode == "remote-null-fast" { "" } else { @@ -398,11 +398,7 @@ async fn command_center_new_reads_server_defaults_for_actual_destination() -> Re serde_json::Value::Null }, &serde_json::json!("high"), - &serde_json::json!(if mode == "local" && explicit_cwd { - "flex" - } else { - "priority" - }), + &serde_json::json!(if explicit_cwd { "flex" } else { "priority" }), ), "{mode} {expected_cwd}" ); diff --git a/codex-rs/tui/src/app/tests/local_command_scroll_tests.rs b/codex-rs/tui/src/app/tests/local_command_scroll_tests.rs index b4b41fbdd3..504a26b4fd 100644 --- a/codex-rs/tui/src/app/tests/local_command_scroll_tests.rs +++ b/codex-rs/tui/src/app/tests/local_command_scroll_tests.rs @@ -233,6 +233,7 @@ async fn settings_confirmations_reveal_their_history_output() -> Result<()> { AppEvent::SelectSessionModel { model: "gpt-5.5".into(), effort: Some(ReasoningEffortConfig::High), + service_tier: None, }, "Failed to update thread settings", ), 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 12a0785507..5841fd45bf 100644 --- a/codex-rs/tui/src/app/tests/model_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/model_defaults_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::collaboration_modes; use codex_app_server_client::AppServerClient; use codex_config::LoaderOverrides; +use crossterm::event::KeyCode; use pretty_assertions::assert_eq; #[tokio::test] @@ -32,28 +33,59 @@ async fn session_model_selection_preserves_defaults_and_updates_active_thread() 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 command = match effort { + ReasoningEffortConfig::High => { + "/model gpt-5.6-terra --reasoning high --speed=standard".into() + } + ReasoningEffortConfig::Ultra => "/model --reasoning=ultra".into(), + ReasoningEffortConfig::Max => "/model --reasoning max".into(), + _ => unreachable!(), + }; + app.chat_widget.apply_external_edit(command); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + let mut messages = Vec::new(); + while let Ok(event) = events.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = &event { + messages.push(lines_to_single_string(&cell.transcript_lines(/*width*/ 80))); + } + Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; + } let settings = next_thread_settings_updated(&mut server, thread_id).await; assert_eq!( ( settings.thread_settings.model.as_str(), - settings.thread_settings.effort.clone() + settings.thread_settings.effort.clone(), + settings.thread_settings.service_tier.as_deref(), ), - ("gpt-5.6-terra", Some(effort.clone())) + ("gpt-5.6-terra", Some(effort.clone()), Some("default")) ); if mode == ModeKind::Default && effort == ReasoningEffortConfig::High { - insta::assert_snapshot!( - "session_only_model_confirmation", - next_history_message(&mut events) - ); + for command in [ + "/model gpt-5.6-terra fast", + "/model gpt-5.6-terra --speeed=fast", + "/model gpt-5.6-terra --reasoning=invalid", + "/model --speed", + "/model --reasoning=", + "/model --reasoning --speed=standard", + "/model --speed=fast --speed standard", + ] { + app.chat_widget.apply_external_edit(command.into()); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + } + while let Ok(event) = events.try_recv() { + assert!(!matches!( + event, + AppEvent::SelectSessionModel { .. } + | AppEvent::AstraSelectedFromModelPicker { .. } + )); + if let AppEvent::InsertHistoryCell(cell) = &event { + messages.push(lines_to_single_string(&cell.transcript_lines(/*width*/ 80))); + } + Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; + } + insta::assert_snapshot!("session_only_model_confirmation", messages.join("\n")); } app.enqueue_thread_notification( thread_id, 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 index 297c047e9c..e1960ed79f 100644 --- 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 @@ -1,5 +1,12 @@ --- source: tui/src/app/tests/model_defaults_tests.rs -expression: next_history_message(&mut events) +expression: "messages.join(\"\\n\")" --- -• Model changed to gpt-5.6-terra high for this session only +• Model changed to gpt-5.6-terra high standard for this session only +■ Unexpected argument 'fast'. Use '--reasoning=' or '--speed='. +■ Unknown model setting '--speeed'. Expected '--reasoning=' or '--speed='. +■ Reasoning level 'invalid' is unavailable for gpt-5.6-terra. Available: low, medium, high, xhigh, max, ultra. +■ Model setting '--speed' requires a value. +■ Model setting '--reasoning' requires a value. +■ Model setting '--reasoning' requires a value. +■ Model setting '--speed' was provided more than once. diff --git a/codex-rs/tui/src/app/thread_settings.rs b/codex-rs/tui/src/app/thread_settings.rs index 8aee623649..6ad9d672b5 100644 --- a/codex-rs/tui/src/app/thread_settings.rs +++ b/codex-rs/tui/src/app/thread_settings.rs @@ -29,6 +29,14 @@ impl App { return; }; params.effort = effort; + self.send_model_settings_update(app_server, params).await; + } + + pub(super) async fn send_model_settings_update( + &mut self, + app_server: &mut AppServerSession, + params: ThreadSettingsUpdateParams, + ) -> bool { let defaulted_to_auto_review = params.approvals_reviewer == Some(AppServerApprovalsReviewer::AutoReview) && (self.chat_widget.config_ref().approvals_reviewer != ApprovalsReviewer::AutoReview @@ -61,6 +69,7 @@ impl App { if defaulted_to_auto_review && settings_updated { self.app_event_tx.send(AppEvent::CyberModelAutoReviewNotice); } + settings_updated } pub(super) fn active_thread_model_setting_update_params( diff --git a/codex-rs/tui/src/app/working_directory.rs b/codex-rs/tui/src/app/working_directory.rs index db28e72c92..b15fb9af8a 100644 --- a/codex-rs/tui/src/app/working_directory.rs +++ b/codex-rs/tui/src/app/working_directory.rs @@ -395,7 +395,9 @@ impl App { Ok(keymap) => keymap, Err(error) => return self.chat_widget.add_error_message(error), }; - config.service_tier = self.chat_widget.configured_service_tier(); + if !is_new_worktree { + config.service_tier = self.chat_widget.configured_service_tier(); + } if is_new_worktree { apply_managed_new_thread_defaults( &mut config, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index a20193149e..613178d0c1 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1231,7 +1231,7 @@ pub(crate) enum AppEvent { /// Update the current model slug in the running app and widget. UpdateModel(String), - /// Apply a final Astra picker action and offer the flourish only if it changed the model on + /// Apply an explicit Astra selection and offer the flourish only if it changed the model on /// its original task. Automatic model updates do not use this event. AstraSelectedFromModelPicker { thread_id: ThreadId, @@ -1269,10 +1269,11 @@ pub(crate) enum AppEvent { effort: Option, }, - /// Apply a model and effort only to the active session, preserving saved defaults. + /// Apply a model, effort, and optional speed only to the active session, preserving saved defaults. SelectSessionModel { model: String, effort: Option, + service_tier: Option, }, /// Show the cyber auto-review notice after the model selection confirmation. diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index ba449c46f4..67e06d5041 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -333,7 +333,7 @@ pub(crate) struct AppServerSession { /// Explicit CLI provider selection, distinct from resolved configuration defaults. pub(crate) model_provider_override: Option, history_support: ThreadHistorySupport, - thread_settings_update_supported: bool, + pub(crate) thread_settings_update_supported: bool, default_model: Option, available_models: Vec, daybreak_api_key_compat: Arc, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs index 7b3c6e7342..0bd9c4d1cb 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs @@ -751,13 +751,13 @@ mod tests { #[test] fn slash_completion_does_not_preserve_existing_draft_tail_for_other_commands() { let mut composer = composer_with_draft_tail( - "/mo", + "/sta", "preserve this draft only for opted-in slash commands", ); assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None); - assert_eq!(composer.draft.textarea.text(), "/model "); - assert_eq!(composer.draft.textarea.cursor(), "/model ".len()); + assert_eq!(composer.draft.textarea.text(), "/status "); + assert_eq!(composer.draft.textarea.cursor(), "/status ".len()); } #[test] diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs index 4cce5d8c7d..b39da3eda7 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs @@ -831,7 +831,7 @@ fn arbitrary_commands_preserve_a_never_used_fresh_opportunity() { pane.select_sparkle_model("gpt-6-astra", &enabled()); assert!(!dots(&draw(&pane.composer, /*width*/ 80, now).0).is_empty()); } - for input in ["hello", "/model extra", "/other", "漢", "/tmp/file"] { + for input in ["hello", "/status extra", "/other", "漢", "/tmp/file"] { let mut pane = pane(); pane.mark_fresh_task_for_sparkle("gpt-5.5", &enabled()); type_text(&mut pane, input); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fccd6e2c20..ab13bba827 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -322,6 +322,7 @@ use self::plugins::PluginListFetchState; use self::plugins::PluginsCacheState; mod plan_implementation; use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE; +mod model_command; mod model_popup_state; mod model_popups; mod notifications; diff --git a/codex-rs/tui/src/chatwidget/model_command.rs b/codex-rs/tui/src/chatwidget/model_command.rs new file mode 100644 index 0000000000..9fa29504c0 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/model_command.rs @@ -0,0 +1,210 @@ +//! Validate inline model choices before applying them through session-only model selection. + +use super::ChatWidget; +use super::session_model_selection::AstraModelPickerAction; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ReasoningEffort; + +#[derive(Default)] +struct ModelCommandArgs<'a> { + model: Option<&'a str>, + effort: Option<&'a str>, + speed: Option<&'a str>, +} + +struct ModelCommand { + model: String, + effort: ReasoningEffort, + service_tier: Option, +} + +fn parse_model_command_args(args: &str) -> Result, String> { + let mut parsed = ModelCommandArgs::default(); + let mut words = args.split_whitespace().peekable(); + while let Some(word) = words.next() { + let (setting, inline_value) = word + .split_once('=') + .map_or((word, None), |(setting, value)| (setting, Some(value))); + match setting { + "--reasoning" => { + set_model_setting(&mut parsed.effort, setting, inline_value, &mut words)?; + } + "--speed" => { + set_model_setting(&mut parsed.speed, setting, inline_value, &mut words)?; + } + _ if word.starts_with("--") => { + return Err(format!( + "Unknown model setting '{setting}'. Expected '--reasoning=' or '--speed='." + )); + } + _ if parsed.model.replace(word).is_some() => { + return Err(format!( + "Unexpected argument '{word}'. Use '--reasoning=' or '--speed='." + )); + } + _ => {} + } + } + Ok(parsed) +} + +fn set_model_setting<'a>( + target: &mut Option<&'a str>, + setting: &str, + inline_value: Option<&'a str>, + words: &mut std::iter::Peekable>, +) -> Result<(), String> { + let value = match inline_value { + Some("") => None, + Some(value) => Some(value), + None => words.next_if(|word| !word.starts_with("--")), + } + .ok_or_else(|| format!("Model setting '{setting}' requires a value."))?; + if target.replace(value).is_some() { + return Err(format!( + "Model setting '{setting}' was provided more than once." + )); + } + Ok(()) +} + +fn resolve_reasoning_effort( + chat: &ChatWidget, + args: &ModelCommandArgs<'_>, + model: &ModelPreset, +) -> Result { + let Some(effort) = args.effort else { + return Ok(if args.model.is_none() { + chat.current_reasoning_effort() + .unwrap_or_else(|| model.default_reasoning_effort.clone()) + } else { + model.default_reasoning_effort.clone() + }); + }; + let supported = if model.supported_reasoning_efforts.is_empty() { + (model.default_reasoning_effort.as_str() == effort) + .then_some(&model.default_reasoning_effort) + } else { + model + .supported_reasoning_efforts + .iter() + .find(|option| option.effort.as_str() == effort) + .map(|option| &option.effort) + }; + supported.cloned().ok_or_else(|| { + let available = if model.supported_reasoning_efforts.is_empty() { + model.default_reasoning_effort.to_string() + } else { + model + .supported_reasoning_efforts + .iter() + .map(|option| option.effort.to_string()) + .collect::>() + .join(", ") + }; + format!( + "Reasoning level '{effort}' is unavailable for {}. Available: {available}.", + model.model + ) + }) +} + +fn resolve_service_tier( + chat: &ChatWidget, + speed: Option<&str>, + model: &ModelPreset, +) -> Result, String> { + let Some(speed) = speed else { + return Ok(None); + }; + if speed == "standard" { + return Ok(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())); + } + if speed == "flex" { + return Ok(Some("flex".to_string())); + } + let tier = model + .service_tiers + .iter() + .find(|tier| tier.id == speed || tier.name.eq_ignore_ascii_case(speed)) + .filter(|tier| chat.config.features.service_tier_enabled(&tier.id)); + tier.map(|tier| Some(tier.id.clone())).ok_or_else(|| { + let available = std::iter::once("standard".to_string()) + .chain(std::iter::once("flex".to_string())) + .chain( + model + .service_tiers + .iter() + .filter(|tier| chat.config.features.service_tier_enabled(&tier.id)) + .map(|tier| tier.id.clone()), + ) + .collect::>() + .join(", "); + format!( + "Speed '{speed}' is unavailable for {}. Available: {available}.", + model.model + ) + }) +} + +fn resolve_model_command(chat: &ChatWidget, args: &str) -> Result { + let args = parse_model_command_args(args)?; + if chat.thread_id().is_none() { + return Err("Session is still starting; try /model again in a moment.".into()); + } + if chat.restrict_model_picker_to_luna_reserve() { + return Err( + "Model arguments are unavailable while using Reserve. Use /model to adjust effort." + .into(), + ); + } + let model_name = args + .model + .map(str::to_owned) + .unwrap_or_else(|| chat.current_model().to_owned()); + let Some(model) = + chat.model_catalog.models.iter().find(|model| { + model.model == model_name && (args.model.is_none() || model.show_in_picker) + }) + else { + return Err(format!( + "Model '{model_name}' is unavailable. Use /model to see available models." + )); + }; + Ok(ModelCommand { + model: model.model.clone(), + effort: resolve_reasoning_effort(chat, &args, model)?, + service_tier: resolve_service_tier(chat, args.speed, model)?, + }) +} + +impl ChatWidget { + pub(super) fn handle_model_command_args(&mut self, args: &str) { + let command = match resolve_model_command(self, args) { + Ok(command) => command, + Err(error) => { + self.add_error_message(error); + return; + } + }; + let ModelCommand { + model, + effort, + service_tier, + } = command; + let warning = self.ultra_reasoning_concurrency_warning(&effort); + let thread_id = self.sparkle_thread_for_picker_action(&model); + self.set_queue_autosend_suppressed(/*suppressed*/ true); + self.app_event_tx.send( + AstraModelPickerAction::SelectSessionModel { + effort: Some(effort), + service_tier, + } + .into_picker_event(thread_id, model), + ); + if let Some(warning) = warning { + self.add_warning_message(warning); + } + } +} diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index f34f76ebed..15f6a354cd 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -125,6 +125,7 @@ impl ChatWidget { self.config .permissions .set_workspace_roots(runtime_workspace_roots); + self.config.service_tier = session.service_tier.clone(); self.effective_service_tier = session.service_tier.clone(); if let Err(err) = self .config diff --git a/codex-rs/tui/src/chatwidget/session_model_selection.rs b/codex-rs/tui/src/chatwidget/session_model_selection.rs index ac9b5c8e66..3e9e3db760 100644 --- a/codex-rs/tui/src/chatwidget/session_model_selection.rs +++ b/codex-rs/tui/src/chatwidget/session_model_selection.rs @@ -1,4 +1,4 @@ -//! Session-only model picker actions leave durable defaults untouched. Astra picker actions retain +//! Session-only model selections leave durable defaults untouched. Explicit Astra selections retain //! their originating task and are checked against its effective model when the app applies them. use super::*; @@ -15,6 +15,7 @@ pub(crate) enum AstraModelPickerAction { }, SelectSessionModel { effort: Option, + service_tier: Option, }, } @@ -36,7 +37,14 @@ impl AstraModelPickerAction { Self::ApplyAdvancedReasoning { effort } => { AppEvent::ApplyAdvancedReasoning { model, effort } } - Self::SelectSessionModel { effort } => AppEvent::SelectSessionModel { model, effort }, + Self::SelectSessionModel { + effort, + service_tier, + } => AppEvent::SelectSessionModel { + model, + effort, + service_tier, + }, } } } @@ -77,6 +85,7 @@ impl ChatWidget { tx.send( AstraModelPickerAction::SelectSessionModel { effort: effort.clone(), + service_tier: None, } .into_picker_event(sparkle_thread, model.clone()), ); diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 79601b9a28..1664e493eb 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -804,6 +804,10 @@ impl ChatWidget { } = prepared; let trimmed = args.trim(); match cmd { + SlashCommand::Model if !trimmed.is_empty() => { + self.handle_model_command_args(trimmed); + self.app_event_tx.send(AppEvent::SettingsSelectionClosed); + } SlashCommand::Export if trimmed.is_empty() => self.show_transcript_export_popup(), SlashCommand::Export => { self.set_queue_autosend_suppressed(/*suppressed*/ true); 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 index 23faa7150a..9db819d0ff 100644 --- a/codex-rs/tui/src/chatwidget/tests/session_model_selection_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/session_model_selection_tests.rs @@ -49,7 +49,7 @@ async fn session_model_selection_accepts_final_choices_without_saving() { 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 } + assert_matches!(selected, AppEvent::SelectSessionModel { model, effort: selected_effort, service_tier: None } if model == expected_model && selected_effort.as_ref() == Some(&effort)); assert!(chat.bottom_pane.no_modal_or_popup_active(), "{picker}"); assert!( @@ -100,7 +100,7 @@ async fn session_model_selection_notifies_the_original_task_after_each_final_ast assert_matches!(events.try_recv(), Ok(AppEvent::AstraSelectedFromModelPicker { thread_id: selected_thread, model, - action: AstraModelPickerAction::SelectSessionModel { effort: selected_effort }, + action: AstraModelPickerAction::SelectSessionModel { effort: selected_effort, service_tier: None }, }) if selected_thread == thread_id && model == "gpt-6-astra" && selected_effort == Some(effort)); assert!( std::iter::from_fn(|| events.try_recv().ok()).all(|event| matches!( @@ -112,6 +112,33 @@ async fn session_model_selection_notifies_the_original_task_after_each_final_ast } } +#[tokio::test] +async fn model_settings_only_command_accepts_the_current_hidden_model() { + let (mut chat, mut events, _ops) = + make_chatwidget_manual(/*model_override*/ Some("gpt-5.5")).await; + chat.thread_id = Some(ThreadId::new()); + let mut preset = get_available_model(&chat, "gpt-5.5"); + preset.show_in_picker = false; + chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![preset])); + + chat.handle_model_command_args("--reasoning high --speed standard"); + + assert_matches!(events.try_recv(), Ok(AppEvent::SelectSessionModel { + model, + effort: Some(ReasoningEffortConfig::High), + service_tier: Some(service_tier), + }) if model == "gpt-5.5" && service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE); + + while events.try_recv().is_ok() {} + chat.handle_model_command_args("gpt-5.5 --reasoning=high"); + assert!( + std::iter::from_fn(|| events.try_recv().ok()).all(|event| !matches!( + event, + AppEvent::SelectSessionModel { .. } | AppEvent::AstraSelectedFromModelPicker { .. } + )) + ); +} + #[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; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index afb372bb7b..65d7965236 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -171,6 +171,7 @@ impl SlashCommand { matches!( self, SlashCommand::Review + | SlashCommand::Model | SlashCommand::Rename | SlashCommand::New | SlashCommand::Clear