Detailed changes
@@ -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",
@@ -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"
@@ -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,
)
});
@@ -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,
)
});
@@ -292,6 +292,7 @@ impl NativeAgent {
action_log.clone(),
session_id.clone(),
prompt_capabilities_rx,
+ vec![],
cx,
)
});
@@ -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,
)
})?;
@@ -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);
@@ -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(),
)
});
@@ -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<_>>()
+ }
}
@@ -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,
)
})))
@@ -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 {