acp: Add support for slash commands (#37304)

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

Release Notes:

- N/A

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Agus Zubiaga <agus@zed.dev>
This commit is contained in:
Bennet Bo Fenner 2025-09-02 10:48:33 +02:00 committed by GitHub
parent f06be6f3ec
commit 374a8bc4cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 621 additions and 321 deletions

4
Cargo.lock generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
)
}
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();
Match::Symbol(SymbolMatch { symbol, .. }) => Self::completion_for_symbol(
symbol,
source_range.clone(),
editor.clone(),
workspace.clone(),
cx,
),
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::Thread(thread) => Some(Self::completion_for_thread(
thread,
source_range.clone(),
false,
editor.clone(),
cx,
)),
cx.spawn(async move |_, cx| {
let matches = search_task.await;
Match::RecentThread(thread) => Some(Self::completion_for_thread(
thread,
source_range.clone(),
true,
editor.clone(),
cx,
)),
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::Rules(user_rules) => Some(Self::completion_for_rules(
user_rules,
source_range.clone(),
editor.clone(),
cx,
)),
Self::completion_for_path(
project_path,
&mat.path_prefix,
is_recent,
mat.is_dir,
source_range.clone(),
editor.clone(),
project.clone(),
cx,
)
}
Match::Fetch(url) => Self::completion_for_fetch(
source_range.clone(),
url,
editor.clone(),
cx,
),
Match::Symbol(SymbolMatch { symbol, .. }) => {
Self::completion_for_symbol(
symbol,
source_range.clone(),
editor.clone(),
workspace.clone(),
cx,
)
}
Match::Entry(EntryMatch { entry, .. }) => Self::completion_for_entry(
entry,
source_range.clone(),
editor.clone(),
&workspace,
cx,
),
})
.collect()
})?;
Match::Thread(thread) => Some(Self::completion_for_thread(
thread,
source_range.clone(),
false,
editor.clone(),
cx,
)),
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,
}])
})
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);

View file

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

View file

@ -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<_>>()
}
}

View file

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

View file

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