mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
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:
parent
9ac94ce3e6
commit
7adb97acb9
9 changed files with 2810 additions and 43 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -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();
|
||||
|
|
|
|||
2118
crates/agent_ui/src/user_slash_command.rs
Normal file
2118
crates/agent_ui/src/user_slash_command.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue