From 374a8bc4cb6fd1eb8d8a35b0654f280feba47997 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 2 Sep 2025 10:48:33 +0200 Subject: [PATCH] acp: Add support for slash commands (#37304) Depends on https://github.com/zed-industries/agent-client-protocol/pull/45 Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra Co-authored-by: Agus Zubiaga --- Cargo.lock | 4 +- Cargo.toml | 2 +- crates/acp_thread/src/acp_thread.rs | 8 + crates/acp_thread/src/connection.rs | 2 +- crates/agent2/src/agent.rs | 1 + crates/agent_servers/src/acp.rs | 9 +- .../agent_ui/src/acp/completion_provider.rs | 438 ++++++++++++++---- crates/agent_ui/src/acp/entry_view_state.rs | 22 +- crates/agent_ui/src/acp/message_editor.rs | 435 ++++++++--------- crates/agent_ui/src/acp/thread_view.rs | 16 +- crates/project/src/lsp_store.rs | 15 + 11 files changed, 626 insertions(+), 326 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4ed798b39082206d99f0d2c0f0ae9e7c0ffdb40d..da9eeabee4a37fcdcc10a2448c6a4c434ab2ee7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -195,9 +195,9 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.2.0-alpha.3" +version = "0.2.0-alpha.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ec42b8b612665799c7667890df4b5f5cb441b18a68619fd770f1e054480ee3f" +checksum = "603941db1d130ee275840c465b73a2312727d4acef97449550ccf033de71301f" dependencies = [ "anyhow", "async-broadcast", diff --git a/Cargo.toml b/Cargo.toml index 6cf3d8858b01eb41afdeb860ca4284be0b9280a2..a96dc5d4d57ac5e5be60ace099e42b6a8123c42e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -430,7 +430,7 @@ zlog_settings = { path = "crates/zlog_settings" } # External crates # -agent-client-protocol = { version = "0.2.0-alpha.3", features = ["unstable"] } +agent-client-protocol = { version = "0.2.0-alpha.4", features = ["unstable"]} aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" } any_vec = "0.14" diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index ab6aa98e99d4977256512b7c178dff26b71fc7e7..f9a955eb9f5c7f5cd5ab077ed3c3afd9dfcd4b8b 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -785,6 +785,7 @@ pub struct AcpThread { session_id: acp::SessionId, token_usage: Option, prompt_capabilities: acp::PromptCapabilities, + available_commands: Vec, _observe_prompt_capabilities: Task>, determine_shell: Shared>, terminals: HashMap>, @@ -858,6 +859,7 @@ impl AcpThread { action_log: Entity, session_id: acp::SessionId, mut prompt_capabilities_rx: watch::Receiver, + available_commands: Vec, cx: &mut Context, ) -> Self { let prompt_capabilities = *prompt_capabilities_rx.borrow(); @@ -897,6 +899,7 @@ impl AcpThread { session_id, token_usage: None, prompt_capabilities, + available_commands, _observe_prompt_capabilities: task, terminals: HashMap::default(), determine_shell, @@ -907,6 +910,10 @@ impl AcpThread { self.prompt_capabilities } + pub fn available_commands(&self) -> Vec { + self.available_commands.clone() + } + pub fn connection(&self) -> &Rc { &self.connection } @@ -2864,6 +2871,7 @@ mod tests { audio: true, embedded_context: true, }), + vec![], cx, ) }); diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 96abd1d2b4cf92698e7046cd4b7e24e6043280ff..7901b08c907811ac1b6b74b975ca66b6b901868f 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -75,7 +75,6 @@ pub trait AgentConnection { fn telemetry(&self) -> Option> { None } - fn into_any(self: Rc) -> Rc; } @@ -339,6 +338,7 @@ mod test_support { audio: true, embedded_context: true, }), + vec![], cx, ) }); diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index e96b4c0cfa32be910a7a77e58a1911deb7e5357a..241e3d389f96a320d8a43e23493c4738a76802d6 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -292,6 +292,7 @@ impl NativeAgent { action_log.clone(), session_id.clone(), prompt_capabilities_rx, + vec![], cx, ) }); diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index b29bfd5d8919f87594ccd26cbf9d7fdc3520ad30..7907083e144c3d6188120a8ae8e24aa0ddbd765b 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -28,7 +28,7 @@ pub struct AcpConnection { connection: Rc, sessions: Rc>>, auth_methods: Vec, - prompt_capabilities: acp::PromptCapabilities, + agent_capabilities: acp::AgentCapabilities, _io_task: Task>, _wait_task: Task>, _stderr_task: Task>, @@ -148,7 +148,7 @@ impl AcpConnection { connection, server_name, sessions, - prompt_capabilities: response.agent_capabilities.prompt_capabilities, + agent_capabilities: response.agent_capabilities, _io_task: io_task, _wait_task: wait_task, _stderr_task: stderr_task, @@ -156,7 +156,7 @@ impl AcpConnection { } pub fn prompt_capabilities(&self) -> &acp::PromptCapabilities { - &self.prompt_capabilities + &self.agent_capabilities.prompt_capabilities } } @@ -223,7 +223,8 @@ impl AgentConnection for AcpConnection { action_log, session_id.clone(), // ACP doesn't currently support per-session prompt capabilities or changing capabilities dynamically. - watch::Receiver::constant(self.prompt_capabilities), + watch::Receiver::constant(self.agent_capabilities.prompt_capabilities), + response.available_commands, cx, ) })?; diff --git a/crates/agent_ui/src/acp/completion_provider.rs b/crates/agent_ui/src/acp/completion_provider.rs index 5b4096706981f11e8340e1017a7955676865eb90..59106c3795aa14794e1fca9ee32049e0cff1314f 100644 --- a/crates/agent_ui/src/acp/completion_provider.rs +++ b/crates/agent_ui/src/acp/completion_provider.rs @@ -1,4 +1,4 @@ -use std::cell::Cell; +use std::cell::{Cell, RefCell}; use std::ops::Range; use std::rc::Rc; use std::sync::Arc; @@ -13,6 +13,7 @@ use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{App, Entity, Task, WeakEntity}; use language::{Buffer, CodeLabel, HighlightId}; use lsp::CompletionContext; +use project::lsp_store::CompletionDocumentation; use project::{ Completion, CompletionIntent, CompletionResponse, Project, ProjectPath, Symbol, WorktreeId, }; @@ -23,7 +24,7 @@ use ui::prelude::*; use workspace::Workspace; use crate::AgentPanel; -use crate::acp::message_editor::MessageEditor; +use crate::acp::message_editor::{MessageEditor, MessageEditorEvent}; use crate::context_picker::file_context_picker::{FileMatch, search_files}; use crate::context_picker::rules_context_picker::{RulesContextEntry, search_rules}; use crate::context_picker::symbol_context_picker::SymbolMatch; @@ -67,6 +68,7 @@ pub struct ContextPickerCompletionProvider { history_store: Entity, prompt_store: Option>, prompt_capabilities: Rc>, + available_commands: Rc>>, } impl ContextPickerCompletionProvider { @@ -76,6 +78,7 @@ impl ContextPickerCompletionProvider { history_store: Entity, prompt_store: Option>, prompt_capabilities: Rc>, + available_commands: Rc>>, ) -> Self { Self { message_editor, @@ -83,6 +86,7 @@ impl ContextPickerCompletionProvider { history_store, prompt_store, prompt_capabilities, + available_commands, } } @@ -369,7 +373,42 @@ impl ContextPickerCompletionProvider { }) } - fn search( + fn search_slash_commands( + &self, + query: String, + cx: &mut App, + ) -> Task> { + let commands = self.available_commands.borrow().clone(); + if commands.is_empty() { + return Task::ready(Vec::new()); + } + + cx.spawn(async move |cx| { + let candidates = commands + .iter() + .enumerate() + .map(|(id, command)| StringMatchCandidate::new(id, &command.name)) + .collect::>(); + + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + true, + 100, + &Arc::new(AtomicBool::default()), + cx.background_executor().clone(), + ) + .await; + + matches + .into_iter() + .map(|mat| commands[mat.candidate_id].clone()) + .collect() + }) + } + + fn search_mentions( &self, mode: Option, query: String, @@ -651,10 +690,10 @@ impl CompletionProvider for ContextPickerCompletionProvider { let offset_to_line = buffer.point_to_offset(line_start); let mut lines = buffer.text_for_range(line_start..position).lines(); let line = lines.next()?; - MentionCompletion::try_parse( - self.prompt_capabilities.get().embedded_context, + ContextCompletion::try_parse( line, offset_to_line, + self.prompt_capabilities.get().embedded_context, ) }); let Some(state) = state else { @@ -667,97 +706,169 @@ impl CompletionProvider for ContextPickerCompletionProvider { let project = workspace.read(cx).project().clone(); let snapshot = buffer.read(cx).snapshot(); - let source_range = snapshot.anchor_before(state.source_range.start) - ..snapshot.anchor_after(state.source_range.end); + let source_range = snapshot.anchor_before(state.source_range().start) + ..snapshot.anchor_after(state.source_range().end); let editor = self.message_editor.clone(); - let MentionCompletion { mode, argument, .. } = state; - let query = argument.unwrap_or_else(|| "".to_string()); - - let search_task = self.search(mode, query, Arc::::default(), cx); - - cx.spawn(async move |_, cx| { - let matches = search_task.await; - - let completions = cx.update(|cx| { - matches - .into_iter() - .filter_map(|mat| match mat { - Match::File(FileMatch { mat, is_recent }) => { - let project_path = ProjectPath { - worktree_id: WorktreeId::from_usize(mat.worktree_id), - path: mat.path.clone(), + match state { + ContextCompletion::SlashCommand(SlashCommandCompletion { + command, argument, .. + }) => { + let search_task = self.search_slash_commands(command.unwrap_or_default(), cx); + cx.background_spawn(async move { + let completions = search_task + .await + .into_iter() + .map(|command| { + let new_text = if let Some(argument) = argument.as_ref() { + format!("/{} {}", command.name, argument) + } else { + format!("/{} ", command.name) }; - Self::completion_for_path( - project_path, - &mat.path_prefix, - is_recent, - mat.is_dir, - source_range.clone(), - editor.clone(), - project.clone(), - cx, - ) - } - - Match::Symbol(SymbolMatch { symbol, .. }) => Self::completion_for_symbol( - symbol, - source_range.clone(), - editor.clone(), - workspace.clone(), - cx, - ), - - Match::Thread(thread) => Some(Self::completion_for_thread( - thread, - source_range.clone(), - false, - editor.clone(), - cx, - )), - - Match::RecentThread(thread) => Some(Self::completion_for_thread( - thread, - source_range.clone(), - true, - editor.clone(), - cx, - )), - - Match::Rules(user_rules) => Some(Self::completion_for_rules( - user_rules, - source_range.clone(), - editor.clone(), - cx, - )), + let is_missing_argument = argument.is_none() && command.input.is_some(); + Completion { + replace_range: source_range.clone(), + new_text, + label: CodeLabel::plain(command.name.to_string(), None), + documentation: Some(CompletionDocumentation::SingleLine( + command.description.into(), + )), + source: project::CompletionSource::Custom, + icon_path: None, + insert_text_mode: None, + confirm: Some(Arc::new({ + let editor = editor.clone(); + move |intent, _window, cx| { + if !is_missing_argument { + cx.defer({ + let editor = editor.clone(); + move |cx| { + editor + .update(cx, |_editor, cx| { + match intent { + CompletionIntent::Complete + | CompletionIntent::CompleteWithInsert + | CompletionIntent::CompleteWithReplace => { + if !is_missing_argument { + cx.emit(MessageEditorEvent::Send); + } + } + CompletionIntent::Compose => {} + } + }) + .ok(); + } + }); + } + is_missing_argument + } + })), + } + }) + .collect(); + + Ok(vec![CompletionResponse { + completions, + // Since this does its own filtering (see `filter_completions()` returns false), + // there is no benefit to computing whether this set of completions is incomplete. + is_incomplete: true, + }]) + }) + } + ContextCompletion::Mention(MentionCompletion { mode, argument, .. }) => { + let query = argument.unwrap_or_default(); + let search_task = + self.search_mentions(mode, query, Arc::::default(), cx); - Match::Fetch(url) => Self::completion_for_fetch( - source_range.clone(), - url, - editor.clone(), - cx, - ), + cx.spawn(async move |_, cx| { + let matches = search_task.await; - Match::Entry(EntryMatch { entry, .. }) => Self::completion_for_entry( - entry, - source_range.clone(), - editor.clone(), - &workspace, - cx, - ), - }) - .collect() - })?; - - Ok(vec![CompletionResponse { - completions, - // Since this does its own filtering (see `filter_completions()` returns false), - // there is no benefit to computing whether this set of completions is incomplete. - is_incomplete: true, - }]) - }) + let completions = cx.update(|cx| { + matches + .into_iter() + .filter_map(|mat| match mat { + Match::File(FileMatch { mat, is_recent }) => { + let project_path = ProjectPath { + worktree_id: WorktreeId::from_usize(mat.worktree_id), + path: mat.path.clone(), + }; + + Self::completion_for_path( + project_path, + &mat.path_prefix, + is_recent, + mat.is_dir, + source_range.clone(), + editor.clone(), + project.clone(), + cx, + ) + } + + Match::Symbol(SymbolMatch { symbol, .. }) => { + Self::completion_for_symbol( + symbol, + source_range.clone(), + editor.clone(), + workspace.clone(), + cx, + ) + } + + Match::Thread(thread) => Some(Self::completion_for_thread( + thread, + source_range.clone(), + false, + editor.clone(), + cx, + )), + + Match::RecentThread(thread) => Some(Self::completion_for_thread( + thread, + source_range.clone(), + true, + editor.clone(), + cx, + )), + + Match::Rules(user_rules) => Some(Self::completion_for_rules( + user_rules, + source_range.clone(), + editor.clone(), + cx, + )), + + Match::Fetch(url) => Self::completion_for_fetch( + source_range.clone(), + url, + editor.clone(), + cx, + ), + + Match::Entry(EntryMatch { entry, .. }) => { + Self::completion_for_entry( + entry, + source_range.clone(), + editor.clone(), + &workspace, + cx, + ) + } + }) + .collect() + })?; + + Ok(vec![CompletionResponse { + completions, + // Since this does its own filtering (see `filter_completions()` returns false), + // there is no benefit to computing whether this set of completions is incomplete. + is_incomplete: true, + }]) + }) + } + } } fn is_completion_trigger( @@ -775,14 +886,14 @@ impl CompletionProvider for ContextPickerCompletionProvider { let offset_to_line = buffer.point_to_offset(line_start); let mut lines = buffer.text_for_range(line_start..position).lines(); if let Some(line) = lines.next() { - MentionCompletion::try_parse( - self.prompt_capabilities.get().embedded_context, + ContextCompletion::try_parse( line, offset_to_line, + self.prompt_capabilities.get().embedded_context, ) .map(|completion| { - completion.source_range.start <= offset_to_line + position.column as usize - && completion.source_range.end >= offset_to_line + position.column as usize + completion.source_range().start <= offset_to_line + position.column as usize + && completion.source_range().end >= offset_to_line + position.column as usize }) .unwrap_or(false) } else { @@ -851,7 +962,7 @@ fn confirm_completion_callback( .clone() .update(cx, |message_editor, cx| { message_editor - .confirm_completion( + .confirm_mention_completion( crease_text, start, content_len, @@ -867,6 +978,89 @@ fn confirm_completion_callback( }) } +enum ContextCompletion { + SlashCommand(SlashCommandCompletion), + Mention(MentionCompletion), +} + +impl ContextCompletion { + fn source_range(&self) -> Range { + match self { + Self::SlashCommand(completion) => completion.source_range.clone(), + Self::Mention(completion) => completion.source_range.clone(), + } + } + + fn try_parse(line: &str, offset_to_line: usize, allow_non_file_mentions: bool) -> Option { + if let Some(command) = SlashCommandCompletion::try_parse(line, offset_to_line) { + Some(Self::SlashCommand(command)) + } else if let Some(mention) = + MentionCompletion::try_parse(allow_non_file_mentions, line, offset_to_line) + { + Some(Self::Mention(mention)) + } else { + None + } + } +} + +#[derive(Debug, Default, PartialEq)] +struct SlashCommandCompletion { + source_range: Range, + command: Option, + argument: Option, +} + +impl SlashCommandCompletion { + fn try_parse(line: &str, offset_to_line: usize) -> Option { + // If we decide to support commands that are not at the beginning of the prompt, we can remove this check + if !line.starts_with('/') || offset_to_line != 0 { + return None; + } + + let last_command_start = line.rfind('/')?; + if last_command_start >= line.len() { + return Some(Self::default()); + } + if last_command_start > 0 + && line + .chars() + .nth(last_command_start - 1) + .is_some_and(|c| !c.is_whitespace()) + { + return None; + } + + let rest_of_line = &line[last_command_start + 1..]; + + let mut command = None; + let mut argument = None; + let mut end = last_command_start + 1; + + if let Some(command_text) = rest_of_line.split_whitespace().next() { + command = Some(command_text.to_string()); + end += command_text.len(); + + // Find the start of arguments after the command + if let Some(args_start) = + rest_of_line[command_text.len()..].find(|c: char| !c.is_whitespace()) + { + let args = &rest_of_line[command_text.len() + args_start..].trim_end(); + if !args.is_empty() { + argument = Some(args.to_string()); + end += args.len() + 1; + } + } + } + + Some(Self { + source_range: last_command_start + offset_to_line..end + offset_to_line, + command, + argument, + }) + } +} + #[derive(Debug, Default, PartialEq)] struct MentionCompletion { source_range: Range, @@ -932,6 +1126,62 @@ impl MentionCompletion { mod tests { use super::*; + #[test] + fn test_slash_command_completion_parse() { + assert_eq!( + SlashCommandCompletion::try_parse("/", 0), + Some(SlashCommandCompletion { + source_range: 0..1, + command: None, + argument: None, + }) + ); + + assert_eq!( + SlashCommandCompletion::try_parse("/help", 0), + Some(SlashCommandCompletion { + source_range: 0..5, + command: Some("help".to_string()), + argument: None, + }) + ); + + assert_eq!( + SlashCommandCompletion::try_parse("/help ", 0), + Some(SlashCommandCompletion { + source_range: 0..5, + command: Some("help".to_string()), + argument: None, + }) + ); + + assert_eq!( + SlashCommandCompletion::try_parse("/help arg1", 0), + Some(SlashCommandCompletion { + source_range: 0..10, + command: Some("help".to_string()), + argument: Some("arg1".to_string()), + }) + ); + + assert_eq!( + SlashCommandCompletion::try_parse("/help arg1 arg2", 0), + Some(SlashCommandCompletion { + source_range: 0..15, + command: Some("help".to_string()), + argument: Some("arg1 arg2".to_string()), + }) + ); + + assert_eq!(SlashCommandCompletion::try_parse("Lorem Ipsum", 0), None); + + assert_eq!(SlashCommandCompletion::try_parse("Lorem /", 0), None); + + assert_eq!(SlashCommandCompletion::try_parse("Lorem /help", 0), None); + + assert_eq!(SlashCommandCompletion::try_parse("Lorem/", 0), None); + } + #[test] fn test_mention_completion_parse() { assert_eq!(MentionCompletion::try_parse(true, "Lorem Ipsum", 0), None); diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs index 0103219e31e8210440e66637cce8101d283210ea..4a91e93fa894953ef2f1f730ffa0d3896213e625 100644 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ b/crates/agent_ui/src/acp/entry_view_state.rs @@ -1,7 +1,11 @@ -use std::{cell::Cell, ops::Range, rc::Rc}; +use std::{ + cell::{Cell, RefCell}, + ops::Range, + rc::Rc, +}; use acp_thread::{AcpThread, AgentThreadEntry}; -use agent_client_protocol::{PromptCapabilities, ToolCallId}; +use agent_client_protocol::{self as acp, ToolCallId}; use agent2::HistoryStore; use collections::HashMap; use editor::{Editor, EditorMode, MinimapVisibility}; @@ -26,8 +30,8 @@ pub struct EntryViewState { history_store: Entity, prompt_store: Option>, entries: Vec, - prevent_slash_commands: bool, - prompt_capabilities: Rc>, + prompt_capabilities: Rc>, + available_commands: Rc>>, } impl EntryViewState { @@ -36,8 +40,8 @@ impl EntryViewState { project: Entity, history_store: Entity, prompt_store: Option>, - prompt_capabilities: Rc>, - prevent_slash_commands: bool, + prompt_capabilities: Rc>, + available_commands: Rc>>, ) -> Self { Self { workspace, @@ -45,8 +49,8 @@ impl EntryViewState { history_store, prompt_store, entries: Vec::new(), - prevent_slash_commands, prompt_capabilities, + available_commands, } } @@ -85,8 +89,8 @@ impl EntryViewState { self.history_store.clone(), self.prompt_store.clone(), self.prompt_capabilities.clone(), + self.available_commands.clone(), "Edit message - @ to include context", - self.prevent_slash_commands, editor::EditorMode::AutoHeight { min_lines: 1, max_lines: None, @@ -471,7 +475,7 @@ mod tests { history_store, None, Default::default(), - false, + Default::default(), ) }); diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index b9e85e0ee34b3dccd0dcd4a22c1fbaa05031e2d9..b51bc2e0a3d10a3647fafd816684c7382c5dde36 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -12,7 +12,7 @@ use collections::{HashMap, HashSet}; use editor::{ Addon, Anchor, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorEvent, EditorMode, EditorSnapshot, EditorStyle, ExcerptId, FoldPlaceholder, MultiBuffer, - SemanticsProvider, ToOffset, + ToOffset, actions::Paste, display_map::{Crease, CreaseId, FoldId}, }; @@ -22,8 +22,8 @@ use futures::{ }; use gpui::{ Animation, AnimationExt as _, AppContext, ClipboardEntry, Context, Entity, EntityId, - EventEmitter, FocusHandle, Focusable, HighlightStyle, Image, ImageFormat, Img, KeyContext, - Subscription, Task, TextStyle, UnderlineStyle, WeakEntity, pulsating_between, + EventEmitter, FocusHandle, Focusable, Image, ImageFormat, Img, KeyContext, Subscription, Task, + TextStyle, WeakEntity, pulsating_between, }; use language::{Buffer, Language}; use language_model::LanguageModelImage; @@ -33,7 +33,7 @@ use prompt_store::{PromptId, PromptStore}; use rope::Point; use settings::Settings; use std::{ - cell::Cell, + cell::{Cell, RefCell}, ffi::OsStr, fmt::Write, ops::{Range, RangeInclusive}, @@ -42,20 +42,18 @@ use std::{ sync::Arc, time::Duration, }; -use text::{OffsetRangeExt, ToOffset as _}; +use text::OffsetRangeExt; use theme::ThemeSettings; use ui::{ ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, ButtonStyle, Color, Element as _, FluentBuilder as _, Icon, IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon, LabelSize, ParentElement, Render, SelectableButton, SharedString, Styled, - TextSize, TintColor, Toggleable, Window, div, h_flex, px, + TextSize, TintColor, Toggleable, Window, div, h_flex, }; use util::{ResultExt, debug_panic}; use workspace::{Workspace, notifications::NotifyResultExt as _}; use zed_actions::agent::Chat; -const PARSE_SLASH_COMMAND_DEBOUNCE: Duration = Duration::from_millis(50); - pub struct MessageEditor { mention_set: MentionSet, editor: Entity, @@ -63,7 +61,6 @@ pub struct MessageEditor { workspace: WeakEntity, history_store: Entity, prompt_store: Option>, - prevent_slash_commands: bool, prompt_capabilities: Rc>, _subscriptions: Vec, _parse_slash_command_task: Task<()>, @@ -86,8 +83,8 @@ impl MessageEditor { history_store: Entity, prompt_store: Option>, prompt_capabilities: Rc>, + available_commands: Rc>>, placeholder: impl Into>, - prevent_slash_commands: bool, mode: EditorMode, window: &mut Window, cx: &mut Context, @@ -99,16 +96,14 @@ impl MessageEditor { }, None, ); - let completion_provider = ContextPickerCompletionProvider::new( + let completion_provider = Rc::new(ContextPickerCompletionProvider::new( cx.weak_entity(), workspace.clone(), history_store.clone(), prompt_store.clone(), prompt_capabilities.clone(), - ); - let semantics_provider = Rc::new(SlashCommandSemanticsProvider { - range: Cell::new(None), - }); + available_commands, + )); let mention_set = MentionSet::default(); let editor = cx.new(|cx| { let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx)); @@ -119,15 +114,12 @@ impl MessageEditor { editor.set_show_indent_guides(false, cx); editor.set_soft_wrap(); editor.set_use_modal_editing(true); - editor.set_completion_provider(Some(Rc::new(completion_provider))); + editor.set_completion_provider(Some(completion_provider.clone())); editor.set_context_menu_options(ContextMenuOptions { min_entries_visible: 12, max_entries_visible: 12, placement: Some(ContextMenuPlacement::Above), }); - if prevent_slash_commands { - editor.set_semantics_provider(Some(semantics_provider.clone())); - } editor.register_addon(MessageEditorAddon::new()); editor }); @@ -143,17 +135,8 @@ impl MessageEditor { let mut subscriptions = Vec::new(); subscriptions.push(cx.subscribe_in(&editor, window, { - let semantics_provider = semantics_provider.clone(); move |this, editor, event, window, cx| { if let EditorEvent::Edited { .. } = event { - if prevent_slash_commands { - this.highlight_slash_command( - semantics_provider.clone(), - editor.clone(), - window, - cx, - ); - } let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx)); this.mention_set.remove_invalid(snapshot); cx.notify(); @@ -168,7 +151,6 @@ impl MessageEditor { workspace, history_store, prompt_store, - prevent_slash_commands, prompt_capabilities, _subscriptions: subscriptions, _parse_slash_command_task: Task::ready(()), @@ -191,7 +173,7 @@ impl MessageEditor { .text_anchor }); - self.confirm_completion( + self.confirm_mention_completion( thread.title.clone(), start, thread.title.len(), @@ -227,7 +209,7 @@ impl MessageEditor { .collect() } - pub fn confirm_completion( + pub fn confirm_mention_completion( &mut self, crease_text: SharedString, start: text::Anchor, @@ -687,7 +669,6 @@ impl MessageEditor { .mention_set .contents(&self.prompt_capabilities.get(), cx); let editor = self.editor.clone(); - let prevent_slash_commands = self.prevent_slash_commands; cx.spawn(async move |_, cx| { let contents = contents.await?; @@ -706,14 +687,16 @@ impl MessageEditor { let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot); if crease_range.start > ix { - let chunk = if prevent_slash_commands - && ix == 0 - && parse_slash_command(&text[ix..]).is_some() - { - format!(" {}", &text[ix..crease_range.start]).into() - } else { - text[ix..crease_range.start].into() - }; + //todo(): Custom slash command ContentBlock? + // let chunk = if prevent_slash_commands + // && ix == 0 + // && parse_slash_command(&text[ix..]).is_some() + // { + // format!(" {}", &text[ix..crease_range.start]).into() + // } else { + // text[ix..crease_range.start].into() + // }; + let chunk = text[ix..crease_range.start].into(); chunks.push(chunk); } let chunk = match mention { @@ -769,14 +752,16 @@ impl MessageEditor { } if ix < text.len() { - let last_chunk = if prevent_slash_commands - && ix == 0 - && parse_slash_command(&text[ix..]).is_some() - { - format!(" {}", text[ix..].trim_end()) - } else { - text[ix..].trim_end().to_owned() - }; + //todo(): Custom slash command ContentBlock? + // let last_chunk = if prevent_slash_commands + // && ix == 0 + // && parse_slash_command(&text[ix..]).is_some() + // { + // format!(" {}", text[ix..].trim_end()) + // } else { + // text[ix..].trim_end().to_owned() + // }; + let last_chunk = text[ix..].trim_end().to_owned(); if !last_chunk.is_empty() { chunks.push(last_chunk.into()); } @@ -971,7 +956,14 @@ impl MessageEditor { cx, ); }); - tasks.push(self.confirm_completion(file_name, anchor, content_len, uri, window, cx)); + tasks.push(self.confirm_mention_completion( + file_name, + anchor, + content_len, + uri, + window, + cx, + )); } cx.spawn(async move |_, _| { join_all(tasks).await; @@ -1133,48 +1125,6 @@ impl MessageEditor { cx.notify(); } - fn highlight_slash_command( - &mut self, - semantics_provider: Rc, - editor: Entity, - window: &mut Window, - cx: &mut Context, - ) { - struct InvalidSlashCommand; - - self._parse_slash_command_task = cx.spawn_in(window, async move |_, cx| { - cx.background_executor() - .timer(PARSE_SLASH_COMMAND_DEBOUNCE) - .await; - editor - .update_in(cx, |editor, window, cx| { - let snapshot = editor.snapshot(window, cx); - let range = parse_slash_command(&editor.text(cx)); - semantics_provider.range.set(range); - if let Some((start, end)) = range { - editor.highlight_text::( - vec![ - snapshot.buffer_snapshot.anchor_after(start) - ..snapshot.buffer_snapshot.anchor_before(end), - ], - HighlightStyle { - underline: Some(UnderlineStyle { - thickness: px(1.), - color: Some(gpui::red()), - wavy: true, - }), - ..Default::default() - }, - cx, - ); - } else { - editor.clear_highlights::(cx); - } - }) - .ok(); - }) - } - pub fn text(&self, cx: &App) -> String { self.editor.read(cx).text(cx) } @@ -1264,7 +1214,7 @@ pub(crate) fn insert_crease_for_mention( let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len); let placeholder = FoldPlaceholder { - render: render_fold_icon_button( + render: render_mention_fold_button( crease_label, crease_icon, start..end, @@ -1294,7 +1244,7 @@ pub(crate) fn insert_crease_for_mention( Some((crease_id, tx)) } -fn render_fold_icon_button( +fn render_mention_fold_button( label: SharedString, icon: SharedString, range: Range, @@ -1471,118 +1421,6 @@ impl MentionSet { } } -struct SlashCommandSemanticsProvider { - range: Cell>, -} - -impl SemanticsProvider for SlashCommandSemanticsProvider { - fn hover( - &self, - buffer: &Entity, - position: text::Anchor, - cx: &mut App, - ) -> Option>>> { - let snapshot = buffer.read(cx).snapshot(); - let offset = position.to_offset(&snapshot); - let (start, end) = self.range.get()?; - if !(start..end).contains(&offset) { - return None; - } - let range = snapshot.anchor_after(start)..snapshot.anchor_after(end); - Some(Task::ready(Some(vec![project::Hover { - contents: vec![project::HoverBlock { - text: "Slash commands are not supported".into(), - kind: project::HoverBlockKind::PlainText, - }], - range: Some(range), - language: None, - }]))) - } - - fn inline_values( - &self, - _buffer_handle: Entity, - _range: Range, - _cx: &mut App, - ) -> Option>>> { - None - } - - fn inlay_hints( - &self, - _buffer_handle: Entity, - _range: Range, - _cx: &mut App, - ) -> Option>>> { - None - } - - fn resolve_inlay_hint( - &self, - _hint: project::InlayHint, - _buffer_handle: Entity, - _server_id: lsp::LanguageServerId, - _cx: &mut App, - ) -> Option>> { - None - } - - fn supports_inlay_hints(&self, _buffer: &Entity, _cx: &mut App) -> bool { - false - } - - fn document_highlights( - &self, - _buffer: &Entity, - _position: text::Anchor, - _cx: &mut App, - ) -> Option>>> { - None - } - - fn definitions( - &self, - _buffer: &Entity, - _position: text::Anchor, - _kind: editor::GotoDefinitionKind, - _cx: &mut App, - ) -> Option>>>> { - None - } - - fn range_for_rename( - &self, - _buffer: &Entity, - _position: text::Anchor, - _cx: &mut App, - ) -> Option>>>> { - None - } - - fn perform_rename( - &self, - _buffer: &Entity, - _position: text::Anchor, - _new_name: String, - _cx: &mut App, - ) -> Option>> { - None - } -} - -fn parse_slash_command(text: &str) -> Option<(usize, usize)> { - if let Some(remainder) = text.strip_prefix('/') { - let pos = remainder - .find(char::is_whitespace) - .unwrap_or(remainder.len()); - let command = &remainder[..pos]; - if !command.is_empty() && command.chars().all(char::is_alphanumeric) { - return Some((0, 1 + command.len())); - } - } - None -} - pub struct MessageEditorAddon {} impl MessageEditorAddon { @@ -1610,7 +1448,13 @@ impl Addon for MessageEditorAddon { #[cfg(test)] mod tests { - use std::{cell::Cell, ops::Range, path::Path, rc::Rc, sync::Arc}; + use std::{ + cell::{Cell, RefCell}, + ops::Range, + path::Path, + rc::Rc, + sync::Arc, + }; use acp_thread::MentionUri; use agent_client_protocol as acp; @@ -1657,8 +1501,8 @@ mod tests { history_store.clone(), None, Default::default(), + Default::default(), "Test", - false, EditorMode::AutoHeight { min_lines: 1, max_lines: None, @@ -1764,7 +1608,163 @@ mod tests { } #[gpui::test] - async fn test_context_completion_provider(cx: &mut TestAppContext) { + async fn test_completion_provider_commands(cx: &mut TestAppContext) { + init_test(cx); + + let app_state = cx.update(AppState::test); + + cx.update(|cx| { + language::init(cx); + editor::init(cx); + workspace::init(app_state.clone(), cx); + Project::init_settings(cx); + }); + + let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; + let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let workspace = window.root(cx).unwrap(); + + let mut cx = VisualTestContext::from_window(*window, cx); + + let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(context_store, cx)); + let prompt_capabilities = Rc::new(Cell::new(acp::PromptCapabilities::default())); + let available_commands = Rc::new(RefCell::new(vec![ + acp::AvailableCommand { + name: "quick-math".to_string(), + description: "2 + 2 = 4 - 1 = 3".to_string(), + input: None, + }, + acp::AvailableCommand { + name: "say-hello".to_string(), + description: "Say hello to whoever you want".to_string(), + input: Some(acp::AvailableCommandInput::Unstructured { + hint: "Who do you want to say hello to?".to_string(), + }), + }, + ])); + + let editor = workspace.update_in(&mut cx, |workspace, window, cx| { + let workspace_handle = cx.weak_entity(); + let message_editor = cx.new(|cx| { + MessageEditor::new( + workspace_handle, + project.clone(), + history_store.clone(), + None, + prompt_capabilities.clone(), + available_commands.clone(), + "Test", + EditorMode::AutoHeight { + max_lines: None, + min_lines: 1, + }, + window, + cx, + ) + }); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item( + Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))), + true, + true, + None, + window, + cx, + ); + }); + message_editor.read(cx).focus_handle(cx).focus(window); + message_editor.read(cx).editor().clone() + }); + + cx.simulate_input("/"); + + editor.update_in(&mut cx, |editor, window, cx| { + assert_eq!(editor.text(cx), "/"); + assert!(editor.has_visible_completions_menu()); + + assert_eq!( + current_completion_labels_with_documentation(editor), + &[ + ("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()), + ("say-hello".into(), "Say hello to whoever you want".into()) + ] + ); + editor.set_text("", window, cx); + }); + + cx.simulate_input("/qui"); + + editor.update_in(&mut cx, |editor, window, cx| { + assert_eq!(editor.text(cx), "/qui"); + assert!(editor.has_visible_completions_menu()); + + assert_eq!( + current_completion_labels_with_documentation(editor), + &[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())] + ); + editor.set_text("", window, cx); + }); + + editor.update_in(&mut cx, |editor, window, cx| { + assert!(editor.has_visible_completions_menu()); + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + cx.run_until_parked(); + + editor.update_in(&mut cx, |editor, window, cx| { + assert_eq!(editor.text(cx), "/quick-math "); + assert!(!editor.has_visible_completions_menu()); + editor.set_text("", window, cx); + }); + + cx.simulate_input("/say"); + + editor.update_in(&mut cx, |editor, _window, cx| { + assert_eq!(editor.text(cx), "/say"); + assert!(editor.has_visible_completions_menu()); + + assert_eq!( + current_completion_labels_with_documentation(editor), + &[("say-hello".into(), "Say hello to whoever you want".into())] + ); + }); + + editor.update_in(&mut cx, |editor, window, cx| { + assert!(editor.has_visible_completions_menu()); + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + cx.run_until_parked(); + + editor.update_in(&mut cx, |editor, _window, cx| { + assert_eq!(editor.text(cx), "/say-hello "); + assert!(editor.has_visible_completions_menu()); + + assert_eq!( + current_completion_labels_with_documentation(editor), + &[("say-hello".into(), "Say hello to whoever you want".into())] + ); + }); + + cx.simulate_input("GPT5"); + + editor.update_in(&mut cx, |editor, window, cx| { + assert!(editor.has_visible_completions_menu()); + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + cx.run_until_parked(); + + editor.update_in(&mut cx, |editor, _window, cx| { + assert_eq!(editor.text(cx), "/say-hello GPT5"); + assert!(!editor.has_visible_completions_menu()); + }); + } + + #[gpui::test] + async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) { init_test(cx); let app_state = cx.update(AppState::test); @@ -1857,8 +1857,8 @@ mod tests { history_store.clone(), None, prompt_capabilities.clone(), + Default::default(), "Test", - false, EditorMode::AutoHeight { max_lines: None, min_lines: 1, @@ -1888,7 +1888,6 @@ mod tests { assert_eq!(editor.text(cx), "Lorem @"); assert!(editor.has_visible_completions_menu()); - // Only files since we have default capabilities assert_eq!( current_completion_labels(editor), &[ @@ -2284,4 +2283,20 @@ mod tests { .map(|completion| completion.label.text) .collect::>() } + + fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> { + let completions = editor.current_completions().expect("Missing completions"); + completions + .into_iter() + .map(|completion| { + ( + completion.label.text, + completion + .documentation + .map(|d| d.text().to_string()) + .unwrap_or_default(), + ) + }) + .collect::>() + } } diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 5e842d713d3d2e20f71e5a0b5e5c1fce773bed8d..c039826c83affe53d8354bf2c744bbe40b1015b8 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -35,7 +35,7 @@ use project::{Project, ProjectEntryId}; use prompt_store::{PromptId, PromptStore}; use rope::Point; use settings::{Settings as _, SettingsStore}; -use std::cell::Cell; +use std::cell::{Cell, RefCell}; use std::path::Path; use std::sync::Arc; use std::time::Instant; @@ -284,6 +284,7 @@ pub struct AcpThreadView { should_be_following: bool, editing_message: Option, prompt_capabilities: Rc>, + available_commands: Rc>>, is_loading_contents: bool, _cancel_task: Option>, _subscriptions: [Subscription; 3], @@ -325,7 +326,7 @@ impl AcpThreadView { cx: &mut Context, ) -> Self { let prompt_capabilities = Rc::new(Cell::new(acp::PromptCapabilities::default())); - let prevent_slash_commands = agent.clone().downcast::().is_some(); + let available_commands = Rc::new(RefCell::new(vec![])); let placeholder = if agent.name() == "Zed Agent" { format!("Message the {} — @ to include context", agent.name()) @@ -340,8 +341,8 @@ impl AcpThreadView { history_store.clone(), prompt_store.clone(), prompt_capabilities.clone(), + available_commands.clone(), placeholder, - prevent_slash_commands, editor::EditorMode::AutoHeight { min_lines: MIN_EDITOR_LINES, max_lines: Some(MAX_EDITOR_LINES), @@ -364,7 +365,7 @@ impl AcpThreadView { history_store.clone(), prompt_store.clone(), prompt_capabilities.clone(), - prevent_slash_commands, + available_commands.clone(), ) }); @@ -396,11 +397,12 @@ impl AcpThreadView { editing_message: None, edits_expanded: false, plan_expanded: false, + prompt_capabilities, + available_commands, editor_expanded: false, should_be_following: false, history_store, hovered_recent_history_item: None, - prompt_capabilities, is_loading_contents: false, _subscriptions: subscriptions, _cancel_task: None, @@ -486,6 +488,9 @@ impl AcpThreadView { Ok(thread) => { let action_log = thread.read(cx).action_log().clone(); + this.available_commands + .replace(thread.read(cx).available_commands()); + this.prompt_capabilities .set(thread.read(cx).prompt_capabilities()); @@ -5532,6 +5537,7 @@ pub(crate) mod tests { audio: true, embedded_context: true, }), + vec![], cx, ) }))) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 3f04f38607415e9678944c5546aa84abf4446597..1315915203e917a7be8dd6e41031495971ddec24 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -12952,6 +12952,21 @@ pub enum CompletionDocumentation { }, } +impl CompletionDocumentation { + #[cfg(any(test, feature = "test-support"))] + pub fn text(&self) -> SharedString { + match self { + CompletionDocumentation::Undocumented => "".into(), + CompletionDocumentation::SingleLine(s) => s.clone(), + CompletionDocumentation::MultiLinePlainText(s) => s.clone(), + CompletionDocumentation::MultiLineMarkdown(s) => s.clone(), + CompletionDocumentation::SingleLineAndMultiLinePlainText { single_line, .. } => { + single_line.clone() + } + } + } +} + impl From for CompletionDocumentation { fn from(docs: lsp::Documentation) -> Self { match docs {