mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[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:
committed by
GitHub
Unverified
parent
47f1d7b40b
commit
7c0e54bf59
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
}],
|
||||
|
||||
@@ -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}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -13,7 +13,6 @@ pub enum SlashCommand {
|
||||
// DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so
|
||||
// more frequently used commands should be listed first.
|
||||
Model,
|
||||
Fast,
|
||||
Ide,
|
||||
Permissions,
|
||||
Keymap,
|
||||
@@ -104,9 +103,6 @@ impl SlashCommand {
|
||||
SlashCommand::MemoryDrop => "DO NOT USE",
|
||||
SlashCommand::MemoryUpdate => "DO NOT USE",
|
||||
SlashCommand::Model => "choose what model and reasoning effort to use",
|
||||
SlashCommand::Fast => {
|
||||
"toggle Fast mode to enable fastest inference with increased plan usage"
|
||||
}
|
||||
SlashCommand::Ide => {
|
||||
"include current selection, open files, and other context from your IDE"
|
||||
}
|
||||
@@ -151,7 +147,6 @@ impl SlashCommand {
|
||||
| SlashCommand::Rename
|
||||
| SlashCommand::Plan
|
||||
| SlashCommand::Goal
|
||||
| SlashCommand::Fast
|
||||
| SlashCommand::Ide
|
||||
| SlashCommand::Keymap
|
||||
| SlashCommand::Mcp
|
||||
@@ -184,7 +179,6 @@ impl SlashCommand {
|
||||
| SlashCommand::Init
|
||||
| SlashCommand::Compact
|
||||
| SlashCommand::Model
|
||||
| SlashCommand::Fast
|
||||
| SlashCommand::Personality
|
||||
| SlashCommand::Permissions
|
||||
| SlashCommand::Keymap
|
||||
|
||||
Reference in New Issue
Block a user