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