mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
02170996e6
## Why Enterprise and business-like ChatGPT plans should get Codex's Fast service tier by default when the user or caller has not made an explicit service-tier choice. At the same time, callers need a durable way to choose standard routing without adding a new persisted `standard` service tier value. This keeps existing config compatibility while letting core own the managed default policy. ## What changed - Resolve the effective service tier in core at session creation: explicit `fast` or `flex` wins, explicit null/clear or `[notice].fast_default_opt_out = true` resolves to standard routing, and otherwise eligible ChatGPT plans resolve to Fast when FastMode is enabled. - Add `[notice].fast_default_opt_out` as the persisted opt-out marker for managed Fast defaults. - Treat app-server/TUI `service_tier: null` as an explicit standard/clear choice by preserving that intent through config loading. - Update TUI rendering to use core's effective service tier for startup and status surfaces while still keeping `config.service_tier` as the explicit configured choice. - Update `/fast off` to clear `service_tier`, persist the opt-out marker, and send explicit standard for subsequent turns. ## Verification - Added unit coverage for config override/notice handling, service-tier resolution, runtime null clearing, and `/fast off` turn propagation. - `cargo build -p codex-cli` Full test suite was not run locally per author request.
816 lines
31 KiB
Rust
816 lines
31 KiB
Rust
//! Slash-command dispatch and local-recall handoff for `ChatWidget`.
|
|
//!
|
|
//! `ChatComposer` parses slash input and stages recognized command text for local
|
|
//! Up-arrow recall before returning an input result. This module owns the app-level
|
|
//! dispatch step and records the staged entry once the command has been handled, so
|
|
//! slash-command recall follows the same submitted-input rule as ordinary text.
|
|
|
|
use super::*;
|
|
use crate::bottom_pane::prompt_args::parse_slash_name;
|
|
use crate::bottom_pane::slash_commands;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum SlashCommandDispatchSource {
|
|
Live,
|
|
Queued,
|
|
}
|
|
|
|
struct PreparedSlashCommandArgs {
|
|
args: String,
|
|
text_elements: Vec<TextElement>,
|
|
local_images: Vec<LocalImageAttachment>,
|
|
remote_image_urls: Vec<String>,
|
|
mention_bindings: Vec<MentionBinding>,
|
|
source: SlashCommandDispatchSource,
|
|
}
|
|
|
|
const SIDE_STARTING_CONTEXT_LABEL: &str = "Side starting...";
|
|
const SIDE_REVIEW_UNAVAILABLE_MESSAGE: &str =
|
|
"'/side' is unavailable while code review is running.";
|
|
const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str = "Press Esc to return to the main thread first.";
|
|
|
|
impl ChatWidget {
|
|
/// Dispatch a bare slash command and record its staged local-history entry.
|
|
///
|
|
/// The composer stages history before returning `InputResult::Command`; this wrapper commits
|
|
/// that staged entry after dispatch so slash-command recall follows the same "submitted input"
|
|
/// rule as normal text.
|
|
pub(super) fn handle_slash_command_dispatch(&mut self, cmd: SlashCommand) {
|
|
self.dispatch_command(cmd);
|
|
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
|
|
/// local command recall still tracks the original command invocation. Treating this wrapper as
|
|
/// the only input-result entry point avoids double-recording commands with inline args.
|
|
pub(super) fn handle_slash_command_with_args_dispatch(
|
|
&mut self,
|
|
cmd: SlashCommand,
|
|
args: String,
|
|
text_elements: Vec<TextElement>,
|
|
) {
|
|
self.dispatch_command_with_args(cmd, args, text_elements);
|
|
self.bottom_pane.record_pending_slash_command_history();
|
|
}
|
|
|
|
fn apply_plan_slash_command(&mut self) -> bool {
|
|
if !self.collaboration_modes_enabled() {
|
|
self.add_info_message(
|
|
"Collaboration modes are disabled.".to_string(),
|
|
Some("Enable collaboration modes to use /plan.".to_string()),
|
|
);
|
|
return false;
|
|
}
|
|
if let Some(mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) {
|
|
self.set_collaboration_mask(mask);
|
|
true
|
|
} else {
|
|
self.add_info_message(
|
|
"Plan mode unavailable right now.".to_string(),
|
|
/*hint*/ None,
|
|
);
|
|
false
|
|
}
|
|
}
|
|
|
|
fn request_side_conversation(
|
|
&mut self,
|
|
parent_thread_id: ThreadId,
|
|
user_message: Option<UserMessage>,
|
|
) {
|
|
self.set_side_conversation_context_label(Some(SIDE_STARTING_CONTEXT_LABEL.to_string()));
|
|
self.request_redraw();
|
|
self.app_event_tx.send(AppEvent::StartSide {
|
|
parent_thread_id,
|
|
user_message,
|
|
});
|
|
}
|
|
|
|
fn request_empty_side_conversation(&mut self) {
|
|
let Some(parent_thread_id) = self.thread_id else {
|
|
self.add_error_message("'/side' is unavailable before the session starts.".to_string());
|
|
return;
|
|
};
|
|
|
|
self.request_side_conversation(parent_thread_id, /*user_message*/ None);
|
|
}
|
|
|
|
pub(super) fn dispatch_command(&mut self, cmd: SlashCommand) {
|
|
if !self.ensure_slash_command_allowed_in_side_conversation(cmd) {
|
|
return;
|
|
}
|
|
if !self.ensure_side_command_allowed_outside_review(cmd) {
|
|
return;
|
|
}
|
|
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
|
|
let message = format!(
|
|
"'/{}' is disabled while a task is in progress.",
|
|
cmd.command()
|
|
);
|
|
self.add_to_history(history_cell::new_error_event(message));
|
|
self.bottom_pane.drain_pending_submission_state();
|
|
self.request_redraw();
|
|
return;
|
|
}
|
|
|
|
match cmd {
|
|
SlashCommand::Feedback => {
|
|
if !self.config.feedback_enabled {
|
|
let params = crate::bottom_pane::feedback_disabled_params();
|
|
self.bottom_pane.show_selection_view(params);
|
|
self.request_redraw();
|
|
return;
|
|
}
|
|
// Step 1: pick a category (UI built in feedback_view)
|
|
let params =
|
|
crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone());
|
|
self.bottom_pane.show_selection_view(params);
|
|
self.request_redraw();
|
|
}
|
|
SlashCommand::New => {
|
|
self.app_event_tx.send(AppEvent::NewSession);
|
|
}
|
|
SlashCommand::Clear => {
|
|
self.app_event_tx.send(AppEvent::ClearUi);
|
|
}
|
|
SlashCommand::Resume => {
|
|
self.app_event_tx.send(AppEvent::OpenResumePicker);
|
|
}
|
|
SlashCommand::Fork => {
|
|
self.app_event_tx.send(AppEvent::ForkCurrentSession);
|
|
}
|
|
SlashCommand::Init => {
|
|
let init_target = self.config.cwd.join(DEFAULT_AGENTS_MD_FILENAME);
|
|
if init_target.exists() {
|
|
let message = format!(
|
|
"{DEFAULT_AGENTS_MD_FILENAME} already exists here. Skipping /init to avoid overwriting it."
|
|
);
|
|
self.add_info_message(message, /*hint*/ None);
|
|
return;
|
|
}
|
|
const INIT_PROMPT: &str = include_str!("../../prompt_for_init_command.md");
|
|
self.submit_user_message(INIT_PROMPT.to_string().into());
|
|
}
|
|
SlashCommand::Compact => {
|
|
self.clear_token_usage();
|
|
if !self.bottom_pane.is_task_running() {
|
|
self.bottom_pane.set_task_running(/*running*/ true);
|
|
}
|
|
self.app_event_tx.compact();
|
|
}
|
|
SlashCommand::Review => {
|
|
self.open_review_popup();
|
|
}
|
|
SlashCommand::Rename => {
|
|
self.session_telemetry
|
|
.counter("codex.thread.rename", /*inc*/ 1, &[]);
|
|
self.show_rename_prompt();
|
|
}
|
|
SlashCommand::Model => {
|
|
self.open_model_popup();
|
|
}
|
|
SlashCommand::Fast => {
|
|
let next_tier = if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) {
|
|
None
|
|
} else {
|
|
Some(ServiceTier::Fast)
|
|
};
|
|
self.set_service_tier_selection(next_tier);
|
|
}
|
|
SlashCommand::Realtime => {
|
|
if !self.realtime_conversation_enabled() {
|
|
return;
|
|
}
|
|
if self.realtime_conversation.is_live() {
|
|
self.stop_realtime_conversation_from_ui();
|
|
} else {
|
|
self.start_realtime_conversation();
|
|
}
|
|
}
|
|
SlashCommand::Settings => {
|
|
if !self.realtime_audio_device_selection_enabled() {
|
|
return;
|
|
}
|
|
self.open_realtime_audio_popup();
|
|
}
|
|
SlashCommand::Personality => {
|
|
self.open_personality_popup();
|
|
}
|
|
SlashCommand::Plan => {
|
|
self.apply_plan_slash_command();
|
|
}
|
|
SlashCommand::Collab => {
|
|
if !self.collaboration_modes_enabled() {
|
|
self.add_info_message(
|
|
"Collaboration modes are disabled.".to_string(),
|
|
Some("Enable collaboration modes to use /collab.".to_string()),
|
|
);
|
|
return;
|
|
}
|
|
self.open_collaboration_modes_popup();
|
|
}
|
|
SlashCommand::Side => {
|
|
self.request_empty_side_conversation();
|
|
}
|
|
SlashCommand::Agent | SlashCommand::MultiAgents => {
|
|
self.app_event_tx.send(AppEvent::OpenAgentPicker);
|
|
}
|
|
SlashCommand::Approvals => {
|
|
self.open_permissions_popup();
|
|
}
|
|
SlashCommand::Permissions => {
|
|
self.open_permissions_popup();
|
|
}
|
|
SlashCommand::ElevateSandbox => {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
|
|
let windows_degraded_sandbox_enabled =
|
|
matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken);
|
|
if !windows_degraded_sandbox_enabled
|
|
|| !crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
|
{
|
|
// This command should not be visible/recognized outside degraded mode,
|
|
// but guard anyway in case something dispatches it directly.
|
|
return;
|
|
}
|
|
|
|
let Some(preset) = builtin_approval_presets()
|
|
.into_iter()
|
|
.find(|preset| preset.id == "auto")
|
|
else {
|
|
// Avoid panicking in interactive UI; treat this as a recoverable
|
|
// internal error.
|
|
self.add_error_message(
|
|
"Internal error: missing the 'auto' approval preset.".to_string(),
|
|
);
|
|
return;
|
|
};
|
|
|
|
if let Err(err) = self
|
|
.config
|
|
.permissions
|
|
.approval_policy
|
|
.can_set(&preset.approval)
|
|
{
|
|
self.add_error_message(err.to_string());
|
|
return;
|
|
}
|
|
|
|
self.session_telemetry.counter(
|
|
"codex.windows_sandbox.setup_elevated_sandbox_command",
|
|
/*inc*/ 1,
|
|
&[],
|
|
);
|
|
self.app_event_tx
|
|
.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset });
|
|
}
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
let _ = &self.session_telemetry;
|
|
// Not supported; on non-Windows this command should never be reachable.
|
|
}
|
|
}
|
|
SlashCommand::SandboxReadRoot => {
|
|
self.add_error_message(
|
|
"Usage: /sandbox-add-read-dir <absolute-directory-path>".to_string(),
|
|
);
|
|
}
|
|
SlashCommand::Experimental => {
|
|
self.open_experimental_popup();
|
|
}
|
|
SlashCommand::Memories => {
|
|
self.open_memories_popup();
|
|
}
|
|
SlashCommand::Quit | SlashCommand::Exit => {
|
|
self.request_quit_without_confirmation();
|
|
}
|
|
SlashCommand::Logout => {
|
|
self.app_event_tx.send(AppEvent::Logout);
|
|
}
|
|
// SlashCommand::Undo => {
|
|
// self.app_event_tx.send(AppEvent::CodexOp(Op::Undo));
|
|
// }
|
|
SlashCommand::Copy => {
|
|
self.copy_last_agent_markdown();
|
|
}
|
|
SlashCommand::Diff => {
|
|
self.add_diff_in_progress();
|
|
let tx = self.app_event_tx.clone();
|
|
tokio::spawn(async move {
|
|
let text = match get_git_diff().await {
|
|
Ok((is_git_repo, diff_text)) => {
|
|
if is_git_repo {
|
|
diff_text
|
|
} else {
|
|
"`/diff` — _not inside a git repository_".to_string()
|
|
}
|
|
}
|
|
Err(e) => format!("Failed to compute diff: {e}"),
|
|
};
|
|
tx.send(AppEvent::DiffResult(text));
|
|
});
|
|
}
|
|
SlashCommand::Mention => {
|
|
self.insert_str("@");
|
|
}
|
|
SlashCommand::Skills => {
|
|
self.open_skills_menu();
|
|
}
|
|
SlashCommand::Status => {
|
|
if self.should_prefetch_rate_limits() {
|
|
let request_id = self.next_status_refresh_request_id;
|
|
self.next_status_refresh_request_id =
|
|
self.next_status_refresh_request_id.wrapping_add(1);
|
|
self.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id));
|
|
self.app_event_tx.send(AppEvent::RefreshRateLimits {
|
|
origin: RateLimitRefreshOrigin::StatusCommand { request_id },
|
|
});
|
|
} else {
|
|
self.add_status_output(
|
|
/*refreshing_rate_limits*/ false, /*request_id*/ None,
|
|
);
|
|
}
|
|
}
|
|
SlashCommand::DebugConfig => {
|
|
self.add_debug_config_output();
|
|
}
|
|
SlashCommand::Title => {
|
|
self.open_terminal_title_setup();
|
|
}
|
|
SlashCommand::Statusline => {
|
|
self.open_status_line_setup();
|
|
}
|
|
SlashCommand::Theme => {
|
|
self.open_theme_picker();
|
|
}
|
|
SlashCommand::Ps => {
|
|
self.add_ps_output();
|
|
}
|
|
SlashCommand::Stop => {
|
|
self.clean_background_terminals();
|
|
}
|
|
SlashCommand::MemoryDrop => {
|
|
self.add_app_server_stub_message("Memory maintenance");
|
|
}
|
|
SlashCommand::MemoryUpdate => {
|
|
self.add_app_server_stub_message("Memory maintenance");
|
|
}
|
|
SlashCommand::Mcp => {
|
|
self.add_mcp_output(McpServerStatusDetail::ToolsAndAuthOnly);
|
|
}
|
|
SlashCommand::Apps => {
|
|
self.add_connectors_output();
|
|
}
|
|
SlashCommand::Plugins => {
|
|
self.add_plugins_output();
|
|
}
|
|
SlashCommand::Rollout => {
|
|
if let Some(path) = self.rollout_path() {
|
|
self.add_info_message(
|
|
format!("Current rollout path: {}", path.display()),
|
|
/*hint*/ None,
|
|
);
|
|
} else {
|
|
self.add_info_message(
|
|
"Rollout path is not available yet.".to_string(),
|
|
/*hint*/ None,
|
|
);
|
|
}
|
|
}
|
|
SlashCommand::TestApproval => {
|
|
use std::collections::HashMap;
|
|
|
|
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
|
|
use codex_protocol::protocol::FileChange;
|
|
|
|
self.on_apply_patch_approval_request(
|
|
"1".to_string(),
|
|
ApplyPatchApprovalRequestEvent {
|
|
call_id: "1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
changes: HashMap::from([
|
|
(
|
|
PathBuf::from("/tmp/test.txt"),
|
|
FileChange::Add {
|
|
content: "test".to_string(),
|
|
},
|
|
),
|
|
(
|
|
PathBuf::from("/tmp/test2.txt"),
|
|
FileChange::Update {
|
|
unified_diff: "+test\n-test2".to_string(),
|
|
move_path: None,
|
|
},
|
|
),
|
|
]),
|
|
reason: None,
|
|
grant_root: Some(PathBuf::from("/tmp")),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run an inline slash command.
|
|
///
|
|
/// Branches that prepare arguments should pass `record_history: false` to the composer because
|
|
/// the staged slash-command entry is the recall record; using the normal submission-history
|
|
/// path as well would make a single command appear twice during Up-arrow navigation.
|
|
pub(super) fn dispatch_command_with_args(
|
|
&mut self,
|
|
cmd: SlashCommand,
|
|
args: String,
|
|
text_elements: Vec<TextElement>,
|
|
) {
|
|
if !self.ensure_slash_command_allowed_in_side_conversation(cmd) {
|
|
return;
|
|
}
|
|
if !self.ensure_side_command_allowed_outside_review(cmd) {
|
|
return;
|
|
}
|
|
if !cmd.supports_inline_args() {
|
|
self.dispatch_command(cmd);
|
|
return;
|
|
}
|
|
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
|
|
let message = format!(
|
|
"'/{}' is disabled while a task is in progress.",
|
|
cmd.command()
|
|
);
|
|
self.add_to_history(history_cell::new_error_event(message));
|
|
self.request_redraw();
|
|
return;
|
|
}
|
|
|
|
let trimmed = args.trim();
|
|
if trimmed.is_empty() {
|
|
self.dispatch_command(cmd);
|
|
return;
|
|
}
|
|
|
|
let Some((prepared_args, prepared_elements)) =
|
|
self.prepare_live_inline_args(args, text_elements)
|
|
else {
|
|
return;
|
|
};
|
|
self.dispatch_prepared_command_with_args(
|
|
cmd,
|
|
PreparedSlashCommandArgs {
|
|
args: prepared_args,
|
|
text_elements: prepared_elements,
|
|
local_images: Vec::new(),
|
|
remote_image_urls: Vec::new(),
|
|
mention_bindings: Vec::new(),
|
|
source: SlashCommandDispatchSource::Live,
|
|
},
|
|
);
|
|
}
|
|
|
|
fn prepare_live_inline_args(
|
|
&mut self,
|
|
args: String,
|
|
text_elements: Vec<TextElement>,
|
|
) -> Option<(String, Vec<TextElement>)> {
|
|
if self.bottom_pane.composer_text().is_empty() {
|
|
Some((args, text_elements))
|
|
} else {
|
|
self.bottom_pane
|
|
.prepare_inline_args_submission(/*record_history*/ false)
|
|
}
|
|
}
|
|
|
|
fn prepared_inline_user_message(
|
|
&mut self,
|
|
args: String,
|
|
text_elements: Vec<TextElement>,
|
|
mut local_images: Vec<LocalImageAttachment>,
|
|
mut remote_image_urls: Vec<String>,
|
|
mut mention_bindings: Vec<MentionBinding>,
|
|
source: SlashCommandDispatchSource,
|
|
) -> UserMessage {
|
|
if source == SlashCommandDispatchSource::Live {
|
|
local_images = self
|
|
.bottom_pane
|
|
.take_recent_submission_images_with_placeholders();
|
|
remote_image_urls = self.take_remote_image_urls();
|
|
mention_bindings = self.bottom_pane.take_recent_submission_mention_bindings();
|
|
}
|
|
UserMessage {
|
|
text: args,
|
|
local_images,
|
|
remote_image_urls,
|
|
text_elements,
|
|
mention_bindings,
|
|
}
|
|
}
|
|
|
|
fn dispatch_prepared_command_with_args(
|
|
&mut self,
|
|
cmd: SlashCommand,
|
|
prepared: PreparedSlashCommandArgs,
|
|
) {
|
|
let PreparedSlashCommandArgs {
|
|
args,
|
|
text_elements,
|
|
local_images,
|
|
remote_image_urls,
|
|
mention_bindings,
|
|
source,
|
|
} = 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::Mcp => match trimmed.to_ascii_lowercase().as_str() {
|
|
"verbose" => self.add_mcp_output(McpServerStatusDetail::Full),
|
|
_ => self.add_error_message("Usage: /mcp [verbose]".to_string()),
|
|
},
|
|
SlashCommand::Rename if !trimmed.is_empty() => {
|
|
if !self.ensure_thread_rename_allowed() {
|
|
return;
|
|
}
|
|
self.session_telemetry
|
|
.counter("codex.thread.rename", /*inc*/ 1, &[]);
|
|
let Some(name) = crate::legacy_core::util::normalize_thread_name(&args) else {
|
|
self.add_error_message("Thread name cannot be empty.".to_string());
|
|
return;
|
|
};
|
|
self.app_event_tx.set_thread_name(name);
|
|
}
|
|
SlashCommand::Plan if !trimmed.is_empty() => {
|
|
if !self.apply_plan_slash_command() {
|
|
return;
|
|
}
|
|
let user_message = self.prepared_inline_user_message(
|
|
args,
|
|
text_elements,
|
|
local_images,
|
|
remote_image_urls,
|
|
mention_bindings,
|
|
source,
|
|
);
|
|
if self.is_session_configured() {
|
|
self.reasoning_buffer.clear();
|
|
self.full_reasoning_buffer.clear();
|
|
self.set_status_header(String::from("Working"));
|
|
self.submit_user_message(user_message);
|
|
} else {
|
|
self.queue_user_message(user_message);
|
|
}
|
|
}
|
|
SlashCommand::Side if !trimmed.is_empty() => {
|
|
let Some(parent_thread_id) = self.thread_id else {
|
|
self.add_error_message(
|
|
"'/side' is unavailable before the session starts.".to_string(),
|
|
);
|
|
return;
|
|
};
|
|
let user_message = self.prepared_inline_user_message(
|
|
args,
|
|
text_elements,
|
|
local_images,
|
|
remote_image_urls,
|
|
mention_bindings,
|
|
source,
|
|
);
|
|
self.request_side_conversation(parent_thread_id, Some(user_message));
|
|
}
|
|
SlashCommand::Review if !trimmed.is_empty() => {
|
|
self.submit_op(AppCommand::review(ReviewRequest {
|
|
target: ReviewTarget::Custom { instructions: args },
|
|
user_facing_hint: None,
|
|
}));
|
|
}
|
|
SlashCommand::Resume if !trimmed.is_empty() => {
|
|
self.app_event_tx
|
|
.send(AppEvent::ResumeSessionByIdOrName(args));
|
|
}
|
|
SlashCommand::SandboxReadRoot if !trimmed.is_empty() => {
|
|
self.app_event_tx
|
|
.send(AppEvent::BeginWindowsSandboxGrantReadRoot { path: args });
|
|
}
|
|
_ => self.dispatch_command(cmd),
|
|
}
|
|
if source == SlashCommandDispatchSource::Live {
|
|
self.bottom_pane.drain_pending_submission_state();
|
|
}
|
|
}
|
|
|
|
pub(super) fn submit_queued_slash_prompt(&mut self, user_message: UserMessage) -> QueueDrain {
|
|
let UserMessage {
|
|
text,
|
|
local_images,
|
|
remote_image_urls,
|
|
text_elements,
|
|
mention_bindings,
|
|
} = user_message;
|
|
let Some((name, rest, rest_offset)) = parse_slash_name(&text) else {
|
|
self.submit_user_message(UserMessage {
|
|
text,
|
|
local_images,
|
|
remote_image_urls,
|
|
text_elements,
|
|
mention_bindings,
|
|
});
|
|
return QueueDrain::Stop;
|
|
};
|
|
|
|
if name.contains('/') {
|
|
self.submit_user_message(UserMessage {
|
|
text,
|
|
local_images,
|
|
remote_image_urls,
|
|
text_elements,
|
|
mention_bindings,
|
|
});
|
|
return QueueDrain::Stop;
|
|
}
|
|
|
|
let Some(cmd) = slash_commands::find_builtin_command(name, self.builtin_command_flags())
|
|
else {
|
|
self.add_info_message(
|
|
format!(
|
|
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
|
|
),
|
|
/*hint*/ None,
|
|
);
|
|
return QueueDrain::Continue;
|
|
};
|
|
|
|
if rest.is_empty() {
|
|
self.dispatch_command(cmd);
|
|
return self.queued_command_drain_result(cmd);
|
|
}
|
|
|
|
if !cmd.supports_inline_args() {
|
|
self.submit_user_message(UserMessage {
|
|
text,
|
|
local_images,
|
|
remote_image_urls,
|
|
text_elements,
|
|
mention_bindings,
|
|
});
|
|
return QueueDrain::Stop;
|
|
}
|
|
|
|
let args_elements = Self::slash_command_args_elements(rest, rest_offset, &text_elements);
|
|
self.dispatch_prepared_command_with_args(
|
|
cmd,
|
|
PreparedSlashCommandArgs {
|
|
args: rest.trim().to_string(),
|
|
text_elements: args_elements,
|
|
local_images,
|
|
remote_image_urls,
|
|
mention_bindings,
|
|
source: SlashCommandDispatchSource::Queued,
|
|
},
|
|
);
|
|
self.queued_command_drain_result(cmd)
|
|
}
|
|
|
|
fn builtin_command_flags(&self) -> slash_commands::BuiltinCommandFlags {
|
|
#[cfg(target_os = "windows")]
|
|
let allow_elevate_sandbox = {
|
|
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
|
|
matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken)
|
|
};
|
|
#[cfg(not(target_os = "windows"))]
|
|
let allow_elevate_sandbox = false;
|
|
|
|
slash_commands::BuiltinCommandFlags {
|
|
collaboration_modes_enabled: self.collaboration_modes_enabled(),
|
|
connectors_enabled: self.connectors_enabled(),
|
|
plugins_command_enabled: self.config.features.enabled(Feature::Plugins),
|
|
fast_command_enabled: self.fast_mode_enabled(),
|
|
personality_command_enabled: self.config.features.enabled(Feature::Personality),
|
|
realtime_conversation_enabled: self.realtime_conversation_enabled(),
|
|
audio_device_selection_enabled: self.realtime_audio_device_selection_enabled(),
|
|
allow_elevate_sandbox,
|
|
side_conversation_active: self.active_side_conversation,
|
|
}
|
|
}
|
|
|
|
fn queued_command_drain_result(&self, cmd: SlashCommand) -> QueueDrain {
|
|
if self.is_user_turn_pending_or_running() || !self.bottom_pane.no_modal_or_popup_active() {
|
|
return QueueDrain::Stop;
|
|
}
|
|
match cmd {
|
|
SlashCommand::Fast
|
|
| SlashCommand::Status
|
|
| SlashCommand::DebugConfig
|
|
| SlashCommand::Ps
|
|
| SlashCommand::Stop
|
|
| SlashCommand::MemoryDrop
|
|
| SlashCommand::MemoryUpdate
|
|
| SlashCommand::Mcp
|
|
| SlashCommand::Apps
|
|
| SlashCommand::Plugins
|
|
| SlashCommand::Rollout
|
|
| SlashCommand::Copy
|
|
| SlashCommand::Diff
|
|
| SlashCommand::Rename
|
|
| SlashCommand::TestApproval => QueueDrain::Continue,
|
|
SlashCommand::Feedback
|
|
| SlashCommand::New
|
|
| SlashCommand::Clear
|
|
| SlashCommand::Resume
|
|
| SlashCommand::Fork
|
|
| SlashCommand::Init
|
|
| SlashCommand::Compact
|
|
| SlashCommand::Review
|
|
| SlashCommand::Model
|
|
| SlashCommand::Realtime
|
|
| SlashCommand::Settings
|
|
| SlashCommand::Personality
|
|
| SlashCommand::Plan
|
|
| SlashCommand::Collab
|
|
| SlashCommand::Side
|
|
| SlashCommand::Agent
|
|
| SlashCommand::MultiAgents
|
|
| SlashCommand::Approvals
|
|
| SlashCommand::Permissions
|
|
| SlashCommand::ElevateSandbox
|
|
| SlashCommand::SandboxReadRoot
|
|
| SlashCommand::Experimental
|
|
| SlashCommand::Memories
|
|
| SlashCommand::Quit
|
|
| SlashCommand::Exit
|
|
| SlashCommand::Logout
|
|
| SlashCommand::Mention
|
|
| SlashCommand::Skills
|
|
| SlashCommand::Title
|
|
| SlashCommand::Statusline
|
|
| SlashCommand::Theme => QueueDrain::Stop,
|
|
}
|
|
}
|
|
|
|
fn slash_command_args_elements(
|
|
rest: &str,
|
|
rest_offset: usize,
|
|
text_elements: &[TextElement],
|
|
) -> Vec<TextElement> {
|
|
if rest.is_empty() || text_elements.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
text_elements
|
|
.iter()
|
|
.filter_map(|elem| {
|
|
if elem.byte_range.end <= rest_offset {
|
|
return None;
|
|
}
|
|
let start = elem.byte_range.start.saturating_sub(rest_offset);
|
|
let mut end = elem.byte_range.end.saturating_sub(rest_offset);
|
|
if start >= rest.len() {
|
|
return None;
|
|
}
|
|
end = end.min(rest.len());
|
|
(start < end).then_some(elem.map_range(|_| ByteRange { start, end }))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn ensure_slash_command_allowed_in_side_conversation(&mut self, cmd: SlashCommand) -> bool {
|
|
if !self.active_side_conversation || cmd.available_in_side_conversation() {
|
|
return true;
|
|
}
|
|
self.add_error_message(format!(
|
|
"'/{}' is unavailable in side conversations. {SIDE_SLASH_COMMAND_UNAVAILABLE_HINT}",
|
|
cmd.command()
|
|
));
|
|
self.bottom_pane.drain_pending_submission_state();
|
|
false
|
|
}
|
|
|
|
fn ensure_side_command_allowed_outside_review(&mut self, cmd: SlashCommand) -> bool {
|
|
if cmd != SlashCommand::Side || !self.is_review_mode {
|
|
return true;
|
|
}
|
|
|
|
self.add_error_message(SIDE_REVIEW_UNAVAILABLE_MESSAGE.to_string());
|
|
self.bottom_pane.drain_pending_submission_state();
|
|
false
|
|
}
|
|
}
|