diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index bfe8dc4b24..4ce2a76039 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1271,29 +1271,47 @@ impl App { } } } - AppEvent::PersistServiceTierSelection { service_tier } => { + AppEvent::PersistServiceTierSelection { + service_tier, + service_tier_id, + } => { 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_id.or_else(|| { + service_tier.map(|service_tier| service_tier.request_value().to_string()) + }); + let service_tier_segments = if let Some(profile) = profile { + vec![ + "profiles".to_string(), + profile.to_string(), + "service_tier".to_string(), + ] + } else { + vec!["service_tier".to_string()] + }; + let service_tier_edit = match &self.config.service_tier { + Some(service_tier) => ConfigEdit::SetPath { + segments: service_tier_segments, + value: service_tier.clone().into(), + }, + None => ConfigEdit::ClearPath { + segments: service_tier_segments, + }, + }; let mut edits = ConfigEditsBuilder::new(&self.config.codex_home) - .with_profile(profile) - .set_service_tier(service_tier); - if service_tier.is_none() { + .with_edits([service_tier_edit]); + if self.config.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" - } else { - "off" - }; - let mut message = format!("Fast mode set to {status}"); + let mut message = + if let Some(service_tier_id) = &self.config.service_tier { + format!("Service tier set to {service_tier_id}") + } else { + "Service tier cleared".to_string() + }; if let Some(profile) = profile { message.push_str(" for "); message.push_str(profile); @@ -1302,14 +1320,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_event.rs b/codex-rs/tui/src/app_event.rs index 4ee405f495..ee7eb113d9 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -570,6 +570,7 @@ pub(crate) enum AppEvent { /// Persist the selected service tier to the appropriate config. PersistServiceTierSelection { service_tier: Option, + service_tier_id: 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..4595713f1c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -194,6 +194,7 @@ 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 crate::bottom_pane::paste_burst::FlushResult; use crate::bottom_pane::prompt_args::parse_slash_name; use crate::key_hint::KeyBindingListExt; @@ -276,6 +277,7 @@ 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), + 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 +401,6 @@ pub(crate) struct ChatComposer { ide_context_active: bool, connectors_enabled: bool, plugins_command_enabled: bool, - fast_command_enabled: bool, goal_command_enabled: bool, personality_command_enabled: bool, realtime_conversation_enabled: bool, @@ -413,6 +414,7 @@ pub(crate) struct ChatComposer { side_conversation_context_label: Option, // Agent label injected into the footer's contextual row when multi-agent mode is active. active_agent_label: Option, + service_tier_commands: Vec, history_search: Option, submit_keys: Vec, queue_keys: Vec, @@ -489,7 +491,6 @@ 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, goal_command_enabled: self.goal_command_enabled, personality_command_enabled: self.personality_command_enabled, realtime_conversation_enabled: self.realtime_conversation_enabled, @@ -580,7 +581,6 @@ impl ChatComposer { ide_context_active: false, connectors_enabled: false, plugins_command_enabled: false, - fast_command_enabled: false, goal_command_enabled: false, personality_command_enabled: false, realtime_conversation_enabled: false, @@ -596,6 +596,7 @@ impl ChatComposer { status_line_enabled: false, side_conversation_context_label: None, active_agent_label: None, + service_tier_commands: Vec::new(), history_search: None, submit_keys: vec![key_hint::plain(KeyCode::Enter)], queue_keys: vec![key_hint::plain(KeyCode::Tab)], @@ -693,14 +694,15 @@ impl ChatComposer { self.connectors_enabled = enabled; } - pub fn set_fast_command_enabled(&mut self, enabled: bool) { - self.fast_command_enabled = enabled; - } - pub fn set_goal_command_enabled(&mut self, enabled: bool) { self.goal_command_enabled = enabled; } + pub(crate) fn set_service_tier_commands(&mut self, commands: Vec) { + self.service_tier_commands = commands; + self.sync_popups(); + } + /// Replace composer, editor, and footer-hint key bindings from one runtime snapshot. /// /// Submit and queue bindings are cached here because composer dispatch must @@ -1762,24 +1764,23 @@ 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) = popup.selected_item() { + let selected_command_text = match &selected { + CommandItem::Builtin(cmd) => format!("/{}", cmd.command()), + CommandItem::ServiceTier(command) => format!("/{}", command.command), + }; + if matches!(selected, CommandItem::Builtin(SlashCommand::Skills)) { + self.stage_selected_command_history(&selected); self.textarea.set_text_clearing_elements(""); self.is_bash_mode = false; - return (InputResult::Command(cmd), true); + return (InputResult::Command(SlashCommand::Skills), 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 +1801,16 @@ 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) = popup.selected_item() { + let selected_command_text = match selected { + CommandItem::Builtin(cmd) => format!("/{}", cmd.command()), + CommandItem::ServiceTier(command) => format!("/{}", command.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 +1825,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_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 +2631,11 @@ impl ChatComposer { { let treat_as_plain_text = input_starts_with_space || name.contains('/'); if !treat_as_plain_text { - let is_builtin = + let is_known = slash_commands::find_builtin_command(name, self.builtin_command_flags()) - .is_some(); - if !is_builtin { + .is_some() + || self.find_service_tier_command(name).is_some(); + if !is_known { let message = format!( r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# ); @@ -2705,6 +2713,7 @@ impl ChatComposer { InputResult::Submitted { .. } | InputResult::Queued { .. } | InputResult::Command(_) + | InputResult::ServiceTierCommand(_) | InputResult::CommandWithArgs(_, _, _) ) { self.textarea.enter_vim_normal_mode(); @@ -2845,6 +2854,12 @@ impl ChatComposer { if !rest.is_empty() { return None; } + if let Some(command) = self.find_service_tier_command(name) { + self.stage_slash_command_history(); + self.textarea.set_text_clearing_elements(""); + self.is_bash_mode = false; + return Some(InputResult::ServiceTierCommand(command)); + } let cmd = slash_commands::find_builtin_command(name, self.builtin_command_flags())?; if cmd.supports_inline_args() && parse_slash_name(text).is_some_and(|(_, full_rest, _)| !full_rest.is_empty()) @@ -2972,15 +2987,13 @@ impl ChatComposer { self.stage_slash_command_history_text(self.textarea.text().trim().to_string()); } - /// Stage a popup-selected command using its canonical command text. - /// - /// 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 { - return; - } - self.stage_slash_command_history_text(format!("/{}", cmd.command())); + fn stage_selected_command_history(&mut self, command: &CommandItem) { + let command_text = match command { + CommandItem::Builtin(SlashCommand::Clear) => return, + CommandItem::Builtin(cmd) => format!("/{}", cmd.command()), + CommandItem::ServiceTier(command) => format!("/{}", command.command), + }; + self.stage_slash_command_history_text(command_text); } /// Store the provided command text and the current composer adornments in the pending slot. @@ -3739,6 +3752,7 @@ impl ChatComposer { fn is_known_slash_name(&self, name: &str) -> bool { slash_commands::find_builtin_command(name, self.builtin_command_flags()).is_some() + || self.find_service_tier_command(name).is_some() } /// If the cursor is currently within a slash command on the first line, @@ -3781,6 +3795,17 @@ impl ChatComposer { } slash_commands::has_builtin_prefix(name, self.builtin_command_flags()) + || self + .service_tier_commands + .iter() + .any(|command| command.command.starts_with(name)) + } + + fn find_service_tier_command(&self, name: &str) -> Option { + self.service_tier_commands + .iter() + .find(|command| command.command == name) + .cloned() } /// Synchronize `self.command_popup` with the current text in the @@ -3826,23 +3851,24 @@ 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 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, + 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,7 +7574,7 @@ mod tests { Some(CommandItem::Builtin(cmd)) => { assert_eq!(cmd.command(), "model") } - None => panic!("no selected command for '/mo'"), + other => panic!("expected model selected for '/mo', got {other:?}"), }, _ => panic!("slash popup not active after typing '/mo'"), } @@ -7601,7 +7627,7 @@ mod tests { Some(CommandItem::Builtin(cmd)) => { assert_eq!(cmd.command(), "resume") } - None => panic!("no selected command for '/res'"), + other => panic!("expected resume selected for '/res', got {other:?}"), }, _ => panic!("slash popup not active after typing '/res'"), } @@ -7664,6 +7690,9 @@ mod tests { InputResult::CommandWithArgs(_, _, _) => { panic!("expected command dispatch without args for '/init'") } + InputResult::ServiceTierCommand(_) => { + panic!("expected built-in command dispatch for '/init'") + } InputResult::Submitted { text, .. } => { panic!("expected command dispatch, but composer submitted literal text: {text}") } @@ -8110,6 +8139,9 @@ mod tests { InputResult::CommandWithArgs(_, _, _) => { panic!("expected command dispatch without args for '/diff'") } + InputResult::ServiceTierCommand(_) => { + panic!("expected built-in command dispatch for '/diff'") + } InputResult::Submitted { text, .. } => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } @@ -8304,6 +8336,9 @@ mod tests { InputResult::CommandWithArgs(_, _, _) => { panic!("expected command dispatch without args for '/mention'") } + InputResult::ServiceTierCommand(_) => { + panic!("expected built-in command dispatch for '/mention'") + } 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..898f507239 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -10,6 +10,7 @@ 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::ServiceTierCommand; use crate::render::Insets; use crate::render::RectExt; use crate::slash_command::SlashCommand; @@ -23,14 +24,16 @@ 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)>, + service_tiers: Vec, state: ScrollState, } @@ -39,7 +42,6 @@ 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) goal_command_enabled: bool, pub(crate) personality_command_enabled: bool, pub(crate) realtime_conversation_enabled: bool, @@ -54,7 +56,6 @@ impl From for slash_commands::BuiltinCommandFlags { 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, goal_command_enabled: value.goal_command_enabled, personality_command_enabled: value.personality_command_enabled, realtime_conversation_enabled: value.realtime_conversation_enabled, @@ -66,7 +67,7 @@ impl From for slash_commands::BuiltinCommandFlags { } impl CommandPopup { - pub(crate) fn new(flags: CommandPopupFlags) -> Self { + pub(crate) fn new(flags: CommandPopupFlags, service_tiers: Vec) -> Self { // Keep built-in availability in sync with the composer. let builtins: Vec<(&'static str, SlashCommand)> = slash_commands::builtins_for_input(flags.into()) @@ -77,6 +78,7 @@ impl CommandPopup { Self { command_filter: String::new(), builtins, + service_tiers, state: ScrollState::new(), } } @@ -139,6 +141,12 @@ impl CommandPopup { } out.push((CommandItem::Builtin(*cmd), None)); } + out.extend( + self.service_tiers + .iter() + .cloned() + .map(|command| (CommandItem::ServiceTier(command), None)), + ); return out; } @@ -172,6 +180,14 @@ impl CommandPopup { for (_, cmd) in self.builtins.iter() { push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0); } + for service_tier in self.service_tiers.iter() { + push_match( + CommandItem::ServiceTier(service_tier.clone()), + &service_tier.command, + None, + 0, + ); + } out.extend(exact); out.extend(prefix); @@ -189,9 +205,14 @@ 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, description) = match item { + CommandItem::Builtin(cmd) => { + (format!("/{}", cmd.command()), cmd.description().to_string()) + } + CommandItem::ServiceTier(command) => { + (format!("/{}", command.command), command.description) + } + }; GenericDisplayRow { name, name_prefix_spans: Vec::new(), @@ -227,7 +248,7 @@ 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()) } } @@ -253,9 +274,24 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + fn popup(flags: CommandPopupFlags) -> CommandPopup { + CommandPopup::new(flags, Vec::new()) + } + + fn builtin_names(popup: &CommandPopup) -> Vec<&'static str> { + popup + .filtered_items() + .into_iter() + .filter_map(|item| match item { + CommandItem::Builtin(cmd) => Some(cmd.command()), + CommandItem::ServiceTier(_) => None, + }) + .collect() + } + #[test] fn filter_includes_init_when_typing_prefix() { - let mut popup = CommandPopup::new(CommandPopupFlags::default()); + let mut popup = popup(CommandPopupFlags::default()); // Simulate the composer line starting with '/in' so the popup filters // matching commands by prefix. popup.on_composer_text_change("/in".to_string()); @@ -263,9 +299,9 @@ mod tests { // Access the filtered list via the selected command and ensure that // one of the matches is the new "init" command. let matches = popup.filtered_items(); - let has_init = matches.iter().any(|item| match item { - CommandItem::Builtin(cmd) => cmd.command() == "init", - }); + let has_init = matches + .iter() + .any(|item| matches!(item, CommandItem::Builtin(cmd) if cmd.command() == "init")); assert!( has_init, "expected '/init' to appear among filtered commands" @@ -274,7 +310,7 @@ mod tests { #[test] fn selecting_init_by_exact_match() { - let mut popup = CommandPopup::new(CommandPopupFlags::default()); + let mut popup = popup(CommandPopupFlags::default()); popup.on_composer_text_change("/init".to_string()); // When an exact match exists, the selected command should be that @@ -282,48 +318,72 @@ mod tests { let selected = popup.selected_item(); match selected { Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"), - None => panic!("expected a selected command for exact match"), + other => panic!("expected init selected for exact match, got {other:?}"), } } #[test] fn model_is_first_suggestion_for_mo() { - let mut popup = CommandPopup::new(CommandPopupFlags::default()); + let mut popup = popup(CommandPopupFlags::default()); 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"), - None => panic!("expected at least one match for '/mo'"), + other => panic!("expected model as first match for '/mo', got {other:?}"), } } + #[test] + fn service_tier_command_is_suggested_from_model_metadata() { + let mut popup = CommandPopup::new( + CommandPopupFlags::default(), + vec![ + ServiceTierCommand::new( + "priority", + "priority-id".to_string(), + "fastest inference".to_string(), + ) + .expect("valid service tier command"), + ], + ); + popup.on_composer_text_change("/pri".to_string()); + + assert_eq!( + popup.selected_item(), + Some(CommandItem::ServiceTier(ServiceTierCommand { + command: "priority".to_string(), + id: "priority-id".to_string(), + description: "fastest inference".to_string(), + })) + ); + let rows = popup.rows_from_matches(popup.filtered()); + assert_eq!( + rows.into_iter() + .map(|row| (row.name, row.match_indices, row.description)) + .collect::>(), + vec![( + "/priority".to_string(), + Some(vec![1, 2, 3]), + Some("fastest inference".to_string()), + )] + ); + } + #[test] fn filtered_commands_keep_presentation_order_for_prefix() { - let mut popup = CommandPopup::new(CommandPopupFlags::default()); + let mut popup = popup(CommandPopupFlags::default()); popup.on_composer_text_change("/m".to_string()); - let cmds: Vec<&str> = popup - .filtered_items() - .into_iter() - .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), - }) - .collect(); + let cmds = builtin_names(&popup); assert_eq!(cmds, vec!["model", "memories", "mention", "mcp"]); } #[test] fn prefix_filter_limits_matches_for_ac() { - let mut popup = CommandPopup::new(CommandPopupFlags::default()); + let mut popup = popup(CommandPopupFlags::default()); popup.on_composer_text_change("/ac".to_string()); - let cmds: Vec<&str> = popup - .filtered_items() - .into_iter() - .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), - }) - .collect(); + let cmds = builtin_names(&popup); assert!( !cmds.contains(&"compact"), "expected prefix search for '/ac' to exclude 'compact', got {cmds:?}" @@ -332,7 +392,7 @@ mod tests { #[test] fn quit_hidden_in_empty_filter_but_shown_for_prefix() { - let mut popup = CommandPopup::new(CommandPopupFlags::default()); + let mut popup = popup(CommandPopupFlags::default()); popup.on_composer_text_change("/".to_string()); let items = popup.filtered_items(); assert!(!items.contains(&CommandItem::Builtin(SlashCommand::Quit))); @@ -344,16 +404,10 @@ mod tests { #[test] fn collab_command_hidden_when_collaboration_modes_disabled() { - let mut popup = CommandPopup::new(CommandPopupFlags::default()); + let mut popup = popup(CommandPopupFlags::default()); popup.on_composer_text_change("/".to_string()); - let cmds: Vec<&str> = popup - .filtered_items() - .into_iter() - .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), - }) - .collect(); + let cmds = builtin_names(&popup); assert!( !cmds.contains(&"collab"), "expected '/collab' to be hidden when collaboration modes are disabled, got {cmds:?}" @@ -366,11 +420,10 @@ mod tests { #[test] fn collab_command_visible_when_collaboration_modes_enabled() { - let mut popup = CommandPopup::new(CommandPopupFlags { + let mut popup = popup(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, @@ -388,11 +441,10 @@ mod tests { #[test] fn plan_command_visible_when_collaboration_modes_enabled() { - let mut popup = CommandPopup::new(CommandPopupFlags { + let mut popup = popup(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, @@ -410,11 +462,10 @@ mod tests { #[test] fn personality_command_hidden_when_disabled() { - let mut popup = CommandPopup::new(CommandPopupFlags { + let mut popup = popup(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, @@ -424,13 +475,7 @@ mod tests { }); popup.on_composer_text_change("/pers".to_string()); - let cmds: Vec<&str> = popup - .filtered_items() - .into_iter() - .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), - }) - .collect(); + let cmds = builtin_names(&popup); assert!( !cmds.contains(&"personality"), "expected '/personality' to be hidden when disabled, got {cmds:?}" @@ -439,11 +484,10 @@ mod tests { #[test] fn personality_command_visible_when_enabled() { - let mut popup = CommandPopup::new(CommandPopupFlags { + let mut popup = popup(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, @@ -461,11 +505,10 @@ mod tests { #[test] fn settings_command_hidden_when_audio_device_selection_is_disabled() { - let mut popup = CommandPopup::new(CommandPopupFlags { + let mut popup = popup(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, @@ -475,13 +518,7 @@ mod tests { }); popup.on_composer_text_change("/aud".to_string()); - let cmds: Vec<&str> = popup - .filtered_items() - .into_iter() - .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), - }) - .collect(); + let cmds = builtin_names(&popup); assert!( !cmds.contains(&"settings"), @@ -491,14 +528,8 @@ mod tests { #[test] fn debug_commands_are_hidden_from_popup() { - let popup = CommandPopup::new(CommandPopupFlags::default()); - let cmds: Vec<&str> = popup - .filtered_items() - .into_iter() - .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), - }) - .collect(); + let popup = popup(CommandPopupFlags::default()); + let cmds = builtin_names(&popup); assert!( !cmds.iter().any(|name| name.starts_with("debug")), diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 71075f3d06..8ca75af918 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -178,6 +178,7 @@ pub(crate) use chat_composer::ChatComposer; pub(crate) use chat_composer::ChatComposerConfig; pub(crate) use chat_composer::InputResult; pub(crate) use chat_composer::QueuedInputAction; +use slash_commands::ServiceTierCommand; use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::status_indicator_widget::StatusIndicatorWidget; @@ -394,13 +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_goal_command_enabled(&mut self, enabled: bool) { + self.composer.set_goal_command_enabled(enabled); self.request_redraw(); } - pub fn set_goal_command_enabled(&mut self, enabled: bool) { - self.composer.set_goal_command_enabled(enabled); + pub(crate) 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..53b141c39b 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -10,12 +10,38 @@ 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) command: String, + pub(crate) id: String, + pub(crate) description: String, +} + +impl ServiceTierCommand { + pub(crate) fn new(name: &str, id: String, description: String) -> Option { + let command = service_tier_command_name(name)?; + Some(Self { + command, + id, + description, + }) + } +} + +fn service_tier_command_name(name: &str) -> Option { + let command = name.trim().to_ascii_lowercase().replace(' ', "-"); + (!command.is_empty() + && command + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-')) + .then_some(command) +} + #[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) goal_command_enabled: bool, pub(crate) personality_command_enabled: bool, pub(crate) realtime_conversation_enabled: bool, @@ -35,7 +61,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) @@ -76,7 +101,6 @@ mod tests { collaboration_modes_enabled: true, connectors_enabled: true, plugins_command_enabled: true, - fast_command_enabled: true, goal_command_enabled: true, personality_command_enabled: true, realtime_conversation_enabled: true, @@ -116,13 +140,6 @@ mod tests { ); } - #[test] - fn fast_command_is_hidden_when_disabled() { - let mut flags = all_enabled_flags(); - flags.fast_command_enabled = false; - assert_eq!(find_builtin_command("fast", flags), None); - } - #[test] fn goal_command_is_hidden_when_disabled() { let mut flags = all_enabled_flags(); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index adaba76b24..4aca23217a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -57,6 +57,7 @@ use crate::bottom_pane::StatusSurfacePreviewData; use crate::bottom_pane::StatusSurfacePreviewItem; use crate::bottom_pane::TerminalTitleItem; use crate::bottom_pane::TerminalTitleSetupView; +use crate::bottom_pane::slash_commands::ServiceTierCommand; use crate::diff_model::FileChange; use crate::legacy_core::DEFAULT_AGENTS_MD_FILENAME; use crate::legacy_core::config::Config; @@ -158,6 +159,7 @@ use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; use codex_protocol::models::MessagePhase; use codex_protocol::models::local_image_label_text; +use codex_protocol::openai_models::SPEED_TIER_FAST; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::plan_tool::PlanItemArg as UpdatePlanItemArg; use codex_protocol::plan_tool::StepStatus as UpdatePlanItemStatus; @@ -2115,7 +2117,7 @@ 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(); @@ -5087,7 +5089,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(); @@ -5327,6 +5329,9 @@ impl ChatWidget { InputResult::Command(cmd) => { self.handle_slash_command_dispatch(cmd); } + InputResult::ServiceTierCommand(command) => { + self.handle_service_tier_slash_command(command); + } InputResult::CommandWithArgs(cmd, args, text_elements) => { self.handle_slash_command_with_args_dispatch(cmd, args, text_elements); } @@ -9176,7 +9181,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(); @@ -9292,12 +9297,21 @@ impl ChatWidget { } /// Set Fast mode in the widget's config copy. + #[cfg_attr(not(test), allow(dead_code))] 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 set_service_tier_id(&mut self, service_tier_id: Option) { + self.effective_service_tier = service_tier_id.as_deref().and_then(|service_tier_id| { + ServiceTier::from_request_value(service_tier_id) + .or_else(|| (service_tier_id == SPEED_TIER_FAST).then_some(ServiceTier::Fast)) + }); + self.config.service_tier = service_tier_id; + } + pub(crate) fn current_service_tier(&self) -> Option { self.effective_service_tier } @@ -9306,7 +9320,10 @@ impl ChatWidget { self.config .service_tier .as_deref() - .and_then(ServiceTier::from_request_value) + .and_then(|service_tier_id| { + ServiceTier::from_request_value(service_tier_id) + .or_else(|| (service_tier_id == SPEED_TIER_FAST).then_some(ServiceTier::Fast)) + }) } pub(crate) fn fast_default_opt_out(&self) -> Option { @@ -9398,11 +9415,17 @@ impl ChatWidget { self.refresh_model_dependent_surfaces(); } - fn set_service_tier_selection(&mut self, service_tier: Option) { - if service_tier.is_none() { + fn set_service_tier_selection( + &mut self, + service_tier: Option, + service_tier_id: Option, + ) { + if service_tier_id.is_none() && service_tier.is_none() { self.config.notices.fast_default_opt_out = Some(true); } - self.set_service_tier(service_tier); + let next_service_tier_id = service_tier_id + .or_else(|| service_tier.map(|service_tier| service_tier.request_value().to_string())); + self.set_service_tier_id(next_service_tier_id.clone()); self.app_event_tx .send(AppEvent::CodexOp(AppCommand::override_turn_context( /*cwd*/ None, @@ -9413,12 +9436,39 @@ impl ChatWidget { /*model*/ None, /*effort*/ None, /*summary*/ None, - Some(service_tier.map(|service_tier| service_tier.request_value().to_string())), + Some(next_service_tier_id.clone()), /*collaboration_mode*/ None, /*personality*/ None, ))); self.app_event_tx - .send(AppEvent::PersistServiceTierSelection { service_tier }); + .send(AppEvent::PersistServiceTierSelection { + service_tier, + service_tier_id: next_service_tier_id, + }); + } + + fn handle_service_tier_slash_command(&mut self, command: ServiceTierCommand) { + let active_service_tier_id_matches_command = self + .config + .service_tier + .as_deref() + .is_some_and(|service_tier_id| service_tier_id == command.id) + || self.effective_service_tier.is_some_and(|service_tier| { + ServiceTier::from_request_value(&command.id) == Some(service_tier) + || (matches!(service_tier, ServiceTier::Fast) && command.id == SPEED_TIER_FAST) + }); + let next_service_tier_id = if active_service_tier_id_matches_command { + None + } else { + Some(command.id) + }; + self.set_service_tier_selection( + next_service_tier_id + .as_deref() + .and_then(ServiceTier::from_request_value), + next_service_tier_id, + ); + self.bottom_pane.record_pending_slash_command_history(); } pub(crate) fn toggle_fast_mode_from_ui(&mut self) { @@ -9427,7 +9477,10 @@ impl ChatWidget { } else { Some(ServiceTier::Fast) }; - self.set_service_tier_selection(next_tier); + self.set_service_tier_selection( + next_tier, + next_tier.map(|service_tier| service_tier.request_value().to_string()), + ); } pub(crate) fn current_model(&self) -> &str { @@ -9456,9 +9509,40 @@ 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_service_tier_commands(&mut self) { + let commands = self.current_model_service_tier_commands(); + self.bottom_pane.set_service_tier_commands(commands); + } + + fn current_model_service_tier_commands(&self) -> Vec { + if !self.fast_mode_enabled() { + return Vec::new(); + } + 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() + .filter_map(|tier| { + ServiceTierCommand::new(&tier.name, tier.id, tier.description) + }) + .collect() + }) + }) + .unwrap_or_default() + } + + fn current_model_service_tier_command(&self, name: &str) -> Option { + self.current_model_service_tier_commands() + .into_iter() + .find(|command| command.command == name) } fn sync_personality_command_enabled(&mut self) { @@ -9654,6 +9738,7 @@ impl ChatWidget { /// header/title (`refresh_model_display`) but forget the footer status line /// (`refresh_status_line`). fn refresh_model_dependent_surfaces(&mut self) { + self.sync_service_tier_commands(); self.refresh_model_display(); self.refresh_status_line(); } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 6d1278ea2d..8b4c46b53a 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -184,9 +184,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 +569,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,6 +789,22 @@ impl ChatWidget { return QueueDrain::Stop; } + if let Some(command) = self.current_model_service_tier_command(name) { + if rest.is_empty() { + self.handle_service_tier_slash_command(command); + } else { + self.submit_user_message(UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + }); + return QueueDrain::Stop; + } + return QueueDrain::Continue; + } + let Some(cmd) = slash_commands::find_builtin_command(name, self.builtin_command_flags()) else { self.add_info_message( @@ -881,7 +873,6 @@ impl ChatWidget { 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(), 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 +886,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/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 14f856bee5..9e12a2b45c 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1,5 +1,6 @@ use super::*; use codex_app_server_protocol::PluginAvailability; +use codex_protocol::openai_models::SPEED_TIER_FAST; use pretty_assertions::assert_eq; pub(super) async fn test_config() -> Config { @@ -391,8 +392,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": SPEED_TIER_FAST, + "name": "fast", + "description": "fastest inference with increased plan usage", + })] } else { Vec::new() }; @@ -406,7 +411,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..d035067fce 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1,4 +1,5 @@ use super::*; +use codex_protocol::openai_models::SPEED_TIER_FAST; use pretty_assertions::assert_eq; fn complete_turn_with_message(chat: &mut ChatWidget, turn_id: &str, message: Option<&str>) { @@ -1021,14 +1022,14 @@ async fn slash_rename_without_existing_thread_name_starts_empty() { } #[tokio::test] -async fn usage_error_slash_command_is_available_from_local_recall() { +async fn service_tier_slash_command_with_args_submits_literal_text() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + chat.set_model("gpt-5.4"); + set_fast_mode_test_catalog(&mut chat); chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); submit_composer_text(&mut chat, "/fast maybe"); - assert_eq!(chat.bottom_pane.composer_text(), ""); - let cells = drain_insert_history(&mut rx); let rendered = cells .iter() @@ -1036,10 +1037,9 @@ async fn usage_error_slash_command_is_available_from_local_recall() { .collect::>() .join("\n"); assert!( - rendered.contains("Usage: /fast [on|off|status]"), - "expected usage message, got: {rendered:?}" + !rendered.contains("Usage: /fast"), + "service tier slash commands should not expose the old /fast usage: {rendered:?}" ); - assert_eq!(recall_latest_after_clearing(&mut chat), "/fast maybe"); } #[tokio::test] @@ -1810,11 +1810,12 @@ 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; +async fn service_tier_slash_command_updates_and_persists_local_service_tier_id() { + 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); + submit_composer_text(&mut chat, "/fast"); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); assert!( @@ -1823,7 +1824,7 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() { AppEvent::CodexOp(Op::OverrideTurnContext { service_tier: Some(Some(service_tier)), .. - }) if service_tier == ServiceTier::Fast.request_value() + }) if service_tier == SPEED_TIER_FAST )), "expected fast-mode override app event; events: {events:?}" ); @@ -1831,11 +1832,14 @@ 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: None, + service_tier_id: Some(service_tier_id), } + if service_tier_id == SPEED_TIER_FAST )), - "expected fast-mode persistence app event; events: {events:?}" + "expected service-tier persistence app event; events: {events:?}" ); + assert_eq!(chat.current_service_tier(), Some(ServiceTier::Fast)); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); } @@ -1887,12 +1891,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); + submit_composer_text(&mut chat, "/fast"); let _events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); @@ -1904,20 +1909,21 @@ async fn user_turn_carries_service_tier_after_fast_toggle() { Op::UserTurn { service_tier: Some(Some(service_tier)), .. - } if service_tier == ServiceTier::Fast.request_value() => {} + } if service_tier == SPEED_TIER_FAST => {} other => panic!("expected Op::UserTurn with fast service tier, got {other:?}"), } } #[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")); @@ -1929,7 +1935,7 @@ async fn queued_fast_slash_applies_before_next_queued_message() { AppEvent::CodexOp(Op::OverrideTurnContext { service_tier: Some(Some(service_tier)), .. - }) if service_tier == ServiceTier::Fast.request_value() + }) if service_tier == SPEED_TIER_FAST )), "expected queued /fast to update service tier before next turn; events: {events:?}" ); @@ -1939,7 +1945,7 @@ async fn queued_fast_slash_applies_before_next_queued_message() { items, service_tier: Some(Some(service_tier)), .. - } if service_tier == ServiceTier::Fast.request_value() => assert_eq!( + } if service_tier == SPEED_TIER_FAST => assert_eq!( items, vec![UserInput::Text { text: "hello after fast".to_string(), @@ -1952,15 +1958,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); + submit_composer_text(&mut chat, "/fast"); let _events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); - chat.dispatch_command_with_args(SlashCommand::Fast, "off".to_string(), Vec::new()); + submit_composer_text(&mut chat, "/fast"); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); assert!( events.iter().any(|event| matches!( @@ -1975,7 +1982,10 @@ async fn user_turn_sends_standard_override_after_fast_is_turned_off() { assert!( events.iter().any(|event| matches!( event, - AppEvent::PersistServiceTierSelection { service_tier: None } + AppEvent::PersistServiceTierSelection { + service_tier: None, + service_tier_id: None, + } )), "expected fast-mode opt-out persistence app event; events: {events:?}" ); @@ -2044,6 +2054,30 @@ async fn raw_slash_command_reports_usage_for_invalid_arg() { ); } +#[tokio::test] +async fn service_tier_slash_command_clears_effective_session_tier() { + let (mut chat, mut 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*/ true); + chat.config.service_tier_id = None; + chat.config.service_tier = None; + chat.effective_service_tier = Some(ServiceTier::Fast); + + submit_composer_text(&mut chat, "/fast"); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::CodexOp(Op::OverrideTurnContext { + service_tier: Some(None), + .. + }) + )), + "expected slash command to clear effective service tier; events: {events:?}" + ); +} + #[tokio::test] async fn compact_queues_user_messages_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; 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