Use model service tier slash commands

Expose model service tier choices as slash commands and route selections through service_tier_id so custom tier ids remain preserved.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Ahmed Ibrahim
2026-05-07 19:59:24 +03:00
co-authored by Codex
parent 3444b0d60a
commit 92f7c7abfa
11 changed files with 445 additions and 233 deletions
+36 -18
View File
@@ -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}"
));
}
}
+1
View File
@@ -570,6 +570,7 @@ pub(crate) enum AppEvent {
/// Persist the selected service tier to the appropriate config.
PersistServiceTierSelection {
service_tier: Option<ServiceTier>,
service_tier_id: Option<String>,
},
/// Open the device picker for a realtime microphone or speaker.
+91 -56
View File
@@ -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<String>,
// Agent label injected into the footer's contextual row when multi-agent mode is active.
active_agent_label: Option<String>,
service_tier_commands: Vec<ServiceTierCommand>,
history_search: Option<HistorySearchSession>,
submit_keys: Vec<KeyBinding>,
queue_keys: Vec<KeyBinding>,
@@ -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<ServiceTierCommand>) {
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<ServiceTierCommand> {
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}")
}
+104 -73
View File
@@ -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<ServiceTierCommand>,
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<CommandPopupFlags> 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<CommandPopupFlags> for slash_commands::BuiltinCommandFlags {
}
impl CommandPopup {
pub(crate) fn new(flags: CommandPopupFlags) -> Self {
pub(crate) fn new(flags: CommandPopupFlags, service_tiers: Vec<ServiceTierCommand>) -> 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<_>>(),
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")),
+5 -4
View File
@@ -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<ServiceTierCommand>) {
self.composer.set_service_tier_commands(commands);
self.request_redraw();
}
+27 -10
View File
@@ -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<Self> {
let command = service_tier_command_name(name)?;
Some(Self {
command,
id,
description,
})
}
}
fn service_tier_command_name(name: &str) -> Option<String> {
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();
+98 -13
View File
@@ -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<ServiceTier>) {
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<String>) {
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<ServiceTier> {
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<bool> {
@@ -9398,11 +9415,17 @@ impl ChatWidget {
self.refresh_model_dependent_surfaces();
}
fn set_service_tier_selection(&mut self, service_tier: Option<ServiceTier>) {
if service_tier.is_none() {
fn set_service_tier_selection(
&mut self,
service_tier: Option<ServiceTier>,
service_tier_id: Option<String>,
) {
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<ServiceTierCommand> {
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<ServiceTierCommand> {
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();
}
+17 -27
View File
@@ -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
+9 -3
View File
@@ -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",
@@ -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::<Vec<_>>()
.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::<Vec<_>>();
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::<Vec<_>>();
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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;
-6
View File
@@ -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