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
This commit is contained in:
Cole Miller 2025-08-19 02:00:41 -04:00 committed by GitHub
parent 7bcea7dc2c
commit d30b017d1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 263 additions and 16 deletions

View file

@ -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
}
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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)]

View file

@ -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,
)
});

View file

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

View file

@ -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)]

View file

@ -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,
)