Cargo.lock π
@@ -408,6 +408,7 @@ dependencies = [
"streaming_diff",
"task",
"telemetry",
+ "tempfile",
"terminal",
"terminal_view",
"text",
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>
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(-)
@@ -408,6 +408,7 @@ dependencies = [
"streaming_diff",
"task",
"telemetry",
+ "tempfile",
"terminal",
"terminal_view",
"text",
@@ -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
@@ -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(),
)
});
@@ -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(),
@@ -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(®istry, move |this, registry, event, cx| match event {
+ SlashCommandRegistryEvent::CommandsChanged => {
+ this.refresh_cached_user_commands_from_registry(®istry, cx);
+ }
+ })
+ .detach();
+
+ // Initialize cached commands and errors from registry
+ let mut commands = registry.read(cx).commands().clone();
+ let mut errors = registry.read(cx).errors().to_vec();
+ let server_command_names = available_commands
+ .borrow()
+ .iter()
+ .map(|command| command.name.clone())
+ .collect::<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(®istry, 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(|_| {
@@ -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;
@@ -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();
@@ -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")
+ );
+ }
+}
@@ -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 {