acp: Add support for slash commands (#37304)

Bennet Bo Fenner , Antonio Scandurra , and Agus Zubiaga created

Depends on
https://github.com/zed-industries/agent-client-protocol/pull/45

Release Notes:

- N/A

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Agus Zubiaga <agus@zed.dev>

Change summary

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 
crates/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(-)

Detailed changes

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",

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"

crates/acp_thread/src/acp_thread.rs 🔗

@@ -785,6 +785,7 @@ pub struct AcpThread {
     session_id: acp::SessionId,
     token_usage: Option<TokenUsage>,
     prompt_capabilities: acp::PromptCapabilities,
+    available_commands: Vec<acp::AvailableCommand>,
     _observe_prompt_capabilities: Task<anyhow::Result<()>>,
     determine_shell: Shared<Task<String>>,
     terminals: HashMap<acp::TerminalId, Entity<Terminal>>,
@@ -858,6 +859,7 @@ impl AcpThread {
         action_log: Entity<ActionLog>,
         session_id: acp::SessionId,
         mut prompt_capabilities_rx: watch::Receiver<acp::PromptCapabilities>,
+        available_commands: Vec<acp::AvailableCommand>,
         cx: &mut Context<Self>,
     ) -> 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<acp::AvailableCommand> {
+        self.available_commands.clone()
+    }
+
     pub fn connection(&self) -> &Rc<dyn AgentConnection> {
         &self.connection
     }
@@ -2864,6 +2871,7 @@ mod tests {
                         audio: true,
                         embedded_context: true,
                     }),
+                    vec![],
                     cx,
                 )
             });

crates/acp_thread/src/connection.rs 🔗

@@ -75,7 +75,6 @@ pub trait AgentConnection {
     fn telemetry(&self) -> Option<Rc<dyn AgentTelemetry>> {
         None
     }
-
     fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
 }
 
@@ -339,6 +338,7 @@ mod test_support {
                         audio: true,
                         embedded_context: true,
                     }),
+                    vec![],
                     cx,
                 )
             });

crates/agent2/src/agent.rs 🔗

@@ -292,6 +292,7 @@ impl NativeAgent {
                 action_log.clone(),
                 session_id.clone(),
                 prompt_capabilities_rx,
+                vec![],
                 cx,
             )
         });

crates/agent_servers/src/acp.rs 🔗

@@ -28,7 +28,7 @@ pub struct AcpConnection {
     connection: Rc<acp::ClientSideConnection>,
     sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
     auth_methods: Vec<acp::AuthMethod>,
-    prompt_capabilities: acp::PromptCapabilities,
+    agent_capabilities: acp::AgentCapabilities,
     _io_task: Task<Result<()>>,
     _wait_task: Task<Result<()>>,
     _stderr_task: Task<Result<()>>,
@@ -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,
                 )
             })?;

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<HistoryStore>,
     prompt_store: Option<Entity<PromptStore>>,
     prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
+    available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
 }
 
 impl ContextPickerCompletionProvider {
@@ -76,6 +78,7 @@ impl ContextPickerCompletionProvider {
         history_store: Entity<HistoryStore>,
         prompt_store: Option<Entity<PromptStore>>,
         prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
+        available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
     ) -> 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<Vec<acp::AvailableCommand>> {
+        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::<Vec<_>>();
+
+            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<ContextPickerMode>,
         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::<AtomicBool>::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::<AtomicBool>::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<usize> {
+        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<Self> {
+        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<usize>,
+    command: Option<String>,
+    argument: Option<String>,
+}
+
+impl SlashCommandCompletion {
+    fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
+        // 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<usize>,
@@ -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);

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<HistoryStore>,
     prompt_store: Option<Entity<PromptStore>>,
     entries: Vec<Entry>,
-    prevent_slash_commands: bool,
-    prompt_capabilities: Rc<Cell<PromptCapabilities>>,
+    prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
+    available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
 }
 
 impl EntryViewState {
@@ -36,8 +40,8 @@ impl EntryViewState {
         project: Entity<Project>,
         history_store: Entity<HistoryStore>,
         prompt_store: Option<Entity<PromptStore>>,
-        prompt_capabilities: Rc<Cell<PromptCapabilities>>,
-        prevent_slash_commands: bool,
+        prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
+        available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
     ) -> 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(),
             )
         });
 

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<Editor>,
@@ -63,7 +61,6 @@ pub struct MessageEditor {
     workspace: WeakEntity<Workspace>,
     history_store: Entity<HistoryStore>,
     prompt_store: Option<Entity<PromptStore>>,
-    prevent_slash_commands: bool,
     prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
     _subscriptions: Vec<Subscription>,
     _parse_slash_command_task: Task<()>,
@@ -86,8 +83,8 @@ impl MessageEditor {
         history_store: Entity<HistoryStore>,
         prompt_store: Option<Entity<PromptStore>>,
         prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
+        available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
         placeholder: impl Into<Arc<str>>,
-        prevent_slash_commands: bool,
         mode: EditorMode,
         window: &mut Window,
         cx: &mut Context<Self>,
@@ -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<SlashCommandSemanticsProvider>,
-        editor: Entity<Editor>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        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::<InvalidSlashCommand>(
-                            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::<InvalidSlashCommand>(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<Anchor>,
@@ -1471,118 +1421,6 @@ impl MentionSet {
     }
 }
 
-struct SlashCommandSemanticsProvider {
-    range: Cell<Option<(usize, usize)>>,
-}
-
-impl SemanticsProvider for SlashCommandSemanticsProvider {
-    fn hover(
-        &self,
-        buffer: &Entity<Buffer>,
-        position: text::Anchor,
-        cx: &mut App,
-    ) -> Option<Task<Option<Vec<project::Hover>>>> {
-        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<Buffer>,
-        _range: Range<text::Anchor>,
-        _cx: &mut App,
-    ) -> Option<Task<anyhow::Result<Vec<project::InlayHint>>>> {
-        None
-    }
-
-    fn inlay_hints(
-        &self,
-        _buffer_handle: Entity<Buffer>,
-        _range: Range<text::Anchor>,
-        _cx: &mut App,
-    ) -> Option<Task<anyhow::Result<Vec<project::InlayHint>>>> {
-        None
-    }
-
-    fn resolve_inlay_hint(
-        &self,
-        _hint: project::InlayHint,
-        _buffer_handle: Entity<Buffer>,
-        _server_id: lsp::LanguageServerId,
-        _cx: &mut App,
-    ) -> Option<Task<anyhow::Result<project::InlayHint>>> {
-        None
-    }
-
-    fn supports_inlay_hints(&self, _buffer: &Entity<Buffer>, _cx: &mut App) -> bool {
-        false
-    }
-
-    fn document_highlights(
-        &self,
-        _buffer: &Entity<Buffer>,
-        _position: text::Anchor,
-        _cx: &mut App,
-    ) -> Option<Task<Result<Vec<project::DocumentHighlight>>>> {
-        None
-    }
-
-    fn definitions(
-        &self,
-        _buffer: &Entity<Buffer>,
-        _position: text::Anchor,
-        _kind: editor::GotoDefinitionKind,
-        _cx: &mut App,
-    ) -> Option<Task<Result<Option<Vec<project::LocationLink>>>>> {
-        None
-    }
-
-    fn range_for_rename(
-        &self,
-        _buffer: &Entity<Buffer>,
-        _position: text::Anchor,
-        _cx: &mut App,
-    ) -> Option<Task<Result<Option<Range<text::Anchor>>>>> {
-        None
-    }
-
-    fn perform_rename(
-        &self,
-        _buffer: &Entity<Buffer>,
-        _position: text::Anchor,
-        _new_name: String,
-        _cx: &mut App,
-    ) -> Option<Task<Result<project::ProjectTransaction>>> {
-        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::<Vec<_>>()
     }
+
+    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::<Vec<_>>()
+    }
 }

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<usize>,
     prompt_capabilities: Rc<Cell<PromptCapabilities>>,
+    available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
     is_loading_contents: bool,
     _cancel_task: Option<Task<()>>,
     _subscriptions: [Subscription; 3],
@@ -325,7 +326,7 @@ impl AcpThreadView {
         cx: &mut Context<Self>,
     ) -> Self {
         let prompt_capabilities = Rc::new(Cell::new(acp::PromptCapabilities::default()));
-        let prevent_slash_commands = agent.clone().downcast::<ClaudeCode>().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,
                 )
             })))

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<lsp::Documentation> for CompletionDocumentation {
     fn from(docs: lsp::Documentation) -> Self {
         match docs {