agent_ui: Render skills as creases (#56689)
Some checks are pending
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Has been skipped

Closes AI-230

This PR makes skills, added as /-mentions, be rendered in the agent
panel as creases, like anything you'd @-mention. Naturally, clicking on
the crease button opens the corresponding skill file in a buffer.

It turned out to be quite a bit of plumbing to make this work,
particularly as I am also introducing an interface to display dividers
and headers in the completion menu. This was relevant to me to add
because it sets a good foundation to convert many agent panel-related
actions as slash commands.

Release Notes:

- N/A

---------

Co-authored-by: MartinYe1234 <52641447+MartinYe1234@users.noreply.github.com>
This commit is contained in:
Danilo Leal 2026-05-14 19:20:34 -03:00 committed by GitHub
parent f511076cdb
commit 700b0b5de6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1023 additions and 375 deletions

View file

@ -93,35 +93,6 @@ pub fn subagent_session_info_from_meta(meta: &Option<acp::Meta>) -> Option<Subag
.and_then(|v| serde_json::from_value(v.clone()).ok())
}
/// Key used in ACP `AvailableCommand` meta to indicate where a skill
/// originated from (e.g. `"global"` or a worktree root name). Set by
/// the native agent so the completion popup can surface skill origin to
/// disambiguate same-named global vs. project-local skills.
pub const SKILL_SOURCE_META_KEY: &str = "zed.skill_source";
/// Borrowing accessor for the skill source label stored in ACP meta.
/// Prefer this over [`skill_source_from_meta`] in hot paths (e.g. per-
/// command iteration during validation), since it avoids allocating
/// a `SharedString` for callers that only need to compare against a
/// `&str`.
pub fn skill_source_str_from_meta(meta: &Option<acp::Meta>) -> Option<&str> {
meta.as_ref()
.and_then(|m| m.get(SKILL_SOURCE_META_KEY))
.and_then(|v| v.as_str())
}
/// Helper to extract skill source label from ACP meta as an owned
/// `SharedString`. Use this when the value needs to outlive the meta
/// reference; otherwise prefer [`skill_source_str_from_meta`].
pub fn skill_source_from_meta(meta: &Option<acp::Meta>) -> Option<SharedString> {
skill_source_str_from_meta(meta).map(|s| SharedString::from(s.to_owned()))
}
/// Helper to create meta tagging an `AvailableCommand` with a skill source.
pub fn meta_with_skill_source(source: &str) -> acp::Meta {
acp::Meta::from_iter([(SKILL_SOURCE_META_KEY.into(), source.into())])
}
#[derive(Debug)]
pub struct UserMessage {
pub id: Option<UserMessageId>,

View file

@ -64,6 +64,11 @@ pub enum MentionUri {
MergeConflict {
file_path: String,
},
Skill {
name: String,
source: String,
skill_file_path: PathBuf,
},
}
impl MentionUri {
@ -261,6 +266,40 @@ impl MentionUri {
} else if path.starts_with("/agent/merge-conflict") {
let file_path = single_query_param(&url, "path")?.unwrap_or_default();
Ok(Self::MergeConflict { file_path })
} else if path.starts_with("/agent/skill") {
let mut name = None;
let mut source = None;
let mut skill_file_path = None;
for (key, value) in url.query_pairs() {
match key.as_ref() {
"name" => {
if name.replace(value.to_string()).is_some() {
bail!("duplicate skill name query parameter");
}
}
"source" => {
if source.replace(value.to_string()).is_some() {
bail!("duplicate skill source query parameter");
}
}
"path" => {
if skill_file_path
.replace(PathBuf::from(value.to_string()))
.is_some()
{
bail!("duplicate skill file path query parameter");
}
}
_ => bail!("invalid query parameter"),
}
}
Ok(Self::Skill {
name: name.context("missing skill name")?,
source: source.context("missing skill source")?,
skill_file_path: skill_file_path.context("missing skill file path")?,
})
} else {
bail!("invalid zed url: {:?}", input);
}
@ -303,6 +342,13 @@ impl MentionUri {
..
} => selection_name(path.as_deref(), line_range),
MentionUri::Fetch { url } => url.to_string(),
MentionUri::Skill { name, source, .. } => {
if source.is_empty() {
format!("{} (global)", name)
} else {
format!("{} ({})", name, source)
}
}
}
}
@ -337,6 +383,9 @@ impl MentionUri {
)
.into(),
),
MentionUri::Skill {
skill_file_path, ..
} => Some(skill_file_path.to_string_lossy().into_owned().into()),
_ => None,
}
}
@ -358,6 +407,7 @@ impl MentionUri {
MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(),
MentionUri::GitDiff { .. } => IconName::GitBranch.path().into(),
MentionUri::MergeConflict { .. } => IconName::GitMergeConflict.path().into(),
MentionUri::Skill { .. } => IconName::Sparkle.path().into(),
}
}
@ -465,6 +515,19 @@ impl MentionUri {
url.query_pairs_mut().append_pair("path", file_path);
url
}
MentionUri::Skill {
name,
source,
skill_file_path,
} => {
let mut url = Url::parse("zed:///").unwrap();
url.set_path("/agent/skill");
url.query_pairs_mut()
.append_pair("name", name)
.append_pair("source", source)
.append_pair("path", &skill_file_path.to_string_lossy());
url
}
}
}
}
@ -705,6 +768,20 @@ mod tests {
assert_eq!(parsed.to_uri().to_string(), rule_uri);
}
#[test]
fn test_parse_skill_uri_round_trip() {
let skill_uri = MentionUri::Skill {
name: "rust-best-practices".to_string(),
source: "my-personal-project".to_string(),
skill_file_path: PathBuf::from(path!("/path/to/skills/rust-best-practices/SKILL.md")),
};
let serialized = skill_uri.to_uri().to_string();
let parsed = MentionUri::parse(&serialized, PathStyle::local()).unwrap();
assert_eq!(parsed, skill_uri);
}
#[test]
fn test_parse_fetch_http_uri() {
let http_uri = "http://example.com/path?query=value#fragment";

View file

@ -91,6 +91,25 @@ pub struct SkillLoadingErrorsUpdated {
pub errors: Vec<SkillLoadingError>,
}
#[derive(Clone, Debug)]
pub struct NativeAvailableSkill {
pub name: String,
pub description: String,
pub source: SharedString,
pub skill_file_path: PathBuf,
}
impl From<&Skill> for NativeAvailableSkill {
fn from(skill: &Skill) -> Self {
Self {
name: skill.name.clone(),
description: skill.description.clone(),
source: skill.source.scope_prefix().to_string().into(),
skill_file_path: skill.skill_file_path.clone(),
}
}
}
struct ProjectState {
project: Entity<Project>,
project_context: Entity<ProjectContext>,
@ -1266,21 +1285,7 @@ impl NativeAgent {
Some(command)
});
// Skills are exposed as slash commands regardless of
// `disable_model_invocation`. The flag controls catalog visibility
// for the model, not user-driven invocation — that's the whole
// point of marking a skill model-disabled.
let skill_commands = state.skills.iter().map(|skill| {
// The meta carries the literal scope prefix the popup
// inserts as `/<prefix>:<name>`: empty for globals (so
// the inserted text is `/:<name>`) and the worktree root
// name for project-locals. See `SkillSource::scope_prefix`.
acp::AvailableCommand::new(skill.name.clone(), skill.description.clone()).meta(
acp_thread::meta_with_skill_source(skill.source.scope_prefix()),
)
});
mcp_commands.chain(skill_commands).collect()
mcp_commands.collect()
}
pub fn load_thread(
@ -1721,6 +1726,24 @@ impl NativeAgentConnection {
.update(cx, |agent, cx| agent.ensure_skills_scan_started(cx));
}
pub fn available_skills(
&self,
session_id: &acp::SessionId,
cx: &App,
) -> Vec<NativeAvailableSkill> {
self.0
.read(cx)
.session_project_state(session_id)
.map(|state| {
state
.skills
.iter()
.map(NativeAvailableSkill::from)
.collect()
})
.unwrap_or_default()
}
pub fn load_thread(
&self,
id: acp::SessionId,
@ -3807,7 +3830,7 @@ mod internal_tests {
}
#[gpui::test]
async fn test_skills_appear_as_slash_commands(cx: &mut TestAppContext) {
async fn test_skills_appear_as_available_skills(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
cx.update_flags(true, vec!["skills".to_string()]);
@ -3816,8 +3839,8 @@ mod internal_tests {
let skills_dir = global_skills_dir();
// Two skills: one model-invocable (default), one slash-only via
// `disable-model-invocation: true`. Both should still appear as
// slash commands.
// `disable-model-invocation: true`. Both should still appear in
// the slash menu as first-class skills.
let visible_dir = skills_dir.join("visible-skill");
fs.create_dir(&visible_dir).await.unwrap();
fs.insert_file(
@ -3841,9 +3864,9 @@ mod internal_tests {
cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx));
let connection = NativeAgentConnection(agent.clone());
let _acp_thread = cx
let acp_thread = cx
.update(|cx| {
Rc::new(connection).new_session(
Rc::new(connection.clone()).new_session(
project.clone(),
PathList::new(&[Path::new("/")]),
cx,
@ -3854,21 +3877,34 @@ mod internal_tests {
cx.run_until_parked();
let project_id = project.entity_id();
let session_id = acp_thread.read_with(cx, |thread, _cx| thread.session_id().clone());
// Both skills should be exposed as slash commands.
agent.read_with(cx, |agent, cx| {
let commands = NativeAgent::build_available_commands_for_project(
agent.projects.get(&project_id),
cx,
);
let names: Vec<&str> = commands.iter().map(|c| c.name.as_str()).collect();
assert!(
!names.contains(&"visible-skill"),
"skills should not be exposed as ACP slash commands: {names:?}"
);
assert!(
!names.contains(&"deploy"),
"slash-only skills should not be exposed as ACP slash commands: {names:?}"
);
});
cx.update(|cx| {
let skills = connection.available_skills(&session_id, cx);
let names: Vec<&str> = skills.iter().map(|skill| skill.name.as_str()).collect();
assert!(
names.contains(&"visible-skill"),
"visible skill missing from slash commands: {names:?}"
"visible skill missing from available skills: {names:?}"
);
assert!(
names.contains(&"deploy"),
"slash-only skill missing from slash commands: {names:?}"
"slash-only skill missing from available skills: {names:?}"
);
});
@ -3927,9 +3963,9 @@ mod internal_tests {
cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx));
let connection = NativeAgentConnection(agent.clone());
let _acp_thread = cx
let acp_thread = cx
.update(|cx| {
Rc::new(connection).new_session(
Rc::new(connection.clone()).new_session(
project.clone(),
PathList::new(&[Path::new("/project")]),
cx,
@ -3940,6 +3976,7 @@ mod internal_tests {
cx.run_until_parked();
let project_id = project.entity_id();
let session_id = acp_thread.read_with(cx, |thread, _cx| thread.session_id().clone());
let worktree_id = project.read_with(cx, |project, cx| {
project.worktrees(cx).next().unwrap().read(cx).id()
});
@ -3980,15 +4017,18 @@ mod internal_tests {
});
cx.run_until_parked();
agent.read_with(cx, |agent, cx| {
agent.read_with(cx, |agent, _cx| {
let state = agent.projects.get(&project_id).unwrap();
let names: Vec<&str> = state.skills.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["my-skill"]);
let commands = NativeAgent::build_available_commands_for_project(Some(state), cx);
let command_names: Vec<&str> = commands.iter().map(|c| c.name.as_str()).collect();
});
cx.update(|cx| {
let skills = connection.available_skills(&session_id, cx);
let skill_names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
assert!(
command_names.contains(&"my-skill"),
"trusted skill should appear in slash commands: {command_names:?}"
skill_names.contains(&"my-skill"),
"trusted skill should appear in available skills: {skill_names:?}"
);
});
}

View file

@ -233,6 +233,8 @@ impl UserMessage {
const OPEN_DIAGNOSTICS_TAG: &str = "<diagnostics>";
const OPEN_DIFFS_TAG: &str = "<diffs>";
const MERGE_CONFLICT_TAG: &str = "<merge_conflicts>";
const OPEN_SKILLS_TAG: &str =
"<skills>\nThe user has attached the following agent skills:\n";
let mut file_context = OPEN_FILES_TAG.to_string();
let mut directory_context = OPEN_DIRECTORIES_TAG.to_string();
@ -244,6 +246,7 @@ impl UserMessage {
let mut diagnostics_context = OPEN_DIAGNOSTICS_TAG.to_string();
let mut diffs_context = OPEN_DIFFS_TAG.to_string();
let mut merge_conflict_context = MERGE_CONFLICT_TAG.to_string();
let mut skills_context = OPEN_SKILLS_TAG.to_string();
for chunk in &self.content {
let chunk = match chunk {
@ -360,6 +363,14 @@ impl UserMessage {
)
.ok();
}
MentionUri::Skill { name, source, .. } => {
let label = if source.is_empty() {
format!("{} (global)", name)
} else {
format!("{} ({})", name, source)
};
write!(&mut skills_context, "\nSkill: {}\n{}\n", label, content).ok();
}
}
language_model::MessageContent::Text(uri.as_link().to_string())
@ -434,6 +445,13 @@ impl UserMessage {
.push(language_model::MessageContent::Text(diagnostics_context));
}
if skills_context.len() > OPEN_SKILLS_TAG.len() {
skills_context.push_str("</skills>\n");
message
.content
.push(language_model::MessageContent::Text(skills_context));
}
if merge_conflict_context.len() > MERGE_CONFLICT_TAG.len() {
merge_conflict_context.push_str("</merge_conflicts>\n");
message

View file

@ -19,8 +19,8 @@ use multi_buffer::ToOffset as _;
use ordered_float::OrderedFloat;
use project::lsp_store::{CompletionDocumentation, SymbolLocation};
use project::{
Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, DiagnosticSummary,
PathMatchCandidateSet, Project, ProjectPath, Symbol, WorktreeId,
Completion, CompletionDisplayOptions, CompletionGroup, CompletionIntent, CompletionResponse,
DiagnosticSummary, PathMatchCandidateSet, Project, ProjectPath, Symbol, WorktreeId,
};
use prompt_store::{PromptStore, UserPromptId};
use rope::Point;
@ -297,18 +297,39 @@ pub struct RulesContextEntry {
pub title: SharedString,
}
#[derive(Debug, Clone)]
pub struct AvailableSkill {
pub name: Arc<str>,
pub description: Arc<str>,
/// Scope prefix for this skill: empty for global skills, or the
/// worktree root name for project-local skills.
pub source: SharedString,
pub skill_file_path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct AvailableCommand {
pub name: Arc<str>,
pub description: Arc<str>,
pub requires_argument: bool,
/// Origin label for this command (e.g. `"global"` or a worktree
/// root name for skills). When present, it's displayed in the
/// autocomplete popup after the command name so users can
/// disambiguate same-named commands from different scopes.
pub source: Option<SharedString>,
}
#[derive(Debug, Clone)]
enum SlashCompletionCandidate {
Skill(AvailableSkill),
Command(AvailableCommand),
}
impl SlashCompletionCandidate {
fn name(&self) -> &Arc<str> {
match self {
Self::Skill(skill) => &skill.name,
Self::Command(command) => &command.name,
}
}
}
pub trait PromptCompletionProviderDelegate: Send + Sync + 'static {
fn supports_context(&self, mode: PromptContextType, cx: &App) -> bool {
self.supported_modes(cx).contains(&mode)
@ -317,6 +338,9 @@ pub trait PromptCompletionProviderDelegate: Send + Sync + 'static {
fn supports_images(&self, cx: &App) -> bool;
fn available_commands(&self, cx: &App) -> Vec<AvailableCommand>;
fn available_skills(&self, _cx: &App) -> Vec<AvailableSkill> {
Vec::new()
}
fn confirm_command(&self, cx: &mut App);
/// Called once each time the user opens slash-command autocomplete
@ -374,6 +398,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
// completion menu will still be shown after "@category " is
// inserted
confirm: Some(Arc::new(|_, _, _| true)),
group: None,
}),
PromptContextEntry::Action(action) => {
let selection = workspace.update(cx, |workspace, cx| {
@ -431,6 +456,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
mention_set,
workspace,
)),
group: None,
}
}
@ -470,6 +496,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
mention_set,
workspace,
)),
group: None,
}
}
@ -536,6 +563,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
mention_set,
workspace,
)),
group: None,
})
}
@ -601,6 +629,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
mention_set,
workspace,
)),
group: None,
})
}
@ -641,6 +670,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
mention_set,
workspace,
)),
group: None,
})
}
@ -686,6 +716,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
// completion menu will still be shown after "@category " is
// inserted
confirm: Some(on_action),
group: None,
})
}
@ -783,6 +814,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
mention_set,
workspace,
)),
group: None,
}
}
@ -824,31 +856,48 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
mention_set,
workspace,
)),
group: None,
}
}
fn search_slash_commands(&self, query: String, cx: &mut App) -> Task<Vec<AvailableCommand>> {
fn search_slash_commands(
&self,
query: String,
cx: &mut App,
) -> Task<Vec<SlashCompletionCandidate>> {
// Notify the delegate that slash autocomplete is being
// invoked, so it can lazily kick off any work that produces
// additional commands. Whatever it produces won't be visible
// in the current autocomplete pass (we read `available_commands`
// synchronously below), but will appear on the next invocation.
// additional commands or skills. Whatever it produces won't be
// visible in the current autocomplete pass (we read available
// items synchronously below), but will appear on the next
// invocation.
self.source.slash_autocomplete_invoked(cx);
let commands = self.source.available_commands(cx);
if commands.is_empty() {
let mut candidates = self
.source
.available_skills(cx)
.into_iter()
.map(SlashCompletionCandidate::Skill)
.collect::<Vec<_>>();
candidates.extend(
self.source
.available_commands(cx)
.into_iter()
.map(SlashCompletionCandidate::Command),
);
if candidates.is_empty() {
return Task::ready(Vec::new());
}
cx.spawn(async move |cx| {
let candidates = commands
let string_match_candidates = candidates
.iter()
.enumerate()
.map(|(id, command)| StringMatchCandidate::new(id, &command.name))
.map(|(id, candidate)| StringMatchCandidate::new(id, candidate.name()))
.collect::<Vec<_>>();
let matches = fuzzy::match_strings(
&candidates,
&string_match_candidates,
&query,
false,
true,
@ -860,7 +909,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
matches
.into_iter()
.map(|mat| commands[mat.candidate_id].clone())
.map(|mat| candidates[mat.candidate_id].clone())
.collect()
})
}
@ -1246,6 +1295,7 @@ impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletio
PromptCompletion::SlashCommand(SlashCommandCompletion {
command, argument, ..
}) => {
let show_section_headers = command.is_none() && argument.is_none();
let search_task = self.search_slash_commands(command.unwrap_or_default(), cx);
// Resolve the muted-text highlight up front: the
// completion build happens on a background thread where
@ -1255,80 +1305,145 @@ impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletio
.syntax()
.highlight_id("variable")
.map(HighlightId::new);
cx.background_spawn(async move {
let completions = search_task
.await
.into_iter()
.map(|command| {
// Qualify the inserted text with the skill's
// scope prefix as `/<prefix>:<name>` when the
// command carries one. The prefix is empty
// for global skills (so the inserted text
// is `/:<name>`) and the worktree root name
// for project-locals (so the inserted text
// is `/<worktree>:<name>`). The `:`
// separator namespaces skill scopes away
// from MCP server prefixes
// (`/<server>.<name>`), and the empty
// prefix means a worktree literally named
// `global` no longer collides with the
// global source. MCP commands have no
// source meta and keep the bare `/<name>`
// form.
//
// Composed in a single `format!` to avoid
// building an intermediate `qualified_name`
// string just to splice it into the final
// text.
let new_text = match (command.source.as_ref(), argument.as_ref()) {
(Some(source), Some(argument)) => {
format!("/{}:{} {}", source, command.name, argument)
}
(Some(source), None) => {
format!("/{}:{} ", source, command.name)
}
(None, Some(argument)) => {
format!("/{} {}", command.name, argument)
}
(None, None) => format!("/{} ", command.name),
};
let is_missing_argument =
command.requires_argument && argument.is_none();
let label = build_slash_command_label(&command, source_highlight_id);
Completion {
replace_range: source_range.clone(),
new_text,
label,
documentation: Some(CompletionDocumentation::MultiLinePlainText(
command.description.into(),
)),
source: project::CompletionSource::Custom,
icon_path: None,
match_start: None,
snippet_deduplication_key: None,
insert_text_mode: None,
confirm: Some(Arc::new({
let source = source.clone();
move |intent, _window, cx| {
if !is_missing_argument {
cx.defer({
let source = source.clone();
move |cx| match intent {
CompletionIntent::Complete
| CompletionIntent::CompleteWithInsert
| CompletionIntent::CompleteWithReplace => {
source.confirm_command(cx);
}
CompletionIntent::Compose => {}
}
});
}
false
type SkillInfo = (
String,
SharedString,
Arc<dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync>,
);
let slash_candidates: Task<Vec<(SlashCompletionCandidate, Option<SkillInfo>)>> = {
let source = source.clone();
cx.spawn(async move |_this, cx| {
let candidates = search_task.await;
cx.update(|cx| {
candidates
.into_iter()
.map(|candidate| match &candidate {
SlashCompletionCandidate::Skill(skill) => {
let uri = MentionUri::Skill {
name: skill.name.to_string(),
source: skill.source.to_string(),
skill_file_path: skill.skill_file_path.clone(),
};
let new_text = format!("{} ", uri.as_link());
let new_text_len = new_text.len();
let icon_path = uri.icon_path(cx);
let crease_text: SharedString = uri.name().into();
let confirm = confirm_completion_callback(
crease_text,
source_range.start,
new_text_len - 1,
uri,
source.clone(),
editor.clone(),
mention_set.clone(),
workspace.clone(),
);
(candidate, Some((new_text, icon_path, confirm)))
}
})),
SlashCompletionCandidate::Command(_) => (candidate, None),
})
.collect::<Vec<(SlashCompletionCandidate, Option<SkillInfo>)>>()
})
})
};
cx.background_spawn(async move {
let mut slash_candidates = slash_candidates.await;
slash_candidates.sort_by_key(|(candidate, _)| match candidate {
SlashCompletionCandidate::Skill(_) => 0,
SlashCompletionCandidate::Command(_) => 1,
});
let completions = slash_candidates
.into_iter()
.map(|(candidate, skill_info)| match candidate {
SlashCompletionCandidate::Skill(skill) => {
let label = build_slash_item_label(
&skill.name,
Some(&skill.source),
source_highlight_id,
);
let Some((new_text, icon_path, confirm)) = skill_info else {
unreachable!("skill candidates always have confirm callbacks")
};
Completion {
replace_range: source_range.clone(),
new_text,
label,
documentation: Some(
CompletionDocumentation::MultiLinePlainText(
skill.description.into(),
),
),
source: project::CompletionSource::Custom,
icon_path: Some(icon_path),
match_start: None,
snippet_deduplication_key: None,
insert_text_mode: None,
confirm: Some(confirm),
group: show_section_headers.then(|| CompletionGroup {
key: "skills".into(),
label: Some("Skills".into()),
}),
}
}
SlashCompletionCandidate::Command(command) => {
let label =
build_slash_command_label(&command, source_highlight_id);
let new_text = match (command.source.as_ref(), argument.as_ref()) {
(Some(source), Some(argument)) => {
format!("/{}:{} {}", source, command.name, argument)
}
(Some(source), None) => {
format!("/{}:{} ", source, command.name)
}
(None, Some(argument)) => {
format!("/{} {}", command.name, argument)
}
(None, None) => format!("/{} ", command.name),
};
let is_missing_argument =
command.requires_argument && argument.is_none();
Completion {
replace_range: source_range.clone(),
new_text,
label,
documentation: Some(
CompletionDocumentation::MultiLinePlainText(
command.description.into(),
),
),
source: project::CompletionSource::Custom,
icon_path: None,
match_start: None,
snippet_deduplication_key: None,
insert_text_mode: None,
confirm: Some(Arc::new({
let source = source.clone();
move |intent, _window, cx| {
if !is_missing_argument {
cx.defer({
let source = source.clone();
move |cx| match intent {
CompletionIntent::Complete
| CompletionIntent::CompleteWithInsert
| CompletionIntent::CompleteWithReplace => {
source.confirm_command(cx);
}
CompletionIntent::Compose => {}
}
});
}
false
}
})),
group: show_section_headers.then(|| CompletionGroup {
key: "agent-commands".into(),
label: Some("Agent Commands".into()),
}),
}
}
})
.collect();
@ -1338,8 +1453,6 @@ impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletio
display_options: CompletionDisplayOptions {
dynamic_width: true,
},
// 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,
}])
})
@ -1367,6 +1480,7 @@ impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletio
}
}
let show_section_headers = mode.is_none() && argument.is_none();
let query = argument.unwrap_or_default();
let search_task =
self.search_mentions(mode, query, Arc::<AtomicBool>::default(), cx);
@ -1397,118 +1511,156 @@ impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletio
};
cx.spawn(async move |_, cx| {
let matches = search_task.await;
let mut matches = search_task.await;
if show_section_headers {
matches.sort_by_key(|mat| match mat {
Match::File(FileMatch {
is_recent: true, ..
})
| Match::RecentThread(_) => 0,
Match::Entry(_) | Match::BranchDiff(_) => 1,
_ => 2,
});
}
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(),
};
.filter_map(|mat| {
let group = if show_section_headers {
match &mat {
Match::File(FileMatch {
is_recent: true, ..
})
| Match::RecentThread(_) => Some(CompletionGroup {
key: "recent".into(),
label: None,
}),
Match::Entry(_) | Match::BranchDiff(_) => {
Some(CompletionGroup {
key: "context".into(),
label: None,
})
}
_ => None,
}
} else {
None
};
let mut completion = match mat {
Match::File(FileMatch { mat, is_recent }) => {
let project_path = ProjectPath {
worktree_id: WorktreeId::from_usize(mat.worktree_id),
path: mat.path.clone(),
};
// If path is empty, this means we're matching with the root directory itself
// so we use the path_prefix as the name
let path_prefix = if mat.path.is_empty() {
project
.read(cx)
.worktree_for_id(project_path.worktree_id, cx)
.map(|wt| wt.read(cx).root_name().into())
.unwrap_or_else(|| mat.path_prefix.clone())
} else {
mat.path_prefix.clone()
};
// If path is empty, this means we're matching with the root directory itself
// so we use the path_prefix as the name
let path_prefix = if mat.path.is_empty() {
project
.read(cx)
.worktree_for_id(project_path.worktree_id, cx)
.map(|wt| wt.read(cx).root_name().into())
.unwrap_or_else(|| mat.path_prefix.clone())
} else {
mat.path_prefix.clone()
};
Self::completion_for_path(
project_path,
&path_prefix,
is_recent,
mat.is_dir,
Self::completion_for_path(
project_path,
&path_prefix,
is_recent,
mat.is_dir,
source_range.clone(),
source.clone(),
editor.clone(),
mention_set.clone(),
workspace.clone(),
project.clone(),
label_max_chars,
cx,
)
}
Match::Symbol(SymbolMatch { symbol, .. }) => {
Self::completion_for_symbol(
symbol,
source_range.clone(),
source.clone(),
editor.clone(),
mention_set.clone(),
workspace.clone(),
label_max_chars,
cx,
)
}
Match::Thread(thread) => Some(Self::completion_for_thread(
thread.session_id,
Some(thread.title),
source_range.clone(),
false,
source.clone(),
editor.clone(),
mention_set.clone(),
workspace.clone(),
project.clone(),
label_max_chars,
cx,
)
}
Match::Symbol(SymbolMatch { symbol, .. }) => {
Self::completion_for_symbol(
symbol,
source_range.clone(),
source.clone(),
editor.clone(),
mention_set.clone(),
workspace.clone(),
label_max_chars,
cx,
)
}
Match::Thread(thread) => Some(Self::completion_for_thread(
thread.session_id,
Some(thread.title),
source_range.clone(),
false,
source.clone(),
editor.clone(),
mention_set.clone(),
workspace.clone(),
cx,
)),
Match::RecentThread(thread) => Some(Self::completion_for_thread(
thread.session_id,
Some(thread.title),
source_range.clone(),
true,
source.clone(),
editor.clone(),
mention_set.clone(),
workspace.clone(),
cx,
)),
Match::Rules(user_rules) => Some(Self::completion_for_rules(
user_rules,
source_range.clone(),
source.clone(),
editor.clone(),
mention_set.clone(),
workspace.clone(),
cx,
)),
Match::Fetch(url) => Self::completion_for_fetch(
source_range.clone(),
url,
source.clone(),
editor.clone(),
mention_set.clone(),
workspace.clone(),
cx,
),
Match::Entry(EntryMatch { entry, .. }) => {
Self::completion_for_entry(
entry,
source_range.clone(),
editor.clone(),
mention_set.clone(),
&workspace,
cx,
)
}
Match::BranchDiff(branch_diff) => {
Some(Self::build_branch_diff_completion(
branch_diff.base_ref,
)),
Match::RecentThread(thread) => {
Some(Self::completion_for_thread(
thread.session_id,
Some(thread.title),
source_range.clone(),
true,
source.clone(),
editor.clone(),
mention_set.clone(),
workspace.clone(),
cx,
))
}
Match::Rules(user_rules) => Some(Self::completion_for_rules(
user_rules,
source_range.clone(),
source.clone(),
editor.clone(),
mention_set.clone(),
workspace.clone(),
cx,
))
)),
Match::Fetch(url) => Self::completion_for_fetch(
source_range.clone(),
url,
source.clone(),
editor.clone(),
mention_set.clone(),
workspace.clone(),
cx,
),
Match::Entry(EntryMatch { entry, .. }) => {
Self::completion_for_entry(
entry,
source_range.clone(),
editor.clone(),
mention_set.clone(),
&workspace,
cx,
)
}
Match::BranchDiff(branch_diff) => {
Some(Self::build_branch_diff_completion(
branch_diff.base_ref,
source_range.clone(),
source.clone(),
editor.clone(),
mention_set.clone(),
workspace.clone(),
cx,
))
}
};
if let Some(completion) = &mut completion {
completion.group = group;
}
completion
})
.collect::<Vec<_>>()
});
@ -1655,17 +1807,31 @@ pub struct SlashCommandCompletion {
impl SlashCommandCompletion {
pub 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 mut last_command_start = None;
for (idx, _) in line.rmatch_indices('/') {
if line[idx + 1..]
.chars()
.next()
.is_some_and(|c| c.is_whitespace())
{
continue;
}
if idx > 0
&& line[..idx]
.chars()
.last()
.is_some_and(|c| !c.is_whitespace())
{
continue;
}
last_command_start = Some(idx);
break;
}
let (prefix, last_command) = line.rsplit_once('/')?;
if prefix.chars().last().is_some_and(|c| !c.is_whitespace())
|| last_command.starts_with(char::is_whitespace)
{
return None;
}
let last_command_start = last_command_start?;
let last_command = &line[last_command_start + 1..];
let mut argument = None;
let mut command = None;
@ -1679,7 +1845,7 @@ impl SlashCommandCompletion {
};
Some(Self {
source_range: prefix.len() + offset_to_line
source_range: last_command_start + offset_to_line
..line
.rfind(|c: char| !c.is_whitespace())
.unwrap_or_else(|| line.len())
@ -2171,27 +2337,28 @@ pub fn extract_file_name_and_directory(
)
}
/// Build the autocomplete-popup label for a slash command, appending
/// the command's origin (a worktree root name for project-local
/// skills) after the name when one is present and non-empty. The
/// suffix is styled with the muted `variable` highlight and excluded
/// from the fuzzy filter range so typing the source doesn't match
/// the entry.
///
/// Global skills carry an empty source (the literal scope prefix is
/// empty so the popup inserts `/:<name>`), and render with no
/// subtext — the source column is reserved for project-local skills
/// where the worktree name disambiguates same-named entries.
fn build_slash_command_label(
command: &AvailableCommand,
source_highlight_id: Option<HighlightId>,
) -> CodeLabel {
let source = command.source.as_ref().filter(|source| !source.is_empty());
build_slash_item_label(&command.name, command.source.as_ref(), source_highlight_id)
}
/// Build the autocomplete-popup label for a slash menu item, appending
/// the item origin after the name when one is present and non-empty.
/// The suffix is styled with the muted `variable` highlight and excluded
/// from the fuzzy filter range so typing the source doesn't match the entry.
fn build_slash_item_label(
name: &Arc<str>,
source: Option<&SharedString>,
source_highlight_id: Option<HighlightId>,
) -> CodeLabel {
let source = source.filter(|source| !source.is_empty());
let Some(source) = source else {
return CodeLabel::plain(command.name.to_string(), None);
return CodeLabel::plain(name.to_string(), None);
};
let mut builder = CodeLabelBuilder::default();
builder.push_str(&command.name, None);
builder.push_str(name, None);
// Two spaces gives a touch of breathing room between the name and
// the muted source label.
builder.push_str(" ", None);
@ -2203,7 +2370,7 @@ fn build_slash_command_label(
// (`filter_completions()` is false), so this is mostly defensive
// — but it keeps the displayed filter consistent with what we
// actually matched against.
builder.respan_filter_range(Some(&command.name));
builder.respan_filter_range(Some(name));
builder.build()
}
@ -2547,9 +2714,41 @@ mod tests {
assert_eq!(SlashCommandCompletion::try_parse("Lorem Ipsum", 0), None);
assert_eq!(SlashCommandCompletion::try_parse("Lorem /", 0), None);
assert_eq!(
SlashCommandCompletion::try_parse("Lorem /", 0),
Some(SlashCommandCompletion {
source_range: 6..7,
command: None,
argument: None,
})
);
assert_eq!(SlashCommandCompletion::try_parse("Lorem /help", 0), None);
assert_eq!(
SlashCommandCompletion::try_parse("Lorem /help", 0),
Some(SlashCommandCompletion {
source_range: 6..11,
command: Some("help".to_string()),
argument: None,
})
);
assert_eq!(
SlashCommandCompletion::try_parse("Lorem /help /test", 0),
Some(SlashCommandCompletion {
source_range: 12..17,
command: Some("test".to_string()),
argument: None,
})
);
assert_eq!(
SlashCommandCompletion::try_parse("/help", 10),
Some(SlashCommandCompletion {
source_range: 10..15,
command: Some("help".to_string()),
argument: None,
})
);
assert_eq!(SlashCommandCompletion::try_parse("Lorem/", 0), None);

View file

@ -78,7 +78,7 @@ use crate::agent_connection_store::{
AgentConnectedState, AgentConnectionEntryEvent, AgentConnectionStore,
};
use crate::agent_diff::AgentDiff;
use crate::completion_provider::AgentContextSelection;
use crate::completion_provider::{AgentContextSelection, AvailableSkill};
use crate::entry_view_state::{EntryViewEvent, ViewEvent};
use crate::message_editor::{InputAttempt, MessageEditor, MessageEditorEvent};
use crate::profile_selector::{ProfileProvider, ProfileSelector};
@ -1025,9 +1025,17 @@ impl ConversationView {
cx: &mut Context<Self>,
) -> Entity<ThreadView> {
let agent_id = self.agent.agent_id();
let connection = thread.read(cx).connection().clone();
let session_id = thread.read(cx).session_id().clone();
let available_skills = connection
.clone()
.downcast::<agent::NativeAgentConnection>()
.map(|native_connection| native_available_skills(&native_connection, &session_id, cx))
.unwrap_or_default();
let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::new(
thread.read(cx).prompt_capabilities(),
thread.read(cx).available_commands().to_vec(),
available_skills,
)));
let action_log = thread.read(cx).action_log().clone();
@ -1653,7 +1661,17 @@ impl ConversationView {
}
AcpThreadEvent::AvailableCommandsUpdated(available_commands) => {
if let Some(thread_view) = self.thread_view(&session_id) {
let has_commands = !available_commands.is_empty();
let available_skills = thread
.read(cx)
.connection()
.clone()
.downcast::<agent::NativeAgentConnection>()
.map(|native_connection| {
native_available_skills(&native_connection, &session_id, cx)
})
.unwrap_or_default();
let has_slash_completions =
!available_commands.is_empty() || !available_skills.is_empty();
let agent_display_name = self
.agent_server_store
@ -1662,13 +1680,12 @@ impl ConversationView {
.unwrap_or_else(|| self.agent.agent_id().0.to_string().into());
let new_placeholder =
placeholder_text(agent_display_name.as_ref(), has_commands);
placeholder_text(agent_display_name.as_ref(), has_slash_completions);
thread_view.update(cx, |thread_view, cx| {
thread_view
.session_capabilities
.write()
.set_available_commands(available_commands.clone());
let mut session_capabilities = thread_view.session_capabilities.write();
session_capabilities.set_available_commands(available_commands.clone());
session_capabilities.set_available_skills(available_skills);
thread_view.message_editor.update(cx, |editor, cx| {
editor.set_placeholder_text(&new_placeholder, window, cx);
});
@ -2903,9 +2920,29 @@ fn loading_contents_spinner(size: IconSize) -> AnyElement {
.into_any_element()
}
fn native_available_skills(
native_connection: &agent::NativeAgentConnection,
session_id: &acp::SessionId,
cx: &App,
) -> Vec<AvailableSkill> {
native_connection
.available_skills(session_id, cx)
.into_iter()
.map(|skill| AvailableSkill {
name: skill.name.into(),
description: skill.description.into(),
source: skill.source,
skill_file_path: skill.skill_file_path,
})
.collect()
}
fn placeholder_text(agent_name: &str, has_commands: bool) -> String {
if agent_name == agent::ZED_AGENT_ID.as_ref() {
format!("Message the {} — @ to include context", agent_name)
format!(
"Message the {}, @ to include context, / for commands",
agent_name
)
} else if has_commands {
format!(
"Message {} — @ to include context, / for commands",

View file

@ -393,8 +393,8 @@ impl ThreadView {
let session_id = thread.read(cx).session_id().clone();
let parent_session_id = thread.read(cx).parent_session_id().cloned();
let has_commands = !session_capabilities.read().available_commands().is_empty();
let placeholder = placeholder_text(agent_display_name.as_ref(), has_commands);
let has_slash_completions = session_capabilities.read().has_slash_completions();
let placeholder = placeholder_text(agent_display_name.as_ref(), has_slash_completions);
let mut should_auto_submit = false;
let mut show_external_source_prompt_warning = false;
@ -1029,7 +1029,7 @@ impl ThreadView {
.read()
.available_commands()
.iter()
.any(|command| command.name == "logout");
.any(|available_command| available_command.name == "logout");
if can_login && !logout_supported {
message_editor.update(cx, |editor, cx| editor.clear(window, cx));
self.clear_external_source_prompt_warning(cx);
@ -9409,6 +9409,21 @@ pub(crate) fn open_link(
MentionUri::TerminalSelection { .. } => {}
MentionUri::GitDiff { .. } => {}
MentionUri::MergeConflict { .. } => {}
MentionUri::Skill {
skill_file_path, ..
} => {
workspace
.open_abs_path(
skill_file_path,
workspace::OpenOptions {
focus: Some(true),
..Default::default()
},
window,
cx,
)
.detach_and_log_err(cx);
}
})
} else {
cx.open_url(&url);

View file

@ -140,6 +140,9 @@ impl MentionSet {
..
} => self.confirm_mention_for_symbol(abs_path, line_range, cx),
MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx),
MentionUri::Skill {
skill_file_path, ..
} => self.confirm_mention_for_skill(skill_file_path, cx),
MentionUri::Diagnostics {
include_errors,
include_warnings,
@ -289,6 +292,9 @@ impl MentionSet {
..
} => self.confirm_mention_for_symbol(abs_path, line_range, cx),
MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx),
MentionUri::Skill {
skill_file_path, ..
} => self.confirm_mention_for_skill(skill_file_path, cx),
MentionUri::Diagnostics {
include_errors,
include_warnings,
@ -440,6 +446,26 @@ impl MentionSet {
})
}
fn confirm_mention_for_skill(
&self,
skill_file_path: PathBuf,
cx: &mut Context<Self>,
) -> Task<Result<Mention>> {
cx.background_spawn(async move {
let content = std::fs::read_to_string(&skill_file_path).map_err(|e| {
anyhow!(
"Failed to read skill file {}: {}",
skill_file_path.display(),
e
)
})?;
Ok(Mention::Text {
content,
tracked_buffers: Vec::new(),
})
})
}
fn confirm_mention_for_rule(
&mut self,
id: PromptId,

View file

@ -3,8 +3,9 @@ use crate::SendImmediately;
use crate::{
ChatWithFollow,
completion_provider::{
AgentContextSelection, PromptCompletionProvider, PromptCompletionProviderDelegate,
PromptContextAction, PromptContextType, SlashCommandCompletion,
AgentContextSelection, AvailableCommand, AvailableSkill, PromptCompletionProvider,
PromptCompletionProviderDelegate, PromptContextAction, PromptContextType,
SlashCommandCompletion,
},
mention_set::{Mention, MentionImage, MentionSet, insert_crease_for_mention},
};
@ -47,19 +48,29 @@ use zed_actions::agent::{Chat, PasteRaw};
pub struct SessionCapabilities {
prompt_capabilities: acp::PromptCapabilities,
available_commands: Vec<acp::AvailableCommand>,
available_skills: Vec<AvailableSkill>,
}
impl SessionCapabilities {
pub fn new(
prompt_capabilities: acp::PromptCapabilities,
available_commands: Vec<acp::AvailableCommand>,
available_skills: Vec<AvailableSkill>,
) -> Self {
Self {
prompt_capabilities,
available_commands,
available_skills,
}
}
pub fn from_acp_commands(
prompt_capabilities: acp::PromptCapabilities,
available_commands: Vec<acp::AvailableCommand>,
) -> Self {
Self::new(prompt_capabilities, available_commands, Vec::new())
}
pub fn supports_images(&self) -> bool {
self.prompt_capabilities.image
}
@ -72,6 +83,14 @@ impl SessionCapabilities {
&self.available_commands
}
pub fn available_skills(&self) -> &[AvailableSkill] {
&self.available_skills
}
pub fn has_slash_completions(&self) -> bool {
!self.available_commands.is_empty() || !self.available_skills.is_empty()
}
fn supported_modes(&self, has_thread_store: bool) -> Vec<PromptContextType> {
let mut supported = vec![PromptContextType::File, PromptContextType::Symbol];
if self.prompt_capabilities.embedded_context {
@ -88,18 +107,22 @@ impl SessionCapabilities {
supported
}
pub fn completion_commands(&self) -> Vec<crate::completion_provider::AvailableCommand> {
pub fn completion_commands(&self) -> Vec<AvailableCommand> {
self.available_commands
.iter()
.map(|cmd| crate::completion_provider::AvailableCommand {
name: cmd.name.clone().into(),
description: cmd.description.clone().into(),
requires_argument: cmd.input.is_some(),
source: acp_thread::skill_source_from_meta(&cmd.meta),
.map(|command| AvailableCommand {
name: command.name.clone().into(),
description: command.description.clone().into(),
requires_argument: command.input.is_some(),
source: None,
})
.collect()
}
pub fn completion_skills(&self) -> Vec<AvailableSkill> {
self.available_skills.clone()
}
pub fn set_prompt_capabilities(&mut self, prompt_capabilities: acp::PromptCapabilities) {
self.prompt_capabilities = prompt_capabilities;
}
@ -107,6 +130,10 @@ impl SessionCapabilities {
pub fn set_available_commands(&mut self, available_commands: Vec<acp::AvailableCommand>) {
self.available_commands = available_commands;
}
pub fn set_available_skills(&mut self, available_skills: Vec<AvailableSkill>) {
self.available_skills = available_skills;
}
}
pub type SharedSessionCapabilities = Arc<RwLock<SessionCapabilities>>;
@ -128,10 +155,14 @@ impl PromptCompletionProviderDelegate for MessageEditorCompletionDelegate {
.supported_modes(self.has_thread_store)
}
fn available_commands(&self, _cx: &App) -> Vec<crate::completion_provider::AvailableCommand> {
fn available_commands(&self, _cx: &App) -> Vec<AvailableCommand> {
self.session_capabilities.read().completion_commands()
}
fn available_skills(&self, _cx: &App) -> Vec<AvailableSkill> {
self.session_capabilities.read().completion_skills()
}
fn slash_autocomplete_invoked(&self, cx: &mut App) {
// This may be called synchronously from inside a `MessageEditor`
// update (e.g. when pasting a slash command triggers completions),
@ -606,7 +637,7 @@ impl MessageEditor {
let command_name = parsed_command.command?;
let available_command = available_commands
.iter()
.find(|command| command.name == command_name)?;
.find(|available_command| available_command.name == command_name)?;
let acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput {
mut hint,
@ -717,6 +748,7 @@ impl MessageEditor {
fn validate_slash_commands(
text: &str,
available_commands: &[acp::AvailableCommand],
available_skills: &[AvailableSkill],
agent_id: &AgentId,
) -> Result<()> {
if let Some(parsed_command) = SlashCommandCompletion::try_parse(text, 0) {
@ -729,7 +761,7 @@ impl MessageEditor {
// (`/github.create_pr`), and skills (whose bare name
// is registered for the unqualified `/<name>` form).
//
// 2. Skill scope qualifier `/<scope>:<name>`. The popup
// 2. Trusted native skill scope qualifier `/<scope>:<name>`. The popup
// inserts this colon-separated form to disambiguate
// same-named skills, so the validator splits on the
// LAST `:` to recover scope + bare name. Skill
@ -740,23 +772,24 @@ impl MessageEditor {
// scope is allowed to be empty: `/:<name>` is the
// qualified form for a global skill (see
// `SkillSource::scope_prefix`). The validator then
// confirms an available command with that bare
// name has a `zed.skill_source` meta tag whose
// value equals the typed scope (including empty
// for globals). Without this branch, every
// autocomplete pick of a same-named skill would be
// rejected as "not supported" before reaching the
// resolver.
// checks the `available_skills` slice for an entry
// whose `skill.name` matches the bare name and
// whose `skill.source` equals the typed scope
// (including empty for globals). Without this
// branch, every autocomplete pick of a same-named
// skill would be rejected as "not supported"
// before reaching the resolver.
let direct_match = available_commands
.iter()
.any(|cmd| cmd.name == command_name);
.any(|available_command| available_command.name == command_name)
|| available_skills
.iter()
.any(|skill| skill.name.as_ref() == command_name);
let scope_match = !direct_match
&& command_name.rsplit_once(':').is_some_and(|(scope, bare)| {
!bare.is_empty()
&& available_commands.iter().any(|cmd| {
cmd.name == bare
&& acp_thread::skill_source_str_from_meta(&cmd.meta)
== Some(scope)
&& available_skills.iter().any(|skill| {
skill.name.as_ref() == bare && skill.source.as_ref() == scope
})
});
@ -765,7 +798,7 @@ impl MessageEditor {
"The /{} command is not supported by {}.\n\nAvailable commands: {}",
command_name,
agent_id,
Self::format_available_commands(available_commands),
Self::format_available_commands(available_commands, available_skills),
));
}
}
@ -773,25 +806,23 @@ impl MessageEditor {
Ok(())
}
/// Render the available-commands list for error messages. Skills
/// Render the available-commands list for error messages. Trusted native skills
/// are shown in their qualified `/<scope>:<name>` form so users
/// see the exact text the popup would insert — otherwise the
/// listing would contain confusing duplicates like `/foo, /foo`
/// when both a global and a project-local skill share a name.
/// Globals carry an empty scope and so render as `/:<name>`.
fn format_available_commands(commands: &[acp::AvailableCommand]) -> String {
if commands.is_empty() {
fn format_available_commands(
commands: &[acp::AvailableCommand],
skills: &[AvailableSkill],
) -> String {
if commands.is_empty() && skills.is_empty() {
return "none".to_string();
}
commands
skills
.iter()
.map(|cmd| {
if let Some(scope) = acp_thread::skill_source_str_from_meta(&cmd.meta) {
format!("/{}:{}", scope, cmd.name)
} else {
format!("/{}", cmd.name)
}
})
.map(|skill| format!("/{}:{}", skill.source, skill.name))
.chain(commands.iter().map(|command| format!("/{}", command.name)))
.collect::<Vec<_>>()
.join(", ")
}
@ -802,16 +833,23 @@ impl MessageEditor {
cx: &mut Context<Self>,
) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
let text = self.editor.read(cx).text(cx);
let available_commands = self
.session_capabilities
.read()
.available_commands()
.to_vec();
let (available_commands, available_skills) = {
let session_capabilities = self.session_capabilities.read();
(
session_capabilities.available_commands().to_vec(),
session_capabilities.available_skills().to_vec(),
)
};
let agent_id = self.agent_id.clone();
let build_task = self.build_content_blocks(full_mention_content, cx);
cx.spawn(async move |_, _cx| {
Self::validate_slash_commands(&text, &available_commands, &agent_id)?;
Self::validate_slash_commands(
&text,
&available_commands,
&available_skills,
&agent_id,
)?;
build_task.await
})
}
@ -2113,7 +2151,7 @@ mod tests {
use util::{path, paths::PathStyle, rel_path::rel_path};
use workspace::{AppState, Item, MultiWorkspace, Workspace};
use crate::completion_provider::{AgentContextSelection, PromptContextType};
use crate::completion_provider::{AgentContextSelection, AvailableSkill, PromptContextType};
use crate::{
conversation_view::tests::init_test,
mention_set::insert_crease_for_mention,
@ -2122,57 +2160,86 @@ mod tests {
},
};
#[test]
fn test_session_capabilities_keep_commands_and_skills_separate() {
let skill_file_path = PathBuf::from("/tmp/SKILL.md");
let skill = AvailableSkill {
name: "deploy".into(),
description: "Deploy the app".into(),
source: "".into(),
skill_file_path: skill_file_path.clone(),
};
let session_capabilities = SessionCapabilities::new(
acp::PromptCapabilities::default(),
vec![acp::AvailableCommand::new("help", "Get help")],
vec![skill],
);
assert_eq!(session_capabilities.completion_commands().len(), 1);
let skills = session_capabilities.completion_skills();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name.as_ref(), "deploy");
assert_eq!(skills[0].skill_file_path, skill_file_path);
}
#[test]
fn test_validate_slash_commands_accepts_scope_qualified_skill() {
let agent_id = AgentId::from("Zed");
let make_skill_command = |name: &str, scope: &str| {
acp::AvailableCommand::new(name, "desc").meta(acp_thread::meta_with_skill_source(scope))
let make_skill = |name: &str, source: &str| AvailableSkill {
name: name.into(),
description: "desc".into(),
source: source.into(),
skill_file_path: PathBuf::from(format!("/tmp/{source}-{name}/SKILL.md")),
};
// Global skills carry an empty scope (so the popup inserts
// `/:<name>`); project-local skills carry their worktree root
// name. The empty-scope encoding means a worktree literally
// named `global` no longer collides with the global source.
let commands = vec![
make_skill_command("deploy", ""),
make_skill_command("deploy", "zed"),
acp::AvailableCommand::new("help", "Get help"),
];
let commands = vec![acp::AvailableCommand::new("help", "Get help")];
let skills = vec![make_skill("deploy", ""), make_skill("deploy", "zed")];
let no_skills = Vec::new();
// Bare name still works (current behavior — the resolver
// applies project-overrides-global for unqualified commands).
MessageEditor::validate_slash_commands("/deploy", &commands, &agent_id)
MessageEditor::validate_slash_commands("/deploy", &commands, &skills, &agent_id)
.expect("bare /deploy should validate when a skill named `deploy` exists");
MessageEditor::validate_slash_commands("/zed:deploy", &commands, &no_skills, &agent_id)
.expect_err("scope-qualified skills should require a first-class available skill");
// Scope-qualified forms both validate, each pointing at the
// matching source. `/:<name>` is the qualified form for a
// global skill; `/<worktree>:<name>` is the qualified form
// for a project-local skill.
MessageEditor::validate_slash_commands("/:deploy", &commands, &agent_id)
MessageEditor::validate_slash_commands("/:deploy", &commands, &skills, &agent_id)
.expect("/:deploy should validate when a global skill named `deploy` exists");
MessageEditor::validate_slash_commands("/zed:deploy", &commands, &agent_id).expect(
MessageEditor::validate_slash_commands("/zed:deploy", &commands, &skills, &agent_id).expect(
"/zed:deploy should validate when a project skill named `deploy` exists in the `zed` worktree",
);
// Hand-typed `/global:<name>` is NOT an alias for `/:<name>`.
// It looks for a project-local skill from a worktree named
// `global`, and fails when no such worktree skill exists.
MessageEditor::validate_slash_commands("/global:deploy", &commands, &agent_id).expect_err(
"/global:deploy should fail when no worktree named `global` has a `deploy` skill",
);
MessageEditor::validate_slash_commands("/global:deploy", &commands, &skills, &agent_id)
.expect_err(
"/global:deploy should fail when no worktree named `global` has a `deploy` skill",
);
// The `:` separator is what distinguishes a skill scope from
// an MCP server prefix — the dotted form `/zed.deploy` is an
// MCP-style lookup, which doesn't match here.
MessageEditor::validate_slash_commands("/zed.deploy", &commands, &agent_id)
MessageEditor::validate_slash_commands("/zed.deploy", &commands, &skills, &agent_id)
.expect_err("/zed.deploy (dotted) should be treated as an MCP-style prefix and fail");
// Wrong scope is rejected so the resolver doesn't silently
// fall through when the user meant a skill. `zed:help` looks
// like a skill scope qualifier but no skill named `help`
// exists in the `zed` worktree (it's an MCP command).
let err = MessageEditor::validate_slash_commands("/zed:help", &commands, &agent_id)
.expect_err("/zed:help should fail — `help` is an MCP command, not a worktree skill");
let err =
MessageEditor::validate_slash_commands("/zed:help", &commands, &skills, &agent_id)
.expect_err(
"/zed:help should fail — `help` is an MCP command, not a worktree skill",
);
let err_message = err.to_string();
assert!(
err_message.contains("/zed:help"),
@ -2393,7 +2460,7 @@ mod tests {
let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
let thread_store = None;
let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::new(
let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::from_acp_commands(
acp::PromptCapabilities::default(),
vec![],
)));
@ -2555,7 +2622,7 @@ mod tests {
let mut cx = VisualTestContext::from_window(window.into(), cx);
let thread_store = None;
let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::new(
let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::from_acp_commands(
acp::PromptCapabilities::default(),
vec![
acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
@ -2729,7 +2796,7 @@ mod tests {
let mut cx = VisualTestContext::from_window(window.into(), cx);
let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::new(
let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::from_acp_commands(
acp::PromptCapabilities::default(),
vec![acp::AvailableCommand::new("hello", "Say hello")],
)));
@ -2884,7 +2951,7 @@ mod tests {
}
let thread_store = cx.new(|cx| ThreadStore::new(cx));
let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::new(
let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::from_acp_commands(
acp::PromptCapabilities::default(),
vec![],
)));

View file

@ -180,6 +180,11 @@ fn open_mention_uri(
MentionUri::Rule { id, .. } => {
open_rule(workspace, id, window, cx);
}
MentionUri::Skill {
skill_file_path, ..
} => {
open_skill_file(workspace, skill_file_path, window, cx);
}
MentionUri::Fetch { url } => {
cx.open_url(url.as_str());
}
@ -192,6 +197,25 @@ fn open_mention_uri(
});
}
fn open_skill_file(
workspace: &mut Workspace,
skill_file_path: PathBuf,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
workspace
.open_abs_path(
skill_file_path,
OpenOptions {
focus: Some(true),
..Default::default()
},
window,
cx,
)
.detach_and_log_err(cx);
}
fn open_file(
workspace: &mut Workspace,
abs_path: PathBuf,

View file

@ -676,6 +676,7 @@ impl ConsoleQueryBarCompletionProvider {
confirm: None,
source: project::CompletionSource::Custom,
insert_text_mode: None,
group: None,
})
})
.collect::<Vec<_>>();
@ -787,6 +788,7 @@ impl ConsoleQueryBarCompletionProvider {
confirm: None,
source: project::CompletionSource::Dap { sort_text },
insert_text_mode: None,
group: None,
}
})
.collect();

View file

@ -1400,6 +1400,7 @@ impl editor::CompletionProvider for FeedbackCompletionProvider {
snippet_deduplication_key: None,
insert_text_mode: None,
confirm: None,
group: None,
})
.collect();

View file

@ -486,6 +486,7 @@ impl CompletionBuilder {
confirm: None,
match_start: None,
snippet_deduplication_key: None,
group: None,
}
}
}

View file

@ -13,7 +13,7 @@ use markdown::{CopyButtonVisibility, Markdown, MarkdownElement};
use multi_buffer::Anchor;
use ordered_float::OrderedFloat;
use project::lsp_store::CompletionDocumentation;
use project::{CodeAction, Completion, TaskSourceKind};
use project::{CodeAction, Completion, CompletionGroup, TaskSourceKind};
use project::{CompletionDisplayOptions, CompletionSource};
use task::DebugScenario;
use task::TaskContext;
@ -29,8 +29,8 @@ use std::{
};
use task::ResolvedTask;
use ui::{
Color, IntoElement, ListItem, Pixels, Popover, ScrollAxes, Scrollbars, Styled, Tooltip,
WithScrollbar, prelude::*,
Divider, ListItem, ListSubHeader, Popover, ScrollAxes, Scrollbars, Tooltip, WithScrollbar,
prelude::*,
};
use util::ResultExt;
@ -68,6 +68,26 @@ const MARKDOWN_CACHE_AFTER_ITEMS: usize = 2;
const RESOLVE_BEFORE_ITEMS: usize = 4;
const RESOLVE_AFTER_ITEMS: usize = 4;
#[derive(Clone, Debug)]
pub enum CompletionMenuEntry {
Match(StringMatch),
Divider,
GroupHeader(SharedString),
}
impl CompletionMenuEntry {
pub fn as_match(&self) -> Option<&StringMatch> {
match self {
CompletionMenuEntry::Match(m) => Some(m),
CompletionMenuEntry::Divider | CompletionMenuEntry::GroupHeader(_) => None,
}
}
pub fn is_selectable(&self) -> bool {
matches!(self, CompletionMenuEntry::Match(_))
}
}
pub enum CodeContextMenu {
Completions(CompletionsMenu),
CodeActions(CodeActionsMenu),
@ -235,7 +255,7 @@ pub struct CompletionsMenu {
/// String match candidate for each completion, grouped by `match_start`.
match_candidates: Arc<[(Option<text::Anchor>, Vec<StringMatchCandidate>)]>,
/// Entries displayed in the menu, which is a filtered and sorted subset of `match_candidates`.
pub entries: Rc<RefCell<Box<[StringMatch]>>>,
pub entries: Rc<RefCell<Box<[CompletionMenuEntry]>>>,
pub selected_item: usize,
filter_task: Task<()>,
cancel_filter: Arc<AtomicBool>,
@ -376,6 +396,7 @@ impl CompletionsMenu {
confirm: None,
insert_text_mode: None,
source: CompletionSource::Custom,
group: None,
})
.collect();
@ -390,11 +411,13 @@ impl CompletionsMenu {
let entries = choices
.iter()
.enumerate()
.map(|(id, completion)| StringMatch {
candidate_id: id,
score: 1.,
positions: vec![],
string: completion.clone(),
.map(|(id, completion)| {
CompletionMenuEntry::Match(StringMatch {
candidate_id: id,
score: 1.,
positions: vec![],
string: completion.clone(),
})
})
.collect();
Self {
@ -430,12 +453,20 @@ impl CompletionsMenu {
window: &mut Window,
cx: &mut Context<Editor>,
) {
let index = if self.scroll_handle.y_flipped() {
self.entries.borrow().len() - 1
let entries = self.entries.borrow();
if entries.is_empty() {
return;
}
let start = if self.scroll_handle.y_flipped() {
entries.len() - 1
} else {
0
};
self.update_selection_index(index, provider, window, cx);
drop(entries);
let index = self.find_selectable_entry(start, !self.scroll_handle.y_flipped());
if let Some(index) = index {
self.update_selection_index(index, provider, window, cx);
}
}
fn select_last(
@ -444,12 +475,20 @@ impl CompletionsMenu {
window: &mut Window,
cx: &mut Context<Editor>,
) {
let index = if self.scroll_handle.y_flipped() {
let entries = self.entries.borrow();
if entries.is_empty() {
return;
}
let start = if self.scroll_handle.y_flipped() {
0
} else {
self.entries.borrow().len() - 1
entries.len() - 1
};
self.update_selection_index(index, provider, window, cx);
drop(entries);
let index = self.find_selectable_entry(start, self.scroll_handle.y_flipped());
if let Some(index) = index {
self.update_selection_index(index, provider, window, cx);
}
}
fn select_prev(
@ -494,18 +533,70 @@ impl CompletionsMenu {
}
fn prev_match_index(&self) -> usize {
if self.selected_item > 0 {
let entries = self.entries.borrow();
let len = entries.len();
if len == 0 {
return 0;
}
let mut index = if self.selected_item > 0 {
self.selected_item - 1
} else {
self.entries.borrow().len() - 1
len - 1
};
let start = index;
loop {
if entries[index].is_selectable() {
return index;
}
index = if index > 0 { index - 1 } else { len - 1 };
if index == start {
return self.selected_item;
}
}
}
fn next_match_index(&self) -> usize {
if self.selected_item + 1 < self.entries.borrow().len() {
let entries = self.entries.borrow();
let len = entries.len();
if len == 0 {
return 0;
}
let mut index = if self.selected_item + 1 < len {
self.selected_item + 1
} else {
0
};
let start = index;
loop {
if entries[index].is_selectable() {
return index;
}
index = if index + 1 < len { index + 1 } else { 0 };
if index == start {
return self.selected_item;
}
}
}
fn find_selectable_entry(&self, start: usize, forward: bool) -> Option<usize> {
let entries = self.entries.borrow();
let len = entries.len();
if len == 0 {
return None;
}
let mut index = start;
loop {
if entries[index].is_selectable() {
return Some(index);
}
if forward {
index = if index + 1 < len { index + 1 } else { 0 };
} else {
index = if index > 0 { index - 1 } else { len - 1 };
}
if index == start {
return None;
}
}
}
@ -520,7 +611,7 @@ impl CompletionsMenu {
if let Some(provider) = provider {
let entries = self.entries.borrow();
let entry = if self.selected_item < entries.len() {
Some(&entries[self.selected_item])
entries[self.selected_item].as_match()
} else {
None
};
@ -590,12 +681,19 @@ impl CompletionsMenu {
// This filtering doesn't happen if the completions are currently being updated.
let completions = self.completions.borrow();
let candidate_ids = entry_indices
.map(|i| entries[i].candidate_id)
.filter_map(|i| entries[i].as_match().map(|m| m.candidate_id))
.filter(|i| completions[*i].documentation.is_none());
// Current selection is always resolved even if it already has documentation, to handle
// out-of-spec language servers that return more results later.
let selected_candidate_id = entries[self.selected_item].candidate_id;
let Some(selected_candidate_id) = entries[self.selected_item]
.as_match()
.map(|m| m.candidate_id)
else {
drop(entries);
drop(completions);
return;
};
let candidate_ids = iter::once(selected_candidate_id)
.chain(candidate_ids.filter(|id| *id != selected_candidate_id))
.collect::<Vec<usize>>();
@ -658,7 +756,7 @@ impl CompletionsMenu {
if index >= entries.len() {
return None;
}
let candidate_id = entries[index].candidate_id;
let candidate_id = entries[index].as_match()?.candidate_id;
let completions = self.completions.borrow();
match &completions[candidate_id].documentation {
Some(CompletionDocumentation::MultiLineMarkdown(source)) if !source.is_empty() => self
@ -767,7 +865,7 @@ impl CompletionsMenu {
}
pub fn visible(&self) -> bool {
!self.entries.borrow().is_empty()
self.entries.borrow().iter().any(|e| e.as_match().is_some())
}
fn origin(&self) -> ContextMenuOrigin {
@ -792,6 +890,7 @@ impl CompletionsMenu {
.borrow()
.iter()
.enumerate()
.filter_map(|(ix, entry)| entry.as_match().map(|m| (ix, m)))
.max_by_key(|(_, mat)| {
let completion = &completions[mat.candidate_id];
let documentation = &completion.documentation;
@ -828,8 +927,23 @@ impl CompletionsMenu {
entries.borrow()[range]
.iter()
.enumerate()
.map(|(ix, mat)| {
.map(|(ix, entry)| {
let item_ix = start_ix + ix;
let Some(mat) = entry.as_match() else {
return match entry {
CompletionMenuEntry::GroupHeader(label) => div()
.child(ListSubHeader::new(label.clone()).inset(true))
.into_any_element(),
CompletionMenuEntry::Divider => h_flex()
.flex_1()
.size_full()
.child(Divider::horizontal())
.into_any_element(),
CompletionMenuEntry::Match(_) => unreachable!(),
};
};
let completion = &completions_guard[mat.candidate_id];
let documentation = if show_completion_documentation {
&completion.documentation
@ -1033,6 +1147,7 @@ impl CompletionsMenu {
)
.end_slot::<Label>(documentation_label),
)
.into_any_element()
})
.collect()
}),
@ -1072,7 +1187,10 @@ impl CompletionsMenu {
return None;
}
let mat = &self.entries.borrow()[self.selected_item];
let entries = self.entries.borrow();
let Some(mat) = entries[self.selected_item].as_match() else {
return None;
};
let completions = self.completions.borrow();
let multiline_docs = match completions[mat.candidate_id].documentation.as_ref() {
Some(CompletionDocumentation::MultiLinePlainText(text)) => div().child(text.clone()),
@ -1258,8 +1376,27 @@ impl CompletionsMenu {
window: &mut Window,
cx: &mut Context<Editor>,
) {
*self.entries.borrow_mut() = matches.into_boxed_slice();
self.selected_item = 0;
let completions = self.completions.borrow();
let mut entries: Vec<CompletionMenuEntry> = Vec::with_capacity(matches.len());
let mut last_group: Option<&CompletionGroup> = None;
for mat in matches {
let group = completions[mat.candidate_id].group.as_ref();
if group != last_group {
if group.is_some() || last_group.is_some() {
if !entries.is_empty() {
entries.push(CompletionMenuEntry::Divider);
}
if let Some(label) = group.and_then(|g| g.label.as_ref()) {
entries.push(CompletionMenuEntry::GroupHeader(label.clone()));
}
}
last_group = group;
}
entries.push(CompletionMenuEntry::Match(mat));
}
drop(completions);
*self.entries.borrow_mut() = entries.into_boxed_slice();
self.selected_item = self.find_selectable_entry(0, true).unwrap_or(0);
self.handle_selection_changed(provider.as_deref(), window, cx);
}

View file

@ -598,6 +598,7 @@ impl Editor {
},
insert_text_mode: Some(InsertTextMode::AS_IS),
confirm: None,
group: None,
}));
completions.extend(
@ -775,7 +776,8 @@ impl Editor {
let candidate_id = {
let entries = completions_menu.entries.borrow();
let mat = entries.get(item_ix.unwrap_or(completions_menu.selected_item))?;
let entry = entries.get(item_ix.unwrap_or(completions_menu.selected_item))?;
let mat = entry.as_match()?;
if self.show_edit_predictions_in_menu() {
self.discard_edit_prediction(EditPredictionDiscardReason::Rejected, cx);
}
@ -1347,6 +1349,7 @@ fn snippet_completions(
confirm: None,
match_start: Some(start),
snippet_deduplication_key: Some((snippet_index, prefix_index)),
group: None,
}
}));
}

View file

@ -522,7 +522,11 @@ async fn test_hidden_edit_prediction_opens_snippet_menu_for_strong_prefix_match(
panic!("expected completions menu");
};
let entries = menu.entries.borrow();
assert!(entries.iter().any(|entry| entry.string == "Theta"));
assert!(
entries
.iter()
.any(|entry| { entry.as_match().is_some_and(|m| m.string == "Theta") })
);
});
}
@ -1536,6 +1540,7 @@ impl CompletionProvider for FakeCompletionMenuProvider {
snippet_deduplication_key: None,
insert_text_mode: None,
confirm: None,
group: None,
})
.collect();

View file

@ -22227,7 +22227,7 @@ async fn test_completions_default_resolve_data_handling(cx: &mut TestAppContext)
.entries
.borrow()
.iter()
.map(|mat| mat.string.clone())
.filter_map(|entry| entry.as_match().map(|m| m.string.clone()))
.collect::<Vec<String>>(),
items
.iter()
@ -22379,7 +22379,10 @@ async fn test_completions_in_languages_with_extra_word_characters(cx: &mut TestA
fn completion_menu_entries(menu: &CompletionsMenu) -> Vec<String> {
let entries = menu.entries.borrow();
entries.iter().map(|mat| mat.string.clone()).collect()
entries
.iter()
.filter_map(|entry| entry.as_match().map(|m| m.string.clone()))
.collect()
}
#[gpui::test]
@ -31298,7 +31301,8 @@ pub fn check_displayed_completions(expected: Vec<&'static str>, cx: &mut EditorL
let entries = menu.entries.borrow();
let entries = entries
.iter()
.map(|entry| entry.string.as_str())
.filter_map(|entry| entry.as_match())
.map(|m| m.string.as_str())
.collect::<Vec<_>>();
assert_eq!(entries, expected);
} else {
@ -31385,7 +31389,7 @@ async fn test_mixed_completions_with_multi_word_snippet(cx: &mut TestAppContext)
let entries = context_menu.entries.borrow();
entries
.iter()
.map(|entry| entry.string.clone())
.filter_map(|entry| entry.as_match().map(|m| m.string.clone()))
.collect_vec()
}
_ => vec![],

View file

@ -673,6 +673,7 @@ impl CompletionProvider for RustStyleCompletionProvider {
source: CompletionSource::Custom,
insert_text_mode: None,
confirm: None,
group: None,
})
.collect(),
display_options: CompletionDisplayOptions::default(),

View file

@ -106,6 +106,7 @@ impl CompletionProvider for ActionCompletionProvider {
snippet_deduplication_key: None,
insert_text_mode: None,
confirm: None,
group: None,
}
})
.collect();

View file

@ -3518,6 +3518,7 @@ impl CompletionProvider for KeyContextCompletionProvider {
snippet_deduplication_key: None,
insert_text_mode: None,
confirm: None,
group: None,
})
.collect(),
display_options: CompletionDisplayOptions::default(),

View file

@ -10804,6 +10804,7 @@ impl LspStore {
insert_text_mode: None,
icon_path: None,
confirm: None,
group: None,
}]))),
0,
false,
@ -13781,6 +13782,7 @@ async fn populate_labels_for_completions(
confirm: None,
match_start: None,
snippet_deduplication_key: None,
group: None,
});
}
None => {
@ -13797,6 +13799,7 @@ async fn populate_labels_for_completions(
confirm: None,
match_start: None,
snippet_deduplication_key: None,
group: None,
});
}
}

View file

@ -529,6 +529,17 @@ impl CompletionIntent {
}
}
/// Describes a visual group for a completion item in the menu.
/// When the group changes between consecutive completions, the menu inserts a divider.
/// If a label is provided, a non-selectable header row is also rendered.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CompletionGroup {
/// Identity of this group, used to detect transitions between consecutive items.
pub key: SharedString,
/// When set, a non-selectable header with this text is rendered below the divider.
pub label: Option<SharedString>,
}
/// Similar to `CoreCompletion`, but with extra metadata attached.
#[derive(Clone)]
pub struct Completion {
@ -557,6 +568,10 @@ pub struct Completion {
/// If `true` is returned, the editor will show a new completion menu after this completion is confirmed.
/// if no confirmation is provided or `false` is returned, the completion will be committed.
pub confirm: Option<Arc<dyn Send + Sync + Fn(CompletionIntent, &mut Window, &mut App) -> bool>>,
/// An optional group for this completion. When the group changes between consecutive
/// items, the completion menu inserts a divider. If the group also carries a label,
/// a non-selectable header row is rendered below the divider.
pub group: Option<CompletionGroup>,
}
#[derive(Debug, Clone)]