[codex] Generalize service tier slash commands (#21745)

## Why

`/fast` was wired as a one-off slash command even though model metadata
now exposes service tiers as catalog data. That meant adding another
tier, such as a slower/cheaper tier, would require more hardcoded TUI
plumbing instead of letting the model catalog drive the available
commands.

This change makes service-tier commands data-driven: each advertised
`service_tiers` entry becomes a `/name` command using the catalog
description, while the request path sends the tier `id` only when the
selected model supports it.

## What Changed

- Removed the hardcoded `/fast` slash-command variant and introduced
dynamic service-tier command items in the composer and command popup.
- Added toggle behavior for service-tier commands: invoking `/name`
selects that tier, and invoking it again clears the selection.
- Preserved the existing Fast-mode keybinding/status affordances by
resolving the current model tier whose name is `fast`, while still
sending the tier request value such as `priority`.
- Persisted service-tier selections as raw request strings so non-fast
tiers can round-trip through config.
- Updated the Bedrock catalog entry to advertise fast support through
`service_tiers` with `id: "priority"` and `name: "fast"`.
- Added defensive filtering in core so unsupported selected service
tiers are omitted from `/responses` requests.

## Validation

- Added/updated coverage for dynamic service-tier slash command lookup,
popup descriptions, composer dispatch, TUI fast toggling, and
unsupported-tier omission in core request construction.
- Local tests were not run per request.

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Ahmed IbrahimandCodex authored and GitHub committed 2026-05-08 20:09:51 +03:00
1 parent 47f1d7b40b
commit 7c0e54bf59
24 files changed
+919 -412

No files matched your search

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