mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 05:51:14 +00:00
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:
parent
f06be6f3ec
commit
374a8bc4cb
11 changed files with 621 additions and 321 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue