mirror of
https://github.com/openai/codex.git
synced 2026-10-26 18:38:47 +00:00
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:
@@ -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}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user