From d30b017d1f7dda921ebd1ab6a3ef726e1f796571 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Tue, 19 Aug 2025 02:00:41 -0400 Subject: [PATCH] Prevent sending slash commands in CC threads (#36453) 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 --- 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(-) diff --git a/crates/agent2/src/native_agent_server.rs b/crates/agent2/src/native_agent_server.rs index cadd88a846..6f09ee1175 100644 --- a/crates/agent2/src/native_agent_server.rs +++ b/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) }) } + + fn into_any(self: Rc) -> Rc { + self + } } diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index b3b8a33170..8f8aa1d788 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/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, cx: &mut App, ) -> Task>>; + + fn into_any(self: Rc) -> Rc; +} + +impl dyn AgentServer { + pub fn downcast(self: Rc) -> Option> { + self.into_any().downcast().ok() + } } impl std::fmt::Debug for AgentServerCommand { diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index 9b273cb091..7034d6fbce 100644 --- a/crates/agent_servers/src/claude.rs +++ b/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) -> Rc { + self + } } struct ClaudeAgentConnection { diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index ad883f6da8..167e632d79 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/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) -> Rc { + self + } } #[cfg(test)] diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs index 18ef1ce2ab..0b0b8471a7 100644 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ b/crates/agent_ui/src/acp/entry_view_state.rs @@ -24,6 +24,7 @@ pub struct EntryViewState { thread_store: Entity, text_thread_store: Entity, entries: Vec, + prevent_slash_commands: bool, } impl EntryViewState { @@ -32,6 +33,7 @@ impl EntryViewState { project: Entity, thread_store: Entity, text_thread_store: Entity, + 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, ) }); diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index e5ecf43ef5..a32d0ce6ce 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/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, @@ -55,6 +61,9 @@ pub struct MessageEditor { workspace: WeakEntity, thread_store: Entity, text_thread_store: Entity, + prevent_slash_commands: bool, + _subscriptions: Vec, + _parse_slash_command_task: Task<()>, } #[derive(Clone, Copy)] @@ -73,6 +82,7 @@ impl MessageEditor { thread_store: Entity, text_thread_store: Entity, placeholder: impl Into>, + prevent_slash_commands: bool, mode: EditorMode, window: &mut Window, cx: &mut Context, @@ -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, + 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(); + }) + } + #[cfg(test)] pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context) { self.editor.update(cx, |editor, cx| { @@ -1416,6 +1511,118 @@ 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); + 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, + _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 +} + #[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, diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index b3ebe86674..2cfedfe840 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/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 { + let prevent_slash_commands = agent.clone().downcast::().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>> { Task::ready(Ok(Rc::new(self.connection.clone()))) } + + fn into_any(self: Rc) -> Rc { + self + } } #[derive(Clone)] diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 3fc673bad9..6fe981fd6e 100644 --- a/crates/editor/src/hover_popover.rs +++ b/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, + language_registry: Option<&Arc>, language: Option>, cx: &mut AsyncWindowContext, ) -> Option> { @@ -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, )