//! 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, local_images: Vec, remote_image_urls: Vec, mention_bindings: Vec, 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, ) { 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, ) { 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 ".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, ) { 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, ) -> Option<(String, Vec)> { 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, mut local_images: Vec, mut remote_image_urls: Vec, mut mention_bindings: Vec, 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 { 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 } }