mod cloud_mode_v2_view; mod data_source; mod search_item; pub(super) mod view; #[cfg(feature = "local_fs")] use std::path::PathBuf; use ai::skills::SkillReference; pub use cloud_mode_v2_view::{CloudModeV2SlashCommandView, Section as CloudModeV2Section}; pub use data_source::*; #[cfg(not(target_family = "wasm"))] use galaxy_cli::agent::Harness; use galaxy_core::features::FeatureFlag; use galaxy_core::send_telemetry_from_ctx; use galaxy_core::ui::appearance::Appearance; use galaxy_core::ui::theme::AnsiColorIdentifier; #[cfg(feature = "local_fs")] use galaxy_util::path::{CleanPathResult, LineAndColumnArg}; use galaxyui::clipboard::ClipboardContent; use galaxyui::{AppContext, SingletonEntity, ViewContext}; pub use view::{CloseReason, InlineSlashCommandView, SlashCommandsEvent}; use crate::ai::agent::conversation::AIConversationId; #[cfg(not(target_family = "wasm"))] use crate::ai::agent_conversations_model::AgentConversationsModel; #[cfg(not(target_family = "wasm"))] use crate::ai::agent_management::telemetry::AgentManagementTelemetryEvent; #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] use crate::ai::ambient_agents::telemetry::HandoffEntryPoint; use crate::ai::blocklist::agent_view::{ AgentViewEntryOrigin, DismissalStrategy, EphemeralMessage, ENTER_OR_EXIT_CONFIRMATION_WINDOW, }; #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] use crate::ai::blocklist::handoff::PendingCloudLaunch; use crate::ai::blocklist::{ BlocklistAIHistoryModel, InputTypeAutoDetectionSource, PendingAttachment, QueuedQuery, QueuedQueryId, QueuedQueryModel, QueuedQueryOrigin, SlashCommandRequest, }; use crate::ai::conversation_rename::rename_conversation; use crate::cloud_object::model::persistence::CloudModel; use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint; use crate::search::slash_command_menu::static_commands::commands::{self, COMMAND_REGISTRY}; use crate::search::slash_command_menu::static_commands::Availability; use crate::search::slash_command_menu::{SlashCommandId, StaticCommand}; use crate::server::ids::SyncId; use crate::server::telemetry::SlashCommandAcceptedDetails; use crate::settings::AISettings; use crate::tab::SelectedTabColor; use crate::terminal::input::decorations::InputBackgroundJobOptions; use crate::terminal::input::inline_menu::{InlineMenuAction, InlineMenuType}; use crate::terminal::input::message_bar::Message; use crate::terminal::input::models::InlineModelSelectorTab; use crate::terminal::input::slash_command_model::{ SlashCommandEntryState, UpdatedSlashCommandModel, }; use crate::terminal::input::{ CompletionsTrigger, Event, Input, InputAction, InputSuggestionsMode, UserQueryMenuAction, }; #[cfg(feature = "local_fs")] use crate::terminal::model::session::Session; use crate::terminal::view::TerminalAction; use crate::ui_components::color_dot; use crate::view_components::DismissibleToast; use crate::workflows::{WorkflowSelectionSource, WorkflowSource, WorkflowType}; use crate::workspace::{ForkedConversationDestination, ToastStack, WorkspaceAction}; use crate::TelemetryEvent; #[derive(Debug, Clone)] pub enum AcceptSlashCommandOrSavedPrompt { SlashCommand { id: SlashCommandId, }, SavedPrompt { id: SyncId, }, /// A skill selected from browse or search. Contains name (for display/insertion) and path/bundled_skill_id (for execution). Skill { reference: SkillReference, name: String, }, } impl InlineMenuAction for AcceptSlashCommandOrSavedPrompt { const MENU_TYPE: InlineMenuType = InlineMenuType::SlashCommands; } #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum SlashCommandTrigger { Input { cmd_or_ctrl_enter: bool }, Keybinding, } impl SlashCommandTrigger { fn cmd_or_ctrl_enter() -> Self { Self::Input { cmd_or_ctrl_enter: true, } } pub fn input() -> Self { Self::Input { cmd_or_ctrl_enter: false, } } pub(super) fn keybinding() -> Self { Self::Keybinding } pub fn is_keybinding(&self) -> bool { matches!(self, Self::Keybinding) } fn is_cmd_or_ctrl_enter(&self) -> bool { matches!( self, Self::Input { cmd_or_ctrl_enter: true } ) } } #[cfg(feature = "local_fs")] fn open_file_command_path( session: &Session, current_dir: &str, raw_arg: &str, ) -> (PathBuf, Option) { let parsed_path = CleanPathResult::with_line_and_column_number(raw_arg.trim()); // The argument may contain shell-escaped characters (e.g. `\ ` for spaces) from auto-suggest. // Unescape them so the path matches the actual filesystem entry. let unescaped_path = session.shell_family().unescape(&parsed_path.path); // Expand `~` to the user's home directory. let expanded_path = shellexpand::tilde(&unescaped_path); let shell_path = session .convert_directory_to_typed_path_buf(current_dir.to_owned()) .join(session.convert_directory_to_typed_path_buf(expanded_path.into_owned())) .normalize(); let file_path = session .maybe_convert_to_native_path(&shell_path.to_path()) .unwrap_or_else(|err| { log::warn!("unable to convert /open-file path to native path: {err:?}"); PathBuf::from(shell_path.to_string_lossy().into_owned()) }); (file_path, parsed_path.line_and_column_num) } impl Input { fn is_slash_command_available(&self, command: &StaticCommand, ctx: &AppContext) -> bool { let slash_command_data_source = if self.is_cloud_mode_input_v2_composing(ctx) { let Some(data_source) = self.cloud_mode_composer_slash_command_data_source.as_ref() else { return false; }; data_source } else { &self.slash_command_data_source }; slash_command_data_source .as_ref(ctx) .command_is_active(command, ctx) } pub(super) fn select_slash_command( &mut self, command: &StaticCommand, trigger: SlashCommandTrigger, ctx: &mut ViewContext, ) { if !self.is_slash_command_available(command, ctx) { return; } if command.argument.as_ref().is_none() { self.execute_slash_command( command, None, trigger, /*is_queued_prompt*/ false, None, None, ctx, ); } else if command .argument .as_ref() .is_some_and(|arg| arg.should_execute_on_selection) { // TODO (zachbai): this is a hack for Oz launch. Caller // should probably be invoking `execute_slash_command` in this case. let argument = if !self.suggestions_mode_model.as_ref(ctx).is_slash_commands() { let trimmed = self.buffer_text(ctx).trim().to_owned(); (!trimmed.is_empty()).then_some(trimmed) } else { None }; self.execute_slash_command( command, argument.as_ref(), trigger, /*is_queued_prompt*/ false, None, None, ctx, ); } else { self.editor.update(ctx, |editor, ctx| { editor.set_buffer_text(&format!("{} ", command.name), ctx); }); } } pub(super) fn close_slash_commands_menu(&mut self, ctx: &mut ViewContext) { self.suggestions_mode_model.update(ctx, |model, ctx| { model.set_mode(InputSuggestionsMode::Closed, ctx); }); ctx.notify(); } pub(super) fn handle_slash_command_model_event( &mut self, event: &UpdatedSlashCommandModel, ctx: &mut ViewContext, ) { // Refresh decorations if the slash command detection state changed, since // detected commands affect syntax highlighting. let new_state = self.slash_command_model.as_ref(ctx).state(); if event.old_state.is_detected_command() != new_state.is_detected_command() { let _ = self .debounce_input_background_tx .try_send(InputBackgroundJobOptions::default().with_command_decoration()); } match self.slash_command_model.as_ref(ctx).state().clone() { SlashCommandEntryState::None | SlashCommandEntryState::DisabledUntilEmptyBuffer => { if self.suggestions_mode_model.as_ref(ctx).is_slash_commands() { self.close_slash_commands_menu(ctx); } } SlashCommandEntryState::Composing { .. } => { if self.suggestions_mode_model.as_ref(ctx).is_closed() { self.open_slash_commands_menu(ctx); } else if !self.suggestions_mode_model.as_ref(ctx).is_slash_commands() { self.slash_command_model.update(ctx, |model, ctx| { model.disable(ctx); }); } } SlashCommandEntryState::SlashCommand(detected_command) => { // If there is only one result (or zero, but that should be impossible if there is // a valid command in the input) OR if the user has started typing arguments, hide // the menu. if self.suggestions_mode_model.as_ref(ctx).is_slash_commands() && (self .inline_slash_commands_view .as_ref(ctx) .result_count(ctx) < 2 || detected_command.argument.is_some()) { self.close_slash_commands_menu(ctx); } if detected_command.command.auto_enter_ai_mode || !FeatureFlag::AgentView.is_enabled() { self.enter_ai_mode(Some(InputTypeAutoDetectionSource::SlashCommand), ctx); } if detected_command.command.name == commands::EDIT.name && detected_command .argument .as_ref() .is_some_and(|argument| argument.is_empty()) && self.suggestions_mode_model.as_ref(ctx).is_closed() { self.open_completion_suggestions(CompletionsTrigger::SlashCommandAutoOpen, ctx); } } SlashCommandEntryState::SkillCommand(detected_skill) => { // Hide the menu once the user has started typing the prompt if self.suggestions_mode_model.as_ref(ctx).is_slash_commands() && (self .inline_slash_commands_view .as_ref(ctx) .result_count(ctx) < 2 || detected_skill.argument.is_some()) { self.close_slash_commands_menu(ctx); } // Skill commands always require AI mode self.enter_ai_mode(Some(InputTypeAutoDetectionSource::SlashCommand), ctx); } } } pub(crate) fn handle_slash_commands_menu_event( &mut self, event: &SlashCommandsEvent, ctx: &mut ViewContext, ) { match event { SlashCommandsEvent::Close(reason) => { if reason.is_manual_dismissal() { self.slash_command_model.update(ctx, |model, ctx| { model.disable(ctx); }); } self.suggestions_mode_model.update(ctx, |model, ctx| { model.set_mode(InputSuggestionsMode::Closed, ctx); }); ctx.notify(); } SlashCommandsEvent::SelectedSavedPrompt { id } => { let Some(workflow) = CloudModel::as_ref(ctx).get_workflow(id).cloned() else { log::warn!("Tried to execute workflow for id {id:?} but it does not exist"); return; }; let is_in_agent_view = FeatureFlag::AgentView.is_enabled() && self.agent_view_controller.as_ref(ctx).is_fullscreen(); send_telemetry_from_ctx!( TelemetryEvent::SlashCommandAccepted { command_details: SlashCommandAcceptedDetails::SavedPrompt, is_in_agent_view, }, ctx ); self.show_workflows_info_box_on_workflow_selection( WorkflowType::Cloud(Box::new(workflow)), WorkflowSource::WarpAI, WorkflowSelectionSource::SlashMenu, None, ctx, ); } SlashCommandsEvent::SelectedStaticCommand { id, cmd_or_ctrl_enter, } => { let Some(command) = COMMAND_REGISTRY.get_command(id) else { return; }; self.select_slash_command( command, SlashCommandTrigger::Input { cmd_or_ctrl_enter: *cmd_or_ctrl_enter, }, ctx, ); } SlashCommandsEvent::SelectedSkill { name, reference: _ } => { // Insert /{skill-name} into the buffer self.editor.update(ctx, |editor, ctx| { editor.set_buffer_text(format!("/{name} ").as_str(), ctx); }); self.close_slash_commands_menu(ctx); } } } /// Executes the given `command` with `argument`, if any. /// /// When `is_queued_prompt` is true, this is the first send of a previously queued prompt: /// the input buffer is left alone so the user doesn't lose anything they've typed while /// the agent was busy. /// /// Returns `true` if execution was 'handled' (whether or not it resulted in success or failure). #[allow(clippy::too_many_arguments)] pub(super) fn execute_slash_command( &mut self, command: &StaticCommand, argument: Option<&String>, trigger: SlashCommandTrigger, is_queued_prompt: bool, queued_conversation_id: Option, queued_query_id: Option, ctx: &mut ViewContext, ) -> bool { fn show_error_toast(message: String, ctx: &mut ViewContext) { let window_id = ctx.window_id(); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { toast_stack.add_ephemeral_toast(DismissibleToast::error(message), window_id, ctx); }); } // Safety net: commands whose availability requires AI should not execute when AI is // globally disabled. They're normally filtered out of the slash command menu, but this // protects keybinding-triggered execution where a bound key may still address the command. if command.availability.contains(Availability::AI_ENABLED) && !AISettings::as_ref(ctx).is_any_ai_enabled(ctx) { show_error_toast(format!("{} requires AI to be enabled", command.name), ctx); return true; } // Handle the slash command action based on its kind match command.name { _add_mcp if command.name == commands::ADD_MCP.name => { ctx.dispatch_typed_action(&TerminalAction::OpenAddMCPPane); } _add_prompt if command.name == commands::ADD_PROMPT.name => { ctx.dispatch_typed_action(&TerminalAction::OpenAddPromptPane); } _add_rule if command.name == commands::ADD_RULE.name => { ctx.dispatch_typed_action(&TerminalAction::OpenAddRulePane); } _agent_or_new if command.name == commands::NEW.name || command.name == commands::AGENT.name => { if !self .ai_context_model .as_ref(ctx) .can_start_new_conversation() { self.ephemeral_message_model.update(ctx, |model, ctx| { let appearance = Appearance::handle(ctx).as_ref(ctx); let message = Message::from_text( "cannot start new conversation while terminal command is running", ) .with_text_color(appearance.theme().ansi_fg_red()); model.show_ephemeral_message( EphemeralMessage::new( message, DismissalStrategy::Timer(ENTER_OR_EXIT_CONFIRMATION_WINDOW), ), ctx, ); }); return true; } // Keybindings can be triggered reflexively while users are already in an active // conversation, so we gate only this path behind a second-press confirmation. // Typed `/agent`/`/new` and slash-menu execution stay single-step by design. if trigger.is_keybinding() && self.agent_view_controller.as_ref(ctx).is_active() { let should_start_new_conversation = self.agent_view_controller.update(ctx, |controller, ctx| { controller .should_start_new_conversation_for_keybinding(command.name, ctx) }); if !should_start_new_conversation { // Keep the current input/conversation untouched on first press; only the // ephemeral confirmation prompt should change. return true; } } let prompt = argument.and_then(|argument| { let trimmed = argument.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_owned()) } }); ctx.emit(Event::EnterAgentView { initial_prompt: prompt, conversation_id: None, origin: AgentViewEntryOrigin::SlashCommand { trigger }, }); } _cloud_agent if command.name == commands::CLOUD_AGENT.name => { let prompt = argument.and_then(|argument| { let trimmed = argument.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_owned()) } }); ctx.emit(Event::EnterCloudAgentView { initial_prompt: prompt, }); } _create_docker_sandbox if command.name == commands::CREATE_DOCKER_SANDBOX.name => { ctx.emit(Event::CreateDockerSandbox); } _ if command.name == commands::CONVERSATIONS.name => { if self.is_cloud_mode_input_v2_composing(ctx) { self.suggestions_mode_model.update(ctx, |model, ctx| { model.set_mode(InputSuggestionsMode::Closed, ctx); }); self.clear_buffer_and_reset_undo_stack(ctx); if let Some(view) = self.cloud_mode_v2_history_menu_view.clone() { view.update(ctx, |v, ctx| { v.arm_initial_buffer_sync(ctx); }); } ctx.dispatch_typed_action_deferred(InputAction::OpenInlineHistoryMenu); return true; } else if FeatureFlag::AgentView.is_enabled() { self.open_conversation_menu(ctx); } else { ctx.dispatch_typed_action(&TerminalAction::OpenConversationsPalette); } } _rename_tab if command.name == commands::RENAME_TAB.name => { let Some(name) = argument .map(|name| name.trim()) .filter(|name| !name.is_empty()) else { show_error_toast( "Please provide a tab name after /rename-tab".to_owned(), ctx, ); return true; }; ctx.dispatch_typed_action(&WorkspaceAction::SetActiveTabName(name.to_owned())); } _ if command.name == commands::RENAME_CONVERSATION.name => { let Some(conversation_id) = self .ai_context_model .as_ref(ctx) .selected_conversation_id(ctx) else { show_error_toast( "/rename-conversation requires an active conversation".to_owned(), ctx, ); return true; }; rename_conversation(conversation_id, argument.cloned().unwrap_or_default(), ctx); } _ if command.name == commands::SET_TAB_COLOR.name => { let supported_options = || { color_dot::TAB_COLOR_OPTIONS .iter() .map(|c| c.to_string().to_ascii_lowercase()) .chain(std::iter::once("none".to_owned())) .collect::>() .join(", ") }; let Some(arg) = argument .map(|name| name.trim()) .filter(|name| !name.is_empty()) else { show_error_toast( format!( "Please provide a color after /set-tab-color ({})", supported_options() ), ctx, ); return true; }; let color = if arg.eq_ignore_ascii_case("none") { SelectedTabColor::Cleared } else { let parsed = arg .parse::() .ok() .filter(|c| color_dot::TAB_COLOR_OPTIONS.contains(c)); match parsed { Some(c) => SelectedTabColor::Color(c), None => { show_error_toast( format!( "Unknown tab color '{arg}'. Use one of: {}.", supported_options() ), ctx, ); return true; } } }; ctx.dispatch_typed_action(&WorkspaceAction::SetActiveTabColor(color)); } _ if command.name == commands::CREATE_ENVIRONMENT.name => { // If the user included args after the slash command, treat them as repo paths/URLs. let repos = argument .map(|arg| { arg.split_whitespace() .filter(|s| !s.is_empty()) .map(|s| s.to_string()) .collect() }) .unwrap_or_default(); ctx.emit(Event::TriggerEnvironmentSetup { repos }); } _create_project if command.name == commands::CREATE_NEW_PROJECT.name => { if argument.is_none_or(|args| args.is_empty()) { show_error_toast( "Please describe the project you want to create after /create-new-project" .to_owned(), ctx, ); return true; } let args = argument.expect("args are Some()"); self.initiate_create_new_project(args.to_owned(), ctx); } _edit if command.name == commands::EDIT.name => { #[cfg(feature = "local_fs")] match argument { Some(args) if !args.is_empty() => { let Some(session_id) = self.active_block_session_id() else { return false; }; let Some(session) = self.sessions.as_ref(ctx).get(session_id) else { return false; }; if !session.is_local() { let window_id = ctx.window_id(); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { toast_stack.add_ephemeral_toast( DismissibleToast::error( "The /open-file command is only available for local sessions" .to_owned(), ), window_id, ctx, ); }); return false; } let current_dir = self .active_block_metadata .as_ref() .and_then(|metadata| metadata.current_working_directory()) .map(str::to_owned); let Some(current_dir) = current_dir else { return false; }; let (file_path, line_col) = open_file_command_path(&session, ¤t_dir, args); match std::fs::metadata(&file_path) { Ok(metadata) if metadata.is_file() => { use crate::util::file::external_editor; ctx.dispatch_typed_action(&TerminalAction::OpenCodeInWarp { path: file_path, layout: external_editor::settings::EditorLayout::SplitPane, line_col, }); } Ok(_) => { show_error_toast( "The /open-file command only works for files, not directories" .to_owned(), ctx, ); return true; } Err(_) => { show_error_toast( format!("File not found: {}", file_path.display()), ctx, ); return true; } } } _ => { use crate::server::telemetry::PaletteSource; ctx.emit(Event::OpenFilesPalette { source: PaletteSource::Keybinding, }); } } #[cfg(not(feature = "local_fs"))] { show_error_toast( "The /open-file command is not supported in this build".to_owned(), ctx, ); return true; } } _export_to_clipboard if command.name == commands::EXPORT_TO_CLIPBOARD.name => { let history = BlocklistAIHistoryModel::handle(ctx); let Some(conversation) = history .as_ref(ctx) .active_conversation(self.terminal_view_id) else { show_error_toast("No active conversation to export".to_owned(), ctx); return true; }; let action_model = self.ai_action_model.as_ref(ctx); let conversation_text = conversation.export_to_markdown(Some(action_model)); ctx.clipboard() .write(ClipboardContent::plain_text(conversation_text)); // Show a toast to confirm the export let window_id = ctx.window_id(); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { let toast = DismissibleToast::default(String::from( "Conversation exported to clipboard", )); toast_stack.add_ephemeral_toast(toast, window_id, ctx); }); } _export_to_file if command.name == commands::EXPORT_TO_FILE.name => { #[cfg(not(target_family = "wasm"))] { self.export_conversation_to_file( argument.map(|filename| filename.to_owned()), ctx, ); } #[cfg(target_family = "wasm")] { show_error_toast( "Export conversation to file unsupported in web".to_owned(), ctx, ); return true; } } _index if command.name == commands::INDEX.name => { ctx.dispatch_typed_action(&TerminalAction::IndexProjectSpeedbump); } _init if command.name == commands::INIT.name => { ctx.dispatch_typed_action(&TerminalAction::InitProject); } _changelog if command.name == commands::CHANGELOG.name => { if !FeatureFlag::Changelog.is_enabled() { return false; } ctx.dispatch_typed_action(&WorkspaceAction::ViewLatestChangelog); } _feedback if command.name == commands::FEEDBACK.name => { ctx.dispatch_typed_action(&WorkspaceAction::SendFeedback); } _open_code_review if command.name == commands::OPEN_CODE_REVIEW.name => { ctx.dispatch_typed_action(&TerminalAction::ToggleCodeReviewPane { entrypoint: CodeReviewPaneEntrypoint::SlashCommand, }); } _open_mcp_servers if command.name == commands::OPEN_MCP_SERVERS.name => { ctx.dispatch_typed_action(&TerminalAction::OpenViewMCPPane); } _open_settings_file if command.name == commands::OPEN_SETTINGS_FILE.name => { if !FeatureFlag::SettingsFile.is_enabled() || !cfg!(feature = "local_fs") { return false; } ctx.dispatch_typed_action(&WorkspaceAction::OpenSettingsFile); } _open_project_rules if command.name == commands::OPEN_PROJECT_RULES.name => { ctx.dispatch_typed_action(&TerminalAction::OpenProjectRulesPane); } _open_rules if command.name == commands::OPEN_RULES.name => { ctx.dispatch_typed_action(&TerminalAction::OpenRulesPane); } _edit_skill if command.name == commands::EDIT_SKILL.name => { if !FeatureFlag::ListSkills.is_enabled() { return false; } // Open the skill selector menu - user will select a skill from the inline menu self.open_skill_selector(ctx); } _invoke_skill if command.name == commands::INVOKE_SKILL.name => { if !FeatureFlag::ListSkills.is_enabled() { return false; } if self.is_cloud_mode_input_v2_composing(ctx) { self.apply_v2_slash_section_filter(CloudModeV2Section::Skills, ctx); return true; } // Open the skill selector menu for invocation - skill command will be inserted into buffer self.open_invoke_skill_selector(ctx); } _ if command.name == commands::HOST.name => { if !self.is_cloud_mode_input_v2_composing(ctx) { return false; } // Only open the host selector when a default host is configured. if self .host_selector() .is_none_or(|h| !h.as_ref(ctx).has_default_host()) { return false; } self.suggestions_mode_model.update(ctx, |model, ctx| { model.set_mode(InputSuggestionsMode::Closed, ctx); }); self.clear_buffer_and_reset_undo_stack(ctx); self.open_v2_host_selector(ctx); return true; } _ if command.name == commands::HARNESS.name => { if !self.is_cloud_mode_input_v2_composing(ctx) { // Defensive: the command is registered only when the V2 flag is on and its // availability requires CLOUD_MODE_V2_COMPOSER, so this branch should be unreachable. return false; } self.suggestions_mode_model.update(ctx, |model, ctx| { model.set_mode(InputSuggestionsMode::Closed, ctx); }); self.clear_buffer_and_reset_undo_stack(ctx); self.open_v2_harness_selector(ctx); return true; } _ if command.name == commands::ENVIRONMENT.name => { if !self.is_cloud_mode_input_v2_composing(ctx) { return false; } self.suggestions_mode_model.update(ctx, |model, ctx| { model.set_mode(InputSuggestionsMode::Closed, ctx); }); self.clear_buffer_and_reset_undo_stack(ctx); self.open_v2_environment_selector(ctx); return true; } _ if command.name == commands::MODEL.name => { if self.is_cloud_mode_input_v2_composing(ctx) { self.suggestions_mode_model.update(ctx, |model, ctx| { model.set_mode(InputSuggestionsMode::Closed, ctx); }); self.clear_buffer_and_reset_undo_stack(ctx); self.agent_input_footer.update(ctx, |footer, ctx| { footer.open_v2_model_selector(ctx); }); return true; } else if trigger.is_keybinding() { // A keybinding may carry a pre-existing prompt in the buffer; open // like the model chip so the prompt is parked for search and // restored when a model is selected (or the selector is dismissed). self.open_model_selector_and_snapshot_prompt( InlineModelSelectorTab::BaseAgent, ctx, ); } else { // Typed `/model`: the buffer holds the consumable command text. // Just switch into the model selector; `set_mode` snapshots the // buffer so it's restored on dismiss but cleared on selection. self.suggestions_mode_model.update(ctx, |model, ctx| { model.set_mode(InputSuggestionsMode::ModelSelector, ctx); }); ctx.notify(); } } _profiles if command.name == commands::PROFILE.name => { if !FeatureFlag::InlineProfileSelector.is_enabled() { return false; } self.open_profile_selector(ctx); } _ if command.name == commands::PROMPTS.name => { if self.is_cloud_mode_input_v2_composing(ctx) { self.apply_v2_slash_section_filter(CloudModeV2Section::Prompts, ctx); return true; } if FeatureFlag::AgentView.is_enabled() { self.open_prompts_menu(ctx); } else { return false; } } _rewind if command.name == commands::REWIND.name => { self.open_rewind_menu(ctx); } _pr_comments if command.name == commands::PR_COMMENTS.name => { if !FeatureFlag::PRCommentsSlashCommand.is_enabled() { return false; } let Some(repo_path) = self .active_session_path_if_local(ctx) .map(|path| path.to_path_buf()) .map(|path| path.to_string_lossy().to_string()) else { log::error!("Expected a valid working directory since /pr-comments is only available from the terminal"); return false; }; self.ai_controller.update(ctx, move |controller, ctx| { controller.send_slash_command_request( SlashCommandRequest::FetchReviewComments { repo_path }, ctx, ) }); } _usage if command.name == commands::USAGE.name => { ctx.dispatch_typed_action(&TerminalAction::OpenBillingAndUsagePane); } _cost if command.name == commands::COST.name => { let history = BlocklistAIHistoryModel::handle(ctx); let conversation = history .as_ref(ctx) .active_conversation(self.terminal_view_id); if conversation.is_none() { show_error_toast( "Cannot show conversation cost: no active conversation".to_owned(), ctx, ); } else if conversation.is_some_and(|c| c.is_empty()) { show_error_toast( "Cannot show conversation cost: conversation is empty".to_owned(), ctx, ); } else if conversation.is_some_and(|c| !c.status().is_done()) { show_error_toast( "Cannot show conversation cost: conversation is in progress".to_owned(), ctx, ); } else { ctx.dispatch_typed_action(&TerminalAction::ToggleUsageFooter); } } #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] _ if command.name == commands::MOVE_TO_CLOUD.name => { if !AISettings::as_ref(ctx).is_cloud_handoff_enabled(ctx) { return false; } let prompt = argument .map(|argument| argument.trim()) .filter(|argument| !argument.is_empty()) .map(str::to_owned); if let Some(prompt) = prompt { // `/handoff query` auto-submits, same as `& query`. let attachments = self.collect_cloud_launch_attachments(ctx); let launch = PendingCloudLaunch { prompt, attachments, }; ctx.dispatch_typed_action_deferred( WorkspaceAction::OpenLocalToCloudHandoffPane { launch: Some(launch), environment_id: None, entry_point: HandoffEntryPoint::SlashCommand, }, ); } else if self.source_conversation_has_content(ctx) { // Empty `/handoff` with a non-empty source conversation: // dispatch the immediate empty-prompt handoff (continue / // snapshot rehydration); the workspace synthesizes the // launch and collects attachments. ctx.dispatch_typed_action_deferred( WorkspaceAction::OpenLocalToCloudHandoffPane { launch: None, environment_id: None, entry_point: HandoffEntryPoint::SlashCommand, }, ); } else { // Empty `/handoff` with no source content — surface a toast // so the user knows why nothing happened. The chip falls // back to `&` compose mode here; the slash-command flow // does not because it has no compose-draft state to seed. show_error_toast( "Nothing to hand off — start a conversation first.".to_owned(), ctx, ); } } _ if command.name == commands::FORK.name => { let Some(conversation_id) = self .ai_context_model .as_ref(ctx) .selected_conversation_id(ctx) else { show_error_toast("/fork requires an active conversation".to_owned(), ctx); return true; }; let destination = ForkedConversationDestination::for_fork_trigger(trigger.is_cmd_or_ctrl_enter()); // Move any pending attachments out of the source input so they travel with the // initial prompt into the forked pane and no longer linger on the original input. // Only drain them when a non-empty prompt will actually be sent; the fork drops // attachments when there is no initial prompt, which would silently discard them. let initial_attachments = self.maybe_take_attachments_for_initial_prompt(argument, ctx); ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation { conversation_id, fork_from_exchange: None, summarize_after_fork: false, summarization_prompt: None, initial_prompt: argument.cloned(), initial_attachments, destination, }); } _fork_from if command.name == commands::FORK_FROM.name => { self.open_user_query_menu(UserQueryMenuAction::ForkFrom, ctx); return true; } #[cfg(not(target_family = "wasm"))] _ if command.name == commands::CONTINUE_LOCALLY.name => { let Some(conversation_id) = self .ai_context_model .as_ref(ctx) .selected_conversation_id(ctx) else { show_error_toast( "/continue-locally requires an active conversation".to_owned(), ctx, ); return true; }; if !conversation_is_cloud_oz_for_slash_command(conversation_id, ctx) { show_error_toast( "/continue-locally is only available for cloud Oz conversations".to_owned(), ctx, ); return true; } let destination = ForkedConversationDestination::for_fork_trigger(trigger.is_cmd_or_ctrl_enter()); send_telemetry_from_ctx!( AgentManagementTelemetryEvent::SlashCommandContinueLocally, ctx ); // Move any pending attachments out of the source input so they travel with the // initial prompt into the continued local pane and no longer linger on the // original input. Only drain them when a non-empty prompt will actually be sent; // the fork drops attachments when there is no initial prompt, which would // silently discard them. let initial_attachments = self.maybe_take_attachments_for_initial_prompt(argument, ctx); ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation { conversation_id, fork_from_exchange: None, summarize_after_fork: false, summarization_prompt: None, initial_prompt: argument.cloned(), initial_attachments, destination, }); } _ if command.name == commands::FORK_AND_COMPACT.name => { let Some(conversation_id) = self .ai_context_model .as_ref(ctx) .selected_conversation_id(ctx) else { show_error_toast( "/fork-and-compact requires an active conversation".to_owned(), ctx, ); return true; }; let destination = ForkedConversationDestination::for_fork_trigger(trigger.is_cmd_or_ctrl_enter()); ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation { conversation_id, fork_from_exchange: None, summarize_after_fork: true, summarization_prompt: None, initial_prompt: argument.cloned(), initial_attachments: vec![], destination, }); } _ if command.name == commands::COMPACT_AND.name => { let conversation_id = if is_queued_prompt { let Some(conversation_id) = queued_conversation_id else { log::error!("Queued /compact-and missing conversation id"); return true; }; conversation_id } else { let Some(conversation_id) = self .ai_context_model .as_ref(ctx) .selected_conversation_id(ctx) else { show_error_toast( "/compact-and requires an active conversation".to_owned(), ctx, ); return true; }; conversation_id }; if is_queued_prompt { let Some(queued_query_id) = queued_query_id else { log::error!("Queued /compact-and missing queued query id"); return true; }; self.execute_queued_compact_and( conversation_id, queued_query_id, argument.cloned(), ctx, ); } else { let summarize = WorkspaceAction::SummarizeAIConversation { prompt: None, initial_prompt: argument.cloned(), }; ctx.dispatch_typed_action(&summarize); } } _queue if command.name == commands::QUEUE.name => { let Some(conversation_id) = self .ai_context_model .as_ref(ctx) .selected_conversation_id(ctx) else { show_error_toast("/queue requires an active conversation".to_owned(), ctx); return true; }; let Some(prompt) = argument.filter(|a| !a.is_empty()).cloned() else { show_error_toast("/queue requires a prompt argument".to_owned(), ctx); return true; }; let history = BlocklistAIHistoryModel::handle(ctx); // An empty conversation defaults to `InProgress` even though nothing is // running, so exclude it here to auto-send rather than queue. let should_queue = history .as_ref(ctx) .conversation(&conversation_id) .is_some_and(|c| { !c.is_empty() && (c.status().is_in_progress() || c.status().is_blocked()) }); if should_queue { let attachments = self.ai_context_model.update(ctx, |context_model, ctx| { context_model.take_pending_attachments(ctx) }); QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| { model.append( conversation_id, QueuedQuery::new_with_attachments( prompt, QueuedQueryOrigin::QueueSlashCommand, attachments, ), ctx, ); }); } else { // Not in progress: submit immediately as a regular (non-queued) user query so // the live staging is sent and reset, rather than treated as a queued-row fire. self.submit_user_query_now(prompt, ctx); } } _open_repo if command.name == commands::OPEN_REPO.name => { if !FeatureFlag::InlineRepoMenu.is_enabled() { return false; } self.open_repos_menu(ctx); } _context if command.name == commands::CONTEXT.name => { // Debug command: dump the full context window (system prompt, rules, // messages) to the clipboard so the user can inspect what the model sees. use crate::ai::facts::{AIFact, AIMemory, CloudAIFactModel}; use crate::cloud_object::model::generic_string_model::GenericStringObjectId; use crate::cloud_object::model::persistence::CloudModel; let history = BlocklistAIHistoryModel::handle(ctx); // Extract data from conversation while the borrow is active, // then drop it before using ctx mutably. let context_data = { let Some(conversation) = history .as_ref(ctx) .active_conversation(self.terminal_view_id) else { show_error_toast("No active conversation.".to_owned(), ctx); return true; }; let summary = conversation.progressive_summary().map(str::to_string); let messages: Vec = conversation .bedrock_message_history() .iter() .enumerate() .map(|(i, msg)| { let content_str = match &msg.content { crate::ai::bedrock::convert::MessageContent::Text(t) => { if t.len() > 500 { format!("{}... ({} chars total)", &t[..500], t.len()) } else { t.clone() } } crate::ai::bedrock::convert::MessageContent::ToolUse { name, tool_use_id, .. } => { format!("ToolUse(name={name}, id={tool_use_id})") } crate::ai::bedrock::convert::MessageContent::ToolResult { tool_use_id, content, is_error, } => { let truncated = if content.len() > 200 { format!("{}...", &content[..200]) } else { content.clone() }; format!( "ToolResult(id={tool_use_id}, err={is_error}): {truncated}" ) } crate::ai::bedrock::convert::MessageContent::MultiPart(parts) => { format!("MultiPart({} parts)", parts.len()) } }; format!("[{i}] {:?}: {content_str}", msg.role) }) .collect(); let msg_count = messages.len(); (summary, messages, msg_count) }; let (summary, messages, msg_count) = context_data; // Global rules (no lifetime conflict since CloudModel is separate) let global_rules: Vec<(String, String)> = CloudModel::as_ref(ctx) .get_all_objects_of_type::() .filter_map(|fact| { let AIFact::Memory(AIMemory { name, content, .. }) = fact.model().string_model.clone(); if content.is_empty() { None } else { Some((name.unwrap_or_default(), content)) } }) .collect(); let mut output = String::new(); output.push_str("=== GLOBAL RULES ==="); output.push('\n'); if global_rules.is_empty() { output.push_str("(none)\n"); } else { for (name, content) in &global_rules { output.push_str(&format!("### {name}\n{content}\n\n")); } } output.push('\n'); output.push_str("=== PROGRESSIVE SUMMARY ==="); output.push('\n'); match &summary { Some(s) => { output.push_str(s); output.push('\n'); } None => output.push_str("(none)\n"), } output.push('\n'); output.push_str(&format!("=== MESSAGE HISTORY ({msg_count} messages) ===\n")); for line in &messages { output.push_str(line); output.push('\n'); } ctx.clipboard().write(ClipboardContent::plain_text(output)); let window_id = ctx.window_id(); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { let toast = DismissibleToast::default( "Full context has been copied to the clipboard.".to_string(), ); toast_stack.add_ephemeral_toast(toast, window_id, ctx); }); } _ if slash_command_is_submitted_as_prompt(command) => { // These slash commands just send AI requests with the slash command text as a // prefix, and special handling is done downstream as an implementation detail // of handling user queries with specific slash command prefixes. return false; } _ => { debug_assert!( false, "Attempted to execute slash command with no handler: {}", command.name ); return false; } } // Leave the buffer alone when re-sending a queued prompt (the user may have typed // new input while the agent was busy). if !is_queued_prompt { self.editor.update(ctx, |editor, ctx| { editor.clear_buffer(ctx); }); } // If the command must be executed in AI mode, and we're not already in an agent view, // enter the agent view. if FeatureFlag::AgentView.is_enabled() && command.auto_enter_ai_mode && !self.agent_view_controller.as_ref(ctx).is_active() { self.agent_view_controller.update(ctx, |controller, ctx| { let _ = controller.try_enter_agent_view( None, AgentViewEntryOrigin::SlashCommand { trigger: SlashCommandTrigger::input(), }, ctx, ); }); } let is_in_agent_view = FeatureFlag::AgentView.is_enabled() && self.agent_view_controller.as_ref(ctx).is_active(); send_telemetry_from_ctx!( TelemetryEvent::SlashCommandAccepted { command_details: SlashCommandAcceptedDetails::StaticCommand { command_name: command.name.to_owned(), }, is_in_agent_view, }, ctx ); true } /// Handles cmd+enter (Mac) / ctrl+enter (Linux/Windows) for slash commands. /// /// Returns `true` if the keypress was handled. pub(super) fn maybe_handle_cmd_or_ctrl_shift_enter_for_slash_command( &mut self, ctx: &mut ViewContext, ) -> bool { // If slash command menu is open, accept the selected item with cmd_or_ctrl_enter=true. if matches!( self.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::SlashCommands ) { if self.is_cloud_mode_input_v2_composing(ctx) { if let Some(view) = self.cloud_mode_v2_slash_commands_view.clone() { view.update(ctx, |view, ctx| { view.accept_selected_item(true, ctx); }); } } else { self.inline_slash_commands_view.update(ctx, |view, ctx| { view.accept_selected_item(true, ctx); }); } return true; } // If no menu but slash command detected in buffer, execute with cmd_or_ctrl_enter=true match self.slash_command_model.as_ref(ctx).state() { SlashCommandEntryState::SlashCommand(detected_command) => { let command = detected_command.command.clone(); let argument = detected_command.argument.clone(); if !self.is_slash_command_available(&command, ctx) { return false; } self.execute_slash_command( &command, argument.as_ref(), SlashCommandTrigger::cmd_or_ctrl_enter(), /*is_queued_prompt*/ false, None, None, ctx, ) } SlashCommandEntryState::SkillCommand(_) if self.is_cloud_mode_input_v2_composing(ctx) => { false } SlashCommandEntryState::SkillCommand(detected_skill) => { let reference = detected_skill.reference.clone(); let user_query = detected_skill.argument.clone(); self.execute_skill_command(reference, user_query, None, None, ctx) } SlashCommandEntryState::None | SlashCommandEntryState::Composing { .. } | SlashCommandEntryState::DisabledUntilEmptyBuffer => false, } } fn apply_v2_slash_section_filter( &mut self, section: CloudModeV2Section, ctx: &mut ViewContext, ) { self.editor.update(ctx, |editor, ctx| { editor.set_buffer_text("/", ctx); }); if let Some(view) = self.cloud_mode_v2_slash_commands_view.clone() { view.update(ctx, |v, ctx| { v.set_section_filter(Some(section), ctx); }); } } pub(super) fn maybe_clear_v2_slash_section_filter( &mut self, ctx: &mut ViewContext, ) -> bool { if !self.is_cloud_mode_input_v2_composing(ctx) { return false; } let Some(view) = self.cloud_mode_v2_slash_commands_view.clone() else { return false; }; let has_filter = view.as_ref(ctx).has_section_filter(); if !has_filter { return false; } view.update(ctx, |v, ctx| { v.set_section_filter(None, ctx); }); true } /// Executes a slash command on `enter` keypress. /// /// If the slash command menu is open, then "accepts" the slash command: /// * If the slash command does not take arguments, executes it /// * If the slash command does take arguments, inserts it into the input. /// /// If the slash command menu is not open, then "executes" the slash command in the input, if /// there is one. /// /// Returns `true` if the enter keypress was 'handled', else upstream enter keypress handling /// logic should continue. pub(super) fn maybe_handle_enter_for_slash_command( &mut self, ctx: &mut ViewContext, ) -> bool { if matches!( self.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::SlashCommands ) { if self.is_cloud_mode_input_v2_composing(ctx) { if let Some(view) = self.cloud_mode_v2_slash_commands_view.clone() { view.update(ctx, |view, ctx| { view.accept_selected_item(false, ctx); }); } } else { self.inline_slash_commands_view.update(ctx, |view, ctx| { view.accept_selected_item(false, ctx); }); } return true; } match self.slash_command_model.as_ref(ctx).state() { SlashCommandEntryState::SlashCommand(detected_command) => { let command = detected_command.command.clone(); let argument = detected_command.argument.clone(); if !self.is_slash_command_available(&command, ctx) { return false; } self.execute_slash_command( &command, argument.as_ref(), SlashCommandTrigger::input(), /*is_queued_prompt*/ false, None, None, ctx, ) } SlashCommandEntryState::SkillCommand(_) if self.is_cloud_mode_input_v2_composing(ctx) => { false } SlashCommandEntryState::SkillCommand(detected_skill) => { let reference = detected_skill.reference.clone(); let user_query = detected_skill.argument.clone(); self.execute_skill_command(reference, user_query, None, None, ctx) } SlashCommandEntryState::None | SlashCommandEntryState::Composing { .. } | SlashCommandEntryState::DisabledUntilEmptyBuffer => false, } } /// Drains pending attachments from the input's context model, but only when `argument` /// contains a non-empty prompt. Forked conversations drop attachments when there is no /// initial prompt to send, so draining them unconditionally would silently discard them; /// leaving them staged in the source input instead loses nothing. fn maybe_take_attachments_for_initial_prompt( &mut self, argument: Option<&String>, ctx: &mut ViewContext, ) -> Vec { if argument.is_none_or(|argument| argument.trim().is_empty()) { return Vec::new(); } self.ai_context_model.update(ctx, |context_model, ctx| { context_model.take_pending_attachments(ctx) }) } /// Sends a queued `/compact-and` summary and stores its follow-up on the original conversation. pub(super) fn execute_queued_compact_and( &mut self, conversation_id: AIConversationId, queued_query_id: QueuedQueryId, initial_prompt: Option, ctx: &mut ViewContext, ) { let followup_attachments = QueuedQueryModel::as_ref(ctx) .attachments_for(conversation_id, queued_query_id) .to_vec(); self.ai_controller.update(ctx, move |controller, ctx| { controller.send_queued_slash_command_request( SlashCommandRequest::Summarize { prompt: None }, queued_query_id, Some(conversation_id), ctx, ); }); let Some(initial_prompt) = initial_prompt.filter(|prompt| !prompt.trim().is_empty()) else { return; }; QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| { model.append( conversation_id, QueuedQuery::new_with_attachments( initial_prompt, QueuedQueryOrigin::CompactAndSlashCommand, followup_attachments, ), ctx, ) }); } } /// Whether executing the static slash `command` submits its text to the conversation as an AI /// prompt (handled downstream like a normal user query) rather than performing an immediate /// local action. /// /// This is the single source of truth for the "reiterated as a prompt vs handled immediately" /// distinction: only `/compact`, `/plan`, and `/orchestrate` are sent as prompts (mirroring the /// `command_that_just_sends_ai_request_with_prefix` arm in [`Input::execute_slash_command`]). /// Every other slash command emits an immediate action (forking, switching model, opening a /// menu, etc.), so callers gating prompt queuing or shared-session forwarding should treat those /// as "run now". pub(crate) fn slash_command_is_submitted_as_prompt(command: &StaticCommand) -> bool { command.name == commands::COMPACT.name || command.name == commands::PLAN.name || command.name == commands::ORCHESTRATE.name } /// Returns true when the conversation with `conversation_id` is associated with an Oz /// `AmbientAgentTask`. Callers deciding between `/fork` and `/continue-locally` should also /// check the same `CLOUD_AGENT` context that gates `/continue-locally`. #[cfg(not(target_family = "wasm"))] pub(crate) fn conversation_is_cloud_oz_for_slash_command( conversation_id: AIConversationId, ctx: &AppContext, ) -> bool { let history = BlocklistAIHistoryModel::as_ref(ctx); let Some(conversation) = history.conversation(&conversation_id) else { return false; }; let Some(task_id) = conversation.task_id() else { return false; }; let Some(task) = AgentConversationsModel::as_ref(ctx).get_task_data(&task_id) else { // Permissive: not yet fetched. Matches the data-source default so the command isn't // wrongly blocked while the task fetch is in flight. return true; }; match task .agent_config_snapshot .as_ref() .and_then(|s| s.harness.as_ref()) { Some(config) => config.harness_type == Harness::Oz, None => true, } } /// Tooltip and slash command name for the fork button, returned as a unit so /// callers rendering the button and callers inserting the command always agree. #[cfg(not(target_family = "wasm"))] pub(crate) struct ForkButtonAction { pub tooltip: &'static str, pub command_name: &'static str, } /// Returns the tooltip and slash command for the fork button given an optional /// conversation ID. Uses `/continue-locally` for Oz conversations when `/fork` /// is unavailable in the current cloud-agent context, and `/fork` otherwise. #[cfg(not(target_family = "wasm"))] pub(crate) fn fork_button_action( conversation_id: Option, is_cloud_agent_context: bool, ctx: &AppContext, ) -> ForkButtonAction { if is_cloud_agent_context && conversation_id.is_some_and(|id| conversation_is_cloud_oz_for_slash_command(id, ctx)) { ForkButtonAction { tooltip: "Continue locally", command_name: commands::CONTINUE_LOCALLY.name, } } else { ForkButtonAction { tooltip: "Fork conversation", command_name: commands::FORK.name, } } } #[cfg(test)] #[path = "mod_tests.rs"] mod tests;