diff --git a/Cargo.lock b/Cargo.lock index 02a2efe1737bb13a44d0fa3261be79c51b04a508..419379c21f29c9d68776ebd8e82ee17e5d5a21f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -408,6 +408,7 @@ dependencies = [ "streaming_diff", "task", "telemetry", + "tempfile", "terminal", "terminal_view", "text", diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 15c93dd4bfcaea715802323fd9a731eca274c6a2..39150df423b4db4bbdeba014c7f998e547678d1f 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -139,5 +139,6 @@ recent_projects = { workspace = true, features = ["test-support"] } title_bar = { workspace = true, features = ["test-support"] } semver.workspace = true reqwest_client.workspace = true +tempfile.workspace = true tree-sitter-md.workspace = true unindent.workspace = true diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs index 2d6b1e7148891020d77654f97ccc2e281557f384..329938c4415817ca33ac127c627503abb0632652 100644 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ b/crates/agent_ui/src/acp/entry_view_state.rs @@ -1,6 +1,7 @@ use std::{cell::RefCell, ops::Range, rc::Rc}; use super::thread_history::AcpThreadHistory; +use crate::user_slash_command::{CommandLoadError, UserSlashCommand}; use acp_thread::{AcpThread, AgentThreadEntry}; use agent::ThreadStore; use agent_client_protocol::{self as acp, ToolCallId}; @@ -30,6 +31,8 @@ pub struct EntryViewState { entries: Vec, prompt_capabilities: Rc>, available_commands: Rc>>, + cached_user_commands: Rc>>, + cached_user_command_errors: Rc>>, agent_name: SharedString, } @@ -42,6 +45,8 @@ impl EntryViewState { prompt_store: Option>, prompt_capabilities: Rc>, available_commands: Rc>>, + cached_user_commands: Rc>>, + cached_user_command_errors: Rc>>, agent_name: SharedString, ) -> Self { Self { @@ -53,6 +58,8 @@ impl EntryViewState { entries: Vec::new(), prompt_capabilities, available_commands, + cached_user_commands, + cached_user_command_errors, agent_name, } } @@ -86,7 +93,7 @@ impl EntryViewState { } } else { let message_editor = cx.new(|cx| { - let mut editor = MessageEditor::new( + let mut editor = MessageEditor::new_with_cache( self.workspace.clone(), self.project.clone(), self.thread_store.clone(), @@ -94,6 +101,8 @@ impl EntryViewState { self.prompt_store.clone(), self.prompt_capabilities.clone(), self.available_commands.clone(), + self.cached_user_commands.clone(), + self.cached_user_command_errors.clone(), self.agent_name.clone(), "Edit message - @ to include context", editor::EditorMode::AutoHeight { @@ -469,6 +478,8 @@ mod tests { None, Default::default(), Default::default(), + Default::default(), + Default::default(), "Test Agent".into(), ) }); diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index c3a17a25986b80d7855af45a4dccc3dfe93fff39..3c919a50f6f2de307b3d958ea0a214fdf7de54ef 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -9,6 +9,7 @@ use crate::{ mention_set::{ Mention, MentionImage, MentionSet, insert_crease_for_mention, paste_images_as_context, }, + user_slash_command::{self, CommandLoadError, UserSlashCommand}, }; use acp_thread::{AgentSessionInfo, MentionUri}; use agent::ThreadStore; @@ -21,6 +22,7 @@ use editor::{ MultiBufferSnapshot, ToOffset, actions::Paste, code_context_menus::CodeContextMenu, scroll::Autoscroll, }; +use feature_flags::{FeatureFlagAppExt as _, UserSlashCommandsFeatureFlag}; use futures::{FutureExt as _, future::join_all}; use gpui::{ AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageFormat, @@ -38,12 +40,25 @@ use util::{ResultExt, debug_panic}; use workspace::{CollaboratorId, Workspace}; use zed_actions::agent::{Chat, PasteRaw}; +enum UserSlashCommands { + Cached { + commands: collections::HashMap, + errors: Vec, + }, + FromFs { + fs: Arc, + worktree_roots: Vec, + }, +} + pub struct MessageEditor { mention_set: Entity, editor: Entity, workspace: WeakEntity, prompt_capabilities: Rc>, available_commands: Rc>>, + cached_user_commands: Rc>>, + cached_user_command_errors: Rc>>, agent_name: SharedString, thread_store: Option>, _subscriptions: Vec, @@ -92,6 +107,7 @@ impl PromptCompletionProviderDelegate for Entity { name: cmd.name.clone().into(), description: cmd.description.clone().into(), requires_argument: cmd.input.is_some(), + source: crate::completion_provider::CommandSource::Server, }) .collect() } @@ -99,6 +115,27 @@ impl PromptCompletionProviderDelegate for Entity { fn confirm_command(&self, cx: &mut App) { self.update(cx, |this, cx| this.send(cx)); } + + fn cached_user_commands( + &self, + cx: &App, + ) -> Option> { + let commands = self.read(cx).cached_user_commands.borrow(); + if commands.is_empty() { + None + } else { + Some(commands.clone()) + } + } + + fn cached_user_command_errors(&self, cx: &App) -> Option> { + let errors = self.read(cx).cached_user_command_errors.borrow(); + if errors.is_empty() { + None + } else { + Some(errors.clone()) + } + } } impl MessageEditor { @@ -115,6 +152,42 @@ impl MessageEditor { mode: EditorMode, window: &mut Window, cx: &mut Context, + ) -> Self { + let cached_user_commands = Rc::new(RefCell::new(collections::HashMap::default())); + let cached_user_command_errors = Rc::new(RefCell::new(Vec::new())); + Self::new_with_cache( + workspace, + project, + thread_store, + history, + prompt_store, + prompt_capabilities, + available_commands, + cached_user_commands, + cached_user_command_errors, + agent_name, + placeholder, + mode, + window, + cx, + ) + } + + pub fn new_with_cache( + workspace: WeakEntity, + project: WeakEntity, + thread_store: Option>, + history: WeakEntity, + prompt_store: Option>, + prompt_capabilities: Rc>, + available_commands: Rc>>, + cached_user_commands: Rc>>, + cached_user_command_errors: Rc>>, + agent_name: SharedString, + placeholder: &str, + mode: EditorMode, + window: &mut Window, + cx: &mut Context, ) -> Self { let language = Language::new( language::LanguageConfig { @@ -220,6 +293,8 @@ impl MessageEditor { workspace, prompt_capabilities, available_commands, + cached_user_commands, + cached_user_command_errors, agent_name, thread_store, _subscriptions: subscriptions, @@ -389,14 +464,46 @@ impl MessageEditor { full_mention_content: bool, cx: &mut Context, ) -> Task, Vec>)>> { - // Check for unsupported slash commands before spawning async task + self.contents_with_cache(full_mention_content, None, None, cx) + } + + pub fn contents_with_cache( + &self, + full_mention_content: bool, + cached_user_commands: Option< + collections::HashMap, + >, + cached_user_command_errors: Option>, + cx: &mut Context, + ) -> Task, Vec>)>> { let text = self.editor.read(cx).text(cx); let available_commands = self.available_commands.borrow().clone(); - if let Err(err) = - Self::validate_slash_commands(&text, &available_commands, &self.agent_name) - { - return Task::ready(Err(err)); - } + let agent_name = self.agent_name.clone(); + + let user_slash_commands = if !cx.has_flag::() { + UserSlashCommands::Cached { + commands: collections::HashMap::default(), + errors: Vec::new(), + } + } else if let Some(cached) = cached_user_commands { + UserSlashCommands::Cached { + commands: cached, + errors: cached_user_command_errors.unwrap_or_default(), + } + } else if let Some(workspace) = self.workspace.upgrade() { + let fs = workspace.read(cx).project().read(cx).fs().clone(); + let worktree_roots: Vec = workspace + .read(cx) + .visible_worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) + .collect(); + UserSlashCommands::FromFs { fs, worktree_roots } + } else { + UserSlashCommands::Cached { + commands: collections::HashMap::default(), + errors: Vec::new(), + } + }; let contents = self .mention_set @@ -405,6 +512,59 @@ impl MessageEditor { let supports_embedded_context = self.prompt_capabilities.borrow().embedded_context; cx.spawn(async move |_, cx| { + let (mut user_commands, mut user_command_errors) = match user_slash_commands { + UserSlashCommands::Cached { commands, errors } => (commands, errors), + UserSlashCommands::FromFs { fs, worktree_roots } => { + let load_result = + user_slash_command::load_all_commands_async(&fs, &worktree_roots).await; + + ( + user_slash_command::commands_to_map(&load_result.commands), + load_result.errors, + ) + } + }; + + let server_command_names = available_commands + .iter() + .map(|command| command.name.clone()) + .collect::>(); + user_slash_command::apply_server_command_conflicts_to_map( + &mut user_commands, + &mut user_command_errors, + &server_command_names, + ); + + // Check if the user is trying to use an errored slash command. + // If so, report the error to the user. + if let Some(parsed) = user_slash_command::try_parse_user_command(&text) { + for error in &user_command_errors { + if let Some(error_cmd_name) = error.command_name() { + if error_cmd_name == parsed.name { + return Err(anyhow::anyhow!( + "Failed to load /{}: {}", + parsed.name, + error.message + )); + } + } + } + } + // Errors for commands that don't match the user's input are silently ignored here, + // since the user will see them via the error callout in the thread view. + + // Check if this is a user-defined slash command and expand it + match user_slash_command::try_expand_from_commands(&text, &user_commands) { + Ok(Some(expanded)) => return Ok((vec![expanded.into()], Vec::new())), + Err(err) => return Err(err), + Ok(None) => {} // Not a user command, continue with normal processing + } + + if let Err(err) = Self::validate_slash_commands(&text, &available_commands, &agent_name) + { + return Err(err); + } + let contents = contents.await?; let mut all_tracked_buffers = Vec::new(); @@ -1141,6 +1301,7 @@ mod tests { use agent::{ThreadStore, outline}; use agent_client_protocol as acp; use editor::{AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset}; + use fs::FakeFs; use futures::StreamExt as _; use gpui::{ @@ -1150,6 +1311,7 @@ mod tests { use lsp::{CompletionContext, CompletionTriggerKind}; use project::{CompletionIntent, Project, ProjectPath}; use serde_json::json; + use text::Point; use ui::{App, Context, IntoElement, Render, SharedString, Window}; use util::{path, paths::PathStyle, rel_path::rel_path}; @@ -1178,7 +1340,7 @@ mod tests { let message_editor = cx.update(|window, cx| { cx.new(|cx| { - MessageEditor::new( + MessageEditor::new_with_cache( workspace.downgrade(), project.downgrade(), thread_store.clone(), @@ -1186,6 +1348,8 @@ mod tests { None, Default::default(), Default::default(), + Default::default(), + Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -1253,7 +1417,9 @@ mod tests { }); let (content, _) = message_editor - .update(cx, |message_editor, cx| message_editor.contents(false, cx)) + .update(cx, |message_editor, cx| { + message_editor.contents_with_cache(false, None, None, cx) + }) .await .unwrap(); @@ -1291,7 +1457,7 @@ mod tests { let workspace_handle = workspace.downgrade(); let message_editor = workspace.update_in(cx, |_, window, cx| { cx.new(|cx| { - MessageEditor::new( + MessageEditor::new_with_cache( workspace_handle.clone(), project.downgrade(), thread_store.clone(), @@ -1299,6 +1465,8 @@ mod tests { None, prompt_capabilities.clone(), available_commands.clone(), + Default::default(), + Default::default(), "Claude Code".into(), "Test", EditorMode::AutoHeight { @@ -1318,7 +1486,9 @@ mod tests { }); let contents_result = message_editor - .update(cx, |message_editor, cx| message_editor.contents(false, cx)) + .update(cx, |message_editor, cx| { + message_editor.contents_with_cache(false, None, None, cx) + }) .await; // Should fail because available_commands is empty (no commands supported) @@ -1336,7 +1506,9 @@ mod tests { }); let contents_result = message_editor - .update(cx, |message_editor, cx| message_editor.contents(false, cx)) + .update(cx, |message_editor, cx| { + message_editor.contents_with_cache(false, None, None, cx) + }) .await; assert!(contents_result.is_err()); @@ -1351,7 +1523,9 @@ mod tests { }); let contents_result = message_editor - .update(cx, |message_editor, cx| message_editor.contents(false, cx)) + .update(cx, |message_editor, cx| { + message_editor.contents_with_cache(false, None, None, cx) + }) .await; // Should succeed because /help is in available_commands @@ -1363,7 +1537,9 @@ mod tests { }); let (content, _) = message_editor - .update(cx, |message_editor, cx| message_editor.contents(false, cx)) + .update(cx, |message_editor, cx| { + message_editor.contents_with_cache(false, None, None, cx) + }) .await .unwrap(); @@ -1381,7 +1557,9 @@ mod tests { // The @ mention functionality should not be affected let (content, _) = message_editor - .update(cx, |message_editor, cx| message_editor.contents(false, cx)) + .update(cx, |message_editor, cx| { + message_editor.contents_with_cache(false, None, None, cx) + }) .await .unwrap(); @@ -1454,7 +1632,7 @@ mod tests { let editor = workspace.update_in(&mut cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); let message_editor = cx.new(|cx| { - MessageEditor::new( + MessageEditor::new_with_cache( workspace_handle, project.downgrade(), thread_store.clone(), @@ -1462,6 +1640,8 @@ mod tests { None, prompt_capabilities.clone(), available_commands.clone(), + Default::default(), + Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -1678,7 +1858,7 @@ mod tests { let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); let message_editor = cx.new(|cx| { - MessageEditor::new( + MessageEditor::new_with_cache( workspace_handle, project.downgrade(), Some(thread_store), @@ -1686,6 +1866,8 @@ mod tests { None, prompt_capabilities.clone(), Default::default(), + Default::default(), + Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -2171,7 +2353,7 @@ mod tests { let message_editor = cx.update(|window, cx| { cx.new(|cx| { - let editor = MessageEditor::new( + let editor = MessageEditor::new_with_cache( workspace.downgrade(), project.downgrade(), thread_store.clone(), @@ -2179,6 +2361,8 @@ mod tests { None, Default::default(), Default::default(), + Default::default(), + Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -2280,7 +2464,7 @@ mod tests { let message_editor = cx.update(|window, cx| { cx.new(|cx| { - let mut editor = MessageEditor::new( + let mut editor = MessageEditor::new_with_cache( workspace.downgrade(), project.downgrade(), thread_store.clone(), @@ -2288,6 +2472,8 @@ mod tests { None, Default::default(), Default::default(), + Default::default(), + Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -2360,7 +2546,7 @@ mod tests { let message_editor = cx.update(|window, cx| { cx.new(|cx| { - let mut editor = MessageEditor::new( + let mut editor = MessageEditor::new_with_cache( workspace.downgrade(), project.downgrade(), thread_store.clone(), @@ -2368,6 +2554,8 @@ mod tests { None, Default::default(), Default::default(), + Default::default(), + Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -2411,7 +2599,7 @@ mod tests { let message_editor = cx.update(|window, cx| { cx.new(|cx| { - MessageEditor::new( + MessageEditor::new_with_cache( workspace.downgrade(), project.downgrade(), thread_store.clone(), @@ -2419,6 +2607,8 @@ mod tests { None, Default::default(), Default::default(), + Default::default(), + Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -2465,7 +2655,7 @@ mod tests { let message_editor = cx.update(|window, cx| { cx.new(|cx| { - MessageEditor::new( + MessageEditor::new_with_cache( workspace.downgrade(), project.downgrade(), thread_store.clone(), @@ -2473,6 +2663,8 @@ mod tests { None, Default::default(), Default::default(), + Default::default(), + Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -2520,7 +2712,7 @@ mod tests { let message_editor = cx.update(|window, cx| { cx.new(|cx| { - MessageEditor::new( + MessageEditor::new_with_cache( workspace.downgrade(), project.downgrade(), thread_store.clone(), @@ -2528,6 +2720,8 @@ mod tests { None, Default::default(), Default::default(), + Default::default(), + Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -2548,7 +2742,9 @@ mod tests { }); let (content, _) = message_editor - .update(cx, |message_editor, cx| message_editor.contents(false, cx)) + .update(cx, |message_editor, cx| { + message_editor.contents_with_cache(false, None, None, cx) + }) .await .unwrap(); @@ -2585,7 +2781,7 @@ mod tests { let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); let message_editor = cx.new(|cx| { - MessageEditor::new( + MessageEditor::new_with_cache( workspace_handle, project.downgrade(), thread_store.clone(), @@ -2593,6 +2789,8 @@ mod tests { None, Default::default(), Default::default(), + Default::default(), + Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -2627,7 +2825,9 @@ mod tests { }); let content = message_editor - .update(cx, |editor, cx| editor.contents(false, cx)) + .update(cx, |editor, cx| { + editor.contents_with_cache(false, None, None, cx) + }) .await .unwrap() .0; @@ -2654,7 +2854,9 @@ mod tests { }); let content = message_editor - .update(cx, |editor, cx| editor.contents(false, cx)) + .update(cx, |editor, cx| { + editor.contents_with_cache(false, None, None, cx) + }) .await .unwrap() .0; @@ -2745,7 +2947,7 @@ mod tests { let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); let message_editor = cx.new(|cx| { - MessageEditor::new( + MessageEditor::new_with_cache( workspace_handle, project.downgrade(), thread_store.clone(), @@ -2753,6 +2955,8 @@ mod tests { None, Default::default(), Default::default(), + Default::default(), + Default::default(), "Test Agent".into(), "Test", EditorMode::full(), diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 13c5080136e0e1fefe0e407b5f4ac773a92f4b1a..ceb320321d8326f8330304f185061e58ca520e1d 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -20,7 +20,10 @@ use editor::scroll::Autoscroll; use editor::{ Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, SizingBehavior, }; -use feature_flags::{AgentSharingFeatureFlag, AgentV2FeatureFlag, FeatureFlagAppExt}; +use feature_flags::{ + AgentSharingFeatureFlag, AgentV2FeatureFlag, FeatureFlagAppExt as _, + UserSlashCommandsFeatureFlag, +}; use file_icons::FileIcons; use fs::Fs; use futures::FutureExt as _; @@ -55,7 +58,9 @@ use ui::{ }; use util::defer; use util::{ResultExt, size::format_file_size, time::duration_alt_display}; -use workspace::{CollaboratorId, NewTerminal, Toast, Workspace, notifications::NotificationId}; +use workspace::{ + CollaboratorId, NewTerminal, OpenOptions, Toast, Workspace, notifications::NotificationId, +}; use zed_actions::agent::{Chat, ToggleModelSelector}; use zed_actions::assistant::OpenRulesLibrary; @@ -69,6 +74,9 @@ use crate::acp::message_editor::{MessageEditor, MessageEditorEvent}; use crate::agent_diff::AgentDiff; use crate::profile_selector::{ProfileProvider, ProfileSelector}; use crate::ui::{AgentNotification, AgentNotificationEvent}; +use crate::user_slash_command::{ + self, CommandLoadError, SlashCommandRegistry, SlashCommandRegistryEvent, UserSlashCommand, +}; use crate::{ AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, AuthorizeToolCall, ClearMessageQueue, CycleFavoriteModels, CycleModeSelector, EditFirstQueuedMessage, ExpandMessageEditor, Follow, @@ -324,6 +332,9 @@ pub struct AcpThreadView { thread_retry_status: Option, thread_error: Option, thread_error_markdown: Option>, + command_load_errors: Vec, + command_load_errors_dismissed: bool, + slash_command_registry: Option>, token_limit_callout_dismissed: bool, thread_feedback: ThreadFeedbackState, list_state: ListState, @@ -347,6 +358,8 @@ pub struct AcpThreadView { discarded_partial_edits: HashSet, prompt_capabilities: Rc>, available_commands: Rc>>, + cached_user_commands: Rc>>, + cached_user_command_errors: Rc>>, is_loading_contents: bool, new_server_version_available: Option, resume_thread_metadata: Option, @@ -406,6 +419,9 @@ impl AcpThreadView { ) -> Self { let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let available_commands = Rc::new(RefCell::new(vec![])); + let cached_user_commands = Rc::new(RefCell::new(collections::HashMap::default())); + let cached_user_command_errors = Rc::new(RefCell::new(Vec::new())); + let mut command_load_errors = Vec::new(); let agent_server_store = project.read(cx).agent_server_store().clone(); let agent_display_name = agent_server_store @@ -416,7 +432,7 @@ impl AcpThreadView { let placeholder = placeholder_text(agent_display_name.as_ref(), false); let message_editor = cx.new(|cx| { - let mut editor = MessageEditor::new( + let mut editor = MessageEditor::new_with_cache( workspace.clone(), project.downgrade(), thread_store.clone(), @@ -424,6 +440,8 @@ impl AcpThreadView { prompt_store.clone(), prompt_capabilities.clone(), available_commands.clone(), + cached_user_commands.clone(), + cached_user_command_errors.clone(), agent.name(), &placeholder, editor::EditorMode::AutoHeight { @@ -450,6 +468,8 @@ impl AcpThreadView { prompt_store.clone(), prompt_capabilities.clone(), available_commands.clone(), + cached_user_commands.clone(), + cached_user_command_errors.clone(), agent.name(), ) }); @@ -481,6 +501,46 @@ impl AcpThreadView { && project.read(cx).is_local() && agent.clone().downcast::().is_some(); + // Create SlashCommandRegistry to cache user-defined slash commands and watch for changes + let slash_command_registry = if cx.has_flag::() { + let fs = project.read(cx).fs().clone(); + let worktree_roots: Vec = project + .read(cx) + .visible_worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) + .collect(); + let registry = cx.new(|cx| SlashCommandRegistry::new(fs, worktree_roots, cx)); + + // Subscribe to registry changes to update error display and cached commands + cx.subscribe(®istry, move |this, registry, event, cx| match event { + SlashCommandRegistryEvent::CommandsChanged => { + this.refresh_cached_user_commands_from_registry(®istry, cx); + } + }) + .detach(); + + // Initialize cached commands and errors from registry + let mut commands = registry.read(cx).commands().clone(); + let mut errors = registry.read(cx).errors().to_vec(); + let server_command_names = available_commands + .borrow() + .iter() + .map(|command| command.name.clone()) + .collect::>(); + user_slash_command::apply_server_command_conflicts_to_map( + &mut commands, + &mut errors, + &server_command_names, + ); + command_load_errors = errors.clone(); + *cached_user_commands.borrow_mut() = commands; + *cached_user_command_errors.borrow_mut() = errors; + + Some(registry) + } else { + None + }; + let recent_history_entries = history.read(cx).get_recent_sessions(3); let history_subscription = cx.observe(&history, |this, history, cx| { this.update_recent_history_from_cache(&history, cx); @@ -514,6 +574,9 @@ impl AcpThreadView { thread_retry_status: None, thread_error: None, thread_error_markdown: None, + command_load_errors, + command_load_errors_dismissed: false, + slash_command_registry, token_limit_callout_dismissed: false, thread_feedback: Default::default(), auth_task: None, @@ -532,6 +595,8 @@ impl AcpThreadView { discarded_partial_edits: HashSet::default(), prompt_capabilities, available_commands, + cached_user_commands, + cached_user_command_errors, editor_expanded: false, should_be_following: false, recent_history_entries, @@ -570,6 +635,7 @@ impl AcpThreadView { cx, ); self.available_commands.replace(vec![]); + self.refresh_cached_user_commands(cx); self.new_server_version_available.take(); self.recent_history_entries.clear(); self.turn_tokens = None; @@ -1473,8 +1539,15 @@ impl AcpThreadView { .is_some_and(|profile| profile.tools.is_empty()) }); + let cached_commands = self.cached_slash_commands(cx); + let cached_errors = self.cached_slash_command_errors(cx); let contents = message_editor.update(cx, |message_editor, cx| { - message_editor.contents(full_mention_content, cx) + message_editor.contents_with_cache( + full_mention_content, + Some(cached_commands), + Some(cached_errors), + cx, + ) }); self.thread_error.take(); @@ -1635,8 +1708,15 @@ impl AcpThreadView { .is_some_and(|profile| profile.tools.is_empty()) }); + let cached_commands = self.cached_slash_commands(cx); + let cached_errors = self.cached_slash_command_errors(cx); let contents = self.message_editor.update(cx, |message_editor, cx| { - message_editor.contents(full_mention_content, cx) + message_editor.contents_with_cache( + full_mention_content, + Some(cached_commands), + Some(cached_errors), + cx, + ) }); let message_editor = self.message_editor.clone(); @@ -1998,6 +2078,7 @@ impl AcpThreadView { let has_commands = !available_commands.is_empty(); self.available_commands.replace(available_commands); + self.refresh_cached_user_commands(cx); let agent_display_name = self .agent_server_store @@ -7615,6 +7696,156 @@ impl AcpThreadView { ) } + fn render_command_load_errors(&self, cx: &mut Context) -> Option { + if self.command_load_errors_dismissed || self.command_load_errors.is_empty() { + return None; + } + + let error_count = self.command_load_errors.len(); + let title = if error_count == 1 { + "Failed to load slash command" + } else { + "Failed to load slash commands" + }; + + let workspace = self.workspace.clone(); + + Some( + v_flex() + .w_full() + .p_2() + .gap_1() + .border_t_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().surface_background) + .child( + h_flex() + .justify_between() + .child( + h_flex() + .gap_1() + .child( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + .child( + Label::new(title) + .size(LabelSize::Small) + .color(Color::Warning), + ), + ) + .child( + IconButton::new("dismiss-command-errors", IconName::Close) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Dismiss")) + .on_click(cx.listener(|this, _, _, cx| { + this.clear_command_load_errors(cx); + })), + ), + ) + .children(self.command_load_errors.iter().enumerate().map({ + move |(i, error)| { + let path = error.path.clone(); + let workspace = workspace.clone(); + let file_name = error + .path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| error.path.display().to_string()); + + h_flex() + .id(ElementId::Name(format!("command-error-{i}").into())) + .gap_1() + .px_1() + .py_0p5() + .rounded_sm() + .cursor_pointer() + .hover(|style| style.bg(cx.theme().colors().element_hover)) + .tooltip(Tooltip::text(format!( + "Click to open {}\n\n{}", + error.path.display(), + error.message + ))) + .on_click({ + move |_, window, cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace + .open_abs_path( + path.clone(), + OpenOptions::default(), + window, + cx, + ) + .detach_and_log_err(cx); + }); + } + } + }) + .child( + Label::new(format!("• {}: {}", file_name, error.message)) + .size(LabelSize::Small) + .color(Color::Muted), + ) + } + })), + ) + } + + fn clear_command_load_errors(&mut self, cx: &mut Context) { + self.command_load_errors_dismissed = true; + cx.notify(); + } + + fn refresh_cached_user_commands(&mut self, cx: &mut Context) { + let Some(registry) = self.slash_command_registry.clone() else { + return; + }; + self.refresh_cached_user_commands_from_registry(®istry, cx); + } + + fn refresh_cached_user_commands_from_registry( + &mut self, + registry: &Entity, + cx: &mut Context, + ) { + let (mut commands, mut errors) = registry.read_with(cx, |registry, _| { + (registry.commands().clone(), registry.errors().to_vec()) + }); + let server_command_names = self + .available_commands + .borrow() + .iter() + .map(|command| command.name.clone()) + .collect::>(); + user_slash_command::apply_server_command_conflicts_to_map( + &mut commands, + &mut errors, + &server_command_names, + ); + + self.command_load_errors = errors.clone(); + self.command_load_errors_dismissed = false; + *self.cached_user_commands.borrow_mut() = commands; + *self.cached_user_command_errors.borrow_mut() = errors; + cx.notify(); + } + + /// Returns the cached slash commands, if available. + pub fn cached_slash_commands( + &self, + _cx: &App, + ) -> collections::HashMap { + self.cached_user_commands.borrow().clone() + } + + /// Returns the cached slash command errors, if available. + pub fn cached_slash_command_errors(&self, _cx: &App) -> Vec { + self.cached_user_command_errors.borrow().clone() + } + fn render_thread_error(&mut self, window: &mut Window, cx: &mut Context) -> Option
{ let content = match self.thread_error.as_ref()? { ThreadError::Other(error) => self.render_any_thread_error(error.clone(), window, cx), @@ -8193,6 +8424,7 @@ impl Render for AcpThreadView { .when(self.show_codex_windows_warning, |this| { this.child(self.render_codex_windows_warning(cx)) }) + .children(self.render_command_load_errors(cx)) .children(self.render_thread_error(window, cx)) .when_some( self.new_server_version_available.as_ref().filter(|_| { diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 5350a16c2ca22b315636faa3e2909a776c29cea6..fdde112782bd25c07df4e2ee4603e3cd3fa27a46 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -21,6 +21,7 @@ mod terminal_inline_assistant; mod text_thread_editor; mod text_thread_history; mod ui; +mod user_slash_command; use std::rc::Rc; use std::sync::Arc; diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index 0e97afade70c2ca2346caad4abdeac27b197406e..d0479a834423d287f01414d3025304e9fc7d07d4 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -5,11 +5,14 @@ use std::sync::Arc; use std::sync::atomic::AtomicBool; use crate::acp::AcpThreadHistory; +use crate::user_slash_command::{self, CommandLoadError, UserSlashCommand}; use acp_thread::{AgentSessionInfo, MentionUri}; use anyhow::Result; +use collections::{HashMap, HashSet}; use editor::{ CompletionProvider, Editor, ExcerptId, code_context_menus::COMPLETION_MENU_MAX_WIDTH, }; +use feature_flags::{FeatureFlagAppExt as _, UserSlashCommandsFeatureFlag}; use fuzzy::{PathMatch, StringMatch, StringMatchCandidate}; use gpui::{App, BackgroundExecutor, Entity, SharedString, Task, WeakEntity}; use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId}; @@ -23,6 +26,7 @@ use project::{ use prompt_store::{PromptStore, UserPromptId}; use rope::Point; use text::{Anchor, ToPoint as _}; +use ui::IconName; use ui::prelude::*; use util::ResultExt as _; use util::paths::PathStyle; @@ -182,6 +186,18 @@ pub struct AvailableCommand { pub name: Arc, pub description: Arc, pub requires_argument: bool, + pub source: CommandSource, +} + +/// The source of a slash command, used to differentiate UI behavior. +#[derive(Debug, Clone, PartialEq)] +pub enum CommandSource { + /// Command provided by the ACP server + Server, + /// User-defined command from a markdown file + UserDefined { template: Arc }, + /// User-defined command that failed to load + UserDefinedError { error_message: Arc }, } pub trait PromptCompletionProviderDelegate: Send + Sync + 'static { @@ -193,6 +209,18 @@ pub trait PromptCompletionProviderDelegate: Send + Sync + 'static { fn available_commands(&self, cx: &App) -> Vec; fn confirm_command(&self, cx: &mut App); + + /// Returns cached user-defined slash commands, if available. + /// Default implementation returns None, meaning commands will be loaded from disk. + fn cached_user_commands(&self, _cx: &App) -> Option> { + None + } + + /// Returns cached errors from loading user-defined slash commands, if available. + /// Default implementation returns None. + fn cached_user_command_errors(&self, _cx: &App) -> Option> { + None + } } pub struct PromptCompletionProvider { @@ -687,11 +715,111 @@ impl PromptCompletionProvider { fn search_slash_commands(&self, query: String, cx: &mut App) -> Task> { let commands = self.source.available_commands(cx); - if commands.is_empty() { - return Task::ready(Vec::new()); - } + let server_command_names = commands + .iter() + .map(|command| command.name.as_ref().to_string()) + .collect::>(); + + // Try to use cached user commands and errors first + let cached_user_commands = if cx.has_flag::() { + self.source.cached_user_commands(cx) + } else { + None + }; + + let cached_user_command_errors = if cx.has_flag::() { + self.source.cached_user_command_errors(cx) + } else { + None + }; + + // Get fs and worktree roots for async command loading (only if not cached) + let (fs, worktree_roots) = + if cached_user_commands.is_none() && cx.has_flag::() { + let workspace = self.workspace.upgrade(); + let fs = workspace + .as_ref() + .map(|w| w.read(cx).project().read(cx).fs().clone()); + let roots: Vec = workspace + .map(|workspace| { + workspace + .read(cx) + .visible_worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) + .collect() + }) + .unwrap_or_default(); + (fs, roots) + } else { + (None, Vec::new()) + }; cx.spawn(async move |cx| { + let mut commands = commands; + + // Use cached commands/errors if available, otherwise load from disk + let (mut user_commands, mut user_command_errors): ( + Vec, + Vec, + ) = if let Some(cached) = cached_user_commands { + let errors = cached_user_command_errors.unwrap_or_default(); + (cached.into_values().collect(), errors) + } else if let Some(fs) = fs { + let load_result = + crate::user_slash_command::load_all_commands_async(&fs, &worktree_roots).await; + + (load_result.commands, load_result.errors) + } else { + (Vec::new(), Vec::new()) + }; + + user_slash_command::apply_server_command_conflicts( + &mut user_commands, + &mut user_command_errors, + &server_command_names, + ); + + let conflicting_names: HashSet = user_command_errors + .iter() + .filter_map(|error| error.command_name()) + .filter(|name| server_command_names.contains(name)) + .collect(); + + if !conflicting_names.is_empty() { + commands.retain(|command| !conflicting_names.contains(command.name.as_ref())); + } + + for cmd in user_commands { + commands.push(AvailableCommand { + name: cmd.name.clone(), + description: cmd.description().into(), + requires_argument: cmd.requires_arguments(), + source: CommandSource::UserDefined { + template: cmd.template.clone(), + }, + }); + } + + // Add errored commands so they show up in autocomplete with error indication. + // Errors for commands that don't match the query will be silently ignored here + // since the user will see them via the error callout in the thread view. + for error in user_command_errors { + if let Some(name) = error.command_name() { + commands.push(AvailableCommand { + name: name.into(), + description: "".into(), + requires_argument: false, + source: CommandSource::UserDefinedError { + error_message: error.message.into(), + }, + }); + } + } + + if commands.is_empty() { + return Vec::new(); + } + let candidates = commands .iter() .enumerate() @@ -1045,7 +1173,20 @@ impl CompletionProvider for PromptCompletio .await .into_iter() .map(|command| { - let new_text = if let Some(argument) = argument.as_ref() { + let is_error = + matches!(command.source, CommandSource::UserDefinedError { .. }); + + // For errored commands, show the name with "(load error)" suffix + let label_text = if is_error { + format!("{} (load error)", command.name) + } else { + command.name.to_string() + }; + + // For errored commands, we don't want to insert anything useful + let new_text = if is_error { + format!("/{}", command.name) + } else if let Some(argument) = argument.as_ref() { format!("/{} {}", command.name, argument) } else { format!("/{} ", command.name) @@ -1053,21 +1194,73 @@ impl CompletionProvider for PromptCompletio let is_missing_argument = command.requires_argument && argument.is_none(); + + // For errored commands, use a deprecated-style label to indicate the error + let label = if is_error { + // Create a label where the command name portion has a highlight + // that will be rendered with strikethrough by the completion menu + // (similar to deprecated LSP completions) + CodeLabel::plain(label_text, None) + } else { + CodeLabel::plain(label_text, None) + }; + + // For errored commands, show the error message in documentation + let documentation = + if let CommandSource::UserDefinedError { error_message } = + &command.source + { + Some(CompletionDocumentation::MultiLinePlainText( + error_message.to_string().into(), + )) + } else if !command.description.is_empty() { + Some(CompletionDocumentation::MultiLinePlainText( + command.description.to_string().into(), + )) + } else { + None + }; + + // For errored commands, use a red X icon + let icon_path = if is_error { + Some(IconName::XCircle.path().into()) + } else { + None + }; + Completion { replace_range: source_range.clone(), new_text, - label: CodeLabel::plain(command.name.to_string(), None), - documentation: Some(CompletionDocumentation::MultiLinePlainText( - command.description.into(), - )), - source: project::CompletionSource::Custom, - icon_path: None, + label, + documentation, + source: if is_error { + // Use a custom source that marks this as deprecated/errored + // so the completion menu renders it with strikethrough + project::CompletionSource::Lsp { + insert_range: None, + server_id: language::LanguageServerId(0), + lsp_completion: Box::new(lsp::CompletionItem { + label: command.name.to_string(), + deprecated: Some(true), + ..Default::default() + }), + lsp_defaults: None, + resolved: true, + } + } else { + project::CompletionSource::Custom + }, + icon_path, match_start: None, snippet_deduplication_key: None, insert_text_mode: None, confirm: Some(Arc::new({ let source = source.clone(); move |intent, _window, cx| { + // Don't confirm errored commands + if is_error { + return false; + } if !is_missing_argument { cx.defer({ let source = source.clone(); diff --git a/crates/agent_ui/src/user_slash_command.rs b/crates/agent_ui/src/user_slash_command.rs new file mode 100644 index 0000000000000000000000000000000000000000..15df0e410a0faf4cb93ec0b375f289d1601c5fd0 --- /dev/null +++ b/crates/agent_ui/src/user_slash_command.rs @@ -0,0 +1,2118 @@ +use anyhow::{Result, anyhow}; +use collections::{HashMap, HashSet}; +use fs::Fs; +use futures::StreamExt; +use gpui::{Context, EventEmitter, Task}; +use std::borrow::Cow; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +/// An error that occurred while loading a command file. +#[derive(Debug, Clone)] +pub struct CommandLoadError { + /// The path to the file that failed to load + pub path: PathBuf, + /// The base path of the commands directory (used to derive command name) + pub base_path: PathBuf, + /// A description of the error + pub message: String, +} + +impl CommandLoadError { + /// Derives the command name from the file path, similar to how successful commands are named. + /// Returns None if the command name cannot be determined (e.g., for directory errors). + pub fn command_name(&self) -> Option { + let base_name = self.path.file_stem()?.to_string_lossy().into_owned(); + + // Only derive command name for .md files + if self.path.extension().is_none_or(|ext| ext != "md") { + return None; + } + + let namespace = self + .path + .parent() + .and_then(|parent| parent.strip_prefix(&self.base_path).ok()) + .filter(|rel| !rel.as_os_str().is_empty()) + .map(|rel| { + rel.to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/") + }); + + let name = match &namespace { + Some(namespace) => format!("{}:{}", namespace.replace('/', ":"), base_name), + None => base_name, + }; + + Some(name) + } +} + +impl std::fmt::Display for CommandLoadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Failed to load {}: {}", + self.path.display(), + self.message + ) + } +} + +/// Result of loading commands, including any errors encountered. +#[derive(Debug, Default, Clone)] +pub struct CommandLoadResult { + /// Successfully loaded commands + pub commands: Vec, + /// Errors encountered while loading commands + pub errors: Vec, +} + +/// The scope of a user-defined slash command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandScope { + /// Project-specific command from .zed/commands/ + Project, + /// User-wide command from config_dir()/commands/ + User, +} + +/// A user-defined slash command loaded from a markdown file. +#[derive(Debug, Clone, PartialEq)] +pub struct UserSlashCommand { + /// The command name for invocation. + /// For commands in subdirectories, this is prefixed: "namespace:name" (e.g., "frontend:component") + /// For commands in the root, this is just the filename without .md extension. + pub name: Arc, + /// The template content from the file + pub template: Arc, + /// The namespace (subdirectory path, if any), used for description display + pub namespace: Option>, + /// The full path to the command file + pub path: PathBuf, + /// Whether this is a project or user command + pub scope: CommandScope, +} + +impl UserSlashCommand { + /// Returns a description string for display in completions. + pub fn description(&self) -> String { + String::new() + } + + /// Returns true if this command has any placeholders ($1, $2, etc. or $ARGUMENTS) + pub fn requires_arguments(&self) -> bool { + has_placeholders(&self.template) + } +} + +fn command_base_path(command: &UserSlashCommand) -> PathBuf { + let mut base_path = command.path.clone(); + base_path.pop(); + if let Some(namespace) = &command.namespace { + for segment in namespace.split('/') { + if segment.is_empty() { + continue; + } + if !base_path.pop() { + break; + } + } + } + base_path +} + +impl CommandLoadError { + pub fn from_command(command: &UserSlashCommand, message: String) -> Self { + Self { + path: command.path.clone(), + base_path: command_base_path(command), + message, + } + } +} + +/// Parsed user command from input text +#[derive(Debug, Clone, PartialEq)] +pub struct ParsedUserCommand<'a> { + pub name: &'a str, + pub raw_arguments: &'a str, +} + +/// Returns the path to the user commands directory. +pub fn user_commands_dir() -> PathBuf { + paths::config_dir().join("commands") +} + +/// Returns the path to the project commands directory for a given worktree root. +pub fn project_commands_dir(worktree_root: &Path) -> PathBuf { + worktree_root.join(".zed").join("commands") +} + +/// Events emitted by SlashCommandRegistry +#[derive(Debug, Clone)] +#[allow(dead_code)] // Infrastructure for future caching implementation +pub enum SlashCommandRegistryEvent { + /// Commands have been reloaded + CommandsChanged, +} + +/// A registry that caches user-defined slash commands and watches for changes. +/// Currently used in tests; will be integrated into the UI layer for caching. +#[allow(dead_code)] +pub struct SlashCommandRegistry { + fs: Arc, + commands: HashMap, + errors: Vec, + worktree_roots: Vec, + _watch_task: Option>, +} + +impl EventEmitter for SlashCommandRegistry {} + +#[allow(dead_code)] +impl SlashCommandRegistry { + /// Creates a new registry and starts loading commands. + pub fn new(fs: Arc, worktree_roots: Vec, cx: &mut Context) -> Self { + let mut this = Self { + fs, + commands: HashMap::default(), + errors: Vec::new(), + worktree_roots, + _watch_task: None, + }; + + this.start_watching(cx); + this.reload(cx); + + this + } + + /// Returns all loaded commands. + pub fn commands(&self) -> &HashMap { + &self.commands + } + + /// Returns any errors from the last load. + pub fn errors(&self) -> &[CommandLoadError] { + &self.errors + } + + /// Updates the worktree roots and reloads commands. + pub fn set_worktree_roots(&mut self, roots: Vec, cx: &mut Context) { + if self.worktree_roots != roots { + self.worktree_roots = roots; + self.start_watching(cx); + self.reload(cx); + } + } + + /// Manually triggers a reload of all commands. + pub fn reload(&mut self, cx: &mut Context) { + let fs = self.fs.clone(); + let worktree_roots = self.worktree_roots.clone(); + + cx.spawn(async move |this, cx| { + let result = load_all_commands_async(&fs, &worktree_roots).await; + this.update(cx, |this, cx| { + this.commands = commands_to_map(&result.commands); + this.errors = result.errors; + cx.emit(SlashCommandRegistryEvent::CommandsChanged); + }) + }) + .detach_and_log_err(cx); + } + + fn start_watching(&mut self, cx: &mut Context) { + let fs = self.fs.clone(); + let worktree_roots = self.worktree_roots.clone(); + + let task = cx.spawn(async move |this, cx| { + let user_dir = user_commands_dir(); + let mut dirs_to_watch = vec![user_dir]; + for root in &worktree_roots { + dirs_to_watch.push(project_commands_dir(root)); + } + + let mut watch_streams = Vec::new(); + for dir in &dirs_to_watch { + let (stream, _watcher) = fs.watch(dir, Duration::from_millis(100)).await; + watch_streams.push(stream); + } + + let mut combined = futures::stream::select_all(watch_streams); + + while let Some(events) = combined.next().await { + let should_reload = events.iter().any(|event| { + event.path.extension().is_some_and(|ext| ext == "md") + || event.kind == Some(fs::PathEventKind::Created) + || event.kind == Some(fs::PathEventKind::Removed) + }); + + if should_reload { + let result = load_all_commands_async(&fs, &worktree_roots).await; + let _ = this.update(cx, |this, cx| { + this.commands = commands_to_map(&result.commands); + this.errors = result.errors; + cx.emit(SlashCommandRegistryEvent::CommandsChanged); + }); + } + } + }); + + self._watch_task = Some(task); + } +} + +/// Loads all commands (both project and user) for given worktree roots asynchronously. +pub async fn load_all_commands_async( + fs: &Arc, + worktree_roots: &[PathBuf], +) -> CommandLoadResult { + let mut result = CommandLoadResult::default(); + let mut seen_commands: HashMap = HashMap::default(); + + // Load project commands first + for root in worktree_roots { + let commands_path = project_commands_dir(root); + let project_result = + load_commands_from_path_async(fs, &commands_path, CommandScope::Project).await; + result.errors.extend(project_result.errors); + for cmd in project_result.commands { + if let Some(existing_path) = seen_commands.get(&*cmd.name) { + result.errors.push(CommandLoadError { + path: cmd.path.clone(), + base_path: commands_path.clone(), + message: format!( + "Command '{}' is ambiguous: also defined at {}", + cmd.name, + existing_path.display() + ), + }); + } else { + seen_commands.insert(cmd.name.to_string(), cmd.path.clone()); + result.commands.push(cmd); + } + } + } + + // Load user commands + let user_commands_path = user_commands_dir(); + let user_result = + load_commands_from_path_async(fs, &user_commands_path, CommandScope::User).await; + result.errors.extend(user_result.errors); + for cmd in user_result.commands { + if let Some(existing_path) = seen_commands.get(&*cmd.name) { + result.errors.push(CommandLoadError { + path: cmd.path.clone(), + base_path: user_commands_path.clone(), + message: format!( + "Command '{}' is ambiguous: also defined at {}", + cmd.name, + existing_path.display() + ), + }); + } else { + seen_commands.insert(cmd.name.to_string(), cmd.path.clone()); + result.commands.push(cmd); + } + } + + result +} + +async fn load_commands_from_path_async( + fs: &Arc, + commands_path: &Path, + scope: CommandScope, +) -> CommandLoadResult { + let mut result = CommandLoadResult::default(); + + if !fs.is_dir(commands_path).await { + return result; + } + + load_commands_from_dir_async(fs, commands_path, commands_path, scope, &mut result).await; + result +} + +fn load_commands_from_dir_async<'a>( + fs: &'a Arc, + base_path: &'a Path, + current_path: &'a Path, + scope: CommandScope, + result: &'a mut CommandLoadResult, +) -> futures::future::BoxFuture<'a, ()> { + Box::pin(async move { + let entries = match fs.read_dir(current_path).await { + Ok(entries) => entries, + Err(e) => { + result.errors.push(CommandLoadError { + path: current_path.to_path_buf(), + base_path: base_path.to_path_buf(), + message: format!("Failed to read directory: {}", e), + }); + return; + } + }; + + let entries: Vec<_> = entries.collect().await; + + for entry in entries { + let path = match entry { + Ok(path) => path, + Err(e) => { + result.errors.push(CommandLoadError { + path: current_path.to_path_buf(), + base_path: base_path.to_path_buf(), + message: format!("Failed to read directory entry: {}", e), + }); + continue; + } + }; + + if fs.is_dir(&path).await { + load_commands_from_dir_async(fs, base_path, &path, scope, result).await; + } else if path.extension().is_some_and(|ext| ext == "md") { + match load_command_file_async(fs, base_path, &path, scope).await { + Ok(Some(command)) => result.commands.push(command), + Ok(None) => {} // Empty file, skip silently + Err(e) => { + result.errors.push(CommandLoadError { + path: path.clone(), + base_path: base_path.to_path_buf(), + message: e.to_string(), + }); + } + } + } + } + }) +} + +async fn load_command_file_async( + fs: &Arc, + base_path: &Path, + file_path: &Path, + scope: CommandScope, +) -> Result> { + let base_name = match file_path.file_stem() { + Some(stem) => stem.to_string_lossy().into_owned(), + None => return Ok(None), + }; + + let template = fs.load(file_path).await?; + if template.is_empty() { + return Ok(None); + } + if template.trim().is_empty() { + return Err(anyhow!("Command file contains only whitespace")); + } + + let namespace = file_path + .parent() + .and_then(|parent| parent.strip_prefix(base_path).ok()) + .filter(|rel| !rel.as_os_str().is_empty()) + .map(|rel| { + rel.to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/") + }); + + // Build the full command name: "namespace:basename" or just "basename" + let name = match &namespace { + Some(namespace) => format!("{}:{}", namespace.replace('/', ":"), base_name), + None => base_name, + }; + + Ok(Some(UserSlashCommand { + name: name.into(), + template: template.into(), + namespace: namespace.map(|s| s.into()), + path: file_path.to_path_buf(), + scope, + })) +} + +/// Converts a list of UserSlashCommand to a HashMap for quick lookup. +/// The key is the command name. +pub fn commands_to_map(commands: &[UserSlashCommand]) -> HashMap { + let mut map = HashMap::default(); + for cmd in commands { + map.insert(cmd.name.to_string(), cmd.clone()); + } + map +} + +fn has_error_for_command(errors: &[CommandLoadError], name: &str) -> bool { + errors + .iter() + .any(|error| error.command_name().as_deref() == Some(name)) +} + +fn server_conflict_message(name: &str) -> String { + format!( + "Command '{}' conflicts with server-provided /{}", + name, name + ) +} + +pub fn apply_server_command_conflicts( + commands: &mut Vec, + errors: &mut Vec, + server_command_names: &HashSet, +) { + commands.retain(|command| { + if server_command_names.contains(command.name.as_ref()) { + if !has_error_for_command(errors, command.name.as_ref()) { + errors.push(CommandLoadError::from_command( + command, + server_conflict_message(command.name.as_ref()), + )); + } + false + } else { + true + } + }); +} + +pub fn apply_server_command_conflicts_to_map( + commands: &mut HashMap, + errors: &mut Vec, + server_command_names: &HashSet, +) { + commands.retain(|name, command| { + if server_command_names.contains(name) { + if !has_error_for_command(errors, name) { + errors.push(CommandLoadError::from_command( + command, + server_conflict_message(name), + )); + } + false + } else { + true + } + }); +} + +/// Parses a line of input to extract a user command invocation. +/// Returns None if the line doesn't start with a slash command. +pub fn try_parse_user_command(line: &str) -> Option> { + let line = line.trim_start(); + if !line.starts_with('/') { + return None; + } + + let after_slash = &line[1..]; + let (name, raw_arguments) = if let Some(space_idx) = after_slash.find(char::is_whitespace) { + let name = &after_slash[..space_idx]; + let rest = &after_slash[space_idx..].trim_start(); + (name, *rest) + } else { + (after_slash, "") + }; + + if name.is_empty() { + return None; + } + + Some(ParsedUserCommand { + name, + raw_arguments, + }) +} + +/// Parses command arguments, supporting quoted strings. +/// - Unquoted arguments are space-separated +/// - Quoted arguments can contain spaces: "multi word arg" +/// - Escape sequences: \" for literal quote, \\ for backslash, \n for newline +pub fn parse_arguments(input: &str) -> Result>> { + let mut arguments = Vec::new(); + let mut chars = input.char_indices().peekable(); + + while let Some((start_idx, c)) = chars.next() { + if c.is_whitespace() { + continue; + } + + if c == '"' { + let mut result = String::new(); + let mut closed = false; + + while let Some((_, ch)) = chars.next() { + if ch == '\\' { + if let Some((_, next_ch)) = chars.next() { + match next_ch { + '"' => result.push('"'), + '\\' => result.push('\\'), + 'n' => result.push('\n'), + other => { + return Err(anyhow!("Unknown escape sequence: \\{}", other)); + } + } + } else { + return Err(anyhow!("Unexpected end of input after backslash")); + } + } else if ch == '"' { + closed = true; + break; + } else { + result.push(ch); + } + } + + if !closed { + return Err(anyhow!("Unclosed quote in command arguments")); + } + + arguments.push(Cow::Owned(result)); + } else { + let mut end_idx = start_idx + c.len_utf8(); + while let Some(&(idx, ch)) = chars.peek() { + if ch.is_whitespace() { + break; + } + if ch == '"' { + return Err(anyhow!("Quote in middle of unquoted argument")); + } + end_idx = idx + ch.len_utf8(); + chars.next(); + } + + arguments.push(Cow::Borrowed(&input[start_idx..end_idx])); + } + } + + Ok(arguments) +} + +/// Checks if a template has any placeholders ($1, $2, etc. or $ARGUMENTS) +pub fn has_placeholders(template: &str) -> bool { + count_positional_placeholders(template) > 0 || template.contains("$ARGUMENTS") +} + +/// Counts the highest positional placeholder number in the template. +/// For example, "$1 and $3" returns 3. +pub fn count_positional_placeholders(template: &str) -> usize { + let mut max_placeholder = 0; + let mut chars = template.chars().peekable(); + + while let Some(c) = chars.next() { + if c == '\\' { + chars.next(); + continue; + } + if c == '$' { + let mut num_str = String::new(); + while let Some(&next_c) = chars.peek() { + if next_c.is_ascii_digit() { + num_str.push(next_c); + chars.next(); + } else { + break; + } + } + if !num_str.is_empty() { + if let Ok(n) = num_str.parse::() { + max_placeholder = max_placeholder.max(n); + } + } + } + } + + max_placeholder +} + +/// Validates that arguments match the template's placeholders. +/// Templates can use $ARGUMENTS (all args as one string) or $1, $2, etc. (positional). +pub fn validate_arguments( + command_name: &str, + template: &str, + arguments: &[Cow<'_, str>], +) -> Result<()> { + if template.is_empty() { + return Err(anyhow!("Template cannot be empty")); + } + + let has_arguments_placeholder = template.contains("$ARGUMENTS"); + let positional_count = count_positional_placeholders(template); + + if has_arguments_placeholder { + // $ARGUMENTS accepts any number of arguments (including zero) + // But if there are also positional placeholders, validate those + if positional_count > 0 && arguments.len() < positional_count { + return Err(anyhow!( + "The /{} command requires {} positional {}, but only {} {} provided", + command_name, + positional_count, + if positional_count == 1 { + "argument" + } else { + "arguments" + }, + arguments.len(), + if arguments.len() == 1 { "was" } else { "were" } + )); + } + return Ok(()); + } + + if positional_count == 0 && !arguments.is_empty() { + return Err(anyhow!( + "The /{} command accepts no arguments, but {} {} provided", + command_name, + arguments.len(), + if arguments.len() == 1 { "was" } else { "were" } + )); + } + + if arguments.len() < positional_count { + return Err(anyhow!( + "The /{} command requires {} {}, but only {} {} provided", + command_name, + positional_count, + if positional_count == 1 { + "argument" + } else { + "arguments" + }, + arguments.len(), + if arguments.len() == 1 { "was" } else { "were" } + )); + } + + if arguments.len() > positional_count { + return Err(anyhow!( + "The /{} command accepts {} {}, but {} {} provided", + command_name, + positional_count, + if positional_count == 1 { + "argument" + } else { + "arguments" + }, + arguments.len(), + if arguments.len() == 1 { "was" } else { "were" } + )); + } + + Ok(()) +} + +/// Expands a template by substituting placeholders with arguments. +/// - $ARGUMENTS is replaced with all arguments as a single string +/// - $1, $2, etc. are replaced with positional arguments +/// - \$ produces literal $, \" produces literal ", \n produces newline +pub fn expand_template( + template: &str, + arguments: &[Cow<'_, str>], + raw_arguments: &str, +) -> Result { + let mut result = String::with_capacity(template.len()); + let mut chars = template.char_indices().peekable(); + + while let Some((_, c)) = chars.next() { + if c == '\\' { + if let Some((_, next_c)) = chars.next() { + match next_c { + '$' => result.push('$'), + '"' => result.push('"'), + '\\' => result.push('\\'), + 'n' => result.push('\n'), + other => { + return Err(anyhow!("Unknown escape sequence: \\{}", other)); + } + } + } + } else if c == '$' { + // Check for $ARGUMENTS first + let remaining: String = chars.clone().map(|(_, c)| c).collect(); + if remaining.starts_with("ARGUMENTS") { + result.push_str(raw_arguments); + // Skip "ARGUMENTS" + for _ in 0..9 { + chars.next(); + } + } else { + // Check for positional placeholder $N + let mut num_str = String::new(); + while let Some(&(_, next_c)) = chars.peek() { + if next_c.is_ascii_digit() { + num_str.push(next_c); + chars.next(); + } else { + break; + } + } + if !num_str.is_empty() { + let n: usize = num_str.parse()?; + if n == 0 { + return Err(anyhow!( + "Placeholder $0 is invalid; placeholders start at $1" + )); + } + if let Some(arg) = arguments.get(n - 1) { + result.push_str(arg); + } else { + return Err(anyhow!("Missing argument for placeholder ${}", n)); + } + } else { + result.push('$'); + } + } + } else { + result.push(c); + } + } + + Ok(result) +} + +/// Expands a user slash command, validating arguments and performing substitution. +pub fn expand_user_slash_command( + command_name: &str, + template: &str, + arguments: &[Cow<'_, str>], + raw_arguments: &str, +) -> Result { + validate_arguments(command_name, template, arguments)?; + expand_template(template, arguments, raw_arguments) +} + +/// Attempts to expand a user slash command from input text. +/// Returns Ok(None) if the input is not a user command or the command doesn't exist. +/// Returns Err if the command exists but expansion fails (e.g., missing arguments). +pub fn try_expand_from_commands( + line: &str, + commands: &HashMap, +) -> Result> { + let Some(parsed) = try_parse_user_command(line) else { + return Ok(None); + }; + + let Some(command) = commands.get(parsed.name) else { + return Ok(None); + }; + + let arguments = parse_arguments(parsed.raw_arguments)?; + let expanded = expand_user_slash_command( + parsed.name, + &command.template, + &arguments, + parsed.raw_arguments, + )?; + Ok(Some(expanded)) +} + +#[cfg(test)] +mod tests { + use super::*; + use fs::{FakeFs, Fs, RemoveOptions}; + use gpui::{AppContext as _, TestAppContext}; + use serde_json::json; + use std::sync::Arc; + use text::Rope; + use util::path; + + // ==================== Parsing Tests ==================== + + #[test] + fn test_try_parse_user_command() { + assert_eq!( + try_parse_user_command("/review"), + Some(ParsedUserCommand { + name: "review", + raw_arguments: "" + }) + ); + + assert_eq!( + try_parse_user_command("/review arg1 arg2"), + Some(ParsedUserCommand { + name: "review", + raw_arguments: "arg1 arg2" + }) + ); + + assert_eq!( + try_parse_user_command("/cmd \"multi word\" simple"), + Some(ParsedUserCommand { + name: "cmd", + raw_arguments: "\"multi word\" simple" + }) + ); + + assert_eq!(try_parse_user_command("not a command"), None); + assert_eq!(try_parse_user_command(""), None); + assert_eq!(try_parse_user_command("/"), None); + } + + #[test] + fn test_parse_arguments_simple_unquoted() { + let args = parse_arguments("foo bar").unwrap(); + assert_eq!(args, vec!["foo", "bar"]); + } + + #[test] + fn test_parse_arguments_quoted() { + let args = parse_arguments("\"foo bar\"").unwrap(); + assert_eq!(args, vec!["foo bar"]); + } + + #[test] + fn test_parse_arguments_mixed() { + let args = parse_arguments("\"foo bar\" baz \"qux\"").unwrap(); + assert_eq!(args, vec!["foo bar", "baz", "qux"]); + } + + #[test] + fn test_parse_arguments_escaped_quotes() { + let args = parse_arguments("\"foo \\\"bar\\\" baz\"").unwrap(); + assert_eq!(args, vec!["foo \"bar\" baz"]); + } + + #[test] + fn test_parse_arguments_escaped_backslash() { + let args = parse_arguments("\"foo\\\\bar\"").unwrap(); + assert_eq!(args, vec!["foo\\bar"]); + } + + #[test] + fn test_parse_arguments_unclosed_quote_error() { + let result = parse_arguments("\"foo"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Unclosed quote")); + } + + #[test] + fn test_parse_arguments_quote_in_middle_error() { + let result = parse_arguments("foo\"bar"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Quote in middle")); + } + + #[test] + fn test_parse_arguments_unknown_escape_error() { + let result = parse_arguments("\"\\x\""); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Unknown escape")); + } + + #[test] + fn test_parse_arguments_newline_escape() { + let args = parse_arguments("\"line1\\nline2\"").unwrap(); + assert_eq!(args, vec!["line1\nline2"]); + } + + // ==================== Placeholder Tests ==================== + + #[test] + fn test_count_positional_placeholders() { + assert_eq!(count_positional_placeholders("Hello $1"), 1); + assert_eq!(count_positional_placeholders("$1 and $2"), 2); + assert_eq!(count_positional_placeholders("$1 $1"), 1); + assert_eq!(count_positional_placeholders("$2 then $1"), 2); + assert_eq!(count_positional_placeholders("no placeholders"), 0); + assert_eq!(count_positional_placeholders("\\$1 escaped"), 0); + assert_eq!(count_positional_placeholders("$10 big number"), 10); + } + + #[test] + fn test_has_placeholders() { + assert!(has_placeholders("Hello $1")); + assert!(has_placeholders("$ARGUMENTS")); + assert!(has_placeholders("prefix $ARGUMENTS suffix")); + assert!(!has_placeholders("no placeholders")); + assert!(!has_placeholders("\\$1 escaped")); + } + + // ==================== Template Expansion Tests ==================== + + #[test] + fn test_expand_template_basic() { + let args = vec![Cow::Borrowed("world")]; + let result = expand_template("Hello $1", &args, "world").unwrap(); + assert_eq!(result, "Hello world"); + } + + #[test] + fn test_expand_template_multiple_placeholders() { + let args = vec![Cow::Borrowed("a"), Cow::Borrowed("b")]; + let result = expand_template("$1 and $2", &args, "a b").unwrap(); + assert_eq!(result, "a and b"); + } + + #[test] + fn test_expand_template_repeated_placeholder() { + let args = vec![Cow::Borrowed("x")]; + let result = expand_template("$1 $1", &args, "x").unwrap(); + assert_eq!(result, "x x"); + } + + #[test] + fn test_expand_template_out_of_order() { + let args = vec![Cow::Borrowed("a"), Cow::Borrowed("b")]; + let result = expand_template("$2 then $1", &args, "a b").unwrap(); + assert_eq!(result, "b then a"); + } + + #[test] + fn test_expand_template_escape_sequences() { + let args: Vec> = vec![]; + assert_eq!( + expand_template("line1\\nline2", &args, "").unwrap(), + "line1\nline2" + ); + assert_eq!( + expand_template("cost is \\$1", &args, "").unwrap(), + "cost is $1" + ); + assert_eq!( + expand_template("say \\\"hi\\\"", &args, "").unwrap(), + "say \"hi\"" + ); + assert_eq!( + expand_template("path\\\\file", &args, "").unwrap(), + "path\\file" + ); + } + + #[test] + fn test_expand_template_arguments_placeholder() { + let args = vec![Cow::Borrowed("foo"), Cow::Borrowed("bar")]; + let result = expand_template("All args: $ARGUMENTS", &args, "foo bar").unwrap(); + assert_eq!(result, "All args: foo bar"); + } + + #[test] + fn test_expand_template_arguments_with_positional() { + let args = vec![Cow::Borrowed("first"), Cow::Borrowed("second")]; + let result = expand_template("First: $1, All: $ARGUMENTS", &args, "first second").unwrap(); + assert_eq!(result, "First: first, All: first second"); + } + + #[test] + fn test_expand_template_arguments_empty() { + let args: Vec> = vec![]; + let result = expand_template("Args: $ARGUMENTS", &args, "").unwrap(); + assert_eq!(result, "Args: "); + } + + // ==================== Validation Tests ==================== + + #[test] + fn test_validate_arguments_exact_match() { + let args = vec![Cow::Borrowed("a"), Cow::Borrowed("b")]; + let result = validate_arguments("test", "$1 $2", &args); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_arguments_missing_args() { + let args = vec![Cow::Borrowed("a")]; + let result = validate_arguments("foo", "$1 $2", &args); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("/foo")); + assert!(err.contains("requires 2 arguments")); + } + + #[test] + fn test_validate_arguments_extra_args() { + let args = vec![Cow::Borrowed("a"), Cow::Borrowed("b")]; + let result = validate_arguments("foo", "$1", &args); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("accepts 1 argument")); + } + + #[test] + fn test_validate_arguments_no_placeholders() { + // No args expected, none provided - OK + let args: Vec> = vec![]; + assert!(validate_arguments("test", "no placeholders", &args).is_ok()); + + // No args expected but some provided - Error + let args = vec![Cow::Borrowed("unexpected")]; + let result = validate_arguments("test", "no placeholders", &args); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("accepts no arguments") + ); + } + + #[test] + fn test_validate_arguments_empty_template() { + let args: Vec> = vec![]; + let result = validate_arguments("test", "", &args); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("cannot be empty")); + } + + #[test] + fn test_validate_arguments_with_arguments_placeholder() { + // $ARGUMENTS accepts any number of arguments including zero + let args: Vec> = vec![]; + assert!(validate_arguments("test", "Do: $ARGUMENTS", &args).is_ok()); + + let args = vec![Cow::Borrowed("a"), Cow::Borrowed("b"), Cow::Borrowed("c")]; + assert!(validate_arguments("test", "Do: $ARGUMENTS", &args).is_ok()); + } + + #[test] + fn test_validate_arguments_mixed_placeholders() { + // Both $ARGUMENTS and positional - need at least the positional ones + let args = vec![Cow::Borrowed("first")]; + assert!(validate_arguments("test", "$1 then $ARGUMENTS", &args).is_ok()); + + let args: Vec> = vec![]; + assert!(validate_arguments("test", "$1 then $ARGUMENTS", &args).is_err()); + } + + // ==================== Integration Tests ==================== + + #[test] + fn test_expand_user_slash_command() { + let result = expand_user_slash_command( + "review", + "Please review: $1", + &[Cow::Borrowed("security")], + "security", + ) + .unwrap(); + assert_eq!(result, "Please review: security"); + } + + #[test] + fn test_try_expand_from_commands() { + let commands = vec![ + UserSlashCommand { + name: "greet".into(), + template: "Hello, world!".into(), + namespace: None, + path: PathBuf::from("/greet.md"), + scope: CommandScope::User, + }, + UserSlashCommand { + name: "review".into(), + template: "Review this for: $1".into(), + namespace: None, + path: PathBuf::from("/review.md"), + scope: CommandScope::User, + }, + UserSlashCommand { + name: "search".into(), + template: "Search: $ARGUMENTS".into(), + namespace: None, + path: PathBuf::from("/search.md"), + scope: CommandScope::User, + }, + ]; + let map = commands_to_map(&commands); + + // Command without arguments + assert_eq!( + try_expand_from_commands("/greet", &map).unwrap(), + Some("Hello, world!".to_string()) + ); + + // Command with positional argument + assert_eq!( + try_expand_from_commands("/review security", &map).unwrap(), + Some("Review this for: security".to_string()) + ); + + // Command with $ARGUMENTS + assert_eq!( + try_expand_from_commands("/search foo bar baz", &map).unwrap(), + Some("Search: foo bar baz".to_string()) + ); + + // Unknown command returns None + assert_eq!(try_expand_from_commands("/unknown", &map).unwrap(), None); + + // Not a command returns None + assert_eq!(try_expand_from_commands("just text", &map).unwrap(), None); + } + + #[test] + fn test_try_expand_from_commands_missing_args() { + let commands = vec![UserSlashCommand { + name: "review".into(), + template: "Review: $1".into(), + namespace: None, + path: PathBuf::from("/review.md"), + scope: CommandScope::User, + }]; + let map = commands_to_map(&commands); + + let result = try_expand_from_commands("/review", &map); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("requires 1 argument") + ); + } + + // ==================== Edge Case Tests ==================== + + #[test] + fn test_unicode_command_names() { + // Test that unicode in command names works + let result = try_parse_user_command("/日本語 arg1"); + assert!(result.is_some()); + let parsed = result.unwrap(); + assert_eq!(parsed.name, "日本語"); + assert_eq!(parsed.raw_arguments, "arg1"); + } + + #[test] + fn test_unicode_in_arguments() { + let args = parse_arguments("\"こんにちは\" 世界").unwrap(); + assert_eq!(args, vec!["こんにちは", "世界"]); + } + + #[test] + fn test_unicode_in_template() { + let args = vec![Cow::Borrowed("名前")]; + let result = expand_template("こんにちは、$1さん!", &args, "名前").unwrap(); + assert_eq!(result, "こんにちは、名前さん!"); + } + + #[test] + fn test_command_name_with_emoji() { + // Emoji can be multi-codepoint, test they're handled correctly + let result = try_parse_user_command("/🚀deploy fast"); + assert!(result.is_some()); + let parsed = result.unwrap(); + assert_eq!(parsed.name, "🚀deploy"); + assert_eq!(parsed.raw_arguments, "fast"); + + // Emoji in arguments + let args = parse_arguments("🎉 \"🎊 party\"").unwrap(); + assert_eq!(args, vec!["🎉", "🎊 party"]); + } + + #[test] + fn test_many_placeholders() { + // Test template with many placeholders + let template = "$1 $2 $3 $4 $5 $6 $7 $8 $9 $10"; + assert_eq!(count_positional_placeholders(template), 10); + + let args: Vec> = (1..=10).map(|i| Cow::Owned(i.to_string())).collect(); + let result = expand_template(template, &args, "1 2 3 4 5 6 7 8 9 10").unwrap(); + assert_eq!(result, "1 2 3 4 5 6 7 8 9 10"); + } + + #[test] + fn test_placeholder_zero_is_invalid() { + let args = vec![Cow::Borrowed("a")]; + let result = expand_template("$0", &args, "a"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("$0 is invalid")); + } + + #[test] + fn test_dollar_sign_without_number() { + // Bare $ should be preserved + let args: Vec> = vec![]; + let result = expand_template("cost is $", &args, "").unwrap(); + assert_eq!(result, "cost is $"); + } + + #[test] + fn test_consecutive_whitespace_in_arguments() { + let args = parse_arguments(" a b c ").unwrap(); + assert_eq!(args, vec!["a", "b", "c"]); + } + + #[test] + fn test_empty_input() { + let args = parse_arguments("").unwrap(); + assert!(args.is_empty()); + + let args = parse_arguments(" ").unwrap(); + assert!(args.is_empty()); + } + + #[test] + fn test_command_load_error_command_name() { + let error = CommandLoadError { + path: PathBuf::from(path!("/commands/tools/git/commit.md")), + base_path: PathBuf::from(path!("/commands")), + message: "Failed".into(), + }; + assert_eq!(error.command_name().as_deref(), Some("tools:git:commit")); + + let non_md_error = CommandLoadError { + path: PathBuf::from(path!("/commands/readme.txt")), + base_path: PathBuf::from(path!("/commands")), + message: "Failed".into(), + }; + assert_eq!(non_md_error.command_name(), None); + } + + #[test] + fn test_apply_server_command_conflicts() { + let mut commands = vec![ + UserSlashCommand { + name: "help".into(), + template: "Help text".into(), + namespace: None, + path: PathBuf::from(path!("/commands/help.md")), + scope: CommandScope::User, + }, + UserSlashCommand { + name: "review".into(), + template: "Review $1".into(), + namespace: None, + path: PathBuf::from(path!("/commands/review.md")), + scope: CommandScope::User, + }, + ]; + let mut errors = Vec::new(); + let server_command_names = HashSet::from_iter(["help".to_string()]); + + apply_server_command_conflicts(&mut commands, &mut errors, &server_command_names); + + assert_eq!(commands.len(), 1); + assert_eq!(commands[0].name.as_ref(), "review"); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].command_name().as_deref(), Some("help")); + assert!(errors[0].message.contains("conflicts")); + } + + #[test] + fn test_apply_server_command_conflicts_to_map() { + let command = UserSlashCommand { + name: "tools:git:commit".into(), + template: "Commit".into(), + namespace: Some("tools/git".into()), + path: PathBuf::from(path!("/commands/tools/git/commit.md")), + scope: CommandScope::User, + }; + let mut commands = HashMap::default(); + commands.insert(command.name.to_string(), command.clone()); + let mut errors = Vec::new(); + let server_command_names = HashSet::from_iter([command.name.to_string()]); + + apply_server_command_conflicts_to_map(&mut commands, &mut errors, &server_command_names); + + assert!(commands.is_empty()); + assert_eq!(errors.len(), 1); + assert_eq!( + errors[0].command_name().as_deref(), + Some("tools:git:commit") + ); + } + + // ==================== Async File Loading Tests with FakeFs ==================== + + #[gpui::test] + async fn test_load_commands_from_empty_dir(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/commands"), json!({})).await; + let fs: Arc = fs; + + let result = + load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User) + .await; + + assert!(result.commands.is_empty()); + assert!(result.errors.is_empty()); + } + + #[gpui::test] + async fn test_load_commands_from_nonexistent_dir(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/"), json!({})).await; + let fs: Arc = fs; + + let result = load_commands_from_path_async( + &fs, + Path::new(path!("/nonexistent")), + CommandScope::User, + ) + .await; + + assert!(result.commands.is_empty()); + assert!(result.errors.is_empty()); + } + + #[gpui::test] + async fn test_load_single_command(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/commands"), + json!({ + "review.md": "Please review: $1" + }), + ) + .await; + let fs: Arc = fs; + + let result = + load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User) + .await; + + assert!(result.errors.is_empty()); + assert_eq!(result.commands.len(), 1); + let cmd = &result.commands[0]; + assert_eq!(cmd.name.as_ref(), "review"); + assert_eq!(cmd.template.as_ref(), "Please review: $1"); + assert!(cmd.namespace.is_none()); + assert_eq!(cmd.scope, CommandScope::User); + } + + #[gpui::test] + async fn test_load_commands_with_namespace(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/commands"), + json!({ + "frontend": { + "component.md": "Create component: $1" + } + }), + ) + .await; + let fs: Arc = fs; + + let result = + load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User) + .await; + + assert!(result.errors.is_empty()); + assert_eq!(result.commands.len(), 1); + let cmd = &result.commands[0]; + assert_eq!(cmd.name.as_ref(), "frontend:component"); + assert_eq!(cmd.namespace.as_ref().map(|s| s.as_ref()), Some("frontend")); + } + + #[gpui::test] + async fn test_load_commands_nested_namespace(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/commands"), + json!({ + "tools": { + "git": { + "commit.md": "Git commit: $ARGUMENTS" + } + } + }), + ) + .await; + let fs: Arc = fs; + + let result = + load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User) + .await; + + assert!(result.errors.is_empty()); + assert_eq!(result.commands.len(), 1); + let cmd = &result.commands[0]; + assert_eq!(cmd.name.as_ref(), "tools:git:commit"); + assert_eq!( + cmd.namespace.as_ref().map(|s| s.as_ref()), + Some("tools/git") + ); + } + + #[gpui::test] + async fn test_deeply_nested_namespace(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/commands"), + json!({ + "a": { + "b": { + "c": { + "d": { + "e": { + "deep.md": "Very deep command" + } + } + } + } + } + }), + ) + .await; + let fs: Arc = fs; + + let result = + load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User) + .await; + + assert!(result.errors.is_empty()); + assert_eq!(result.commands.len(), 1); + let cmd = &result.commands[0]; + assert_eq!(cmd.name.as_ref(), "a:b:c:d:e:deep"); + assert_eq!( + cmd.namespace.as_ref().map(|s| s.as_ref()), + Some("a/b/c/d/e") + ); + } + + #[gpui::test] + async fn test_load_commands_empty_file_ignored(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/commands"), + json!({ + "empty.md": "", + "valid.md": "Hello!" + }), + ) + .await; + let fs: Arc = fs; + + let result = + load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User) + .await; + + assert!(result.errors.is_empty()); + assert_eq!(result.commands.len(), 1); + assert_eq!(result.commands[0].name.as_ref(), "valid"); + } + + #[gpui::test] + async fn test_load_commands_non_md_files_ignored(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/commands"), + json!({ + "command.md": "Valid command", + "readme.txt": "Not a command", + "script.sh": "Also not a command" + }), + ) + .await; + let fs: Arc = fs; + + let result = + load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User) + .await; + + assert!(result.errors.is_empty()); + assert_eq!(result.commands.len(), 1); + assert_eq!(result.commands[0].name.as_ref(), "command"); + } + + #[gpui::test] + async fn test_load_project_commands(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".zed": { + "commands": { + "build.md": "Build the project" + } + } + }), + ) + .await; + let fs: Arc = fs; + + let commands_path = project_commands_dir(Path::new(path!("/project"))); + let result = + load_commands_from_path_async(&fs, &commands_path, CommandScope::Project).await; + + assert!(result.errors.is_empty()); + assert_eq!(result.commands.len(), 1); + assert_eq!(result.commands[0].name.as_ref(), "build"); + assert_eq!(result.commands[0].scope, CommandScope::Project); + } + + #[gpui::test] + async fn test_load_all_commands_no_duplicates(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project1"), + json!({ + ".zed": { + "commands": { + "review.md": "Project 1 review" + } + } + }), + ) + .await; + fs.insert_tree( + path!("/project2"), + json!({ + ".zed": { + "commands": { + "build.md": "Project 2 build" + } + } + }), + ) + .await; + let fs: Arc = fs; + + let result = load_all_commands_async( + &fs, + &[ + PathBuf::from(path!("/project1")), + PathBuf::from(path!("/project2")), + ], + ) + .await; + + assert!(result.errors.is_empty()); + assert_eq!(result.commands.len(), 2); + let names: Vec<&str> = result.commands.iter().map(|c| c.name.as_ref()).collect(); + assert!(names.contains(&"review")); + assert!(names.contains(&"build")); + } + + #[gpui::test] + async fn test_load_all_commands_duplicate_error(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project1"), + json!({ + ".zed": { + "commands": { + "deploy.md": "Deploy from project 1" + } + } + }), + ) + .await; + fs.insert_tree( + path!("/project2"), + json!({ + ".zed": { + "commands": { + "deploy.md": "Deploy from project 2" + } + } + }), + ) + .await; + let fs: Arc = fs; + + let result = load_all_commands_async( + &fs, + &[ + PathBuf::from(path!("/project1")), + PathBuf::from(path!("/project2")), + ], + ) + .await; + + // Should have one command and one error + assert_eq!(result.commands.len(), 1); + assert_eq!(result.errors.len(), 1); + assert!(result.errors[0].message.contains("ambiguous")); + assert!(result.errors[0].message.contains("deploy")); + } + + #[gpui::test] + async fn test_registry_loads_commands(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".zed": { + "commands": { + "test.md": "Test command" + } + } + }), + ) + .await; + let fs: Arc = fs; + + let registry = cx.new(|cx| { + SlashCommandRegistry::new(fs.clone(), vec![PathBuf::from(path!("/project"))], cx) + }); + + // Wait for async load + cx.run_until_parked(); + + registry.read_with(cx, |registry: &SlashCommandRegistry, _cx| { + assert!(registry.errors().is_empty()); + assert!(registry.commands().contains_key("test")); + }); + } + + #[gpui::test] + async fn test_registry_updates_worktree_roots(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project1"), + json!({ + ".zed": { + "commands": { + "cmd1.md": "Command 1" + } + } + }), + ) + .await; + fs.insert_tree( + path!("/project2"), + json!({ + ".zed": { + "commands": { + "cmd2.md": "Command 2" + } + } + }), + ) + .await; + let fs: Arc = fs; + + let registry = cx.new(|cx| { + SlashCommandRegistry::new(fs.clone(), vec![PathBuf::from(path!("/project1"))], cx) + }); + + cx.run_until_parked(); + + registry.read_with(cx, |registry: &SlashCommandRegistry, _cx| { + assert!(registry.commands().contains_key("cmd1")); + assert!(!registry.commands().contains_key("cmd2")); + }); + + // Update worktree roots + registry.update(cx, |registry: &mut SlashCommandRegistry, cx| { + registry.set_worktree_roots( + vec![ + PathBuf::from(path!("/project1")), + PathBuf::from(path!("/project2")), + ], + cx, + ); + }); + + cx.run_until_parked(); + + registry.read_with(cx, |registry: &SlashCommandRegistry, _cx| { + assert!(registry.commands().contains_key("cmd1")); + assert!(registry.commands().contains_key("cmd2")); + }); + } + + #[gpui::test] + async fn test_registry_reloads_on_file_change(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".zed": { + "commands": { + "original.md": "Original command" + } + } + }), + ) + .await; + let fs: Arc = fs.clone(); + + let registry = cx.new(|cx| { + SlashCommandRegistry::new(fs.clone(), vec![PathBuf::from(path!("/project"))], cx) + }); + + // Wait for initial load + cx.run_until_parked(); + + registry.read_with(cx, |registry, _cx| { + assert_eq!(registry.commands().len(), 1); + assert!(registry.commands().contains_key("original")); + }); + + // Add a new command file + fs.save( + Path::new(path!("/project/.zed/commands/new.md")), + &Rope::from("New command"), + text::LineEnding::Unix, + ) + .await + .unwrap(); + + // Wait for watcher to process the change + cx.run_until_parked(); + + registry.read_with(cx, |registry, _cx| { + assert_eq!(registry.commands().len(), 2); + assert!(registry.commands().contains_key("original")); + assert!(registry.commands().contains_key("new")); + }); + + // Remove a command file + fs.remove_file( + Path::new(path!("/project/.zed/commands/original.md")), + RemoveOptions::default(), + ) + .await + .unwrap(); + + // Wait for watcher to process the change + cx.run_until_parked(); + + registry.read_with(cx, |registry, _cx| { + assert_eq!(registry.commands().len(), 1); + assert!(!registry.commands().contains_key("original")); + assert!(registry.commands().contains_key("new")); + }); + + // Modify an existing command + fs.save( + Path::new(path!("/project/.zed/commands/new.md")), + &Rope::from("Updated content"), + text::LineEnding::Unix, + ) + .await + .unwrap(); + + cx.run_until_parked(); + + registry.read_with(cx, |registry, _cx| { + let cmd = registry.commands().get("new").unwrap(); + assert_eq!(cmd.template.as_ref(), "Updated content"); + }); + } + + #[gpui::test] + async fn test_concurrent_command_loading(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".zed": { + "commands": { + "cmd1.md": "Command 1", + "cmd2.md": "Command 2", + "cmd3.md": "Command 3" + } + } + }), + ) + .await; + let fs: Arc = fs; + let worktree_roots = vec![PathBuf::from(path!("/project"))]; + + // Spawn multiple load tasks concurrently + let fs1 = fs.clone(); + let roots1 = worktree_roots.clone(); + let task1 = cx + .executor() + .spawn(async move { load_all_commands_async(&fs1, &roots1).await }); + + let fs2 = fs.clone(); + let roots2 = worktree_roots.clone(); + let task2 = cx + .executor() + .spawn(async move { load_all_commands_async(&fs2, &roots2).await }); + + let fs3 = fs.clone(); + let roots3 = worktree_roots.clone(); + let task3 = cx + .executor() + .spawn(async move { load_all_commands_async(&fs3, &roots3).await }); + + // Wait for all tasks to complete + let (result1, result2, result3) = futures::join!(task1, task2, task3); + + // All should succeed with the same results + assert!(result1.errors.is_empty()); + assert!(result2.errors.is_empty()); + assert!(result3.errors.is_empty()); + + assert_eq!(result1.commands.len(), 3); + assert_eq!(result2.commands.len(), 3); + assert_eq!(result3.commands.len(), 3); + } + + // ==================== Symlink Handling Tests ==================== + + #[gpui::test] + async fn test_load_commands_from_symlinked_directory(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + + // Create the actual commands directory with a command + fs.insert_tree( + path!("/actual_commands"), + json!({ + "review.md": "Please review: $1" + }), + ) + .await; + + // Create a symlink from /commands to /actual_commands + fs.insert_tree(path!("/"), json!({})).await; + fs.create_symlink( + Path::new(path!("/commands")), + PathBuf::from(path!("/actual_commands")), + ) + .await + .unwrap(); + + let fs: Arc = fs; + + let result = + load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User) + .await; + + assert!(result.errors.is_empty()); + assert_eq!(result.commands.len(), 1); + assert_eq!(result.commands[0].name.as_ref(), "review"); + } + + #[gpui::test] + async fn test_load_commands_from_symlinked_file(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + + // Create the actual command file + fs.insert_tree( + path!("/actual"), + json!({ + "real_review.md": "Review command content: $1" + }), + ) + .await; + + // Create commands directory with a symlink to the file + fs.insert_tree(path!("/commands"), json!({})).await; + fs.create_symlink( + Path::new(path!("/commands/review.md")), + PathBuf::from(path!("/actual/real_review.md")), + ) + .await + .unwrap(); + + let fs: Arc = fs; + + let result = + load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User) + .await; + + assert!(result.errors.is_empty()); + assert_eq!(result.commands.len(), 1); + assert_eq!(result.commands[0].name.as_ref(), "review"); + assert_eq!( + result.commands[0].template.as_ref(), + "Review command content: $1" + ); + } + + #[gpui::test] + async fn test_load_commands_claude_symlink_pattern(cx: &mut TestAppContext) { + // Simulates the common pattern of symlinking ~/.claude/commands/ to zed's commands dir + let fs = FakeFs::new(cx.executor()); + + // Create Claude's commands directory structure + fs.insert_tree( + path!("/home/user/.claude/commands"), + json!({ + "explain.md": "Explain this code: $ARGUMENTS", + "refactor": { + "extract.md": "Extract method: $1" + } + }), + ) + .await; + + // Create Zed config dir with symlink to Claude's commands + fs.insert_tree(path!("/home/user/.config/zed"), json!({})) + .await; + fs.create_symlink( + Path::new(path!("/home/user/.config/zed/commands")), + PathBuf::from(path!("/home/user/.claude/commands")), + ) + .await + .unwrap(); + + let fs: Arc = fs; + + let result = load_commands_from_path_async( + &fs, + Path::new(path!("/home/user/.config/zed/commands")), + CommandScope::User, + ) + .await; + + assert!(result.errors.is_empty()); + assert_eq!(result.commands.len(), 2); + + let names: Vec<&str> = result.commands.iter().map(|c| c.name.as_ref()).collect(); + assert!(names.contains(&"explain")); + assert!(names.contains(&"refactor:extract")); + } + + #[gpui::test] + async fn test_symlink_to_parent_directory_skipped(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + + // Create a directory structure with a symlink pointing outside the commands dir + // This tests that symlinks to directories outside the command tree are handled + fs.insert_tree( + path!("/commands"), + json!({ + "valid.md": "Valid command" + }), + ) + .await; + + // Create a separate directory + fs.insert_tree( + path!("/other"), + json!({ + "external.md": "External command" + }), + ) + .await; + + // Create a symlink from /commands/external -> /other + fs.create_symlink( + Path::new(path!("/commands/external")), + PathBuf::from(path!("/other")), + ) + .await + .unwrap(); + + let fs: Arc = fs; + + let result = + load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User) + .await; + + // Should have loaded both the valid command and the external one via symlink + assert!(result.commands.iter().any(|c| c.name.as_ref() == "valid")); + assert!( + result + .commands + .iter() + .any(|c| c.name.as_ref() == "external:external") + ); + } + + // ==================== Permission/Error Handling Tests ==================== + + #[gpui::test] + async fn test_load_commands_reports_directory_read_errors(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + + // Create base directory but no commands subdirectory + fs.insert_tree(path!("/"), json!({})).await; + + let fs: Arc = fs; + + // Try to load from a path that exists but isn't a directory + // First create a file where we expect a directory + fs.create_file(Path::new(path!("/commands")), fs::CreateOptions::default()) + .await + .unwrap(); + + let result = + load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User) + .await; + + // Should return empty since /commands is a file, not a directory + assert!(result.commands.is_empty()); + } + + #[gpui::test] + async fn test_load_all_commands_aggregates_errors(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + + // Create two projects with duplicate command names + fs.insert_tree( + path!("/project1"), + json!({ + ".zed": { + "commands": { + "build.md": "Build 1" + } + } + }), + ) + .await; + fs.insert_tree( + path!("/project2"), + json!({ + ".zed": { + "commands": { + "build.md": "Build 2" + } + } + }), + ) + .await; + fs.insert_tree( + path!("/project3"), + json!({ + ".zed": { + "commands": { + "build.md": "Build 3" + } + } + }), + ) + .await; + + let fs: Arc = fs; + + let result = load_all_commands_async( + &fs, + &[ + PathBuf::from(path!("/project1")), + PathBuf::from(path!("/project2")), + PathBuf::from(path!("/project3")), + ], + ) + .await; + + // Should have 1 command (first one) and 2 errors (for duplicates) + assert_eq!(result.commands.len(), 1); + assert_eq!(result.errors.len(), 2); + + // All errors should mention "ambiguous" + for error in &result.errors { + assert!(error.message.contains("ambiguous")); + } + } + + #[gpui::test] + async fn test_mixed_valid_and_empty_files(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + path!("/commands"), + json!({ + "valid.md": "Valid command", + "empty.md": "", + "whitespace_only.md": " ", + "another_valid.md": "Another valid" + }), + ) + .await; + + let fs: Arc = fs; + + let result = + load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User) + .await; + + // Empty file is ignored, whitespace-only is an error + assert_eq!(result.commands.len(), 2); + assert_eq!(result.errors.len(), 1); + assert!(result.errors[0].message.contains("whitespace")); + assert_eq!( + result.errors[0].command_name().as_deref(), + Some("whitespace_only") + ); + } +} diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 470bcfe910bfdc23a5cec22cf45a2b7a6cf46c51..ee657799f09cabbb1b01b64ec084e848090c2160 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -24,6 +24,12 @@ impl FeatureFlag for AcpBetaFeatureFlag { const NAME: &'static str = "acp-beta"; } +pub struct UserSlashCommandsFeatureFlag; + +impl FeatureFlag for UserSlashCommandsFeatureFlag { + const NAME: &'static str = "slash-commands"; +} + pub struct ToolPermissionsFeatureFlag; impl FeatureFlag for ToolPermissionsFeatureFlag {