User-defined slash commands (#46815)

Richard Feldman and Zed Zippy created

## Summary

Implements user-defined slash commands for the agent panel. Users can
create markdown files in `~/.config/zed/commands/` (user-wide) or
`.zed/commands/` (project-specific) that expand into templated text when
invoked via `/command_name` in the chat interface.

## Features

- **File-based commands**: Create `.md` files that become slash commands
- **Template expansion**: Use `$1`, `$2`, etc. for positional arguments,
or `$ARGUMENTS` for all args
- **Namespacing**: Subdirectories create namespaced commands (e.g.,
`frontend/component.md` β†’ `/frontend:component`)
- **Project & user scopes**: Project commands in `.zed/commands/`, user
commands in config dir
- **Claude compatibility**: Can symlink `~/.claude/commands/` for
compatibility
- **Caching**: Commands are cached and watched for file changes
- **Error handling**: Graceful degradation with error display in UI

## Feature Flag

Behind `user-slash-commands` feature flag.

## Testing

52 unit/integration tests covering parsing, validation, expansion, file
loading, symlinks, and error handling.

---

Release Notes:

- N/A (behind feature flag)

---------

Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>

Change summary

Cargo.lock                                  |    1 
crates/agent_ui/Cargo.toml                  |    1 
crates/agent_ui/src/acp/entry_view_state.rs |   13 
crates/agent_ui/src/acp/message_editor.rs   |  258 ++
crates/agent_ui/src/acp/thread_view.rs      |  242 ++
crates/agent_ui/src/agent_ui.rs             |    1 
crates/agent_ui/src/completion_provider.rs  |  213 ++
crates/agent_ui/src/user_slash_command.rs   | 2118 +++++++++++++++++++++++
crates/feature_flags/src/flags.rs           |    6 
9 files changed, 2,810 insertions(+), 43 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -408,6 +408,7 @@ dependencies = [
  "streaming_diff",
  "task",
  "telemetry",
+ "tempfile",
  "terminal",
  "terminal_view",
  "text",

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

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<Entry>,
     prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
     available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
+    cached_user_commands: Rc<RefCell<HashMap<String, UserSlashCommand>>>,
+    cached_user_command_errors: Rc<RefCell<Vec<CommandLoadError>>>,
     agent_name: SharedString,
 }
 
@@ -42,6 +45,8 @@ impl EntryViewState {
         prompt_store: Option<Entity<PromptStore>>,
         prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
         available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
+        cached_user_commands: Rc<RefCell<HashMap<String, UserSlashCommand>>>,
+        cached_user_command_errors: Rc<RefCell<Vec<CommandLoadError>>>,
         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(),
             )
         });

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<String, user_slash_command::UserSlashCommand>,
+        errors: Vec<user_slash_command::CommandLoadError>,
+    },
+    FromFs {
+        fs: Arc<dyn fs::Fs>,
+        worktree_roots: Vec<std::path::PathBuf>,
+    },
+}
+
 pub struct MessageEditor {
     mention_set: Entity<MentionSet>,
     editor: Entity<Editor>,
     workspace: WeakEntity<Workspace>,
     prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
     available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
+    cached_user_commands: Rc<RefCell<collections::HashMap<String, UserSlashCommand>>>,
+    cached_user_command_errors: Rc<RefCell<Vec<CommandLoadError>>>,
     agent_name: SharedString,
     thread_store: Option<Entity<ThreadStore>>,
     _subscriptions: Vec<Subscription>,
@@ -92,6 +107,7 @@ impl PromptCompletionProviderDelegate for Entity<MessageEditor> {
                 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<MessageEditor> {
     fn confirm_command(&self, cx: &mut App) {
         self.update(cx, |this, cx| this.send(cx));
     }
+
+    fn cached_user_commands(
+        &self,
+        cx: &App,
+    ) -> Option<collections::HashMap<String, UserSlashCommand>> {
+        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<Vec<CommandLoadError>> {
+        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>,
+    ) -> 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<Workspace>,
+        project: WeakEntity<Project>,
+        thread_store: Option<Entity<ThreadStore>>,
+        history: WeakEntity<AcpThreadHistory>,
+        prompt_store: Option<Entity<PromptStore>>,
+        prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
+        available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
+        cached_user_commands: Rc<RefCell<collections::HashMap<String, UserSlashCommand>>>,
+        cached_user_command_errors: Rc<RefCell<Vec<CommandLoadError>>>,
+        agent_name: SharedString,
+        placeholder: &str,
+        mode: EditorMode,
+        window: &mut Window,
+        cx: &mut Context<Self>,
     ) -> 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<Self>,
     ) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
-        // 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<String, user_slash_command::UserSlashCommand>,
+        >,
+        cached_user_command_errors: Option<Vec<user_slash_command::CommandLoadError>>,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
         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::<UserSlashCommandsFeatureFlag>() {
+            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<std::path::PathBuf> = 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::<HashSet<_>>();
+            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(),

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<RetryStatus>,
     thread_error: Option<ThreadError>,
     thread_error_markdown: Option<Entity<Markdown>>,
+    command_load_errors: Vec<CommandLoadError>,
+    command_load_errors_dismissed: bool,
+    slash_command_registry: Option<Entity<SlashCommandRegistry>>,
     token_limit_callout_dismissed: bool,
     thread_feedback: ThreadFeedbackState,
     list_state: ListState,
@@ -347,6 +358,8 @@ pub struct AcpThreadView {
     discarded_partial_edits: HashSet<acp::ToolCallId>,
     prompt_capabilities: Rc<RefCell<PromptCapabilities>>,
     available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
+    cached_user_commands: Rc<RefCell<HashMap<String, UserSlashCommand>>>,
+    cached_user_command_errors: Rc<RefCell<Vec<CommandLoadError>>>,
     is_loading_contents: bool,
     new_server_version_available: Option<SharedString>,
     resume_thread_metadata: Option<AgentSessionInfo>,
@@ -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::<agent_servers::Codex>().is_some();
 
+        // Create SlashCommandRegistry to cache user-defined slash commands and watch for changes
+        let slash_command_registry = if cx.has_flag::<UserSlashCommandsFeatureFlag>() {
+            let fs = project.read(cx).fs().clone();
+            let worktree_roots: Vec<std::path::PathBuf> = 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(&registry, move |this, registry, event, cx| match event {
+                SlashCommandRegistryEvent::CommandsChanged => {
+                    this.refresh_cached_user_commands_from_registry(&registry, 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::<HashSet<_>>();
+            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<Self>) -> Option<impl IntoElement> {
+        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>) {
+        self.command_load_errors_dismissed = true;
+        cx.notify();
+    }
+
+    fn refresh_cached_user_commands(&mut self, cx: &mut Context<Self>) {
+        let Some(registry) = self.slash_command_registry.clone() else {
+            return;
+        };
+        self.refresh_cached_user_commands_from_registry(&registry, cx);
+    }
+
+    fn refresh_cached_user_commands_from_registry(
+        &mut self,
+        registry: &Entity<SlashCommandRegistry>,
+        cx: &mut Context<Self>,
+    ) {
+        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::<HashSet<_>>();
+        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<String, UserSlashCommand> {
+        self.cached_user_commands.borrow().clone()
+    }
+
+    /// Returns the cached slash command errors, if available.
+    pub fn cached_slash_command_errors(&self, _cx: &App) -> Vec<CommandLoadError> {
+        self.cached_user_command_errors.borrow().clone()
+    }
+
     fn render_thread_error(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<Div> {
         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(|_| {

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;

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<str>,
     pub description: Arc<str>,
     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<str> },
+    /// User-defined command that failed to load
+    UserDefinedError { error_message: Arc<str> },
 }
 
 pub trait PromptCompletionProviderDelegate: Send + Sync + 'static {
@@ -193,6 +209,18 @@ pub trait PromptCompletionProviderDelegate: Send + Sync + 'static {
 
     fn available_commands(&self, cx: &App) -> Vec<AvailableCommand>;
     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<HashMap<String, UserSlashCommand>> {
+        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<Vec<CommandLoadError>> {
+        None
+    }
 }
 
 pub struct PromptCompletionProvider<T: PromptCompletionProviderDelegate> {
@@ -687,11 +715,111 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
 
     fn search_slash_commands(&self, query: String, cx: &mut App) -> Task<Vec<AvailableCommand>> {
         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::<HashSet<_>>();
+
+        // Try to use cached user commands and errors first
+        let cached_user_commands = if cx.has_flag::<UserSlashCommandsFeatureFlag>() {
+            self.source.cached_user_commands(cx)
+        } else {
+            None
+        };
+
+        let cached_user_command_errors = if cx.has_flag::<UserSlashCommandsFeatureFlag>() {
+            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::<UserSlashCommandsFeatureFlag>() {
+                let workspace = self.workspace.upgrade();
+                let fs = workspace
+                    .as_ref()
+                    .map(|w| w.read(cx).project().read(cx).fs().clone());
+                let roots: Vec<std::path::PathBuf> = 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<UserSlashCommand>,
+                Vec<CommandLoadError>,
+            ) = 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<String> = 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<T: PromptCompletionProviderDelegate> 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<T: PromptCompletionProviderDelegate> 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();

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<String> {
+        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<UserSlashCommand>,
+    /// Errors encountered while loading commands
+    pub errors: Vec<CommandLoadError>,
+}
+
+/// 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<str>,
+    /// The template content from the file
+    pub template: Arc<str>,
+    /// The namespace (subdirectory path, if any), used for description display
+    pub namespace: Option<Arc<str>>,
+    /// 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<dyn Fs>,
+    commands: HashMap<String, UserSlashCommand>,
+    errors: Vec<CommandLoadError>,
+    worktree_roots: Vec<PathBuf>,
+    _watch_task: Option<Task<()>>,
+}
+
+impl EventEmitter<SlashCommandRegistryEvent> for SlashCommandRegistry {}
+
+#[allow(dead_code)]
+impl SlashCommandRegistry {
+    /// Creates a new registry and starts loading commands.
+    pub fn new(fs: Arc<dyn Fs>, worktree_roots: Vec<PathBuf>, cx: &mut Context<Self>) -> 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<String, UserSlashCommand> {
+        &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<PathBuf>, cx: &mut Context<Self>) {
+        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<Self>) {
+        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<Self>) {
+        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<dyn Fs>,
+    worktree_roots: &[PathBuf],
+) -> CommandLoadResult {
+    let mut result = CommandLoadResult::default();
+    let mut seen_commands: HashMap<String, PathBuf> = 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<dyn Fs>,
+    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<dyn Fs>,
+    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<dyn Fs>,
+    base_path: &Path,
+    file_path: &Path,
+    scope: CommandScope,
+) -> Result<Option<UserSlashCommand>> {
+    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<String, UserSlashCommand> {
+    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<UserSlashCommand>,
+    errors: &mut Vec<CommandLoadError>,
+    server_command_names: &HashSet<String>,
+) {
+    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<String, UserSlashCommand>,
+    errors: &mut Vec<CommandLoadError>,
+    server_command_names: &HashSet<String>,
+) {
+    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<ParsedUserCommand<'_>> {
+    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<Vec<Cow<'_, str>>> {
+    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::<usize>() {
+                    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<String> {
+    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<String> {
+    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<String, UserSlashCommand>,
+) -> Result<Option<String>> {
+    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<Cow<'_, str>> = 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<Cow<'_, str>> = 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<Cow<'_, str>> = 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<Cow<'_, str>> = 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<Cow<'_, str>> = 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<Cow<'_, str>> = 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<Cow<'_, str>> = (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<Cow<'_, str>> = 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<dyn Fs> = 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<dyn Fs> = 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<dyn Fs> = 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<dyn Fs> = 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<dyn Fs> = 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<dyn Fs> = 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<dyn Fs> = 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<dyn Fs> = 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<dyn Fs> = 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<dyn Fs> = 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<dyn Fs> = 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<dyn Fs> = 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<dyn Fs> = 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<dyn Fs> = 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<dyn Fs> = 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<dyn Fs> = 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<dyn Fs> = 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<dyn Fs> = 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<dyn Fs> = 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<dyn Fs> = 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<dyn Fs> = 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<dyn Fs> = 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")
+        );
+    }
+}

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 {