diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index e5c5c5adbb..586fcef87f 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -54,6 +54,7 @@ use codex_app_server_protocol::UserInput as V2UserInput; use codex_app_server_protocol::WarningNotification; use codex_config::config_toml::ConfigToml; use codex_core::personality_migration::PERSONALITY_MIGRATION_FILENAME; +use codex_core::test_support::all_model_presets; use codex_features::FEATURES; use codex_features::Feature; use codex_protocol::config_types::CollaborationMode; @@ -375,13 +376,19 @@ async fn turn_start_sends_service_tier_id_to_model_request() -> Result<()> { "never", &BTreeMap::default(), )?; + write_models_cache(codex_home.path())?; + let service_tier_model = all_model_presets() + .iter() + .find(|preset| preset.show_in_picker && !preset.service_tiers.is_empty()) + .expect("bundled model catalog should include a picker model with service tiers"); + let service_tier_id = service_tier_model.service_tiers[0].id.clone(); let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp .send_thread_start_request(ThreadStartParams { - model: Some("mock-model".to_string()), + model: Some(service_tier_model.id.clone()), ..Default::default() }) .await?; @@ -392,7 +399,6 @@ async fn turn_start_sends_service_tier_id_to_model_request() -> Result<()> { .await??; let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; - let service_tier_id = "experimental-tier-id".to_string(); let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 39e6e85e20..53c1eb0d7e 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -711,6 +711,8 @@ impl ModelClient { prompt.output_schema_strict, ); let prompt_cache_key = Some(self.state.thread_id.to_string()); + let service_tier = + service_tier.filter(|service_tier| model_info.supports_service_tier(service_tier)); let request = ResponsesApiRequest { model: model_info.slug.clone(), instructions: instructions.clone(), diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 1362103364..02ec9dc898 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -7,7 +7,6 @@ use codex_config::types::SessionPickerViewMode; use codex_config::types::ToolSuggestDisabledTool; use codex_features::FEATURES; use codex_protocol::config_types::Personality; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; use std::collections::BTreeMap; @@ -33,7 +32,7 @@ pub enum ConfigEdit { effort: Option, }, /// Update the service tier preference for future turns. - SetServiceTier { service_tier: Option }, + SetServiceTier { service_tier: Option }, /// Update the active (or default) model personality. SetModelPersonality { personality: Option }, /// Toggle the acknowledgement flag under `[notice]`. @@ -536,7 +535,9 @@ impl ConfigDocument { }), ConfigEdit::SetServiceTier { service_tier } => Ok(self.write_profile_value( &["service_tier"], - service_tier.map(|service_tier| value(service_tier.to_string())), + service_tier + .as_ref() + .map(|service_tier| value(service_tier.clone())), )), ConfigEdit::SetModelPersonality { personality } => Ok(self.write_profile_value( &["personality"], @@ -1114,7 +1115,7 @@ impl ConfigEditsBuilder { self } - pub fn set_service_tier(mut self, service_tier: Option) -> Self { + pub fn set_service_tier(mut self, service_tier: Option) -> Self { self.edits.push(ConfigEdit::SetServiceTier { service_tier }); self } diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index d4fe30063f..5c8aad0225 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -521,6 +521,10 @@ impl Session { &per_turn_config.agent_roles, )); + let mut per_turn_config = per_turn_config; + per_turn_config.service_tier = per_turn_config + .service_tier + .filter(|service_tier| model_info.supports_service_tier(service_tier)); let per_turn_config = Arc::new(per_turn_config); let turn_metadata_state = Arc::new(TurnMetadataState::new( session_id.to_string(), diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index e962b06d41..21ab43f10d 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -9,6 +9,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelServiceTier; use codex_protocol::openai_models::ModelVisibility; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; @@ -320,9 +321,28 @@ async fn flex_service_tier_is_applied_to_http_turn() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; + let model_slug = "test-flex-model"; + let mut flex_model = test_model_info( + model_slug, + model_slug, + "supports flex tier", + default_input_modalities(), + ); + flex_model.service_tiers = vec![ModelServiceTier { + id: ServiceTier::Flex.request_value().to_string(), + name: "flex".to_string(), + description: "Flexible processing.".to_string(), + }]; let resp_mock = mount_sse_once(&server, sse_completed("resp-1")).await; - let test = test_codex().build(&server).await?; + let mut builder = test_codex() + .with_model(model_slug) + .with_config(move |config| { + config.model_catalog = Some(ModelsResponse { + models: vec![flex_model], + }); + }); + let test = builder.build(&server).await?; test.submit_turn_with_service_tier("flex turn", Some(ServiceTier::Flex)) .await?; @@ -334,6 +354,39 @@ async fn flex_service_tier_is_applied_to_http_turn() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unsupported_service_tier_is_omitted_from_http_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let model_slug = "test-no-tier-model"; + let model = test_model_info( + model_slug, + model_slug, + "no service tiers", + default_input_modalities(), + ); + let resp_mock = mount_sse_once(&server, sse_completed("resp-1")).await; + + let mut builder = test_codex() + .with_model(model_slug) + .with_config(move |config| { + config.model_catalog = Some(ModelsResponse { + models: vec![model], + }); + }); + let test = builder.build(&server).await?; + + test.submit_turn_with_service_tier("fast turn", Some(ServiceTier::Fast)) + .await?; + + let request = resp_mock.single_request(); + let body = request.body_json(); + assert_eq!(body.get("service_tier"), None); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn model_change_from_image_to_text_strips_prior_image_content() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs index 60fa17d368..153c2db2ef 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs @@ -1,14 +1,17 @@ use codex_models_manager::model_info::BASE_INSTRUCTIONS; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Verbosity; use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelServiceTier; use codex_protocol::openai_models::ModelVisibility; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; +use codex_protocol::openai_models::SPEED_TIER_FAST; use codex_protocol::openai_models::TruncationPolicyConfig; use codex_protocol::openai_models::WebSearchToolType; @@ -46,8 +49,12 @@ fn gpt_5_4_cmb_bedrock_model(priority: i32) -> ModelInfo { visibility: ModelVisibility::List, supported_in_api: true, priority, - additional_speed_tiers: vec!["fast".to_string()], - service_tiers: Vec::new(), + additional_speed_tiers: Vec::new(), + service_tiers: vec![ModelServiceTier { + id: ServiceTier::Fast.request_value().to_string(), + name: SPEED_TIER_FAST.to_string(), + description: "Fastest inference with increased plan usage".to_string(), + }], availability_nux: None, upgrade: None, base_instructions: BASE_INSTRUCTIONS.to_string(), diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 70d241677c..96179440bd 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -17,6 +17,7 @@ use ts_rs::TS; use crate::config_types::Personality; use crate::config_types::ReasoningSummary; +use crate::config_types::ServiceTier; use crate::config_types::Verbosity; const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}"; @@ -479,13 +480,23 @@ impl ModelPreset { pub fn supports_fast_mode(&self) -> bool { self.service_tiers .iter() - .any(|tier| tier.id == SPEED_TIER_FAST) + .any(|tier| tier.id == ServiceTier::Fast.request_value()) || self .additional_speed_tiers .iter() .any(|tier| tier == SPEED_TIER_FAST) } +} +impl ModelInfo { + pub fn supports_service_tier(&self, service_tier: &str) -> bool { + self.service_tiers + .iter() + .any(|tier| tier.id == service_tier) + } +} + +impl ModelPreset { /// Filter models based on authentication mode. /// /// In ChatGPT mode, all models are visible. Otherwise, only API-supported models are shown. @@ -853,7 +864,7 @@ mod tests { fn model_preset_supports_fast_mode_from_service_tiers() { let preset = ModelPreset::from(ModelInfo { service_tiers: vec![ModelServiceTier { - id: SPEED_TIER_FAST.to_string(), + id: ServiceTier::Fast.request_value().to_string(), name: "Fast".to_string(), description: "Priority processing.".to_string(), }], diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index bfe8dc4b24..b2261f155a 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1274,26 +1274,21 @@ impl App { AppEvent::PersistServiceTierSelection { service_tier } => { self.refresh_status_line(); let profile = self.active_profile.as_deref(); - self.config.service_tier = - service_tier.map(|service_tier| service_tier.request_value().to_string()); + self.config.service_tier = service_tier.clone(); let mut edits = ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(profile) - .set_service_tier(service_tier); + .set_service_tier(service_tier.clone()); if service_tier.is_none() { self.config.notices.fast_default_opt_out = Some(true); edits = edits.set_fast_default_opt_out(/*opted_out*/ true); } match edits.apply().await { Ok(()) => { - let status = if matches!( - service_tier, - Some(codex_protocol::config_types::ServiceTier::Fast) - ) { - "on" + let mut message = if let Some(service_tier) = service_tier { + format!("Service tier set to {service_tier}") } else { - "off" + "Service tier cleared".to_string() }; - let mut message = format!("Fast mode set to {status}"); if let Some(profile) = profile { message.push_str(" for "); message.push_str(profile); @@ -1302,14 +1297,14 @@ impl App { self.chat_widget.add_info_message(message, /*hint*/ None); } Err(err) => { - tracing::error!(error = %err, "failed to persist fast mode selection"); + tracing::error!(error = %err, "failed to persist service tier selection"); if let Some(profile) = profile { self.chat_widget.add_error_message(format!( - "Failed to save Fast mode for profile `{profile}`: {err}" + "Failed to save service tier for profile `{profile}`: {err}" )); } else { self.chat_widget.add_error_message(format!( - "Failed to save default Fast mode: {err}" + "Failed to save default service tier: {err}" )); } } diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index b114d08be9..05aba144c8 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -617,10 +617,7 @@ impl App { pub(super) fn fresh_session_config(&self) -> Config { let mut config = self.config.clone(); - config.service_tier = self - .chat_widget - .configured_service_tier() - .map(|service_tier| service_tier.request_value().to_string()); + config.service_tier = self.chat_widget.configured_service_tier(); config.notices.fast_default_opt_out = self.chat_widget.fast_default_opt_out(); config } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index eacb6d5053..301808d155 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3829,8 +3829,11 @@ async fn clear_ui_header_shows_fast_status_for_fast_capable_models() { set_fast_mode_test_catalog(&mut app.chat_widget); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); - app.chat_widget - .set_service_tier(Some(codex_protocol::config_types::ServiceTier::Fast)); + app.chat_widget.set_service_tier(Some( + codex_protocol::config_types::ServiceTier::Fast + .request_value() + .to_string(), + )); set_chatgpt_auth(&mut app.chat_widget); set_fast_mode_test_catalog(&mut app.chat_widget); @@ -4481,8 +4484,11 @@ fn active_turn_steer_race_extracts_actual_turn_id_from_mismatch() { #[tokio::test] async fn fresh_session_config_uses_current_service_tier() { let mut app = make_test_app().await; - app.chat_widget - .set_service_tier(Some(codex_protocol::config_types::ServiceTier::Fast)); + app.chat_widget.set_service_tier(Some( + codex_protocol::config_types::ServiceTier::Fast + .request_value() + .to_string(), + )); let config = app.fresh_session_config(); diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 524d8f3c06..4f5f48cfc8 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -63,10 +63,7 @@ impl App { thread_name: None, model: self.chat_widget.current_model().to_string(), model_provider_id: self.config.model_provider_id.clone(), - service_tier: self - .chat_widget - .current_service_tier() - .map(|service_tier| service_tier.request_value().to_string()), + service_tier: self.chat_widget.current_service_tier().map(str::to_string), approval_policy: AskForApproval::from( self.config.permissions.approval_policy.value(), ), diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 4ee405f495..b72e909293 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -43,7 +43,6 @@ use codex_features::Feature; use codex_plugin::PluginCapabilitySummary; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::Personality; -use codex_protocol::config_types::ServiceTier; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_realtime_webrtc::RealtimeWebrtcEvent; @@ -569,7 +568,7 @@ pub(crate) enum AppEvent { /// Persist the selected service tier to the appropriate config. PersistServiceTierSelection { - service_tier: Option, + service_tier: Option, }, /// Open the device picker for a realtime microphone or speaker. diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index e7526203bc..2bd553ba6b 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -192,8 +192,11 @@ use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; use super::skill_popup::MentionItem; use super::skill_popup::SkillPopup; -use super::slash_commands; use super::slash_commands::BuiltinCommandFlags; +use super::slash_commands::ServiceTierCommand; +use super::slash_commands::SlashCommandItem; +use super::slash_commands::find_slash_command; +use super::slash_commands::has_slash_command_prefix; use crate::bottom_pane::paste_burst::FlushResult; use crate::bottom_pane::prompt_args::parse_slash_name; use crate::key_hint::KeyBindingListExt; @@ -276,6 +279,8 @@ pub enum InputResult { /// Callers that dispatch this variant are also responsible for resolving any pending local /// command-history entry that the composer staged before clearing the visible input. Command(SlashCommand), + /// A bare model service-tier command parsed by the composer. + ServiceTierCommand(ServiceTierCommand), /// An inline slash command and its trimmed argument text. /// /// The `TextElement` ranges are rebased into the argument string, while any pending local @@ -399,7 +404,8 @@ pub(crate) struct ChatComposer { ide_context_active: bool, connectors_enabled: bool, plugins_command_enabled: bool, - fast_command_enabled: bool, + service_tier_commands_enabled: bool, + service_tier_commands: Vec, goal_command_enabled: bool, personality_command_enabled: bool, realtime_conversation_enabled: bool, @@ -489,7 +495,7 @@ impl ChatComposer { collaboration_modes_enabled: self.collaboration_modes_enabled, connectors_enabled: self.connectors_enabled, plugins_command_enabled: self.plugins_command_enabled, - fast_command_enabled: self.fast_command_enabled, + service_tier_commands_enabled: self.service_tier_commands_enabled, goal_command_enabled: self.goal_command_enabled, personality_command_enabled: self.personality_command_enabled, realtime_conversation_enabled: self.realtime_conversation_enabled, @@ -580,7 +586,8 @@ impl ChatComposer { ide_context_active: false, connectors_enabled: false, plugins_command_enabled: false, - fast_command_enabled: false, + service_tier_commands_enabled: false, + service_tier_commands: Vec::new(), goal_command_enabled: false, personality_command_enabled: false, realtime_conversation_enabled: false, @@ -693,8 +700,13 @@ impl ChatComposer { self.connectors_enabled = enabled; } - pub fn set_fast_command_enabled(&mut self, enabled: bool) { - self.fast_command_enabled = enabled; + pub fn set_service_tier_commands_enabled(&mut self, enabled: bool) { + self.service_tier_commands_enabled = enabled; + } + + pub fn set_service_tier_commands(&mut self, commands: Vec) { + self.service_tier_commands = commands; + self.sync_popups(); } pub fn set_goal_command_enabled(&mut self, enabled: bool) { @@ -1762,24 +1774,22 @@ impl ChatComposer { // before applying completion. let first_line = self.textarea.text().lines().next().unwrap_or(""); popup.on_composer_text_change(first_line.to_string()); - let selected_cmd = popup.selected_item().map(|sel| { - let CommandItem::Builtin(cmd) = sel; - cmd - }); - if let Some(cmd) = selected_cmd { - if cmd == SlashCommand::Skills { - self.stage_selected_slash_command_history(cmd); + if let Some(selected_cmd) = popup.selected_item() { + let selected_command_text = format!("/{}", selected_cmd.command()); + if let CommandItem::Builtin(cmd) = selected_cmd + && cmd == SlashCommand::Skills + { + self.stage_selected_slash_command_history(&CommandItem::Builtin(cmd)); self.textarea.set_text_clearing_elements(""); self.is_bash_mode = false; return (InputResult::Command(cmd), true); } - let selected_command_text = format!("/{}", cmd.command()); let starts_with_cmd = first_line.trim_start().starts_with(&selected_command_text); if !starts_with_cmd { self.textarea - .set_text_clearing_elements(&format!("/{} ", cmd.command())); + .set_text_clearing_elements(&format!("{selected_command_text} ")); if !self.textarea.text().is_empty() { self.textarea.set_cursor(self.textarea.text().len()); } @@ -1800,17 +1810,13 @@ impl ChatComposer { // while the slash-command popup is active. let first_line = self.textarea.text().lines().next().unwrap_or(""); popup.on_composer_text_change(first_line.to_string()); - let selected_cmd = popup.selected_item().map(|sel| { - let CommandItem::Builtin(cmd) = sel; - cmd - }); - if let Some(cmd) = selected_cmd { - let starts_with_cmd = first_line - .trim_start() - .starts_with(&format!("/{}", cmd.command())); + if let Some(selected_cmd) = popup.selected_item() { + let selected_command_text = format!("/{}", selected_cmd.command()); + let starts_with_cmd = + first_line.trim_start().starts_with(&selected_command_text); if !starts_with_cmd { self.textarea - .set_text_clearing_elements(&format!("/{} ", cmd.command())); + .set_text_clearing_elements(&format!("{selected_command_text} ")); self.is_bash_mode = false; } if !self.textarea.text().is_empty() { @@ -1825,11 +1831,18 @@ impl ChatComposer { .. } => { if let Some(sel) = popup.selected_item() { - let CommandItem::Builtin(cmd) = sel; - self.stage_selected_slash_command_history(cmd); + self.stage_selected_slash_command_history(&sel); self.textarea.set_text_clearing_elements(""); self.is_bash_mode = false; - return (InputResult::Command(cmd), true); + return ( + match sel { + CommandItem::Builtin(cmd) => InputResult::Command(cmd), + CommandItem::ServiceTier(command) => { + InputResult::ServiceTierCommand(command) + } + }, + true, + ); } // Fallback to default newline handling if no command selected. self.handle_key_event_without_popup(key_event) @@ -2624,10 +2637,13 @@ impl ChatComposer { { let treat_as_plain_text = input_starts_with_space || name.contains('/'); if !treat_as_plain_text { - let is_builtin = - slash_commands::find_builtin_command(name, self.builtin_command_flags()) - .is_some(); - if !is_builtin { + let is_known = find_slash_command( + name, + self.builtin_command_flags(), + &self.service_tier_commands, + ) + .is_some(); + if !is_known { let message = format!( r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# ); @@ -2705,6 +2721,7 @@ impl ChatComposer { InputResult::Submitted { .. } | InputResult::Queued { .. } | InputResult::Command(_) + | InputResult::ServiceTierCommand(_) | InputResult::CommandWithArgs(_, _, _) ) { self.textarea.enter_vim_normal_mode(); @@ -2845,21 +2862,28 @@ impl ChatComposer { if !rest.is_empty() { return None; } - let cmd = slash_commands::find_builtin_command(name, self.builtin_command_flags())?; - if cmd.supports_inline_args() + let command = find_slash_command( + name, + self.builtin_command_flags(), + &self.service_tier_commands, + )?; + if command.supports_inline_args() && parse_slash_name(text).is_some_and(|(_, full_rest, _)| !full_rest.is_empty()) { return None; } - if self.reject_slash_command_if_unavailable(cmd) { - self.stage_slash_command_history(cmd); + if self.reject_slash_command_if_unavailable(&command) { + self.stage_slash_command_history(&command); self.record_pending_slash_command_history(); return Some(InputResult::None); } - self.stage_slash_command_history(cmd); + self.stage_slash_command_history(&command); self.textarea.set_text_clearing_elements(""); self.is_bash_mode = false; - Some(InputResult::Command(cmd)) + Some(match command { + SlashCommandItem::Builtin(cmd) => InputResult::Command(cmd), + SlashCommandItem::ServiceTier(command) => InputResult::ServiceTierCommand(command), + }) } /// Check if the input is a slash command with args (e.g., /review args) and dispatch it. @@ -2878,23 +2902,30 @@ impl ChatComposer { return None; } - let cmd = slash_commands::find_builtin_command(name, self.builtin_command_flags())?; + let command = find_slash_command( + name, + self.builtin_command_flags(), + &self.service_tier_commands, + )?; - if !cmd.supports_inline_args() { + if !command.supports_inline_args() { return None; } - if self.reject_slash_command_if_unavailable(cmd) { - self.stage_slash_command_history(cmd); + if self.reject_slash_command_if_unavailable(&command) { + self.stage_slash_command_history(&command); self.record_pending_slash_command_history(); return Some(InputResult::None); } - self.stage_slash_command_history(cmd); + self.stage_slash_command_history(&command); let mut args_elements = Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements()); let trimmed_rest = rest.trim(); args_elements = Self::trim_text_elements(rest, trimmed_rest, args_elements); + let SlashCommandItem::Builtin(cmd) = command else { + return None; + }; Some(InputResult::CommandWithArgs( cmd, trimmed_rest.to_string(), @@ -2928,13 +2959,13 @@ impl ChatComposer { Some((trimmed_rest.to_string(), args_elements)) } - fn reject_slash_command_if_unavailable(&self, cmd: SlashCommand) -> bool { - if !self.is_task_running || cmd.available_during_task() { + fn reject_slash_command_if_unavailable(&self, command: &SlashCommandItem) -> bool { + if !self.is_task_running || command.available_during_task() { return false; } let message = format!( "'/{}' is disabled while a task is in progress.", - cmd.command() + command.command() ); self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_error_event(message), @@ -2965,8 +2996,8 @@ impl ChatComposer { /// Staging snapshots the rich composer state before the textarea is cleared. `ChatWidget` /// commits the staged entry after dispatch so command recall follows the submitted text, not /// the command outcome. - fn stage_slash_command_history(&mut self, cmd: SlashCommand) { - if cmd == SlashCommand::Clear { + fn stage_slash_command_history(&mut self, command: &SlashCommandItem) { + if matches!(command, SlashCommandItem::Builtin(SlashCommand::Clear)) { return; } self.stage_slash_command_history_text(self.textarea.text().trim().to_string()); @@ -2976,11 +3007,11 @@ impl ChatComposer { /// /// Popup filtering text can be partial, so recording the selected command avoids recalling /// `/di` after the user actually accepted `/diff`. - fn stage_selected_slash_command_history(&mut self, cmd: SlashCommand) { - if cmd == SlashCommand::Clear { + fn stage_selected_slash_command_history(&mut self, command: &CommandItem) { + if matches!(command, CommandItem::Builtin(SlashCommand::Clear)) { return; } - self.stage_slash_command_history_text(format!("/{}", cmd.command())); + self.stage_slash_command_history_text(format!("/{}", command.command())); } /// Store the provided command text and the current composer adornments in the pending slot. @@ -3738,7 +3769,12 @@ impl ChatComposer { } fn is_known_slash_name(&self, name: &str) -> bool { - slash_commands::find_builtin_command(name, self.builtin_command_flags()).is_some() + find_slash_command( + name, + self.builtin_command_flags(), + &self.service_tier_commands, + ) + .is_some() } /// If the cursor is currently within a slash command on the first line, @@ -3780,7 +3816,11 @@ impl ChatComposer { return rest_after_name.is_empty(); } - slash_commands::has_builtin_prefix(name, self.builtin_command_flags()) + has_slash_command_prefix( + name, + self.builtin_command_flags(), + &self.service_tier_commands, + ) } /// Synchronize `self.command_popup` with the current text in the @@ -3826,23 +3866,26 @@ impl ChatComposer { let collaboration_modes_enabled = self.collaboration_modes_enabled; let connectors_enabled = self.connectors_enabled; let plugins_command_enabled = self.plugins_command_enabled; - let fast_command_enabled = self.fast_command_enabled; + let service_tier_commands_enabled = self.service_tier_commands_enabled; let goal_command_enabled = self.goal_command_enabled; let personality_command_enabled = self.personality_command_enabled; let realtime_conversation_enabled = self.realtime_conversation_enabled; let audio_device_selection_enabled = self.audio_device_selection_enabled; - let mut command_popup = CommandPopup::new(CommandPopupFlags { - collaboration_modes_enabled, - connectors_enabled, - plugins_command_enabled, - fast_command_enabled, - goal_command_enabled, - personality_command_enabled, - realtime_conversation_enabled, - audio_device_selection_enabled, - windows_degraded_sandbox_active: self.windows_degraded_sandbox_active, - side_conversation_active: self.side_conversation_active, - }); + let mut command_popup = CommandPopup::new( + CommandPopupFlags { + collaboration_modes_enabled, + connectors_enabled, + plugins_command_enabled, + service_tier_commands_enabled, + goal_command_enabled, + personality_command_enabled, + realtime_conversation_enabled, + audio_device_selection_enabled, + windows_degraded_sandbox_active: self.windows_degraded_sandbox_active, + side_conversation_active: self.side_conversation_active, + }, + self.service_tier_commands.clone(), + ); command_popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::Command(command_popup); } @@ -7548,6 +7591,9 @@ mod tests { Some(CommandItem::Builtin(cmd)) => { assert_eq!(cmd.command(), "model") } + Some(CommandItem::ServiceTier(command)) => { + panic!("expected model command, got service tier {command:?}") + } None => panic!("no selected command for '/mo'"), }, _ => panic!("slash popup not active after typing '/mo'"), @@ -7601,12 +7647,47 @@ mod tests { Some(CommandItem::Builtin(cmd)) => { assert_eq!(cmd.command(), "resume") } + Some(CommandItem::ServiceTier(command)) => { + panic!("expected resume command, got service tier {command:?}") + } None => panic!("no selected command for '/res'"), }, _ => panic!("slash popup not active after typing '/res'"), } } + #[test] + fn service_tier_slash_command_dispatches_from_catalog_name() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_service_tier_commands_enabled(/*enabled*/ true); + composer.set_service_tier_commands(vec![ServiceTierCommand { + id: "priority".to_string(), + name: "fast".to_string(), + description: "Fastest inference with increased plan usage".to_string(), + }]); + type_chars_humanlike(&mut composer, &['/', 'f', 'a', 's', 't']); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + result, + InputResult::ServiceTierCommand(ServiceTierCommand { + id: "priority".to_string(), + name: "fast".to_string(), + description: "Fastest inference with increased plan usage".to_string(), + }) + ); + } + fn flush_after_paste_burst(composer: &mut ChatComposer) -> bool { std::thread::sleep(PasteBurst::recommended_active_flush_delay()); composer.flush_paste_burst_if_due() @@ -7664,6 +7745,9 @@ mod tests { InputResult::CommandWithArgs(_, _, _) => { panic!("expected command dispatch without args for '/init'") } + InputResult::ServiceTierCommand(command) => { + panic!("expected init command, got service tier {command:?}") + } InputResult::Submitted { text, .. } => { panic!("expected command dispatch, but composer submitted literal text: {text}") } @@ -7833,7 +7917,7 @@ mod tests { assert_queued_slash("/compact"); assert_queued_slash("/review check regressions"); - assert_queued_slash("/fast on"); + assert_queued_slash("/fast"); assert_queued_slash("/does-not-exist"); } @@ -8110,6 +8194,9 @@ mod tests { InputResult::CommandWithArgs(_, _, _) => { panic!("expected command dispatch without args for '/diff'") } + InputResult::ServiceTierCommand(command) => { + panic!("expected diff command, got service tier {command:?}") + } InputResult::Submitted { text, .. } => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } @@ -8304,6 +8391,9 @@ mod tests { InputResult::CommandWithArgs(_, _, _) => { panic!("expected command dispatch without args for '/mention'") } + InputResult::ServiceTierCommand(command) => { + panic!("expected mention command, got service tier {command:?}") + } InputResult::Submitted { text, .. } => { panic!("expected command dispatch, but composer submitted literal text: {text}") } diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 1618d64025..714fe9dd44 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -9,7 +9,10 @@ use super::selection_popup_common::ColumnWidthMode; use super::selection_popup_common::GenericDisplayRow; use super::selection_popup_common::measure_rows_height_with_col_width_mode; use super::selection_popup_common::render_rows_with_col_width_mode; -use super::slash_commands; +use super::slash_commands::BuiltinCommandFlags; +use super::slash_commands::ServiceTierCommand; +use super::slash_commands::SlashCommandItem; +use super::slash_commands::commands_for_input; use crate::render::Insets; use crate::render::RectExt; use crate::slash_command::SlashCommand; @@ -23,14 +26,15 @@ const COMMAND_COLUMN_WIDTH: ColumnWidthConfig = ColumnWidthConfig::new( ); /// A selectable item in the popup. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum CommandItem { Builtin(SlashCommand), + ServiceTier(ServiceTierCommand), } pub(crate) struct CommandPopup { command_filter: String, - builtins: Vec<(&'static str, SlashCommand)>, + commands: Vec, state: ScrollState, } @@ -39,7 +43,7 @@ pub(crate) struct CommandPopupFlags { pub(crate) collaboration_modes_enabled: bool, pub(crate) connectors_enabled: bool, pub(crate) plugins_command_enabled: bool, - pub(crate) fast_command_enabled: bool, + pub(crate) service_tier_commands_enabled: bool, pub(crate) goal_command_enabled: bool, pub(crate) personality_command_enabled: bool, pub(crate) realtime_conversation_enabled: bool, @@ -48,13 +52,13 @@ pub(crate) struct CommandPopupFlags { pub(crate) side_conversation_active: bool, } -impl From for slash_commands::BuiltinCommandFlags { +impl From for BuiltinCommandFlags { fn from(value: CommandPopupFlags) -> Self { Self { collaboration_modes_enabled: value.collaboration_modes_enabled, connectors_enabled: value.connectors_enabled, plugins_command_enabled: value.plugins_command_enabled, - fast_command_enabled: value.fast_command_enabled, + service_tier_commands_enabled: value.service_tier_commands_enabled, goal_command_enabled: value.goal_command_enabled, personality_command_enabled: value.personality_command_enabled, realtime_conversation_enabled: value.realtime_conversation_enabled, @@ -66,17 +70,23 @@ impl From for slash_commands::BuiltinCommandFlags { } impl CommandPopup { - pub(crate) fn new(flags: CommandPopupFlags) -> Self { + pub(crate) fn new( + flags: CommandPopupFlags, + service_tier_commands: Vec, + ) -> Self { // Keep built-in availability in sync with the composer. - let builtins: Vec<(&'static str, SlashCommand)> = - slash_commands::builtins_for_input(flags.into()) - .into_iter() - .filter(|(name, _)| !name.starts_with("debug")) - .filter(|(_, cmd)| *cmd != SlashCommand::Apps) - .collect(); + let commands = commands_for_input(flags.into(), &service_tier_commands) + .into_iter() + .filter_map(|command| match command { + SlashCommandItem::Builtin(cmd) => (!cmd.command().starts_with("debug") + && cmd != SlashCommand::Apps) + .then_some(CommandItem::Builtin(cmd)), + SlashCommandItem::ServiceTier(command) => Some(CommandItem::ServiceTier(command)), + }) + .collect(); Self { command_filter: String::new(), - builtins, + commands, state: ScrollState::new(), } } @@ -133,11 +143,11 @@ impl CommandPopup { let filter = self.command_filter.trim(); let mut out: Vec<(CommandItem, Option>)> = Vec::new(); if filter.is_empty() { - for (_, cmd) in self.builtins.iter() { - if ALIAS_COMMANDS.contains(cmd) { + for command in self.commands.iter() { + if matches!(command, CommandItem::Builtin(cmd) if ALIAS_COMMANDS.contains(cmd)) { continue; } - out.push((CommandItem::Builtin(*cmd), None)); + out.push((command.clone(), None)); } return out; } @@ -169,8 +179,9 @@ impl CommandPopup { } }; - for (_, cmd) in self.builtins.iter() { - push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0); + for command in self.commands.iter() { + let display = command.command(); + push_match(command.clone(), display, None, 0); } out.extend(exact); @@ -189,9 +200,8 @@ impl CommandPopup { matches .into_iter() .map(|(item, indices)| { - let CommandItem::Builtin(cmd) = item; - let name = format!("/{}", cmd.command()); - let description = cmd.description().to_string(); + let name = format!("/{}", item.command()); + let description = item.description().to_string(); GenericDisplayRow { name, name_prefix_spans: Vec::new(), @@ -227,7 +237,23 @@ impl CommandPopup { let matches = self.filtered_items(); self.state .selected_idx - .and_then(|idx| matches.get(idx).copied()) + .and_then(|idx| matches.get(idx).cloned()) + } +} + +impl CommandItem { + pub(crate) fn command(&self) -> &str { + match self { + Self::Builtin(cmd) => cmd.command(), + Self::ServiceTier(command) => &command.name, + } + } + + fn description(&self) -> &str { + match self { + Self::Builtin(cmd) => cmd.description(), + Self::ServiceTier(command) => &command.description, + } } } @@ -255,7 +281,7 @@ mod tests { #[test] fn filter_includes_init_when_typing_prefix() { - let mut popup = CommandPopup::new(CommandPopupFlags::default()); + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); // Simulate the composer line starting with '/in' so the popup filters // matching commands by prefix. popup.on_composer_text_change("/in".to_string()); @@ -265,6 +291,7 @@ mod tests { let matches = popup.filtered_items(); let has_init = matches.iter().any(|item| match item { CommandItem::Builtin(cmd) => cmd.command() == "init", + CommandItem::ServiceTier(_) => false, }); assert!( has_init, @@ -274,7 +301,7 @@ mod tests { #[test] fn selecting_init_by_exact_match() { - let mut popup = CommandPopup::new(CommandPopupFlags::default()); + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); popup.on_composer_text_change("/init".to_string()); // When an exact match exists, the selected command should be that @@ -282,57 +309,106 @@ mod tests { let selected = popup.selected_item(); match selected { Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"), + Some(CommandItem::ServiceTier(command)) => { + panic!("expected init command, got service tier {command:?}") + } None => panic!("expected a selected command for exact match"), } } #[test] fn model_is_first_suggestion_for_mo() { - let mut popup = CommandPopup::new(CommandPopupFlags::default()); + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); popup.on_composer_text_change("/mo".to_string()); let matches = popup.filtered_items(); match matches.first() { Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"), + Some(CommandItem::ServiceTier(command)) => { + panic!("expected model command, got service tier {command:?}") + } None => panic!("expected at least one match for '/mo'"), } } + #[test] + fn service_tier_command_uses_catalog_name_and_description() { + let mut popup = CommandPopup::new( + CommandPopupFlags { + service_tier_commands_enabled: true, + ..CommandPopupFlags::default() + }, + vec![ServiceTierCommand { + id: "priority".to_string(), + name: "fast".to_string(), + description: "Fastest inference with increased plan usage".to_string(), + }], + ); + popup.on_composer_text_change("/fa".to_string()); + + match popup.selected_item() { + Some(CommandItem::ServiceTier(command)) => assert_eq!( + command, + ServiceTierCommand { + id: "priority".to_string(), + name: "fast".to_string(), + description: "Fastest inference with increased plan usage".to_string(), + } + ), + other => panic!("expected fast service tier to be selected, got {other:?}"), + } + let rows = popup.rows_from_matches(popup.filtered()); + assert_eq!( + rows.first().and_then(|row| row.description.as_deref()), + Some("Fastest inference with increased plan usage") + ); + } + #[test] fn filtered_commands_keep_presentation_order_for_prefix() { - let mut popup = CommandPopup::new(CommandPopupFlags::default()); + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); popup.on_composer_text_change("/m".to_string()); - let cmds: Vec<&str> = popup + let cmds: Vec = popup .filtered_items() .into_iter() .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), + CommandItem::Builtin(cmd) => cmd.command().to_string(), + CommandItem::ServiceTier(command) => command.name, }) .collect(); - assert_eq!(cmds, vec!["model", "memories", "mention", "mcp"]); + assert_eq!( + cmds, + vec![ + "model".to_string(), + "memories".to_string(), + "mention".to_string(), + "mcp".to_string() + ] + ); } #[test] fn prefix_filter_limits_matches_for_ac() { - let mut popup = CommandPopup::new(CommandPopupFlags::default()); + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); popup.on_composer_text_change("/ac".to_string()); - let cmds: Vec<&str> = popup + let cmds: Vec = popup .filtered_items() .into_iter() .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), + CommandItem::Builtin(cmd) => cmd.command().to_string(), + CommandItem::ServiceTier(command) => command.name, }) .collect(); assert!( - !cmds.contains(&"compact"), + !cmds.iter().any(|cmd| cmd == "compact"), "expected prefix search for '/ac' to exclude 'compact', got {cmds:?}" ); } #[test] fn quit_hidden_in_empty_filter_but_shown_for_prefix() { - let mut popup = CommandPopup::new(CommandPopupFlags::default()); + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); popup.on_composer_text_change("/".to_string()); let items = popup.filtered_items(); assert!(!items.contains(&CommandItem::Builtin(SlashCommand::Quit))); @@ -344,159 +420,187 @@ mod tests { #[test] fn collab_command_hidden_when_collaboration_modes_disabled() { - let mut popup = CommandPopup::new(CommandPopupFlags::default()); + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); popup.on_composer_text_change("/".to_string()); - let cmds: Vec<&str> = popup + let cmds: Vec = popup .filtered_items() .into_iter() .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), + CommandItem::Builtin(cmd) => cmd.command().to_string(), + CommandItem::ServiceTier(command) => command.name, }) .collect(); assert!( - !cmds.contains(&"collab"), + !cmds.iter().any(|cmd| cmd == "collab"), "expected '/collab' to be hidden when collaboration modes are disabled, got {cmds:?}" ); assert!( - !cmds.contains(&"plan"), + !cmds.iter().any(|cmd| cmd == "plan"), "expected '/plan' to be hidden when collaboration modes are disabled, got {cmds:?}" ); } #[test] fn collab_command_visible_when_collaboration_modes_enabled() { - let mut popup = CommandPopup::new(CommandPopupFlags { - collaboration_modes_enabled: true, - connectors_enabled: false, - plugins_command_enabled: false, - fast_command_enabled: false, - goal_command_enabled: false, - personality_command_enabled: true, - realtime_conversation_enabled: false, - audio_device_selection_enabled: false, - windows_degraded_sandbox_active: false, - side_conversation_active: false, - }); + let mut popup = CommandPopup::new( + CommandPopupFlags { + collaboration_modes_enabled: true, + connectors_enabled: false, + plugins_command_enabled: false, + service_tier_commands_enabled: false, + goal_command_enabled: false, + personality_command_enabled: true, + realtime_conversation_enabled: false, + audio_device_selection_enabled: false, + windows_degraded_sandbox_active: false, + side_conversation_active: false, + }, + Vec::new(), + ); popup.on_composer_text_change("/collab".to_string()); match popup.selected_item() { Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "collab"), + Some(CommandItem::ServiceTier(command)) => { + panic!("expected collab command, got service tier {command:?}") + } other => panic!("expected collab to be selected for exact match, got {other:?}"), } } #[test] fn plan_command_visible_when_collaboration_modes_enabled() { - let mut popup = CommandPopup::new(CommandPopupFlags { - collaboration_modes_enabled: true, - connectors_enabled: false, - plugins_command_enabled: false, - fast_command_enabled: false, - goal_command_enabled: false, - personality_command_enabled: true, - realtime_conversation_enabled: false, - audio_device_selection_enabled: false, - windows_degraded_sandbox_active: false, - side_conversation_active: false, - }); + let mut popup = CommandPopup::new( + CommandPopupFlags { + collaboration_modes_enabled: true, + connectors_enabled: false, + plugins_command_enabled: false, + service_tier_commands_enabled: false, + goal_command_enabled: false, + personality_command_enabled: true, + realtime_conversation_enabled: false, + audio_device_selection_enabled: false, + windows_degraded_sandbox_active: false, + side_conversation_active: false, + }, + Vec::new(), + ); popup.on_composer_text_change("/plan".to_string()); match popup.selected_item() { Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "plan"), + Some(CommandItem::ServiceTier(command)) => { + panic!("expected plan command, got service tier {command:?}") + } other => panic!("expected plan to be selected for exact match, got {other:?}"), } } #[test] fn personality_command_hidden_when_disabled() { - let mut popup = CommandPopup::new(CommandPopupFlags { - collaboration_modes_enabled: true, - connectors_enabled: false, - plugins_command_enabled: false, - fast_command_enabled: false, - goal_command_enabled: false, - personality_command_enabled: false, - realtime_conversation_enabled: false, - audio_device_selection_enabled: false, - windows_degraded_sandbox_active: false, - side_conversation_active: false, - }); + let mut popup = CommandPopup::new( + CommandPopupFlags { + collaboration_modes_enabled: true, + connectors_enabled: false, + plugins_command_enabled: false, + service_tier_commands_enabled: false, + goal_command_enabled: false, + personality_command_enabled: false, + realtime_conversation_enabled: false, + audio_device_selection_enabled: false, + windows_degraded_sandbox_active: false, + side_conversation_active: false, + }, + Vec::new(), + ); popup.on_composer_text_change("/pers".to_string()); - let cmds: Vec<&str> = popup + let cmds: Vec = popup .filtered_items() .into_iter() .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), + CommandItem::Builtin(cmd) => cmd.command().to_string(), + CommandItem::ServiceTier(command) => command.name, }) .collect(); assert!( - !cmds.contains(&"personality"), + !cmds.iter().any(|cmd| cmd == "personality"), "expected '/personality' to be hidden when disabled, got {cmds:?}" ); } #[test] fn personality_command_visible_when_enabled() { - let mut popup = CommandPopup::new(CommandPopupFlags { - collaboration_modes_enabled: true, - connectors_enabled: false, - plugins_command_enabled: false, - fast_command_enabled: false, - goal_command_enabled: false, - personality_command_enabled: true, - realtime_conversation_enabled: false, - audio_device_selection_enabled: false, - windows_degraded_sandbox_active: false, - side_conversation_active: false, - }); + let mut popup = CommandPopup::new( + CommandPopupFlags { + collaboration_modes_enabled: true, + connectors_enabled: false, + plugins_command_enabled: false, + service_tier_commands_enabled: false, + goal_command_enabled: false, + personality_command_enabled: true, + realtime_conversation_enabled: false, + audio_device_selection_enabled: false, + windows_degraded_sandbox_active: false, + side_conversation_active: false, + }, + Vec::new(), + ); popup.on_composer_text_change("/personality".to_string()); match popup.selected_item() { Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "personality"), + Some(CommandItem::ServiceTier(command)) => { + panic!("expected personality command, got service tier {command:?}") + } other => panic!("expected personality to be selected for exact match, got {other:?}"), } } #[test] fn settings_command_hidden_when_audio_device_selection_is_disabled() { - let mut popup = CommandPopup::new(CommandPopupFlags { - collaboration_modes_enabled: false, - connectors_enabled: false, - plugins_command_enabled: false, - fast_command_enabled: false, - goal_command_enabled: false, - personality_command_enabled: true, - realtime_conversation_enabled: true, - audio_device_selection_enabled: false, - windows_degraded_sandbox_active: false, - side_conversation_active: false, - }); + let mut popup = CommandPopup::new( + CommandPopupFlags { + collaboration_modes_enabled: false, + connectors_enabled: false, + plugins_command_enabled: false, + service_tier_commands_enabled: false, + goal_command_enabled: false, + personality_command_enabled: true, + realtime_conversation_enabled: true, + audio_device_selection_enabled: false, + windows_degraded_sandbox_active: false, + side_conversation_active: false, + }, + Vec::new(), + ); popup.on_composer_text_change("/aud".to_string()); - let cmds: Vec<&str> = popup + let cmds: Vec = popup .filtered_items() .into_iter() .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), + CommandItem::Builtin(cmd) => cmd.command().to_string(), + CommandItem::ServiceTier(command) => command.name, }) .collect(); assert!( - !cmds.contains(&"settings"), + !cmds.iter().any(|cmd| cmd == "settings"), "expected '/settings' to be hidden when audio device selection is disabled, got {cmds:?}" ); } #[test] fn debug_commands_are_hidden_from_popup() { - let popup = CommandPopup::new(CommandPopupFlags::default()); - let cmds: Vec<&str> = popup + let popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); + let cmds: Vec = popup .filtered_items() .into_iter() .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), + CommandItem::Builtin(cmd) => cmd.command().to_string(), + CommandItem::ServiceTier(command) => command.name, }) .collect(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 71075f3d06..5746816b24 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -113,6 +113,7 @@ pub(crate) use list_selection_view::SideContentWidth; pub(crate) use list_selection_view::popup_content_width; pub(crate) use list_selection_view::side_by_side_layout_widths; pub(crate) use memories_settings_view::MemoriesSettingsView; +use slash_commands::ServiceTierCommand; mod feedback_view; mod hooks_browser_view; pub(crate) use feedback_view::FeedbackAudience; @@ -394,8 +395,13 @@ impl BottomPane { self.request_redraw(); } - pub fn set_fast_command_enabled(&mut self, enabled: bool) { - self.composer.set_fast_command_enabled(enabled); + pub fn set_service_tier_commands_enabled(&mut self, enabled: bool) { + self.composer.set_service_tier_commands_enabled(enabled); + self.request_redraw(); + } + + pub fn set_service_tier_commands(&mut self, commands: Vec) { + self.composer.set_service_tier_commands(commands); self.request_redraw(); } diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index c253d49b04..6966b28b81 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -1,4 +1,4 @@ -//! Shared helpers for filtering and matching built-in slash commands. +//! Shared helpers for filtering and matching built-in and model service-tier slash commands. //! //! The same sandbox- and feature-gating rules are used by both the composer //! and the command popup. Centralizing them here keeps those call sites small @@ -10,12 +10,55 @@ use codex_utils_fuzzy_match::fuzzy_match; use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ServiceTierCommand { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) description: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SlashCommandItem { + Builtin(SlashCommand), + ServiceTier(ServiceTierCommand), +} + +impl SlashCommandItem { + pub(crate) fn command(&self) -> &str { + match self { + Self::Builtin(cmd) => cmd.command(), + Self::ServiceTier(command) => &command.name, + } + } + + pub(crate) fn supports_inline_args(&self) -> bool { + match self { + Self::Builtin(cmd) => cmd.supports_inline_args(), + Self::ServiceTier(_) => false, + } + } + + pub(crate) fn available_in_side_conversation(&self) -> bool { + match self { + Self::Builtin(cmd) => cmd.available_in_side_conversation(), + Self::ServiceTier(_) => false, + } + } + + pub(crate) fn available_during_task(&self) -> bool { + match self { + Self::Builtin(cmd) => cmd.available_during_task(), + Self::ServiceTier(_) => false, + } + } +} + #[derive(Clone, Copy, Debug, Default)] pub(crate) struct BuiltinCommandFlags { pub(crate) collaboration_modes_enabled: bool, pub(crate) connectors_enabled: bool, pub(crate) plugins_command_enabled: bool, - pub(crate) fast_command_enabled: bool, + pub(crate) service_tier_commands_enabled: bool, pub(crate) goal_command_enabled: bool, pub(crate) personality_command_enabled: bool, pub(crate) realtime_conversation_enabled: bool, @@ -35,7 +78,6 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st }) .filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps) .filter(|(_, cmd)| flags.plugins_command_enabled || *cmd != SlashCommand::Plugins) - .filter(|(_, cmd)| flags.fast_command_enabled || *cmd != SlashCommand::Fast) .filter(|(_, cmd)| flags.goal_command_enabled || *cmd != SlashCommand::Goal) .filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality) .filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime) @@ -44,6 +86,29 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st .collect() } +pub(crate) fn commands_for_input( + flags: BuiltinCommandFlags, + service_tier_commands: &[ServiceTierCommand], +) -> Vec { + let mut commands = Vec::new(); + let tiers_enabled = flags.service_tier_commands_enabled; + for (_, cmd) in builtins_for_input(flags) { + commands.push(SlashCommandItem::Builtin(cmd)); + if cmd == SlashCommand::Model && tiers_enabled { + commands.extend( + service_tier_commands + .iter() + .cloned() + .map(SlashCommandItem::ServiceTier), + ); + } + } + commands + .into_iter() + .filter(|cmd| !flags.side_conversation_active || cmd.available_in_side_conversation()) + .collect() +} + /// Find a single built-in command by exact name, after applying feature gating. /// /// Side-conversation gating is intentionally enforced by dispatch rather than exact lookup so a @@ -59,24 +124,49 @@ pub(crate) fn find_builtin_command(name: &str, flags: BuiltinCommandFlags) -> Op .then_some(cmd) } -/// Whether any visible built-in fuzzily matches the provided prefix. -pub(crate) fn has_builtin_prefix(name: &str, flags: BuiltinCommandFlags) -> bool { - builtins_for_input(flags) +pub(crate) fn find_slash_command( + name: &str, + flags: BuiltinCommandFlags, + service_tier_commands: &[ServiceTierCommand], +) -> Option { + if let Some(cmd) = find_builtin_command(name, flags) { + return Some(SlashCommandItem::Builtin(cmd)); + } + + let tiers_enabled = flags.service_tier_commands_enabled; + tiers_enabled + .then(|| { + service_tier_commands + .iter() + .find(|command| command.name == name) + .cloned() + .map(SlashCommandItem::ServiceTier) + }) + .flatten() +} + +pub(crate) fn has_slash_command_prefix( + name: &str, + flags: BuiltinCommandFlags, + service_tier_commands: &[ServiceTierCommand], +) -> bool { + commands_for_input(flags, service_tier_commands) .into_iter() - .any(|(command_name, _)| fuzzy_match(command_name, name).is_some()) + .any(|command| fuzzy_match(command.command(), name).is_some()) } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; + use std::slice::from_ref; fn all_enabled_flags() -> BuiltinCommandFlags { BuiltinCommandFlags { collaboration_modes_enabled: true, connectors_enabled: true, plugins_command_enabled: true, - fast_command_enabled: true, + service_tier_commands_enabled: true, goal_command_enabled: true, personality_command_enabled: true, realtime_conversation_enabled: true, @@ -117,10 +207,49 @@ mod tests { } #[test] - fn fast_command_is_hidden_when_disabled() { + fn service_tier_commands_are_hidden_when_disabled() { let mut flags = all_enabled_flags(); - flags.fast_command_enabled = false; - assert_eq!(find_builtin_command("fast", flags), None); + flags.service_tier_commands_enabled = false; + let commands = vec![ServiceTierCommand { + id: "priority".to_string(), + name: "fast".to_string(), + description: "fastest inference".to_string(), + }]; + + assert_eq!(find_slash_command("fast", flags, &commands), None); + } + + #[test] + fn all_service_tiers_are_exposed_as_commands_after_model() { + let commands = vec![ + ServiceTierCommand { + id: "priority".to_string(), + name: "fast".to_string(), + description: "fastest inference".to_string(), + }, + ServiceTierCommand { + id: "batch".to_string(), + name: "slow".to_string(), + description: "slower inference with lower priority".to_string(), + }, + ]; + + let items = commands_for_input(all_enabled_flags(), &commands); + let model_idx = items + .iter() + .position(|item| matches!(item, SlashCommandItem::Builtin(SlashCommand::Model))) + .expect("model command should be visible"); + let inserted = items + .into_iter() + .skip(model_idx + 1) + .take(commands.len()) + .collect::>(); + let expected = commands + .into_iter() + .map(SlashCommandItem::ServiceTier) + .collect::>(); + + assert_eq!(inserted, expected); } #[test] @@ -188,4 +317,22 @@ mod tests { Some(SlashCommand::Review) ); } + + #[test] + fn side_conversation_exact_lookup_still_resolves_service_tier_commands_for_dispatch_error() { + let command = ServiceTierCommand { + id: "priority".to_string(), + name: "fast".to_string(), + description: "fastest inference".to_string(), + }; + let flags = BuiltinCommandFlags { + side_conversation_active: true, + ..all_enabled_flags() + }; + + assert_eq!( + find_slash_command("fast", flags, from_ref(&command)), + Some(SlashCommandItem::ServiceTier(command)) + ); + } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b65f8b1c4d..68e59d9409 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -150,7 +150,6 @@ use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Settings; #[cfg(target_os = "windows")] use codex_protocol::config_types::WindowsSandboxLevel; @@ -344,6 +343,7 @@ use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE; mod realtime; use self::realtime::RealtimeConversationUiState; mod reasoning_shortcuts; +mod service_tiers; mod side; mod status_surfaces; use self::status_surfaces::CachedProjectRootName; @@ -761,7 +761,7 @@ pub(crate) struct ChatWidget { config: Config, raw_output_mode: bool, /// Runtime value resolved by core. `config.service_tier` remains the explicit user choice. - effective_service_tier: Option, + effective_service_tier: Option, /// The unmasked collaboration mode settings (always Default mode). /// /// Masks are applied on top of this base mode to derive the effective mode. @@ -2072,10 +2072,7 @@ impl ChatWidget { self.current_rollout_path = session.rollout_path.clone(); self.current_cwd = Some(session.cwd.to_path_buf()); self.config.cwd = session.cwd.clone(); - self.effective_service_tier = session - .service_tier - .as_deref() - .and_then(ServiceTier::from_request_value); + self.effective_service_tier = session.service_tier.clone(); if let Err(err) = self .config .permissions @@ -2116,15 +2113,15 @@ impl ChatWidget { } self.refresh_model_display(); self.refresh_status_surfaces(); - self.sync_fast_command_enabled(); + self.sync_service_tier_commands(); self.sync_personality_command_enabled(); self.sync_plugins_command_enabled(); self.sync_goal_command_enabled(); self.refresh_plugin_mentions(); if display == SessionConfiguredDisplay::Normal { let startup_tooltip_override = self.startup_tooltip_override.take(); - let show_fast_status = - self.should_show_fast_status(&model_for_header, self.effective_service_tier); + let show_fast_status = self + .should_show_fast_status(&model_for_header, self.effective_service_tier.as_deref()); let session_info_cell = history_cell::new_session_info( &self.config, &model_for_header, @@ -4893,10 +4890,7 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.to_path_buf()); - let effective_service_tier = config - .service_tier - .as_deref() - .and_then(ServiceTier::from_request_value); + let effective_service_tier = config.service_tier.clone(); let current_terminal_info = terminal_info(); let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).ok(); let default_keymap = RuntimeKeymap::defaults(); @@ -5088,7 +5082,7 @@ impl ChatWidget { widget .bottom_pane .set_collaboration_modes_enabled(/*enabled*/ true); - widget.sync_fast_command_enabled(); + widget.sync_service_tier_commands(); widget.sync_personality_command_enabled(); widget.sync_plugins_command_enabled(); widget.sync_goal_command_enabled(); @@ -5328,6 +5322,9 @@ impl ChatWidget { InputResult::Command(cmd) => { self.handle_slash_command_dispatch(cmd); } + InputResult::ServiceTierCommand(command) => { + self.handle_service_tier_command_dispatch(command); + } InputResult::CommandWithArgs(cmd, args, text_elements) => { self.handle_slash_command_with_args_dispatch(cmd, args, text_elements); } @@ -9193,7 +9190,7 @@ impl ChatWidget { } } if feature == Feature::FastMode { - self.sync_fast_command_enabled(); + self.sync_service_tier_commands(); } if feature == Feature::Personality { self.sync_personality_command_enabled(); @@ -9309,28 +9306,6 @@ impl ChatWidget { self.config.personality = Some(personality); } - /// Set Fast mode in the widget's config copy. - pub(crate) fn set_service_tier(&mut self, service_tier: Option) { - self.config.service_tier = - service_tier.map(|service_tier| service_tier.request_value().to_string()); - self.effective_service_tier = service_tier; - } - - pub(crate) fn current_service_tier(&self) -> Option { - self.effective_service_tier - } - - pub(crate) fn configured_service_tier(&self) -> Option { - self.config - .service_tier - .as_deref() - .and_then(ServiceTier::from_request_value) - } - - pub(crate) fn fast_default_opt_out(&self) -> Option { - self.config.notices.fast_default_opt_out - } - pub(crate) fn status_account_display(&self) -> Option<&StatusAccountDisplay> { self.status_account_display.as_ref() } @@ -9365,26 +9340,6 @@ impl ChatWidget { .set_connectors_enabled(self.connectors_enabled()); } - pub(crate) fn should_show_fast_status( - &self, - model: &str, - service_tier: Option, - ) -> bool { - self.model_supports_fast_mode(model) - && matches!(service_tier, Some(ServiceTier::Fast)) - && self.has_chatgpt_account - } - - fn fast_mode_enabled(&self) -> bool { - self.config.features.enabled(Feature::FastMode) - } - - pub(crate) fn can_toggle_fast_mode_from_keybinding(&self) -> bool { - self.fast_mode_enabled() - && !self.is_user_turn_pending_or_running() - && self.bottom_pane.no_modal_or_popup_active() - } - pub(crate) fn set_realtime_audio_device( &mut self, kind: RealtimeAudioDeviceKind, @@ -9416,38 +9371,6 @@ impl ChatWidget { self.refresh_model_dependent_surfaces(); } - fn set_service_tier_selection(&mut self, service_tier: Option) { - if service_tier.is_none() { - self.config.notices.fast_default_opt_out = Some(true); - } - self.set_service_tier(service_tier); - self.app_event_tx - .send(AppEvent::CodexOp(AppCommand::override_turn_context( - /*cwd*/ None, - /*approval_policy*/ None, - /*approvals_reviewer*/ None, - /*permission_profile*/ None, - /*windows_sandbox_level*/ None, - /*model*/ None, - /*effort*/ None, - /*summary*/ None, - Some(service_tier.map(|service_tier| service_tier.request_value().to_string())), - /*collaboration_mode*/ None, - /*personality*/ None, - ))); - self.app_event_tx - .send(AppEvent::PersistServiceTierSelection { service_tier }); - } - - pub(crate) fn toggle_fast_mode_from_ui(&mut self) { - let next_tier = if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) { - None - } else { - Some(ServiceTier::Fast) - }; - self.set_service_tier_selection(next_tier); - } - pub(crate) fn current_model(&self) -> &str { if !self.collaboration_modes_enabled() { return self.current_collaboration_mode.model(); @@ -9474,11 +9397,6 @@ impl ChatWidget { .unwrap_or_else(|| "System default".to_string()) } - fn sync_fast_command_enabled(&mut self) { - self.bottom_pane - .set_fast_command_enabled(self.fast_mode_enabled()); - } - fn sync_personality_command_enabled(&mut self) { self.bottom_pane .set_personality_command_enabled(self.config.features.enabled(Feature::Personality)); @@ -9508,19 +9426,6 @@ impl ChatWidget { .unwrap_or(false) } - fn model_supports_fast_mode(&self, model: &str) -> bool { - self.model_catalog - .try_list_models() - .ok() - .and_then(|models| { - models - .into_iter() - .find(|preset| preset.model == model) - .map(|preset| preset.supports_fast_mode()) - }) - .unwrap_or(false) - } - /// Return whether the effective model currently advertises image-input support. /// /// We intentionally default to `true` when model metadata cannot be read so transient catalog @@ -9660,6 +9565,7 @@ impl ChatWidget { self.session_header.set_model(effective.model()); // Keep composer paste affordances aligned with the currently effective model. self.sync_image_paste_enabled(); + self.sync_service_tier_commands(); self.refresh_terminal_title(); } diff --git a/codex-rs/tui/src/chatwidget/service_tiers.rs b/codex-rs/tui/src/chatwidget/service_tiers.rs new file mode 100644 index 0000000000..f23f44dd6e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/service_tiers.rs @@ -0,0 +1,146 @@ +//! Service-tier selection and model-catalog helpers for `ChatWidget`. + +use super::ChatWidget; +use crate::app_command::AppCommand; +use crate::app_event::AppEvent; +use crate::bottom_pane::slash_commands::ServiceTierCommand; +use codex_features::Feature; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::SPEED_TIER_FAST; + +impl ChatWidget { + pub(crate) fn set_service_tier(&mut self, service_tier: Option) { + self.config.service_tier = service_tier.clone(); + self.effective_service_tier = service_tier; + self.refresh_model_dependent_surfaces(); + } + + pub(crate) fn current_service_tier(&self) -> Option<&str> { + self.effective_service_tier.as_deref() + } + + pub(crate) fn configured_service_tier(&self) -> Option { + self.config.service_tier.clone() + } + + pub(crate) fn fast_default_opt_out(&self) -> Option { + self.config.notices.fast_default_opt_out + } + + pub(crate) fn should_show_fast_status(&self, model: &str, service_tier: Option<&str>) -> bool { + service_tier.is_some_and(|service_tier| { + service_tier == ServiceTier::Fast.request_value() + && self.model_supports_service_tier(model, service_tier) + }) && self.has_chatgpt_account + } + + pub(super) fn fast_mode_enabled(&self) -> bool { + self.config.features.enabled(Feature::FastMode) + } + + pub(crate) fn can_toggle_fast_mode_from_keybinding(&self) -> bool { + self.fast_mode_enabled() + && self.current_model_fast_service_tier().is_some() + && !self.is_user_turn_pending_or_running() + && self.bottom_pane.no_modal_or_popup_active() + } + + pub(crate) fn toggle_fast_mode_from_ui(&mut self) { + let Some(fast_tier) = self.current_model_fast_service_tier() else { + return; + }; + let next_tier = if self.current_service_tier() == Some(fast_tier.id.as_str()) { + None + } else { + Some(fast_tier.id) + }; + self.set_service_tier_selection(next_tier); + } + + pub(crate) fn toggle_service_tier_from_ui(&mut self, command: ServiceTierCommand) { + let next_tier = if self.current_service_tier() == Some(command.id.as_str()) { + None + } else { + Some(command.id) + }; + self.set_service_tier_selection(next_tier); + } + + pub(super) fn sync_service_tier_commands(&mut self) { + self.bottom_pane + .set_service_tier_commands_enabled(self.fast_mode_enabled()); + self.bottom_pane + .set_service_tier_commands(self.current_model_service_tier_commands()); + } + + pub(super) fn current_model_service_tier_commands(&self) -> Vec { + let model = self.current_model(); + self.model_catalog + .try_list_models() + .ok() + .and_then(|models| { + models + .into_iter() + .find(|preset| preset.model == model) + .map(|preset| { + preset + .service_tiers + .into_iter() + .map(|tier| ServiceTierCommand { + id: tier.id, + name: tier.name, + description: tier.description, + }) + .collect() + }) + }) + .unwrap_or_default() + } + + fn set_service_tier_selection(&mut self, service_tier: Option) { + if service_tier.is_none() { + self.config.notices.fast_default_opt_out = Some(true); + } + self.set_service_tier(service_tier.clone()); + self.app_event_tx + .send(AppEvent::CodexOp(AppCommand::override_turn_context( + /*cwd*/ None, + /*approval_policy*/ None, + /*approvals_reviewer*/ None, + /*permission_profile*/ None, + /*windows_sandbox_level*/ None, + /*model*/ None, + /*effort*/ None, + /*summary*/ None, + Some(service_tier.clone()), + /*collaboration_mode*/ None, + /*personality*/ None, + ))); + self.app_event_tx + .send(AppEvent::PersistServiceTierSelection { service_tier }); + } + + fn model_supports_service_tier(&self, model: &str, service_tier: &str) -> bool { + self.model_catalog + .try_list_models() + .ok() + .and_then(|models| { + models + .into_iter() + .find(|preset| preset.model == model) + .map(|preset| { + preset + .service_tiers + .iter() + .any(|tier| tier.id == service_tier) + }) + }) + .unwrap_or(false) + } + + fn current_model_fast_service_tier(&self) -> Option { + self.current_model_service_tier_commands() + .into_iter() + .find(|tier| tier.name.eq_ignore_ascii_case(SPEED_TIER_FAST)) + } +} diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 6d1278ea2d..cedc0e9686 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -9,7 +9,10 @@ use super::goal_validation::GoalObjectiveValidationSource; use super::*; use crate::app_event::ThreadGoalSetMode; use crate::bottom_pane::prompt_args::parse_slash_name; -use crate::bottom_pane::slash_commands; +use crate::bottom_pane::slash_commands::BuiltinCommandFlags; +use crate::bottom_pane::slash_commands::ServiceTierCommand; +use crate::bottom_pane::slash_commands::SlashCommandItem; +use crate::bottom_pane::slash_commands::find_slash_command; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SlashCommandDispatchSource { @@ -48,6 +51,20 @@ impl ChatWidget { self.bottom_pane.record_pending_slash_command_history(); } + pub(super) fn handle_service_tier_command_dispatch(&mut self, command: ServiceTierCommand) { + if self.active_side_conversation { + self.add_error_message(format!( + "'/{}' is unavailable in side conversations. {SIDE_SLASH_COMMAND_UNAVAILABLE_HINT}", + command.name + )); + self.bottom_pane.drain_pending_submission_state(); + self.bottom_pane.record_pending_slash_command_history(); + return; + } + self.toggle_service_tier_from_ui(command); + self.bottom_pane.record_pending_slash_command_history(); + } + /// Dispatch an inline slash command and record its staged local-history entry. /// /// Inline command arguments may later be prepared through the normal submission pipeline, but @@ -184,9 +201,6 @@ impl ChatWidget { SlashCommand::Model => { self.open_model_popup(); } - SlashCommand::Fast => { - self.toggle_fast_mode_from_ui(); - } SlashCommand::Realtime => { if !self.realtime_conversation_enabled() { return; @@ -572,27 +586,6 @@ impl ChatWidget { } = prepared; let trimmed = args.trim(); match cmd { - SlashCommand::Fast => { - match trimmed.to_ascii_lowercase().as_str() { - "on" => self.set_service_tier_selection(Some(ServiceTier::Fast)), - "off" => self.set_service_tier_selection(/*service_tier*/ None), - "status" => { - let status = - if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) { - "on" - } else { - "off" - }; - self.add_info_message( - format!("Fast mode is {status}."), - /*hint*/ None, - ); - } - _ => { - self.add_error_message("Usage: /fast [on|off|status]".to_string()); - } - } - } SlashCommand::Ide => { self.handle_ide_command_args(trimmed); } @@ -813,7 +806,9 @@ impl ChatWidget { return QueueDrain::Stop; } - let Some(cmd) = slash_commands::find_builtin_command(name, self.builtin_command_flags()) + let service_tier_commands = self.current_model_service_tier_commands(); + let Some(command) = + find_slash_command(name, self.builtin_command_flags(), &service_tier_commands) else { self.add_info_message( format!( @@ -825,11 +820,19 @@ impl ChatWidget { }; if rest.is_empty() { - self.dispatch_command(cmd); - return self.queued_command_drain_result(cmd); + return match command { + SlashCommandItem::Builtin(cmd) => { + self.dispatch_command(cmd); + self.queued_command_drain_result(cmd) + } + SlashCommandItem::ServiceTier(command) => { + self.handle_service_tier_command_dispatch(command); + QueueDrain::Continue + } + }; } - if !cmd.supports_inline_args() { + if !command.supports_inline_args() { self.submit_user_message(UserMessage { text, local_images, @@ -839,6 +842,16 @@ impl ChatWidget { }); return QueueDrain::Stop; } + let SlashCommandItem::Builtin(cmd) = command else { + self.submit_user_message(UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + }); + return QueueDrain::Stop; + }; let trimmed_start = rest.trim_start(); let leading_trimmed = rest.len().saturating_sub(trimmed_start.len()); @@ -867,7 +880,7 @@ impl ChatWidget { self.queued_command_drain_result(cmd) } - fn builtin_command_flags(&self) -> slash_commands::BuiltinCommandFlags { + fn builtin_command_flags(&self) -> BuiltinCommandFlags { #[cfg(target_os = "windows")] let allow_elevate_sandbox = { let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); @@ -876,12 +889,12 @@ impl ChatWidget { #[cfg(not(target_os = "windows"))] let allow_elevate_sandbox = false; - slash_commands::BuiltinCommandFlags { + BuiltinCommandFlags { collaboration_modes_enabled: self.collaboration_modes_enabled(), connectors_enabled: self.connectors_enabled(), plugins_command_enabled: self.config.features.enabled(Feature::Plugins), goal_command_enabled: self.config.features.enabled(Feature::Goals), - fast_command_enabled: self.fast_mode_enabled(), + service_tier_commands_enabled: self.fast_mode_enabled(), personality_command_enabled: self.config.features.enabled(Feature::Personality), realtime_conversation_enabled: self.realtime_conversation_enabled(), audio_device_selection_enabled: self.realtime_audio_device_selection_enabled(), @@ -895,8 +908,7 @@ impl ChatWidget { return QueueDrain::Stop; } match cmd { - SlashCommand::Fast - | SlashCommand::Ide + SlashCommand::Ide | SlashCommand::Status | SlashCommand::DebugConfig | SlashCommand::Ps diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 9d4b7103d0..b316d0c5fb 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -10,6 +10,7 @@ use crate::legacy_core::config::Config; use crate::status::format_tokens_compact; use codex_app_server_protocol::AskForApproval; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ServiceTier; use codex_protocol::models::PermissionProfile; use codex_utils_sandbox_summary::summarize_permission_profile; @@ -648,7 +649,7 @@ impl ChatWidget { )), StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()), StatusLineItem::FastMode => Some( - if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) { + if self.current_service_tier() == Some(ServiceTier::Fast.request_value()) { "Fast on".to_string() } else { "Fast off".to_string() @@ -779,13 +780,18 @@ impl ChatWidget { fn model_with_reasoning_display_name(&self) -> String { let label = Self::status_line_reasoning_effort_label(self.effective_reasoning_effort()); - let fast_label = - if self.should_show_fast_status(self.current_model(), self.current_service_tier()) { - " fast" - } else { - "" - }; - format!("{} {label}{fast_label}", self.model_display_name()) + let service_tier_label = self + .current_service_tier() + .and_then(|service_tier| { + self.current_model_service_tier_commands() + .into_iter() + .find(|tier| tier.id == service_tier) + .map(|tier| tier.name) + }) + .filter(|_| self.has_chatgpt_account) + .map(|tier| format!(" {tier}")) + .unwrap_or_default(); + format!("{} {label}{service_tier_label}", self.model_display_name()) } /// Computes the compact runtime status label used by word-based status items. diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 14f856bee5..e03eda919b 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -182,10 +182,7 @@ pub(super) async fn make_chatwidget_manual( }; let current_collaboration_mode = base_mode; let active_collaboration_mask = collaboration_modes::default_mask(model_catalog.as_ref()); - let effective_service_tier = cfg - .service_tier - .as_deref() - .and_then(ServiceTier::from_request_value); + let effective_service_tier = cfg.service_tier.clone(); let mut widget = ChatWidget { app_event_tx, codex_op_target: super::CodexOpTarget::Direct(op_tx), @@ -391,8 +388,12 @@ pub(crate) fn set_chatgpt_auth(chat: &mut ChatWidget) { } fn test_model_info(slug: &str, priority: i32, supports_fast_mode: bool) -> ModelInfo { - let additional_speed_tiers = if supports_fast_mode { - vec![codex_protocol::openai_models::SPEED_TIER_FAST] + let service_tiers = if supports_fast_mode { + vec![json!({ + "id": ServiceTier::Fast.request_value(), + "name": "fast", + "description": "Fastest inference with increased plan usage" + })] } else { Vec::new() }; @@ -406,7 +407,8 @@ fn test_model_info(slug: &str, priority: i32, supports_fast_mode: bool) -> Model "visibility": "list", "supported_in_api": true, "priority": priority, - "additional_speed_tiers": additional_speed_tiers, + "additional_speed_tiers": [], + "service_tiers": service_tiers, "availability_nux": null, "upgrade": null, "base_instructions": "base instructions", diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 87ad226b51..dc8f2e7cb2 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1,6 +1,15 @@ use super::*; +use crate::bottom_pane::slash_commands::ServiceTierCommand; use pretty_assertions::assert_eq; +fn fast_tier_command() -> ServiceTierCommand { + ServiceTierCommand { + id: ServiceTier::Fast.request_value().to_string(), + name: "fast".to_string(), + description: "Fastest inference with increased plan usage".to_string(), + } +} + fn complete_turn_with_message(chat: &mut ChatWidget, turn_id: &str, message: Option<&str>) { if let Some(message) = message { complete_assistant_message( @@ -1023,9 +1032,8 @@ async fn slash_rename_without_existing_thread_name_starts_empty() { #[tokio::test] async fn usage_error_slash_command_is_available_from_local_recall() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; - chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); - submit_composer_text(&mut chat, "/fast maybe"); + submit_composer_text(&mut chat, "/raw maybe"); assert_eq!(chat.bottom_pane.composer_text(), ""); @@ -1036,10 +1044,10 @@ async fn usage_error_slash_command_is_available_from_local_recall() { .collect::>() .join("\n"); assert!( - rendered.contains("Usage: /fast [on|off|status]"), + rendered.contains("Usage: /raw [on|off]"), "expected usage message, got: {rendered:?}" ); - assert_eq!(recall_latest_after_clearing(&mut chat), "/fast maybe"); + assert_eq!(recall_latest_after_clearing(&mut chat), "/raw maybe"); } #[tokio::test] @@ -1811,10 +1819,11 @@ async fn slash_rollout_handles_missing_path() { #[tokio::test] async fn fast_slash_command_updates_and_persists_local_service_tier() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + set_fast_mode_test_catalog(&mut chat); chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); - chat.dispatch_command(SlashCommand::Fast); + chat.handle_service_tier_command_dispatch(fast_tier_command()); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); assert!( @@ -1831,8 +1840,9 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() { events.iter().any(|event| matches!( event, AppEvent::PersistServiceTierSelection { - service_tier: Some(ServiceTier::Fast), + service_tier: Some(service_tier), } + if service_tier == ServiceTier::Fast.request_value() )), "expected fast-mode persistence app event; events: {events:?}" ); @@ -1842,7 +1852,8 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() { #[tokio::test] async fn fast_keybinding_toggle_uses_same_events_as_fast_slash_command() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + set_fast_mode_test_catalog(&mut chat); chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); chat.toggle_fast_mode_from_ui(); @@ -1862,8 +1873,9 @@ async fn fast_keybinding_toggle_uses_same_events_as_fast_slash_command() { events.iter().any(|event| matches!( event, AppEvent::PersistServiceTierSelection { - service_tier: Some(ServiceTier::Fast), + service_tier: Some(service_tier), } + if service_tier == ServiceTier::Fast.request_value() )), "expected fast-mode persistence app event; events: {events:?}" ); @@ -1873,7 +1885,8 @@ async fn fast_keybinding_toggle_uses_same_events_as_fast_slash_command() { #[tokio::test] async fn fast_keybinding_toggle_requires_feature_and_idle_surface() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + set_fast_mode_test_catalog(&mut chat); chat.set_feature_enabled(Feature::FastMode, /*enabled*/ false); assert!(!chat.can_toggle_fast_mode_from_keybinding()); @@ -1887,12 +1900,13 @@ async fn fast_keybinding_toggle_requires_feature_and_idle_surface() { #[tokio::test] async fn user_turn_carries_service_tier_after_fast_toggle() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.thread_id = Some(ThreadId::new()); set_chatgpt_auth(&mut chat); + set_fast_mode_test_catalog(&mut chat); chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); - chat.dispatch_command(SlashCommand::Fast); + chat.handle_service_tier_command_dispatch(fast_tier_command()); let _events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); @@ -1911,13 +1925,14 @@ async fn user_turn_carries_service_tier_after_fast_toggle() { #[tokio::test] async fn queued_fast_slash_applies_before_next_queued_message() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.thread_id = Some(ThreadId::new()); set_chatgpt_auth(&mut chat); + set_fast_mode_test_catalog(&mut chat); chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); handle_turn_started(&mut chat, "turn-1"); - queue_composer_text_with_tab(&mut chat, "/fast on"); + queue_composer_text_with_tab(&mut chat, "/fast"); queue_composer_text_with_tab(&mut chat, "hello after fast"); complete_turn_with_message(&mut chat, "turn-1", Some("done")); @@ -1952,15 +1967,16 @@ async fn queued_fast_slash_applies_before_next_queued_message() { #[tokio::test] async fn user_turn_sends_standard_override_after_fast_is_turned_off() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.thread_id = Some(ThreadId::new()); set_chatgpt_auth(&mut chat); + set_fast_mode_test_catalog(&mut chat); chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); - chat.dispatch_command(SlashCommand::Fast); + chat.handle_service_tier_command_dispatch(fast_tier_command()); let _events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); - chat.dispatch_command_with_args(SlashCommand::Fast, "off".to_string(), Vec::new()); + chat.handle_service_tier_command_dispatch(fast_tier_command()); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); assert!( events.iter().any(|event| matches!( diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 89bb715be5..93e3222a5e 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1124,7 +1124,7 @@ async fn fast_status_indicator_requires_chatgpt_auth() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_fast_mode_test_catalog(&mut chat); assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode()); - chat.set_service_tier(Some(ServiceTier::Fast)); + chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string())); assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),)); @@ -1140,7 +1140,7 @@ async fn fast_status_indicator_is_hidden_for_models_without_fast_support() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; set_fast_mode_test_catalog(&mut chat); assert!(!get_available_model(&chat, "gpt-5.3-codex").supports_fast_mode()); - chat.set_service_tier(Some(ServiceTier::Fast)); + chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string())); set_chatgpt_auth(&mut chat); set_fast_mode_test_catalog(&mut chat); assert!(!get_available_model(&chat, "gpt-5.3-codex").supports_fast_mode()); @@ -1533,7 +1533,7 @@ async fn status_line_fast_mode_renders_on_and_off() { chat.refresh_status_line(); assert_eq!(status_line_text(&chat), Some("Fast off".to_string())); - chat.set_service_tier(Some(ServiceTier::Fast)); + chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string())); chat.refresh_status_line(); assert_eq!(status_line_text(&chat), Some("Fast on".to_string())); } @@ -1546,7 +1546,7 @@ async fn status_line_fast_mode_footer_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.show_welcome_banner = false; chat.config.tui_status_line = Some(vec!["fast-mode".to_string()]); - chat.set_service_tier(Some(ServiceTier::Fast)); + chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string())); chat.refresh_status_line(); let width = 80; @@ -1573,7 +1573,7 @@ async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models( "current-dir".to_string(), ]); chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); - chat.set_service_tier(Some(ServiceTier::Fast)); + chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string())); set_chatgpt_auth(&mut chat); set_fast_mode_test_catalog(&mut chat); assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode()); @@ -1721,7 +1721,7 @@ async fn status_line_model_with_reasoning_fast_footer_snapshot() { "current-dir".to_string(), ]); chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); - chat.set_service_tier(Some(ServiceTier::Fast)); + chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string())); set_chatgpt_auth(&mut chat); set_fast_mode_test_catalog(&mut chat); assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode()); @@ -1755,7 +1755,7 @@ async fn status_line_model_with_reasoning_context_remaining_footer_snapshot() { "current-dir".to_string(), ]); chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); - chat.set_service_tier(Some(ServiceTier::Fast)); + chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string())); set_chatgpt_auth(&mut chat); set_fast_mode_test_catalog(&mut chat); assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode()); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index d5e923f0e3..cce0bc38df 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -13,7 +13,6 @@ pub enum SlashCommand { // DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so // more frequently used commands should be listed first. Model, - Fast, Ide, Permissions, Keymap, @@ -104,9 +103,6 @@ impl SlashCommand { SlashCommand::MemoryDrop => "DO NOT USE", SlashCommand::MemoryUpdate => "DO NOT USE", SlashCommand::Model => "choose what model and reasoning effort to use", - SlashCommand::Fast => { - "toggle Fast mode to enable fastest inference with increased plan usage" - } SlashCommand::Ide => { "include current selection, open files, and other context from your IDE" } @@ -151,7 +147,6 @@ impl SlashCommand { | SlashCommand::Rename | SlashCommand::Plan | SlashCommand::Goal - | SlashCommand::Fast | SlashCommand::Ide | SlashCommand::Keymap | SlashCommand::Mcp @@ -184,7 +179,6 @@ impl SlashCommand { | SlashCommand::Init | SlashCommand::Compact | SlashCommand::Model - | SlashCommand::Fast | SlashCommand::Personality | SlashCommand::Permissions | SlashCommand::Keymap