User-defined slash commands (#46815)

## 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>
This commit is contained in:
Richard Feldman 2026-01-22 01:43:55 -05:00 committed by GitHub
parent 9ac94ce3e6
commit 7adb97acb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 2810 additions and 43 deletions

1
Cargo.lock generated
View file

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

View file

@ -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

View file

@ -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(),
)
});

View file

@ -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(),

View file

@ -20,7 +20,10 @@ use editor::scroll::Autoscroll;
use editor::{
Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, SizingBehavior,
};
use feature_flags::{AgentSharingFeatureFlag, AgentV2FeatureFlag, FeatureFlagAppExt};
use feature_flags::{
AgentSharingFeatureFlag, AgentV2FeatureFlag, FeatureFlagAppExt as _,
UserSlashCommandsFeatureFlag,
};
use file_icons::FileIcons;
use fs::Fs;
use futures::FutureExt as _;
@ -55,7 +58,9 @@ use ui::{
};
use util::defer;
use util::{ResultExt, size::format_file_size, time::duration_alt_display};
use workspace::{CollaboratorId, NewTerminal, Toast, Workspace, notifications::NotificationId};
use workspace::{
CollaboratorId, NewTerminal, OpenOptions, Toast, Workspace, notifications::NotificationId,
};
use zed_actions::agent::{Chat, ToggleModelSelector};
use zed_actions::assistant::OpenRulesLibrary;
@ -69,6 +74,9 @@ use crate::acp::message_editor::{MessageEditor, MessageEditorEvent};
use crate::agent_diff::AgentDiff;
use crate::profile_selector::{ProfileProvider, ProfileSelector};
use crate::ui::{AgentNotification, AgentNotificationEvent};
use crate::user_slash_command::{
self, CommandLoadError, SlashCommandRegistry, SlashCommandRegistryEvent, UserSlashCommand,
};
use crate::{
AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, AuthorizeToolCall, ClearMessageQueue,
CycleFavoriteModels, CycleModeSelector, EditFirstQueuedMessage, ExpandMessageEditor, Follow,
@ -324,6 +332,9 @@ pub struct AcpThreadView {
thread_retry_status: Option<RetryStatus>,
thread_error: Option<ThreadError>,
thread_error_markdown: Option<Entity<Markdown>>,
command_load_errors: Vec<CommandLoadError>,
command_load_errors_dismissed: bool,
slash_command_registry: Option<Entity<SlashCommandRegistry>>,
token_limit_callout_dismissed: bool,
thread_feedback: ThreadFeedbackState,
list_state: ListState,
@ -347,6 +358,8 @@ pub struct AcpThreadView {
discarded_partial_edits: HashSet<acp::ToolCallId>,
prompt_capabilities: Rc<RefCell<PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
cached_user_commands: Rc<RefCell<HashMap<String, UserSlashCommand>>>,
cached_user_command_errors: Rc<RefCell<Vec<CommandLoadError>>>,
is_loading_contents: bool,
new_server_version_available: Option<SharedString>,
resume_thread_metadata: Option<AgentSessionInfo>,
@ -406,6 +419,9 @@ impl AcpThreadView {
) -> Self {
let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
let available_commands = Rc::new(RefCell::new(vec![]));
let cached_user_commands = Rc::new(RefCell::new(collections::HashMap::default()));
let cached_user_command_errors = Rc::new(RefCell::new(Vec::new()));
let mut command_load_errors = Vec::new();
let agent_server_store = project.read(cx).agent_server_store().clone();
let agent_display_name = agent_server_store
@ -416,7 +432,7 @@ impl AcpThreadView {
let placeholder = placeholder_text(agent_display_name.as_ref(), false);
let message_editor = cx.new(|cx| {
let mut editor = MessageEditor::new(
let mut editor = MessageEditor::new_with_cache(
workspace.clone(),
project.downgrade(),
thread_store.clone(),
@ -424,6 +440,8 @@ impl AcpThreadView {
prompt_store.clone(),
prompt_capabilities.clone(),
available_commands.clone(),
cached_user_commands.clone(),
cached_user_command_errors.clone(),
agent.name(),
&placeholder,
editor::EditorMode::AutoHeight {
@ -450,6 +468,8 @@ impl AcpThreadView {
prompt_store.clone(),
prompt_capabilities.clone(),
available_commands.clone(),
cached_user_commands.clone(),
cached_user_command_errors.clone(),
agent.name(),
)
});
@ -481,6 +501,46 @@ impl AcpThreadView {
&& project.read(cx).is_local()
&& agent.clone().downcast::<agent_servers::Codex>().is_some();
// Create SlashCommandRegistry to cache user-defined slash commands and watch for changes
let slash_command_registry = if cx.has_flag::<UserSlashCommandsFeatureFlag>() {
let fs = project.read(cx).fs().clone();
let worktree_roots: Vec<std::path::PathBuf> = project
.read(cx)
.visible_worktrees(cx)
.map(|worktree| worktree.read(cx).abs_path().to_path_buf())
.collect();
let registry = cx.new(|cx| SlashCommandRegistry::new(fs, worktree_roots, cx));
// Subscribe to registry changes to update error display and cached commands
cx.subscribe(&registry, move |this, registry, event, cx| match event {
SlashCommandRegistryEvent::CommandsChanged => {
this.refresh_cached_user_commands_from_registry(&registry, cx);
}
})
.detach();
// Initialize cached commands and errors from registry
let mut commands = registry.read(cx).commands().clone();
let mut errors = registry.read(cx).errors().to_vec();
let server_command_names = available_commands
.borrow()
.iter()
.map(|command| command.name.clone())
.collect::<HashSet<_>>();
user_slash_command::apply_server_command_conflicts_to_map(
&mut commands,
&mut errors,
&server_command_names,
);
command_load_errors = errors.clone();
*cached_user_commands.borrow_mut() = commands;
*cached_user_command_errors.borrow_mut() = errors;
Some(registry)
} else {
None
};
let recent_history_entries = history.read(cx).get_recent_sessions(3);
let history_subscription = cx.observe(&history, |this, history, cx| {
this.update_recent_history_from_cache(&history, cx);
@ -514,6 +574,9 @@ impl AcpThreadView {
thread_retry_status: None,
thread_error: None,
thread_error_markdown: None,
command_load_errors,
command_load_errors_dismissed: false,
slash_command_registry,
token_limit_callout_dismissed: false,
thread_feedback: Default::default(),
auth_task: None,
@ -532,6 +595,8 @@ impl AcpThreadView {
discarded_partial_edits: HashSet::default(),
prompt_capabilities,
available_commands,
cached_user_commands,
cached_user_command_errors,
editor_expanded: false,
should_be_following: false,
recent_history_entries,
@ -570,6 +635,7 @@ impl AcpThreadView {
cx,
);
self.available_commands.replace(vec![]);
self.refresh_cached_user_commands(cx);
self.new_server_version_available.take();
self.recent_history_entries.clear();
self.turn_tokens = None;
@ -1473,8 +1539,15 @@ impl AcpThreadView {
.is_some_and(|profile| profile.tools.is_empty())
});
let cached_commands = self.cached_slash_commands(cx);
let cached_errors = self.cached_slash_command_errors(cx);
let contents = message_editor.update(cx, |message_editor, cx| {
message_editor.contents(full_mention_content, cx)
message_editor.contents_with_cache(
full_mention_content,
Some(cached_commands),
Some(cached_errors),
cx,
)
});
self.thread_error.take();
@ -1635,8 +1708,15 @@ impl AcpThreadView {
.is_some_and(|profile| profile.tools.is_empty())
});
let cached_commands = self.cached_slash_commands(cx);
let cached_errors = self.cached_slash_command_errors(cx);
let contents = self.message_editor.update(cx, |message_editor, cx| {
message_editor.contents(full_mention_content, cx)
message_editor.contents_with_cache(
full_mention_content,
Some(cached_commands),
Some(cached_errors),
cx,
)
});
let message_editor = self.message_editor.clone();
@ -1998,6 +2078,7 @@ impl AcpThreadView {
let has_commands = !available_commands.is_empty();
self.available_commands.replace(available_commands);
self.refresh_cached_user_commands(cx);
let agent_display_name = self
.agent_server_store
@ -7615,6 +7696,156 @@ impl AcpThreadView {
)
}
fn render_command_load_errors(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
if self.command_load_errors_dismissed || self.command_load_errors.is_empty() {
return None;
}
let error_count = self.command_load_errors.len();
let title = if error_count == 1 {
"Failed to load slash command"
} else {
"Failed to load slash commands"
};
let workspace = self.workspace.clone();
Some(
v_flex()
.w_full()
.p_2()
.gap_1()
.border_t_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().surface_background)
.child(
h_flex()
.justify_between()
.child(
h_flex()
.gap_1()
.child(
Icon::new(IconName::Warning)
.size(IconSize::Small)
.color(Color::Warning),
)
.child(
Label::new(title)
.size(LabelSize::Small)
.color(Color::Warning),
),
)
.child(
IconButton::new("dismiss-command-errors", IconName::Close)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.tooltip(Tooltip::text("Dismiss"))
.on_click(cx.listener(|this, _, _, cx| {
this.clear_command_load_errors(cx);
})),
),
)
.children(self.command_load_errors.iter().enumerate().map({
move |(i, error)| {
let path = error.path.clone();
let workspace = workspace.clone();
let file_name = error
.path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| error.path.display().to_string());
h_flex()
.id(ElementId::Name(format!("command-error-{i}").into()))
.gap_1()
.px_1()
.py_0p5()
.rounded_sm()
.cursor_pointer()
.hover(|style| style.bg(cx.theme().colors().element_hover))
.tooltip(Tooltip::text(format!(
"Click to open {}\n\n{}",
error.path.display(),
error.message
)))
.on_click({
move |_, window, cx| {
if let Some(workspace) = workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
workspace
.open_abs_path(
path.clone(),
OpenOptions::default(),
window,
cx,
)
.detach_and_log_err(cx);
});
}
}
})
.child(
Label::new(format!("{}: {}", file_name, error.message))
.size(LabelSize::Small)
.color(Color::Muted),
)
}
})),
)
}
fn clear_command_load_errors(&mut self, cx: &mut Context<Self>) {
self.command_load_errors_dismissed = true;
cx.notify();
}
fn refresh_cached_user_commands(&mut self, cx: &mut Context<Self>) {
let Some(registry) = self.slash_command_registry.clone() else {
return;
};
self.refresh_cached_user_commands_from_registry(&registry, cx);
}
fn refresh_cached_user_commands_from_registry(
&mut self,
registry: &Entity<SlashCommandRegistry>,
cx: &mut Context<Self>,
) {
let (mut commands, mut errors) = registry.read_with(cx, |registry, _| {
(registry.commands().clone(), registry.errors().to_vec())
});
let server_command_names = self
.available_commands
.borrow()
.iter()
.map(|command| command.name.clone())
.collect::<HashSet<_>>();
user_slash_command::apply_server_command_conflicts_to_map(
&mut commands,
&mut errors,
&server_command_names,
);
self.command_load_errors = errors.clone();
self.command_load_errors_dismissed = false;
*self.cached_user_commands.borrow_mut() = commands;
*self.cached_user_command_errors.borrow_mut() = errors;
cx.notify();
}
/// Returns the cached slash commands, if available.
pub fn cached_slash_commands(
&self,
_cx: &App,
) -> collections::HashMap<String, UserSlashCommand> {
self.cached_user_commands.borrow().clone()
}
/// Returns the cached slash command errors, if available.
pub fn cached_slash_command_errors(&self, _cx: &App) -> Vec<CommandLoadError> {
self.cached_user_command_errors.borrow().clone()
}
fn render_thread_error(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<Div> {
let content = match self.thread_error.as_ref()? {
ThreadError::Other(error) => self.render_any_thread_error(error.clone(), window, cx),
@ -8193,6 +8424,7 @@ impl Render for AcpThreadView {
.when(self.show_codex_windows_warning, |this| {
this.child(self.render_codex_windows_warning(cx))
})
.children(self.render_command_load_errors(cx))
.children(self.render_thread_error(window, cx))
.when_some(
self.new_server_version_available.as_ref().filter(|_| {

View file

@ -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;

View file

@ -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();

File diff suppressed because it is too large Load diff

View file

@ -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 {