Prevent sending slash commands in CC threads (#36453)

Cole Miller created

Highlight them as errors in the editor, and add a leading space when
sending them so users don't hit the odd behavior when sending these
commands to the SDK.

Release Notes:

- N/A

Change summary

crates/agent2/src/native_agent_server.rs    |   6 
crates/agent_servers/src/agent_servers.rs   |   9 
crates/agent_servers/src/claude.rs          |   4 
crates/agent_servers/src/gemini.rs          |   6 
crates/agent_ui/src/acp/entry_view_state.rs |   5 
crates/agent_ui/src/acp/message_editor.rs   | 223 ++++++++++++++++++++++
crates/agent_ui/src/acp/thread_view.rs      |   9 
crates/editor/src/hover_popover.rs          |  17 +
8 files changed, 263 insertions(+), 16 deletions(-)

Detailed changes

crates/agent2/src/native_agent_server.rs 🔗

@@ -1,4 +1,4 @@
-use std::{path::Path, rc::Rc, sync::Arc};
+use std::{any::Any, path::Path, rc::Rc, sync::Arc};
 
 use agent_servers::AgentServer;
 use anyhow::Result;
@@ -66,4 +66,8 @@ impl AgentServer for NativeAgentServer {
             Ok(Rc::new(connection) as Rc<dyn acp_thread::AgentConnection>)
         })
     }
+
+    fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+        self
+    }
 }

crates/agent_servers/src/agent_servers.rs 🔗

@@ -18,6 +18,7 @@ use project::Project;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::{
+    any::Any,
     path::{Path, PathBuf},
     rc::Rc,
     sync::Arc,
@@ -40,6 +41,14 @@ pub trait AgentServer: Send {
         project: &Entity<Project>,
         cx: &mut App,
     ) -> Task<Result<Rc<dyn AgentConnection>>>;
+
+    fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
+}
+
+impl dyn AgentServer {
+    pub fn downcast<T: 'static + AgentServer + Sized>(self: Rc<Self>) -> Option<Rc<T>> {
+        self.into_any().downcast().ok()
+    }
 }
 
 impl std::fmt::Debug for AgentServerCommand {

crates/agent_servers/src/claude.rs 🔗

@@ -65,6 +65,10 @@ impl AgentServer for ClaudeCode {
 
         Task::ready(Ok(Rc::new(connection) as _))
     }
+
+    fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+        self
+    }
 }
 
 struct ClaudeAgentConnection {

crates/agent_servers/src/gemini.rs 🔗

@@ -1,5 +1,5 @@
-use std::path::Path;
 use std::rc::Rc;
+use std::{any::Any, path::Path};
 
 use crate::{AgentServer, AgentServerCommand};
 use acp_thread::{AgentConnection, LoadError};
@@ -86,6 +86,10 @@ impl AgentServer for Gemini {
             result
         })
     }
+
+    fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+        self
+    }
 }
 
 #[cfg(test)]

crates/agent_ui/src/acp/entry_view_state.rs 🔗

@@ -24,6 +24,7 @@ pub struct EntryViewState {
     thread_store: Entity<ThreadStore>,
     text_thread_store: Entity<TextThreadStore>,
     entries: Vec<Entry>,
+    prevent_slash_commands: bool,
 }
 
 impl EntryViewState {
@@ -32,6 +33,7 @@ impl EntryViewState {
         project: Entity<Project>,
         thread_store: Entity<ThreadStore>,
         text_thread_store: Entity<TextThreadStore>,
+        prevent_slash_commands: bool,
     ) -> Self {
         Self {
             workspace,
@@ -39,6 +41,7 @@ impl EntryViewState {
             thread_store,
             text_thread_store,
             entries: Vec::new(),
+            prevent_slash_commands,
         }
     }
 
@@ -77,6 +80,7 @@ impl EntryViewState {
                             self.thread_store.clone(),
                             self.text_thread_store.clone(),
                             "Edit message - @ to include context",
+                            self.prevent_slash_commands,
                             editor::EditorMode::AutoHeight {
                                 min_lines: 1,
                                 max_lines: None,
@@ -382,6 +386,7 @@ mod tests {
                 project.clone(),
                 thread_store,
                 text_thread_store,
+                false,
             )
         });
 

crates/agent_ui/src/acp/message_editor.rs 🔗

@@ -10,7 +10,8 @@ use assistant_slash_commands::codeblock_fence_for_path;
 use collections::{HashMap, HashSet};
 use editor::{
     Anchor, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement,
-    EditorMode, EditorStyle, ExcerptId, FoldPlaceholder, MultiBuffer, ToOffset,
+    EditorEvent, EditorMode, EditorStyle, ExcerptId, FoldPlaceholder, MultiBuffer,
+    SemanticsProvider, ToOffset,
     actions::Paste,
     display_map::{Crease, CreaseId, FoldId},
 };
@@ -19,8 +20,9 @@ use futures::{
     future::{Shared, join_all, try_join_all},
 };
 use gpui::{
-    AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, Image,
-    ImageFormat, Img, Task, TextStyle, WeakEntity,
+    AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable,
+    HighlightStyle, Image, ImageFormat, Img, Subscription, Task, TextStyle, UnderlineStyle,
+    WeakEntity,
 };
 use language::{Buffer, Language};
 use language_model::LanguageModelImage;
@@ -28,26 +30,30 @@ use project::{CompletionIntent, Project, ProjectPath, Worktree};
 use rope::Point;
 use settings::Settings;
 use std::{
+    cell::Cell,
     ffi::OsStr,
     fmt::{Display, Write},
     ops::Range,
     path::{Path, PathBuf},
     rc::Rc,
     sync::Arc,
+    time::Duration,
 };
-use text::OffsetRangeExt;
+use text::{OffsetRangeExt, ToOffset as _};
 use theme::ThemeSettings;
 use ui::{
     ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, ButtonStyle, Color, Icon, IconName,
     IconSize, InteractiveElement, IntoElement, Label, LabelCommon, LabelSize, ParentElement,
     Render, SelectableButton, SharedString, Styled, TextSize, TintColor, Toggleable, Window, div,
-    h_flex,
+    h_flex, px,
 };
 use url::Url;
 use util::ResultExt;
 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>,
@@ -55,6 +61,9 @@ pub struct MessageEditor {
     workspace: WeakEntity<Workspace>,
     thread_store: Entity<ThreadStore>,
     text_thread_store: Entity<TextThreadStore>,
+    prevent_slash_commands: bool,
+    _subscriptions: Vec<Subscription>,
+    _parse_slash_command_task: Task<()>,
 }
 
 #[derive(Clone, Copy)]
@@ -73,6 +82,7 @@ impl MessageEditor {
         thread_store: Entity<ThreadStore>,
         text_thread_store: Entity<TextThreadStore>,
         placeholder: impl Into<Arc<str>>,
+        prevent_slash_commands: bool,
         mode: EditorMode,
         window: &mut Window,
         cx: &mut Context<Self>,
@@ -90,6 +100,9 @@ impl MessageEditor {
             text_thread_store.downgrade(),
             cx.weak_entity(),
         );
+        let semantics_provider = Rc::new(SlashCommandSemanticsProvider {
+            range: Cell::new(None),
+        });
         let mention_set = MentionSet::default();
         let editor = cx.new(|cx| {
             let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
@@ -106,6 +119,9 @@ impl MessageEditor {
                 max_entries_visible: 12,
                 placement: Some(ContextMenuPlacement::Above),
             });
+            if prevent_slash_commands {
+                editor.set_semantics_provider(Some(semantics_provider.clone()));
+            }
             editor
         });
 
@@ -114,6 +130,24 @@ impl MessageEditor {
         })
         .detach();
 
+        let mut subscriptions = Vec::new();
+        if prevent_slash_commands {
+            subscriptions.push(cx.subscribe_in(&editor, window, {
+                let semantics_provider = semantics_provider.clone();
+                move |this, editor, event, window, cx| match event {
+                    EditorEvent::Edited { .. } => {
+                        this.highlight_slash_command(
+                            semantics_provider.clone(),
+                            editor.clone(),
+                            window,
+                            cx,
+                        );
+                    }
+                    _ => {}
+                }
+            }));
+        }
+
         Self {
             editor,
             project,
@@ -121,6 +155,9 @@ impl MessageEditor {
             thread_store,
             text_thread_store,
             workspace,
+            prevent_slash_commands,
+            _subscriptions: subscriptions,
+            _parse_slash_command_task: Task::ready(()),
         }
     }
 
@@ -590,6 +627,7 @@ impl MessageEditor {
             self.mention_set
                 .contents(self.project.clone(), self.thread_store.clone(), window, cx);
         let editor = self.editor.clone();
+        let prevent_slash_commands = self.prevent_slash_commands;
 
         cx.spawn(async move |_, cx| {
             let contents = contents.await?;
@@ -612,7 +650,15 @@ impl MessageEditor {
 
                         let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot);
                         if crease_range.start > ix {
-                            chunks.push(text[ix..crease_range.start].into());
+                            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()
+                            };
+                            chunks.push(chunk);
                         }
                         let chunk = match mention {
                             Mention::Text { uri, content } => {
@@ -644,7 +690,14 @@ impl MessageEditor {
                     }
 
                     if ix < text.len() {
-                        let last_chunk = text[ix..].trim_end();
+                        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()
+                        };
                         if !last_chunk.is_empty() {
                             chunks.push(last_chunk.into());
                         }
@@ -990,6 +1043,48 @@ 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();
+        })
+    }
+
     #[cfg(test)]
     pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
         self.editor.update(cx, |editor, cx| {
@@ -1416,6 +1511,118 @@ 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<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);
+        return Some(Task::ready(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<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
+}
+
 #[cfg(test)]
 mod tests {
     use std::{ops::Range, path::Path, sync::Arc};
@@ -1463,6 +1670,7 @@ mod tests {
                     thread_store.clone(),
                     text_thread_store.clone(),
                     "Test",
+                    false,
                     EditorMode::AutoHeight {
                         min_lines: 1,
                         max_lines: None,
@@ -1661,6 +1869,7 @@ mod tests {
                     thread_store.clone(),
                     text_thread_store.clone(),
                     "Test",
+                    false,
                     EditorMode::AutoHeight {
                         max_lines: None,
                         min_lines: 1,

crates/agent_ui/src/acp/thread_view.rs 🔗

@@ -7,7 +7,7 @@ use acp_thread::{AgentConnection, Plan};
 use action_log::ActionLog;
 use agent::{TextThreadStore, ThreadStore};
 use agent_client_protocol::{self as acp};
-use agent_servers::AgentServer;
+use agent_servers::{AgentServer, ClaudeCode};
 use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting};
 use anyhow::bail;
 use audio::{Audio, Sound};
@@ -160,6 +160,7 @@ impl AcpThreadView {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
+        let prevent_slash_commands = agent.clone().downcast::<ClaudeCode>().is_some();
         let message_editor = cx.new(|cx| {
             MessageEditor::new(
                 workspace.clone(),
@@ -167,6 +168,7 @@ impl AcpThreadView {
                 thread_store.clone(),
                 text_thread_store.clone(),
                 "Message the agent - @ to include context",
+                prevent_slash_commands,
                 editor::EditorMode::AutoHeight {
                     min_lines: MIN_EDITOR_LINES,
                     max_lines: Some(MAX_EDITOR_LINES),
@@ -184,6 +186,7 @@ impl AcpThreadView {
                 project.clone(),
                 thread_store.clone(),
                 text_thread_store.clone(),
+                prevent_slash_commands,
             )
         });
 
@@ -3925,6 +3928,10 @@ pub(crate) mod tests {
         ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
             Task::ready(Ok(Rc::new(self.connection.clone())))
         }
+
+        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+            self
+        }
     }
 
     #[derive(Clone)]

crates/editor/src/hover_popover.rs 🔗

@@ -167,7 +167,8 @@ pub fn hover_at_inlay(
 
                 let language_registry = project.read_with(cx, |p, _| p.languages().clone())?;
                 let blocks = vec![inlay_hover.tooltip];
-                let parsed_content = parse_blocks(&blocks, &language_registry, None, cx).await;
+                let parsed_content =
+                    parse_blocks(&blocks, Some(&language_registry), None, cx).await;
 
                 let scroll_handle = ScrollHandle::new();
 
@@ -251,7 +252,9 @@ fn show_hover(
 
     let (excerpt_id, _, _) = editor.buffer().read(cx).excerpt_containing(anchor, cx)?;
 
-    let language_registry = editor.project()?.read(cx).languages().clone();
+    let language_registry = editor
+        .project()
+        .map(|project| project.read(cx).languages().clone());
     let provider = editor.semantics_provider.clone()?;
 
     if !ignore_timeout {
@@ -443,7 +446,8 @@ fn show_hover(
                     text: format!("Unicode character U+{:02X}", invisible as u32),
                     kind: HoverBlockKind::PlainText,
                 }];
-                let parsed_content = parse_blocks(&blocks, &language_registry, None, cx).await;
+                let parsed_content =
+                    parse_blocks(&blocks, language_registry.as_ref(), None, cx).await;
                 let scroll_handle = ScrollHandle::new();
                 let subscription = this
                     .update(cx, |_, cx| {
@@ -493,7 +497,8 @@ fn show_hover(
 
                 let blocks = hover_result.contents;
                 let language = hover_result.language;
-                let parsed_content = parse_blocks(&blocks, &language_registry, language, cx).await;
+                let parsed_content =
+                    parse_blocks(&blocks, language_registry.as_ref(), language, cx).await;
                 let scroll_handle = ScrollHandle::new();
                 hover_highlights.push(range.clone());
                 let subscription = this
@@ -583,7 +588,7 @@ fn same_diagnostic_hover(editor: &Editor, snapshot: &EditorSnapshot, anchor: Anc
 
 async fn parse_blocks(
     blocks: &[HoverBlock],
-    language_registry: &Arc<LanguageRegistry>,
+    language_registry: Option<&Arc<LanguageRegistry>>,
     language: Option<Arc<Language>>,
     cx: &mut AsyncWindowContext,
 ) -> Option<Entity<Markdown>> {
@@ -603,7 +608,7 @@ async fn parse_blocks(
         .new_window_entity(|_window, cx| {
             Markdown::new(
                 combined_text.into(),
-                Some(language_registry.clone()),
+                language_registry.cloned(),
                 language.map(|language| language.name()),
                 cx,
             )