Skip to repository content3421 lines · 126.2 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T01:50:49.244Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
completion_provider.rs
1use std::cmp::Reverse;
2use std::ops::Range;
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::sync::atomic::AtomicBool;
6
7use crate::DEFAULT_THREAD_TITLE;
8use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore};
9use acp_thread::MentionUri;
10use agent_client_protocol::schema::v1 as acp;
11use anyhow::Result;
12use editor::{CompletionProvider, Editor, code_context_menus::COMPLETION_MENU_MAX_WIDTH};
13use futures::FutureExt as _;
14use fuzzy::{PathMatch, StringMatch, StringMatchCandidate};
15use gpui::{
16 App, BackgroundExecutor, Entity, Focusable, Hsla, SharedString, Task, WeakEntity, Window,
17};
18use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId};
19use lsp::CompletionContext;
20use multi_buffer::ToOffset as _;
21use ordered_float::OrderedFloat;
22use project::lsp_store::{CompletionDocumentation, SymbolLocation};
23use project::{
24 Completion, CompletionDisplayOptions, CompletionGroup, CompletionIntent, CompletionResponse,
25 DiagnosticSummary, PathMatchCandidateSet, Project, ProjectPath, Symbol, WorktreeId,
26};
27
28use rope::Point;
29use settings::Settings;
30use terminal::terminal_settings::TerminalSettings;
31use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
32use text::{Anchor, ToOffset as _, ToPoint as _};
33use ui::IconName;
34use ui::prelude::*;
35use util::ResultExt as _;
36use util::paths::PathStyle;
37use util::rel_path::RelPath;
38use util::truncate_and_remove_front;
39use workspace::Workspace;
40
41use crate::AgentPanel;
42use crate::mention_set::MentionSet;
43
44#[derive(Clone)]
45pub(crate) enum AgentContextSelection {
46 Editor(Vec<(Entity<Buffer>, Range<text::Anchor>)>),
47 Terminal(Vec<String>),
48}
49
50#[derive(Clone)]
51pub(crate) enum AgentContextSource {
52 Editor(WeakEntity<Editor>),
53 TerminalView(WeakEntity<TerminalView>),
54 TerminalPanel,
55}
56
57impl AgentContextSource {
58 pub(crate) fn read_selection(
59 &self,
60 workspace: &Workspace,
61 include_current_line: bool,
62 cx: &mut App,
63 ) -> Option<AgentContextSelection> {
64 match self {
65 Self::Editor(handle) => {
66 let editor = handle.upgrade()?;
67 let ranges = editor_selection_ranges(&editor, include_current_line, cx);
68 (!ranges.is_empty()).then_some(AgentContextSelection::Editor(ranges))
69 }
70 Self::TerminalView(handle) => {
71 let terminal_view = handle.upgrade()?;
72 terminal_view_selection(&terminal_view, cx)
73 .map(|text| AgentContextSelection::Terminal(vec![text]))
74 }
75 Self::TerminalPanel => {
76 let panel = workspace.panel::<TerminalPanel>(cx)?;
77 let selections = panel.read(cx).terminal_selections(cx);
78 (!selections.is_empty()).then_some(AgentContextSelection::Terminal(selections))
79 }
80 }
81 }
82
83 pub(crate) fn from_focused(workspace: &Workspace, window: &Window, cx: &App) -> Option<Self> {
84 if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx)
85 && agent_panel.focus_handle(cx).contains_focused(window, cx)
86 {
87 return None;
88 }
89
90 if let Some(active_item) = workspace.active_item(cx) {
91 if let Some(editor) = active_item.act_as::<Editor>(cx) {
92 if editor.focus_handle(cx).is_focused(window) {
93 return Some(Self::Editor(editor.downgrade()));
94 }
95 } else if let Some(terminal_view) = active_item.act_as::<TerminalView>(cx)
96 && terminal_view.focus_handle(cx).is_focused(window)
97 {
98 return Some(Self::TerminalView(terminal_view.downgrade()));
99 }
100 }
101
102 if let Some(panel) = workspace.panel::<TerminalPanel>(cx)
103 && panel.focus_handle(cx).contains_focused(window, cx)
104 {
105 return Some(Self::TerminalPanel);
106 }
107
108 None
109 }
110
111 pub(crate) fn from_active(workspace: &Workspace, cx: &App) -> Option<Self> {
112 if let Some(active_item) = workspace.active_item(cx) {
113 if let Some(editor) = active_item.act_as::<Editor>(cx) {
114 return Some(Self::Editor(editor.downgrade()));
115 } else if let Some(terminal_view) = active_item.act_as::<TerminalView>(cx) {
116 return Some(Self::TerminalView(terminal_view.downgrade()));
117 }
118 }
119 if terminal_panel_dock_is_open(workspace, cx) {
120 return Some(Self::TerminalPanel);
121 }
122 None
123 }
124
125 pub(crate) fn exists(&self, workspace: &Workspace, cx: &App) -> bool {
126 match self {
127 Self::Editor(handle) => handle.upgrade().is_some(),
128 Self::TerminalView(handle) => handle.upgrade().is_some(),
129 Self::TerminalPanel => terminal_panel_dock_is_open(workspace, cx),
130 }
131 }
132}
133
134fn terminal_panel_dock_is_open(workspace: &Workspace, cx: &App) -> bool {
135 if workspace.panel::<TerminalPanel>(cx).is_none() {
136 return false;
137 }
138 let position = TerminalSettings::get_global(cx).dock.into();
139 workspace.dock_at_position(position).read(cx).is_open()
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub(crate) enum PromptContextEntry {
144 Mode(PromptContextType),
145 Action(PromptContextAction),
146}
147
148impl PromptContextEntry {
149 pub fn keyword(&self) -> &'static str {
150 match self {
151 Self::Mode(mode) => mode.keyword(),
152 Self::Action(action) => action.keyword(),
153 }
154 }
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub(crate) enum PromptContextType {
159 File,
160 Symbol,
161 Fetch,
162 Thread,
163 Skill,
164 Diagnostics,
165 BranchDiff,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub(crate) enum PromptContextAction {
170 AddSelections,
171}
172
173impl PromptContextAction {
174 pub fn keyword(&self) -> &'static str {
175 match self {
176 Self::AddSelections => "selection",
177 }
178 }
179
180 pub fn label(&self) -> &'static str {
181 match self {
182 Self::AddSelections => "Selection",
183 }
184 }
185
186 pub fn icon(&self) -> IconName {
187 match self {
188 Self::AddSelections => IconName::Reader,
189 }
190 }
191}
192
193/// A slash command that runs a local UI action against the conversation
194/// (sending feedback) instead of being sent to the agent as part of a prompt.
195/// Each variant maps to a method on `ThreadView`; the completion provider only
196/// surfaces them and emits an event, while `ThreadView` performs the actual
197/// work (see `handle_message_editor_event`).
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum PromptLocalCommand {
200 ThumbsUp,
201 ThumbsDown,
202}
203
204impl PromptLocalCommand {
205 pub fn keyword(&self) -> &'static str {
206 match self {
207 Self::ThumbsUp => "helpful",
208 Self::ThumbsDown => "not-helpful",
209 }
210 }
211
212 pub fn label(&self) -> &'static str {
213 match self {
214 Self::ThumbsUp => "Positive Feedback",
215 Self::ThumbsDown => "Negative Feedback",
216 }
217 }
218
219 pub fn description(&self) -> &'static str {
220 match self {
221 Self::ThumbsUp => {
222 "Rate this response as helpful. Sends the current conversation to the Omega team."
223 }
224 Self::ThumbsDown => {
225 "Rate this response as not helpful. Sends the current conversation to the Omega team."
226 }
227 }
228 }
229
230 pub fn icon(&self) -> IconName {
231 match self {
232 Self::ThumbsUp => IconName::ThumbsUp,
233 Self::ThumbsDown => IconName::ThumbsDown,
234 }
235 }
236}
237
238impl TryFrom<&str> for PromptContextType {
239 type Error = String;
240
241 fn try_from(value: &str) -> Result<Self, Self::Error> {
242 match value {
243 "file" => Ok(Self::File),
244 "symbol" => Ok(Self::Symbol),
245 "fetch" => Ok(Self::Fetch),
246 "thread" => Ok(Self::Thread),
247 "skill" => Ok(Self::Skill),
248 "diagnostics" => Ok(Self::Diagnostics),
249 "diff" => Ok(Self::BranchDiff),
250 _ => Err(format!("Invalid context picker mode: {}", value)),
251 }
252 }
253}
254
255impl PromptContextType {
256 pub fn keyword(&self) -> &'static str {
257 match self {
258 Self::File => "file",
259 Self::Symbol => "symbol",
260 Self::Fetch => "fetch",
261 Self::Thread => "thread",
262 Self::Skill => "skill",
263 Self::Diagnostics => "diagnostics",
264 Self::BranchDiff => "branch diff",
265 }
266 }
267
268 pub fn label(&self) -> &'static str {
269 match self {
270 Self::File => "Files & Directories",
271 Self::Symbol => "Symbols",
272 Self::Fetch => "Fetch",
273 Self::Thread => "Threads",
274 Self::Skill => "Skills",
275 Self::Diagnostics => "Diagnostics",
276 Self::BranchDiff => "Branch Diff",
277 }
278 }
279
280 pub fn icon(&self) -> IconName {
281 match self {
282 Self::File => IconName::File,
283 Self::Symbol => IconName::Code,
284 Self::Fetch => IconName::ToolWeb,
285 Self::Thread => IconName::Thread,
286 Self::Skill => IconName::Sparkle,
287 Self::Diagnostics => IconName::Warning,
288 Self::BranchDiff => IconName::GitBranch,
289 }
290 }
291}
292
293pub(crate) enum Match {
294 File(FileMatch),
295 Symbol(SymbolMatch),
296 Thread(SessionMatch),
297 RecentThread(SessionMatch),
298 Fetch(SharedString),
299 Skill(AvailableSkill),
300 Entry(EntryMatch),
301 BranchDiff(BranchDiffMatch),
302}
303
304#[derive(Debug, Clone)]
305pub struct BranchDiffMatch {
306 pub base_ref: SharedString,
307}
308
309impl Match {
310 pub fn score(&self) -> f64 {
311 match self {
312 Match::File(file) => file.mat.score,
313 Match::Entry(mode) => mode.mat.as_ref().map(|mat| mat.score).unwrap_or(1.),
314 Match::Thread(_) => 1.,
315 Match::RecentThread(_) => 1.,
316 Match::Symbol(_) => 1.,
317 Match::Skill(_) => 1.,
318 Match::Fetch(_) => 1.,
319 Match::BranchDiff(_) => 1.,
320 }
321 }
322}
323
324#[derive(Debug, Clone)]
325pub struct SessionMatch {
326 session_id: acp::SessionId,
327 title: SharedString,
328}
329
330pub struct EntryMatch {
331 mat: Option<StringMatch>,
332 entry: PromptContextEntry,
333}
334
335fn session_title(title: Option<SharedString>) -> SharedString {
336 title
337 .filter(|title| !title.is_empty())
338 .unwrap_or_else(|| SharedString::new_static(DEFAULT_THREAD_TITLE))
339}
340
341#[derive(Debug, Clone)]
342pub struct AvailableSkill {
343 pub name: Arc<str>,
344 pub description: Arc<str>,
345 /// Scope prefix for this skill: empty for global skills, or the
346 /// worktree root name for project-local skills.
347 pub source: SharedString,
348 pub skill_file_path: PathBuf,
349 pub warning: Option<SharedString>,
350}
351
352fn skill_completion_icon_path(
353 skill: &AvailableSkill,
354 uri: &MentionUri,
355 cx: &mut App,
356) -> SharedString {
357 if skill.warning.is_some() {
358 IconName::Warning.path().into()
359 } else {
360 uri.icon_path(cx)
361 }
362}
363
364fn skill_completion_icon_color(skill: &AvailableSkill, cx: &App) -> Option<Hsla> {
365 skill.warning.is_some().then(|| cx.theme().status().warning)
366}
367
368fn skill_completion_documentation(skill: &AvailableSkill) -> CompletionDocumentation {
369 let text = match &skill.warning {
370 Some(warning) => warning.clone(),
371 None => skill.description.to_string().into(),
372 };
373 CompletionDocumentation::MultiLinePlainText(text)
374}
375
376#[derive(Debug, Clone)]
377pub struct AvailableCommand {
378 pub name: Arc<str>,
379 pub description: Arc<str>,
380 pub requires_argument: bool,
381 pub source: Option<SharedString>,
382 /// Source category used to group the command in the slash popup. `None`
383 /// means the command came from an external ACP agent.
384 pub category: Option<acp_thread::CommandCategory>,
385}
386
387impl AvailableCommand {
388 fn category_order(&self) -> u8 {
389 match self.category {
390 Some(acp_thread::CommandCategory::Native) => 0,
391 Some(acp_thread::CommandCategory::Mcp) => 1,
392 None => 2,
393 }
394 }
395
396 /// Completion group key and header label for this command's category.
397 fn group(&self) -> CompletionGroup {
398 let (key, label) = match self.category {
399 Some(acp_thread::CommandCategory::Native) => ("commands", "Commands"),
400 Some(acp_thread::CommandCategory::Mcp) => ("mcp-commands", "MCP Server Commands"),
401 None => ("acp-commands", "Commands"),
402 };
403 CompletionGroup {
404 key: key.into(),
405 label: Some(label.into()),
406 }
407 }
408}
409
410#[derive(Debug, Clone)]
411enum SlashCompletionCandidate {
412 Skill(AvailableSkill),
413 Command(AvailableCommand),
414 LocalCommand(PromptLocalCommand),
415}
416
417impl SlashCompletionCandidate {
418 fn name(&self) -> &str {
419 match self {
420 Self::Skill(skill) => &skill.name,
421 Self::Command(command) => &command.name,
422 Self::LocalCommand(command) => command.keyword(),
423 }
424 }
425}
426
427/// Stable group identity for a slash completion: skills are one group, commands
428/// are grouped by category. This identifies which section header an entry sits
429/// under; the order the groups appear in is decided by relevance (see
430/// [`group_by_relevance`]).
431fn slash_completion_group_key(candidate: &SlashCompletionCandidate) -> u32 {
432 match candidate {
433 SlashCompletionCandidate::Skill(_) => 0,
434 SlashCompletionCandidate::Command(command) => 1 + command.category_order() as u32,
435 SlashCompletionCandidate::LocalCommand(_) => 4,
436 }
437}
438
439/// Reorders `items` (which must already be in relevance/score order, best
440/// first) so that each group's entries stay contiguous while the groups
441/// themselves are ordered by their best-ranked member. The sort is stable, so
442/// within a group the original order is preserved.
443fn group_by_relevance<T>(items: &mut [T], group_key: impl Fn(&T) -> u32) {
444 let mut group_best_rank: collections::HashMap<u32, usize> = collections::HashMap::default();
445 for (rank, item) in items.iter().enumerate() {
446 group_best_rank.entry(group_key(item)).or_insert(rank);
447 }
448 items.sort_by_key(|item| group_best_rank[&group_key(item)]);
449}
450
451pub trait PromptCompletionProviderDelegate: Send + Sync + 'static {
452 fn supports_context(&self, mode: PromptContextType, cx: &App) -> bool {
453 self.supported_modes(cx).contains(&mode)
454 }
455 fn supported_modes(&self, cx: &App) -> Vec<PromptContextType>;
456 fn supports_images(&self, cx: &App) -> bool;
457
458 fn available_commands(&self, cx: &App) -> Vec<AvailableCommand>;
459 fn available_skills(&self, _cx: &App) -> Vec<AvailableSkill> {
460 Vec::new()
461 }
462
463 fn available_local_commands(&self, _cx: &App) -> Vec<PromptLocalCommand> {
464 Vec::new()
465 }
466
467 fn run_local_command(&self, _command: PromptLocalCommand, _cx: &mut App) {}
468
469 fn confirm_command(&self, cx: &mut App);
470
471 /// Called once each time the user opens slash-command autocomplete
472 /// in the editor this delegate serves. Implementations may use it
473 /// to lazily kick off work that produces commands (for example,
474 /// scanning the global skills directory). The default is a no-op.
475 fn slash_autocomplete_invoked(&self, _cx: &mut App) {}
476}
477
478pub struct PromptCompletionProvider<T: PromptCompletionProviderDelegate> {
479 source: Arc<T>,
480 editor: WeakEntity<Editor>,
481 mention_set: Entity<MentionSet>,
482 workspace: WeakEntity<Workspace>,
483}
484
485impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
486 pub fn new(
487 source: T,
488 editor: WeakEntity<Editor>,
489 mention_set: Entity<MentionSet>,
490 workspace: WeakEntity<Workspace>,
491 ) -> Self {
492 Self {
493 source: Arc::new(source),
494 editor,
495 mention_set,
496 workspace,
497 }
498 }
499
500 fn completion_for_entry(
501 entry: PromptContextEntry,
502 source_range: Range<Anchor>,
503 editor: WeakEntity<Editor>,
504 mention_set: WeakEntity<MentionSet>,
505 workspace: &Entity<Workspace>,
506 cx: &mut App,
507 ) -> Option<Completion> {
508 match entry {
509 PromptContextEntry::Mode(mode) => Some(Completion {
510 replace_range: source_range,
511 new_text: format!("@{} ", mode.keyword()),
512 label: CodeLabel::plain(mode.label().to_string(), None),
513 icon_path: Some(mode.icon().path().into()),
514 icon_color: None,
515 documentation: None,
516 source: project::CompletionSource::Custom,
517 match_start: None,
518 snippet_deduplication_key: None,
519 insert_text_mode: None,
520 // This ensures that when a user accepts this completion, the
521 // completion menu will still be shown after "@category " is
522 // inserted
523 confirm: Some(Arc::new(|_, _, _| true)),
524 group: None,
525 }),
526 PromptContextEntry::Action(action) => {
527 let selection = workspace.update(cx, |workspace, cx| {
528 AgentContextSource::from_active(workspace, cx)?
529 .read_selection(workspace, false, cx)
530 });
531 Self::completion_for_action(action, source_range, editor, mention_set, selection)
532 }
533 }
534 }
535
536 fn completion_for_thread(
537 session_id: acp::SessionId,
538 title: Option<SharedString>,
539 source_range: Range<Anchor>,
540 recent: bool,
541 source: Arc<T>,
542 editor: WeakEntity<Editor>,
543 mention_set: WeakEntity<MentionSet>,
544 workspace: Entity<Workspace>,
545 cx: &mut App,
546 ) -> Completion {
547 let title = session_title(title);
548 let uri = MentionUri::Thread {
549 id: session_id,
550 name: title.to_string(),
551 };
552
553 let icon_for_completion = if recent {
554 IconName::HistoryRerun.path().into()
555 } else {
556 uri.icon_path(cx)
557 };
558
559 let new_text = format!("{} ", uri.as_link());
560
561 let new_text_len = new_text.len();
562 Completion {
563 replace_range: source_range.clone(),
564 new_text,
565 label: CodeLabel::plain(title.to_string(), None),
566 documentation: None,
567 insert_text_mode: None,
568 source: project::CompletionSource::Custom,
569 match_start: None,
570 snippet_deduplication_key: None,
571 icon_path: Some(icon_for_completion),
572 icon_color: None,
573 confirm: Some(confirm_completion_callback(
574 title,
575 source_range.start,
576 new_text_len - 1,
577 uri,
578 source,
579 editor,
580 mention_set,
581 workspace,
582 )),
583 group: None,
584 }
585 }
586
587 fn completion_for_skill(
588 skill: AvailableSkill,
589 source_range: Range<Anchor>,
590 source: Arc<T>,
591 editor: WeakEntity<Editor>,
592 mention_set: WeakEntity<MentionSet>,
593 workspace: Entity<Workspace>,
594 cx: &mut App,
595 ) -> Completion {
596 let uri = MentionUri::Skill {
597 name: skill.name.to_string(),
598 source: skill.source.to_string(),
599 skill_file_path: skill.skill_file_path.clone(),
600 };
601 let new_text = format!("{} ", uri.as_link());
602 let new_text_len = new_text.len();
603 let icon_path = skill_completion_icon_path(&skill, &uri, cx);
604 let crease_text: SharedString = uri.name().into();
605 let source_highlight_id = cx
606 .theme()
607 .syntax()
608 .highlight_id("variable")
609 .map(HighlightId::new);
610 let label = build_slash_item_label(&skill.name, Some(&skill.source), source_highlight_id);
611 Completion {
612 replace_range: source_range.clone(),
613 new_text,
614 label,
615 documentation: Some(skill_completion_documentation(&skill)),
616 insert_text_mode: None,
617 source: project::CompletionSource::Custom,
618 match_start: None,
619 snippet_deduplication_key: None,
620 icon_path: Some(icon_path),
621 icon_color: skill_completion_icon_color(&skill, cx),
622 confirm: Some(confirm_completion_callback(
623 crease_text,
624 source_range.start,
625 new_text_len - 1,
626 uri,
627 source,
628 editor,
629 mention_set,
630 workspace,
631 )),
632 group: None,
633 }
634 }
635
636 pub(crate) fn completion_for_path(
637 project_path: ProjectPath,
638 path_prefix: &RelPath,
639 is_recent: bool,
640 is_directory: bool,
641 source_range: Range<Anchor>,
642 source: Arc<T>,
643 editor: WeakEntity<Editor>,
644 mention_set: WeakEntity<MentionSet>,
645 workspace: Entity<Workspace>,
646 project: Entity<Project>,
647 label_max_chars: usize,
648 cx: &mut App,
649 ) -> Option<Completion> {
650 let path_style = project.read(cx).path_style(cx);
651 let (file_name, directory) =
652 extract_file_name_and_directory(&project_path.path, path_prefix, path_style);
653
654 let label = build_code_label_for_path(
655 &file_name,
656 directory.as_ref().map(|s| s.as_ref()),
657 None,
658 label_max_chars,
659 cx,
660 );
661
662 let abs_path = project.read(cx).absolute_path(&project_path, cx)?;
663
664 let uri = if is_directory {
665 MentionUri::Directory { abs_path }
666 } else {
667 MentionUri::File { abs_path }
668 };
669
670 let crease_icon_path = uri.icon_path(cx);
671 let completion_icon_path = if is_recent {
672 IconName::HistoryRerun.path().into()
673 } else {
674 crease_icon_path
675 };
676
677 let new_text = format!("{} ", uri.as_link());
678 let new_text_len = new_text.len();
679 Some(Completion {
680 replace_range: source_range.clone(),
681 new_text,
682 label,
683 documentation: None,
684 source: project::CompletionSource::Custom,
685 icon_path: Some(completion_icon_path),
686 icon_color: None,
687 match_start: None,
688 snippet_deduplication_key: None,
689 insert_text_mode: None,
690 confirm: Some(confirm_completion_callback(
691 file_name,
692 source_range.start,
693 new_text_len - 1,
694 uri,
695 source,
696 editor,
697 mention_set,
698 workspace,
699 )),
700 group: None,
701 })
702 }
703
704 fn completion_for_symbol(
705 symbol: Symbol,
706 source_range: Range<Anchor>,
707 source: Arc<T>,
708 editor: WeakEntity<Editor>,
709 mention_set: WeakEntity<MentionSet>,
710 workspace: Entity<Workspace>,
711 label_max_chars: usize,
712 cx: &mut App,
713 ) -> Option<Completion> {
714 let project = workspace.read(cx).project().clone();
715
716 let (abs_path, file_name) = match &symbol.path {
717 SymbolLocation::InProject(project_path) => (
718 project.read(cx).absolute_path(&project_path, cx)?,
719 project_path.path.file_name()?.to_string().into(),
720 ),
721 SymbolLocation::OutsideProject {
722 abs_path,
723 signature: _,
724 } => (
725 PathBuf::from(abs_path.as_ref()),
726 abs_path.file_name().map(|f| f.to_string_lossy())?,
727 ),
728 };
729
730 let label = build_code_label_for_path(
731 &symbol.name,
732 Some(&file_name),
733 Some(symbol.range.start.0.row + 1),
734 label_max_chars,
735 cx,
736 );
737
738 let uri = MentionUri::Symbol {
739 abs_path,
740 name: symbol.name.clone(),
741 line_range: symbol.range.start.0.row..=symbol.range.end.0.row,
742 };
743 let new_text = format!("{} ", uri.as_link());
744 let new_text_len = new_text.len();
745 let icon_path = uri.icon_path(cx);
746 Some(Completion {
747 replace_range: source_range.clone(),
748 new_text,
749 label,
750 documentation: None,
751 source: project::CompletionSource::Custom,
752 icon_path: Some(icon_path),
753 icon_color: None,
754 match_start: None,
755 snippet_deduplication_key: None,
756 insert_text_mode: None,
757 confirm: Some(confirm_completion_callback(
758 symbol.name.into(),
759 source_range.start,
760 new_text_len - 1,
761 uri,
762 source,
763 editor,
764 mention_set,
765 workspace,
766 )),
767 group: None,
768 })
769 }
770
771 fn completion_for_fetch(
772 source_range: Range<Anchor>,
773 url_to_fetch: SharedString,
774 source: Arc<T>,
775 editor: WeakEntity<Editor>,
776 mention_set: WeakEntity<MentionSet>,
777 workspace: Entity<Workspace>,
778 cx: &mut App,
779 ) -> Option<Completion> {
780 let new_text = format!("@fetch {} ", url_to_fetch);
781 let url_to_fetch = url::Url::parse(url_to_fetch.as_ref())
782 .or_else(|_| url::Url::parse(&format!("https://{url_to_fetch}")))
783 .ok()?;
784 let mention_uri = MentionUri::Fetch {
785 url: url_to_fetch.clone(),
786 };
787 let icon_path = mention_uri.icon_path(cx);
788 Some(Completion {
789 replace_range: source_range.clone(),
790 new_text: new_text.clone(),
791 label: CodeLabel::plain(url_to_fetch.to_string(), None),
792 documentation: None,
793 source: project::CompletionSource::Custom,
794 icon_path: Some(icon_path),
795 icon_color: None,
796 match_start: None,
797 snippet_deduplication_key: None,
798 insert_text_mode: None,
799 confirm: Some(confirm_completion_callback(
800 url_to_fetch.to_string().into(),
801 source_range.start,
802 new_text.len() - 1,
803 mention_uri,
804 source,
805 editor,
806 mention_set,
807 workspace,
808 )),
809 group: None,
810 })
811 }
812
813 pub(crate) fn completion_for_action(
814 action: PromptContextAction,
815 source_range: Range<Anchor>,
816 editor: WeakEntity<Editor>,
817 mention_set: WeakEntity<MentionSet>,
818 selection: Option<AgentContextSelection>,
819 ) -> Option<Completion> {
820 let (new_text, on_action) = match action {
821 PromptContextAction::AddSelections => match selection? {
822 AgentContextSelection::Editor(editor_selections) => {
823 completion_text_for_editor_selections(
824 source_range.clone(),
825 editor,
826 mention_set,
827 editor_selections,
828 )
829 }
830 AgentContextSelection::Terminal(terminal_selections) => {
831 completion_text_for_terminal_selections(
832 source_range.clone(),
833 editor,
834 mention_set,
835 terminal_selections,
836 )
837 }
838 },
839 };
840
841 Some(Completion {
842 replace_range: source_range,
843 new_text,
844 label: CodeLabel::plain(action.label().to_string(), None),
845 icon_path: Some(action.icon().path().into()),
846 icon_color: None,
847 documentation: None,
848 source: project::CompletionSource::Custom,
849 match_start: None,
850 snippet_deduplication_key: None,
851 insert_text_mode: None,
852 // This ensures that when a user accepts this completion, the
853 // completion menu will still be shown after "@category " is
854 // inserted
855 confirm: Some(on_action),
856 group: None,
857 })
858 }
859
860 fn completion_for_diagnostics(
861 source_range: Range<Anchor>,
862 source: Arc<T>,
863 editor: WeakEntity<Editor>,
864 mention_set: WeakEntity<MentionSet>,
865 workspace: Entity<Workspace>,
866 cx: &mut App,
867 ) -> Vec<Completion> {
868 let summary = workspace
869 .read(cx)
870 .project()
871 .read(cx)
872 .diagnostic_summary(false, cx);
873 if summary.error_count == 0 && summary.warning_count == 0 {
874 return Vec::new();
875 }
876 let icon_path = MentionUri::Diagnostics {
877 include_errors: true,
878 include_warnings: false,
879 }
880 .icon_path(cx);
881
882 let mut completions = Vec::new();
883
884 let cases = [
885 (summary.error_count > 0, true, false),
886 (summary.warning_count > 0, false, true),
887 (
888 summary.error_count > 0 && summary.warning_count > 0,
889 true,
890 true,
891 ),
892 ];
893
894 for (condition, include_errors, include_warnings) in cases {
895 if condition {
896 completions.push(Self::build_diagnostics_completion(
897 diagnostics_submenu_label(summary, include_errors, include_warnings),
898 source_range.clone(),
899 source.clone(),
900 editor.clone(),
901 mention_set.clone(),
902 workspace.clone(),
903 icon_path.clone(),
904 include_errors,
905 include_warnings,
906 summary,
907 ));
908 }
909 }
910
911 completions
912 }
913
914 fn build_diagnostics_completion(
915 menu_label: String,
916 source_range: Range<Anchor>,
917 source: Arc<T>,
918 editor: WeakEntity<Editor>,
919 mention_set: WeakEntity<MentionSet>,
920 workspace: Entity<Workspace>,
921 icon_path: SharedString,
922 include_errors: bool,
923 include_warnings: bool,
924 summary: DiagnosticSummary,
925 ) -> Completion {
926 let uri = MentionUri::Diagnostics {
927 include_errors,
928 include_warnings,
929 };
930 let crease_text = diagnostics_crease_label(summary, include_errors, include_warnings);
931 let display_text = format!("@{}", crease_text);
932 let new_text = format!("[{}]({}) ", display_text, uri.to_uri());
933 let new_text_len = new_text.len();
934 Completion {
935 replace_range: source_range.clone(),
936 new_text,
937 label: CodeLabel::plain(menu_label, None),
938 documentation: None,
939 source: project::CompletionSource::Custom,
940 icon_path: Some(icon_path),
941 icon_color: None,
942 match_start: None,
943 snippet_deduplication_key: None,
944 insert_text_mode: None,
945 confirm: Some(confirm_completion_callback(
946 crease_text,
947 source_range.start,
948 new_text_len - 1,
949 uri,
950 source,
951 editor,
952 mention_set,
953 workspace,
954 )),
955 group: None,
956 }
957 }
958
959 fn build_branch_diff_completion(
960 base_ref: SharedString,
961 source_range: Range<Anchor>,
962 source: Arc<T>,
963 editor: WeakEntity<Editor>,
964 mention_set: WeakEntity<MentionSet>,
965 workspace: Entity<Workspace>,
966 cx: &mut App,
967 ) -> Completion {
968 let uri = MentionUri::GitDiff {
969 base_ref: base_ref.to_string(),
970 };
971 let crease_text: SharedString = format!("Branch Diff (vs {})", base_ref).into();
972 let display_text = format!("@{}", crease_text);
973 let new_text = format!("[{}]({}) ", display_text, uri.to_uri());
974 let new_text_len = new_text.len();
975 let icon_path = uri.icon_path(cx);
976
977 Completion {
978 replace_range: source_range.clone(),
979 new_text,
980 label: CodeLabel::plain(crease_text.to_string(), None),
981 documentation: None,
982 source: project::CompletionSource::Custom,
983 icon_path: Some(icon_path),
984 icon_color: None,
985 match_start: None,
986 snippet_deduplication_key: None,
987 insert_text_mode: None,
988 confirm: Some(confirm_completion_callback(
989 crease_text,
990 source_range.start,
991 new_text_len - 1,
992 uri,
993 source,
994 editor,
995 mention_set,
996 workspace,
997 )),
998 group: None,
999 }
1000 }
1001
1002 fn search_slash_commands(
1003 &self,
1004 query: String,
1005 cx: &mut App,
1006 ) -> Task<Vec<SlashCompletionCandidate>> {
1007 // Notify the delegate that slash autocomplete is being
1008 // invoked, so it can lazily kick off any work that produces
1009 // additional commands or skills. Whatever it produces won't be
1010 // visible in the current autocomplete pass (we read available
1011 // items synchronously below), but will appear on the next
1012 // invocation.
1013 self.source.slash_autocomplete_invoked(cx);
1014
1015 let mut candidates = self
1016 .source
1017 .available_commands(cx)
1018 .into_iter()
1019 .map(SlashCompletionCandidate::Command)
1020 .collect::<Vec<_>>();
1021 candidates.extend(
1022 self.source
1023 .available_skills(cx)
1024 .into_iter()
1025 .map(SlashCompletionCandidate::Skill),
1026 );
1027 candidates.extend(
1028 self.source
1029 .available_local_commands(cx)
1030 .into_iter()
1031 .map(SlashCompletionCandidate::LocalCommand),
1032 );
1033 if candidates.is_empty() {
1034 return Task::ready(Vec::new());
1035 }
1036
1037 cx.spawn(async move |cx| {
1038 let string_match_candidates = candidates
1039 .iter()
1040 .enumerate()
1041 .map(|(id, candidate)| StringMatchCandidate::new(id, candidate.name()))
1042 .collect::<Vec<_>>();
1043
1044 let matches = fuzzy::match_strings(
1045 &string_match_candidates,
1046 &query,
1047 false,
1048 true,
1049 100,
1050 &Arc::new(AtomicBool::default()),
1051 cx.background_executor().clone(),
1052 )
1053 .await;
1054
1055 matches
1056 .into_iter()
1057 .map(|mat| candidates[mat.candidate_id].clone())
1058 .collect()
1059 })
1060 }
1061
1062 fn fetch_branch_diff_match(
1063 &self,
1064 workspace: &Entity<Workspace>,
1065 cx: &mut App,
1066 ) -> Option<Task<Option<BranchDiffMatch>>> {
1067 let project = workspace.read(cx).project().clone();
1068 let repo = project.read(cx).active_repository(cx)?;
1069
1070 let default_branch_receiver = repo.update(cx, |repo, _| repo.default_branch(true));
1071
1072 Some(cx.spawn(async move |_cx| {
1073 let base_ref = default_branch_receiver
1074 .await
1075 .ok()
1076 .and_then(|r| r.ok())
1077 .flatten()?;
1078
1079 Some(BranchDiffMatch { base_ref })
1080 }))
1081 }
1082
1083 fn search_mentions(
1084 &self,
1085 mode: Option<PromptContextType>,
1086 query: String,
1087 cancellation_flag: Arc<AtomicBool>,
1088 cx: &mut App,
1089 ) -> Task<Vec<Match>> {
1090 let Some(workspace) = self.workspace.upgrade() else {
1091 return Task::ready(Vec::default());
1092 };
1093 match mode {
1094 Some(PromptContextType::File) => {
1095 let search_files_task = search_files(query, cancellation_flag, &workspace, cx);
1096 cx.background_spawn(async move {
1097 search_files_task
1098 .await
1099 .into_iter()
1100 .map(Match::File)
1101 .collect()
1102 })
1103 }
1104
1105 Some(PromptContextType::Symbol) => {
1106 let search_symbols_task = search_symbols(query, cancellation_flag, &workspace, cx);
1107 cx.background_spawn(async move {
1108 search_symbols_task
1109 .await
1110 .into_iter()
1111 .map(Match::Symbol)
1112 .collect()
1113 })
1114 }
1115
1116 Some(PromptContextType::Thread) => {
1117 let sessions = collect_session_matches(cx);
1118 if !sessions.is_empty() {
1119 let search_task =
1120 filter_sessions_by_query(query, cancellation_flag, sessions, cx);
1121 cx.spawn(async move |_cx| {
1122 search_task.await.into_iter().map(Match::Thread).collect()
1123 })
1124 } else {
1125 Task::ready(Vec::new())
1126 }
1127 }
1128
1129 Some(PromptContextType::Fetch) => {
1130 if !query.is_empty() {
1131 Task::ready(vec![Match::Fetch(query.into())])
1132 } else {
1133 Task::ready(Vec::new())
1134 }
1135 }
1136
1137 Some(PromptContextType::Skill) => {
1138 let skills = self.source.available_skills(cx);
1139 let search_skills_task = search_skills(query, cancellation_flag, skills, cx);
1140 cx.background_spawn(async move {
1141 search_skills_task
1142 .await
1143 .into_iter()
1144 .map(Match::Skill)
1145 .collect::<Vec<_>>()
1146 })
1147 }
1148
1149 Some(PromptContextType::Diagnostics) => Task::ready(Vec::new()),
1150
1151 Some(PromptContextType::BranchDiff) => Task::ready(Vec::new()),
1152
1153 None if query.is_empty() => {
1154 let recent_task = self.recent_context_picker_entries(&workspace, cx);
1155 let entries = self
1156 .available_context_picker_entries(&workspace, cx)
1157 .into_iter()
1158 .map(|mode| {
1159 Match::Entry(EntryMatch {
1160 entry: mode,
1161 mat: None,
1162 })
1163 })
1164 .collect::<Vec<_>>();
1165
1166 let branch_diff_task = if self
1167 .source
1168 .supports_context(PromptContextType::BranchDiff, cx)
1169 {
1170 self.fetch_branch_diff_match(&workspace, cx)
1171 } else {
1172 None
1173 };
1174
1175 cx.spawn(async move |_cx| {
1176 let mut matches = recent_task.await;
1177 matches.extend(entries);
1178
1179 if let Some(branch_diff_task) = branch_diff_task {
1180 if let Some(branch_diff_match) = branch_diff_task.await {
1181 matches.push(Match::BranchDiff(branch_diff_match));
1182 }
1183 }
1184
1185 matches
1186 })
1187 }
1188 None => {
1189 let executor = cx.background_executor().clone();
1190
1191 let search_files_task =
1192 search_files(query.clone(), cancellation_flag, &workspace, cx);
1193
1194 let entries = self.available_context_picker_entries(&workspace, cx);
1195 let entry_candidates = entries
1196 .iter()
1197 .enumerate()
1198 .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword()))
1199 .collect::<Vec<_>>();
1200
1201 let branch_diff_task = if self
1202 .source
1203 .supports_context(PromptContextType::BranchDiff, cx)
1204 {
1205 self.fetch_branch_diff_match(&workspace, cx)
1206 } else {
1207 None
1208 };
1209
1210 cx.spawn(async move |cx| {
1211 let mut matches = search_files_task
1212 .await
1213 .into_iter()
1214 .map(Match::File)
1215 .collect::<Vec<_>>();
1216
1217 let entry_matches = fuzzy::match_strings(
1218 &entry_candidates,
1219 &query,
1220 false,
1221 true,
1222 100,
1223 &Arc::new(AtomicBool::default()),
1224 executor,
1225 )
1226 .await;
1227
1228 matches.extend(entry_matches.into_iter().map(|mat| {
1229 Match::Entry(EntryMatch {
1230 entry: entries[mat.candidate_id],
1231 mat: Some(mat),
1232 })
1233 }));
1234
1235 if let Some(branch_diff_task) = branch_diff_task {
1236 let branch_diff_keyword = PromptContextType::BranchDiff.keyword();
1237 let branch_diff_matches = fuzzy::match_strings(
1238 &[StringMatchCandidate::new(0, branch_diff_keyword)],
1239 &query,
1240 false,
1241 true,
1242 1,
1243 &Arc::new(AtomicBool::default()),
1244 cx.background_executor().clone(),
1245 )
1246 .await;
1247
1248 if !branch_diff_matches.is_empty() {
1249 if let Some(branch_diff_match) = branch_diff_task.await {
1250 matches.push(Match::BranchDiff(branch_diff_match));
1251 }
1252 }
1253 }
1254
1255 matches.sort_by(|a, b| {
1256 b.score()
1257 .partial_cmp(&a.score())
1258 .unwrap_or(std::cmp::Ordering::Equal)
1259 });
1260
1261 matches
1262 })
1263 }
1264 }
1265 }
1266
1267 fn recent_context_picker_entries(
1268 &self,
1269 workspace: &Entity<Workspace>,
1270 cx: &mut App,
1271 ) -> Task<Vec<Match>> {
1272 let mut recent = Vec::with_capacity(6);
1273
1274 let mut mentions = self
1275 .mention_set
1276 .read_with(cx, |store, _cx| store.mentions());
1277 let workspace = workspace.read(cx);
1278 let project = workspace.project().read(cx);
1279 let include_root_name = workspace.visible_worktrees(cx).count() > 1;
1280
1281 if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx)
1282 && let Some(thread) = agent_panel.read(cx).active_agent_thread(cx)
1283 && let Some(title) = thread.read(cx).title()
1284 {
1285 mentions.insert(MentionUri::Thread {
1286 id: thread.read(cx).session_id().clone(),
1287 name: title.to_string(),
1288 });
1289 }
1290
1291 recent.extend(
1292 workspace
1293 .recent_navigation_history_iter(cx)
1294 .filter(|(_, abs_path)| {
1295 abs_path.as_ref().is_none_or(|path| {
1296 !mentions.contains(&MentionUri::File {
1297 abs_path: path.clone(),
1298 })
1299 })
1300 })
1301 .take(4)
1302 .filter_map(|(project_path, _)| {
1303 project
1304 .worktree_for_id(project_path.worktree_id, cx)
1305 .map(|worktree| {
1306 let path_prefix = if include_root_name {
1307 worktree.read(cx).root_name().into()
1308 } else {
1309 RelPath::empty_arc()
1310 };
1311 Match::File(FileMatch {
1312 mat: fuzzy::PathMatch {
1313 score: 1.,
1314 positions: Vec::new(),
1315 worktree_id: project_path.worktree_id.to_usize(),
1316 path: project_path.path,
1317 path_prefix,
1318 is_dir: false,
1319 distance_to_relative_ancestor: 0,
1320 },
1321 is_recent: true,
1322 })
1323 })
1324 }),
1325 );
1326
1327 if !self.source.supports_context(PromptContextType::Thread, cx) {
1328 return Task::ready(recent);
1329 }
1330
1331 let sessions = collect_session_matches(cx);
1332 const RECENT_COUNT: usize = 2;
1333 recent.extend(
1334 sessions
1335 .into_iter()
1336 .filter(|session| {
1337 let uri = MentionUri::Thread {
1338 id: session.session_id.clone(),
1339 name: session.title.to_string(),
1340 };
1341 !mentions.contains(&uri)
1342 })
1343 .take(RECENT_COUNT)
1344 .map(Match::RecentThread),
1345 );
1346
1347 Task::ready(recent)
1348 }
1349
1350 fn available_context_picker_entries(
1351 &self,
1352 workspace: &Entity<Workspace>,
1353 cx: &mut App,
1354 ) -> Vec<PromptContextEntry> {
1355 let mut entries = vec![
1356 PromptContextEntry::Mode(PromptContextType::File),
1357 PromptContextEntry::Mode(PromptContextType::Symbol),
1358 ];
1359
1360 if self.source.supports_context(PromptContextType::Thread, cx) {
1361 entries.push(PromptContextEntry::Mode(PromptContextType::Thread));
1362 }
1363
1364 let has_active_selection = workspace.update(cx, |workspace, cx| {
1365 AgentContextSource::from_active(workspace, cx)
1366 .and_then(|source| source.read_selection(workspace, false, cx))
1367 .is_some()
1368 });
1369 if has_active_selection {
1370 entries.push(PromptContextEntry::Action(
1371 PromptContextAction::AddSelections,
1372 ));
1373 }
1374
1375 if self.source.supports_context(PromptContextType::Skill, cx)
1376 && !self.source.available_skills(cx).is_empty()
1377 {
1378 entries.push(PromptContextEntry::Mode(PromptContextType::Skill));
1379 }
1380
1381 if self.source.supports_context(PromptContextType::Fetch, cx) {
1382 entries.push(PromptContextEntry::Mode(PromptContextType::Fetch));
1383 }
1384
1385 if self
1386 .source
1387 .supports_context(PromptContextType::Diagnostics, cx)
1388 {
1389 let summary = workspace
1390 .read(cx)
1391 .project()
1392 .read(cx)
1393 .diagnostic_summary(false, cx);
1394 if summary.error_count > 0 || summary.warning_count > 0 {
1395 entries.push(PromptContextEntry::Mode(PromptContextType::Diagnostics));
1396 }
1397 }
1398
1399 entries
1400 }
1401}
1402
1403impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletionProvider<T> {
1404 fn completions(
1405 &self,
1406 buffer: &Entity<Buffer>,
1407 buffer_position: Anchor,
1408 _trigger: CompletionContext,
1409 window: &mut Window,
1410 cx: &mut Context<Editor>,
1411 ) -> Task<Result<Vec<CompletionResponse>>> {
1412 let state = buffer.update(cx, |buffer, cx| {
1413 let position = buffer_position.to_point(buffer);
1414 let line_start = Point::new(position.row, 0);
1415 let offset_to_line = buffer.point_to_offset(line_start);
1416 let mut lines = buffer.text_for_range(line_start..position).lines();
1417 let line = lines.next()?;
1418 PromptCompletion::try_parse(line, offset_to_line, &self.source.supported_modes(cx))
1419 });
1420 let Some(state) = state else {
1421 return Task::ready(Ok(Vec::new()));
1422 };
1423
1424 let Some(workspace) = self.workspace.upgrade() else {
1425 return Task::ready(Ok(Vec::new()));
1426 };
1427
1428 let project = workspace.read(cx).project().clone();
1429 let snapshot = buffer.read(cx).snapshot();
1430 let source_range = snapshot.anchor_before(state.source_range().start)
1431 ..snapshot.anchor_after(state.source_range().end);
1432
1433 let source = self.source.clone();
1434 let editor = self.editor.clone();
1435 let mention_set = self.mention_set.downgrade();
1436 match state {
1437 PromptCompletion::SlashCommand(SlashCommandCompletion {
1438 command, argument, ..
1439 }) => {
1440 let search_task = self.search_slash_commands(command.unwrap_or_default(), cx);
1441 // Keep the category section headers visible while the user is
1442 // still narrowing the command name (`/c`); only drop them once
1443 // they've moved on to typing the command's argument, where
1444 // grouping no longer applies.
1445 let show_section_headers = argument.is_none();
1446
1447 let source_highlight_id = cx
1448 .theme()
1449 .syntax()
1450 .highlight_id("variable")
1451 .map(HighlightId::new);
1452
1453 type SkillInfo = (
1454 String,
1455 SharedString,
1456 Option<Hsla>,
1457 Arc<dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync>,
1458 );
1459 let slash_candidates: Task<Vec<(SlashCompletionCandidate, Option<SkillInfo>)>> = {
1460 let source = source.clone();
1461 cx.spawn(async move |_this, cx| {
1462 let candidates = search_task.await;
1463 cx.update(|cx| {
1464 candidates
1465 .into_iter()
1466 .map(|candidate| match &candidate {
1467 SlashCompletionCandidate::Skill(skill) => {
1468 let uri = MentionUri::Skill {
1469 name: skill.name.to_string(),
1470 source: skill.source.to_string(),
1471 skill_file_path: skill.skill_file_path.clone(),
1472 };
1473 let new_text = format!("{} ", uri.as_link());
1474 let new_text_len = new_text.len();
1475 let icon_path = skill_completion_icon_path(skill, &uri, cx);
1476 let icon_color = skill_completion_icon_color(skill, cx);
1477 let crease_text: SharedString = uri.name().into();
1478 let confirm = confirm_completion_callback(
1479 crease_text,
1480 source_range.start,
1481 new_text_len - 1,
1482 uri,
1483 source.clone(),
1484 editor.clone(),
1485 mention_set.clone(),
1486 workspace.clone(),
1487 );
1488 (
1489 candidate,
1490 Some((new_text, icon_path, icon_color, confirm)),
1491 )
1492 }
1493 SlashCompletionCandidate::Command(_)
1494 | SlashCompletionCandidate::LocalCommand(_) => {
1495 (candidate, None)
1496 }
1497 })
1498 .collect::<Vec<(SlashCompletionCandidate, Option<SkillInfo>)>>()
1499 })
1500 })
1501 };
1502
1503 cx.background_spawn(async move {
1504 let mut slash_candidates = slash_candidates.await;
1505 // `slash_candidates` arrives in fuzzy-match order (best
1506 // first). Keep each group's items contiguous so section
1507 // headers render once, but order the groups by their
1508 // best-scoring member. That way an exact/prefix match (e.g.
1509 // `/compa` -> `compact`) floats its whole section to the top
1510 // and becomes the default selection, instead of being
1511 // buried under a less relevant skill. Within a group, the
1512 // fuzzy-match order is preserved (the sort is stable).
1513 group_by_relevance(&mut slash_candidates, |(candidate, _)| {
1514 slash_completion_group_key(candidate)
1515 });
1516 let completions = slash_candidates
1517 .into_iter()
1518 .map(|(candidate, skill_info)| match candidate {
1519 SlashCompletionCandidate::Skill(skill) => {
1520 let label = build_slash_item_label(
1521 &skill.name,
1522 Some(&skill.source),
1523 source_highlight_id,
1524 );
1525 let Some((new_text, icon_path, icon_color, confirm)) = skill_info
1526 else {
1527 unreachable!("skill candidates always have confirm callbacks")
1528 };
1529 Completion {
1530 replace_range: source_range.clone(),
1531 new_text,
1532 label,
1533 documentation: Some(skill_completion_documentation(&skill)),
1534 source: project::CompletionSource::Custom,
1535 icon_path: Some(icon_path),
1536 icon_color,
1537 match_start: None,
1538 snippet_deduplication_key: None,
1539 insert_text_mode: None,
1540 confirm: Some(confirm),
1541 group: show_section_headers.then(|| CompletionGroup {
1542 key: "skills".into(),
1543 label: Some("Skills".into()),
1544 }),
1545 }
1546 }
1547 SlashCompletionCandidate::Command(command) => {
1548 let label =
1549 build_slash_command_label(&command, source_highlight_id);
1550 let new_text = match (command.source.as_ref(), argument.as_ref()) {
1551 (Some(source), Some(argument)) => {
1552 format!("/{}:{} {}", source, command.name, argument)
1553 }
1554 (Some(source), None) => {
1555 format!("/{}:{} ", source, command.name)
1556 }
1557 (None, Some(argument)) => {
1558 format!("/{} {}", command.name, argument)
1559 }
1560 (None, None) => format!("/{} ", command.name),
1561 };
1562
1563 let is_missing_argument =
1564 command.requires_argument && argument.is_none();
1565 let group = show_section_headers.then(|| command.group());
1566
1567 let icon_path = (command.category
1568 == Some(acp_thread::CommandCategory::Native)
1569 && command.name.as_ref() == agent::COMPACT_COMMAND_NAME)
1570 .then(|| IconName::Compact.path().into());
1571
1572 Completion {
1573 replace_range: source_range.clone(),
1574 new_text,
1575 label,
1576 documentation: Some(
1577 CompletionDocumentation::MultiLinePlainText(
1578 command.description.into(),
1579 ),
1580 ),
1581 source: project::CompletionSource::Custom,
1582 icon_path,
1583 icon_color: None,
1584 match_start: None,
1585 snippet_deduplication_key: None,
1586 insert_text_mode: None,
1587 confirm: Some(Arc::new({
1588 let source = source.clone();
1589 move |intent, _window, cx| {
1590 if !is_missing_argument {
1591 cx.defer({
1592 let source = source.clone();
1593 move |cx| match intent {
1594 CompletionIntent::Complete
1595 | CompletionIntent::CompleteWithInsert
1596 | CompletionIntent::CompleteWithReplace => {
1597 source.confirm_command(cx);
1598 }
1599 CompletionIntent::Compose => {}
1600 }
1601 });
1602 }
1603 false
1604 }
1605 })),
1606 group,
1607 }
1608 }
1609 SlashCompletionCandidate::LocalCommand(command) => {
1610 let group = show_section_headers.then(|| CompletionGroup {
1611 key: "local-commands".into(),
1612 label: Some("Actions".into()),
1613 });
1614
1615 Completion {
1616 replace_range: source_range.clone(),
1617 // Local commands aren't part of the prompt;
1618 // confirming one clears the typed text
1619 // rather than leaving `/keyword` behind.
1620 new_text: String::new(),
1621 label: CodeLabel::plain(command.label().to_string(), None),
1622 documentation: Some(
1623 CompletionDocumentation::MultiLinePlainText(
1624 command.description().into(),
1625 ),
1626 ),
1627 source: project::CompletionSource::Custom,
1628 icon_path: Some(command.icon().path().into()),
1629 icon_color: None,
1630 match_start: None,
1631 snippet_deduplication_key: None,
1632 insert_text_mode: None,
1633 confirm: Some(Arc::new({
1634 let source = source.clone();
1635 move |intent, _window, cx| {
1636 cx.defer({
1637 let source = source.clone();
1638 move |cx| match intent {
1639 CompletionIntent::Complete
1640 | CompletionIntent::CompleteWithInsert
1641 | CompletionIntent::CompleteWithReplace => {
1642 source.run_local_command(command, cx);
1643 }
1644 CompletionIntent::Compose => {}
1645 }
1646 });
1647 false
1648 }
1649 })),
1650 group,
1651 }
1652 }
1653 })
1654 .collect();
1655
1656 Ok(vec![CompletionResponse {
1657 completions,
1658 display_options: CompletionDisplayOptions {
1659 dynamic_width: true,
1660 },
1661 is_incomplete: true,
1662 }])
1663 })
1664 }
1665 PromptCompletion::Mention(MentionCompletion { mode, argument, .. }) => {
1666 if let Some(PromptContextType::Diagnostics) = mode {
1667 if argument.is_some() {
1668 return Task::ready(Ok(Vec::new()));
1669 }
1670
1671 let completions = Self::completion_for_diagnostics(
1672 source_range.clone(),
1673 source.clone(),
1674 editor.clone(),
1675 mention_set.clone(),
1676 workspace.clone(),
1677 cx,
1678 );
1679 if !completions.is_empty() {
1680 return Task::ready(Ok(vec![CompletionResponse {
1681 completions,
1682 display_options: CompletionDisplayOptions::default(),
1683 is_incomplete: false,
1684 }]));
1685 }
1686 }
1687
1688 let show_section_headers = mode.is_none() && argument.is_none();
1689 let query = argument.unwrap_or_default();
1690 let search_task =
1691 self.search_mentions(mode, query, Arc::<AtomicBool>::default(), cx);
1692
1693 // Calculate maximum characters available for the full label (file_name + space + directory)
1694 // based on maximum menu width after accounting for padding, spacing, and icon width
1695 let label_max_chars = {
1696 // Base06 left padding + Base06 gap + Base06 right padding + icon width
1697 let used_pixels = DynamicSpacing::Base06.px(cx) * 3.0
1698 + IconSize::XSmall.rems() * window.rem_size();
1699
1700 let style = window.text_style();
1701 let font_id = window.text_system().resolve_font(&style.font());
1702 let font_size = TextSize::Small.rems(cx).to_pixels(window.rem_size());
1703
1704 // Fallback em_width of 10px matches file_finder.rs fallback for TextSize::Small
1705 let em_width = cx
1706 .text_system()
1707 .em_width(font_id, font_size)
1708 .unwrap_or(px(10.0));
1709
1710 // Calculate available pixels for text (file_name + directory)
1711 // Using max width since dynamic_width allows the menu to expand up to this
1712 let available_pixels = COMPLETION_MENU_MAX_WIDTH - used_pixels;
1713
1714 // Convert to character count (total available for file_name + directory)
1715 (f32::from(available_pixels) / f32::from(em_width)) as usize
1716 };
1717
1718 cx.spawn(async move |_, cx| {
1719 let mut matches = search_task.await;
1720 if show_section_headers {
1721 matches.sort_by_key(|mat| match mat {
1722 Match::File(FileMatch {
1723 is_recent: true, ..
1724 })
1725 | Match::RecentThread(_) => 0,
1726 Match::Entry(_) | Match::BranchDiff(_) => 1,
1727 _ => 2,
1728 });
1729 }
1730
1731 let completions = cx.update(|cx| {
1732 matches
1733 .into_iter()
1734 .filter_map(|mat| {
1735 let group = if show_section_headers {
1736 match &mat {
1737 Match::File(FileMatch {
1738 is_recent: true, ..
1739 })
1740 | Match::RecentThread(_) => Some(CompletionGroup {
1741 key: "recent".into(),
1742 label: None,
1743 }),
1744 Match::Entry(_) | Match::BranchDiff(_) => {
1745 Some(CompletionGroup {
1746 key: "context".into(),
1747 label: None,
1748 })
1749 }
1750 _ => None,
1751 }
1752 } else {
1753 None
1754 };
1755 let mut completion = match mat {
1756 Match::File(FileMatch { mat, is_recent }) => {
1757 let project_path = ProjectPath {
1758 worktree_id: WorktreeId::from_usize(mat.worktree_id),
1759 path: mat.path.clone(),
1760 };
1761
1762 // If path is empty, this means we're matching with the root directory itself
1763 // so we use the path_prefix as the name
1764 let path_prefix = if mat.path.is_empty() {
1765 project
1766 .read(cx)
1767 .worktree_for_id(project_path.worktree_id, cx)
1768 .map(|wt| wt.read(cx).root_name().into())
1769 .unwrap_or_else(|| mat.path_prefix.clone())
1770 } else {
1771 mat.path_prefix.clone()
1772 };
1773
1774 Self::completion_for_path(
1775 project_path,
1776 &path_prefix,
1777 is_recent,
1778 mat.is_dir,
1779 source_range.clone(),
1780 source.clone(),
1781 editor.clone(),
1782 mention_set.clone(),
1783 workspace.clone(),
1784 project.clone(),
1785 label_max_chars,
1786 cx,
1787 )
1788 }
1789 Match::Symbol(SymbolMatch { symbol, .. }) => {
1790 Self::completion_for_symbol(
1791 symbol,
1792 source_range.clone(),
1793 source.clone(),
1794 editor.clone(),
1795 mention_set.clone(),
1796 workspace.clone(),
1797 label_max_chars,
1798 cx,
1799 )
1800 }
1801 Match::Thread(thread) => Some(Self::completion_for_thread(
1802 thread.session_id,
1803 Some(thread.title),
1804 source_range.clone(),
1805 false,
1806 source.clone(),
1807 editor.clone(),
1808 mention_set.clone(),
1809 workspace.clone(),
1810 cx,
1811 )),
1812 Match::RecentThread(thread) => {
1813 Some(Self::completion_for_thread(
1814 thread.session_id,
1815 Some(thread.title),
1816 source_range.clone(),
1817 true,
1818 source.clone(),
1819 editor.clone(),
1820 mention_set.clone(),
1821 workspace.clone(),
1822 cx,
1823 ))
1824 }
1825 Match::Skill(skill) => Some(Self::completion_for_skill(
1826 skill,
1827 source_range.clone(),
1828 source.clone(),
1829 editor.clone(),
1830 mention_set.clone(),
1831 workspace.clone(),
1832 cx,
1833 )),
1834 Match::Fetch(url) => Self::completion_for_fetch(
1835 source_range.clone(),
1836 url,
1837 source.clone(),
1838 editor.clone(),
1839 mention_set.clone(),
1840 workspace.clone(),
1841 cx,
1842 ),
1843 Match::Entry(EntryMatch { entry, .. }) => {
1844 Self::completion_for_entry(
1845 entry,
1846 source_range.clone(),
1847 editor.clone(),
1848 mention_set.clone(),
1849 &workspace,
1850 cx,
1851 )
1852 }
1853 Match::BranchDiff(branch_diff) => {
1854 Some(Self::build_branch_diff_completion(
1855 branch_diff.base_ref,
1856 source_range.clone(),
1857 source.clone(),
1858 editor.clone(),
1859 mention_set.clone(),
1860 workspace.clone(),
1861 cx,
1862 ))
1863 }
1864 };
1865 if let Some(completion) = &mut completion {
1866 completion.group = group;
1867 }
1868 completion
1869 })
1870 .collect::<Vec<_>>()
1871 });
1872
1873 Ok(vec![CompletionResponse {
1874 completions,
1875 display_options: CompletionDisplayOptions {
1876 dynamic_width: true,
1877 },
1878 // Since this does its own filtering (see `filter_completions()` returns false),
1879 // there is no benefit to computing whether this set of completions is incomplete.
1880 is_incomplete: true,
1881 }])
1882 })
1883 }
1884 }
1885 }
1886
1887 fn is_completion_trigger(
1888 &self,
1889 buffer: &Entity<language::Buffer>,
1890 position: language::Anchor,
1891 _text: &str,
1892 _trigger_in_words: bool,
1893 cx: &mut Context<Editor>,
1894 ) -> bool {
1895 let buffer = buffer.read(cx);
1896 let position = position.to_point(buffer);
1897 let line_start = Point::new(position.row, 0);
1898 let offset_to_line = buffer.point_to_offset(line_start);
1899 let mut lines = buffer.text_for_range(line_start..position).lines();
1900 if let Some(line) = lines.next() {
1901 PromptCompletion::try_parse(line, offset_to_line, &self.source.supported_modes(cx))
1902 .filter(|completion| {
1903 // Right now we don't support completing arguments of slash commands
1904 let is_slash_command_with_argument = matches!(
1905 completion,
1906 PromptCompletion::SlashCommand(SlashCommandCompletion {
1907 argument: Some(_),
1908 ..
1909 })
1910 );
1911 !is_slash_command_with_argument
1912 })
1913 .map(|completion| {
1914 completion.source_range().start <= offset_to_line + position.column as usize
1915 && completion.source_range().end
1916 >= offset_to_line + position.column as usize
1917 })
1918 .unwrap_or(false)
1919 } else {
1920 false
1921 }
1922 }
1923
1924 fn sort_completions(&self) -> bool {
1925 false
1926 }
1927
1928 fn filter_completions(&self) -> bool {
1929 false
1930 }
1931}
1932
1933fn confirm_completion_callback<T: PromptCompletionProviderDelegate>(
1934 crease_text: SharedString,
1935 start: Anchor,
1936 content_len: usize,
1937 mention_uri: MentionUri,
1938 source: Arc<T>,
1939 editor: WeakEntity<Editor>,
1940 mention_set: WeakEntity<MentionSet>,
1941 workspace: Entity<Workspace>,
1942) -> Arc<dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync> {
1943 Arc::new(move |_, window, cx| {
1944 let source = source.clone();
1945 let editor = editor.clone();
1946 let mention_set = mention_set.clone();
1947 let crease_text = crease_text.clone();
1948 let mention_uri = mention_uri.clone();
1949 let workspace = workspace.clone();
1950 window.defer(cx, move |window, cx| {
1951 if let Some(editor) = editor.upgrade() {
1952 mention_set
1953 .clone()
1954 .update(cx, |mention_set, cx| {
1955 mention_set
1956 .confirm_mention_completion(
1957 crease_text,
1958 start,
1959 content_len,
1960 mention_uri,
1961 source.supports_images(cx),
1962 editor,
1963 &workspace,
1964 window,
1965 cx,
1966 )
1967 .detach();
1968 })
1969 .ok();
1970 }
1971 });
1972 false
1973 })
1974}
1975
1976#[derive(Debug, PartialEq)]
1977enum PromptCompletion {
1978 SlashCommand(SlashCommandCompletion),
1979 Mention(MentionCompletion),
1980}
1981
1982impl PromptCompletion {
1983 fn source_range(&self) -> Range<usize> {
1984 match self {
1985 Self::SlashCommand(completion) => completion.source_range.clone(),
1986 Self::Mention(completion) => completion.source_range.clone(),
1987 }
1988 }
1989
1990 fn try_parse(
1991 line: &str,
1992 offset_to_line: usize,
1993 supported_modes: &[PromptContextType],
1994 ) -> Option<Self> {
1995 if line.contains('@') {
1996 if let Some(mention) =
1997 MentionCompletion::try_parse(line, offset_to_line, supported_modes)
1998 {
1999 return Some(Self::Mention(mention));
2000 }
2001 }
2002 SlashCommandCompletion::try_parse(line, offset_to_line).map(Self::SlashCommand)
2003 }
2004}
2005
2006#[derive(Debug, Default, PartialEq)]
2007pub struct SlashCommandCompletion {
2008 pub source_range: Range<usize>,
2009 pub command: Option<String>,
2010 pub argument: Option<String>,
2011}
2012
2013impl SlashCommandCompletion {
2014 pub fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
2015 let mut last_command_start = None;
2016 for (idx, _) in line.rmatch_indices('/') {
2017 if line[idx + 1..]
2018 .chars()
2019 .next()
2020 .is_some_and(|c| c.is_whitespace())
2021 {
2022 continue;
2023 }
2024
2025 if idx > 0
2026 && line[..idx]
2027 .chars()
2028 .last()
2029 .is_some_and(|c| !c.is_whitespace())
2030 {
2031 continue;
2032 }
2033
2034 last_command_start = Some(idx);
2035 break;
2036 }
2037
2038 let last_command_start = last_command_start?;
2039 let last_command = &line[last_command_start + 1..];
2040
2041 let mut argument = None;
2042 let mut command = None;
2043 if let Some((command_text, args)) = last_command.split_once(char::is_whitespace) {
2044 if !args.is_empty() {
2045 argument = Some(args.trim_end().to_string());
2046 }
2047 command = Some(command_text.to_string());
2048 } else if !last_command.is_empty() {
2049 command = Some(last_command.to_string());
2050 };
2051
2052 Some(Self {
2053 source_range: last_command_start + offset_to_line
2054 ..line
2055 .rfind(|c: char| !c.is_whitespace())
2056 .unwrap_or_else(|| line.len())
2057 + 1
2058 + offset_to_line,
2059 command,
2060 argument,
2061 })
2062 }
2063}
2064
2065#[derive(Debug, Default, PartialEq)]
2066struct MentionCompletion {
2067 source_range: Range<usize>,
2068 mode: Option<PromptContextType>,
2069 argument: Option<String>,
2070}
2071
2072impl MentionCompletion {
2073 fn try_parse(
2074 line: &str,
2075 offset_to_line: usize,
2076 supported_modes: &[PromptContextType],
2077 ) -> Option<Self> {
2078 // Find the rightmost '@' that has a boundary before it and no whitespace immediately after.
2079 // A boundary is the start of the line, whitespace, or an opening bracket.
2080 let mut last_mention_start = None;
2081 for (idx, _) in line.rmatch_indices('@') {
2082 // No whitespace immediately after '@'.
2083 if line[idx + 1..]
2084 .chars()
2085 .next()
2086 .is_some_and(|c| c.is_whitespace())
2087 {
2088 continue;
2089 }
2090
2091 if idx > 0
2092 && line[..idx]
2093 .chars()
2094 .last()
2095 .is_some_and(|c| !c.is_whitespace() && !matches!(c, '(' | '[' | '{'))
2096 {
2097 continue;
2098 }
2099
2100 last_mention_start = Some(idx);
2101 break;
2102 }
2103
2104 let last_mention_start = last_mention_start?;
2105
2106 let rest_of_line = &line[last_mention_start + 1..];
2107
2108 let mut mode = None;
2109 let mut argument = None;
2110
2111 let mut parts = rest_of_line.split_whitespace();
2112 let mut end = last_mention_start + 1;
2113
2114 if let Some(mode_text) = parts.next() {
2115 // Safe since we check no leading whitespace above
2116 end += mode_text.len();
2117
2118 if let Some(parsed_mode) = PromptContextType::try_from(mode_text).ok()
2119 && supported_modes.contains(&parsed_mode)
2120 {
2121 mode = Some(parsed_mode);
2122 } else {
2123 argument = Some(mode_text.to_string());
2124 }
2125 match rest_of_line[mode_text.len()..].find(|c: char| !c.is_whitespace()) {
2126 Some(whitespace_count) => {
2127 if let Some(argument_text) = parts.next() {
2128 // If mode wasn't recognized but we have an argument, don't suggest completions
2129 // (e.g. '@something word')
2130 if mode.is_none() && !argument_text.is_empty() {
2131 return None;
2132 }
2133
2134 argument = Some(argument_text.to_string());
2135 end += whitespace_count + argument_text.len();
2136 }
2137 }
2138 None => {
2139 // Rest of line is entirely whitespace
2140 end += rest_of_line.len() - mode_text.len();
2141 }
2142 }
2143 }
2144
2145 Some(Self {
2146 source_range: last_mention_start + offset_to_line..end + offset_to_line,
2147 mode,
2148 argument,
2149 })
2150 }
2151}
2152
2153fn diagnostics_label(
2154 summary: DiagnosticSummary,
2155 include_errors: bool,
2156 include_warnings: bool,
2157) -> String {
2158 let mut parts = Vec::new();
2159
2160 if include_errors && summary.error_count > 0 {
2161 parts.push(format!(
2162 "{} {}",
2163 summary.error_count,
2164 pluralize("error", summary.error_count)
2165 ));
2166 }
2167
2168 if include_warnings && summary.warning_count > 0 {
2169 parts.push(format!(
2170 "{} {}",
2171 summary.warning_count,
2172 pluralize("warning", summary.warning_count)
2173 ));
2174 }
2175
2176 if parts.is_empty() {
2177 return "Diagnostics".into();
2178 }
2179
2180 let body = if parts.len() == 2 {
2181 format!("{} and {}", parts[0], parts[1])
2182 } else {
2183 parts
2184 .pop()
2185 .expect("at least one part present after non-empty check")
2186 };
2187
2188 format!("Diagnostics: {body}")
2189}
2190
2191fn diagnostics_submenu_label(
2192 summary: DiagnosticSummary,
2193 include_errors: bool,
2194 include_warnings: bool,
2195) -> String {
2196 match (include_errors, include_warnings) {
2197 (true, true) => format!(
2198 "{} {} & {} {}",
2199 summary.error_count,
2200 pluralize("error", summary.error_count),
2201 summary.warning_count,
2202 pluralize("warning", summary.warning_count)
2203 ),
2204 (true, _) => format!(
2205 "{} {}",
2206 summary.error_count,
2207 pluralize("error", summary.error_count)
2208 ),
2209 (_, true) => format!(
2210 "{} {}",
2211 summary.warning_count,
2212 pluralize("warning", summary.warning_count)
2213 ),
2214 _ => "Diagnostics".into(),
2215 }
2216}
2217
2218fn diagnostics_crease_label(
2219 summary: DiagnosticSummary,
2220 include_errors: bool,
2221 include_warnings: bool,
2222) -> SharedString {
2223 diagnostics_label(summary, include_errors, include_warnings).into()
2224}
2225
2226pub(crate) fn pluralize(noun: &str, count: usize) -> String {
2227 if count == 1 {
2228 noun.to_string()
2229 } else {
2230 format!("{noun}s")
2231 }
2232}
2233
2234pub(crate) fn search_files(
2235 query: String,
2236 cancellation_flag: Arc<AtomicBool>,
2237 workspace: &Entity<Workspace>,
2238 cx: &App,
2239) -> Task<Vec<FileMatch>> {
2240 if query.is_empty() {
2241 let workspace = workspace.read(cx);
2242 let project = workspace.project().read(cx);
2243 let visible_worktrees = workspace.visible_worktrees(cx).collect::<Vec<_>>();
2244 let include_root_name = visible_worktrees.len() > 1;
2245
2246 let recent_matches = workspace
2247 .recent_navigation_history(Some(10), cx)
2248 .into_iter()
2249 .map(|(project_path, _)| {
2250 let path_prefix = if include_root_name {
2251 project
2252 .worktree_for_id(project_path.worktree_id, cx)
2253 .map(|wt| wt.read(cx).root_name().into())
2254 .unwrap_or_else(|| RelPath::empty_arc())
2255 } else {
2256 RelPath::empty_arc()
2257 };
2258
2259 FileMatch {
2260 mat: PathMatch {
2261 score: 0.,
2262 positions: Vec::new(),
2263 worktree_id: project_path.worktree_id.to_usize(),
2264 path: project_path.path,
2265 path_prefix,
2266 distance_to_relative_ancestor: 0,
2267 is_dir: false,
2268 },
2269 is_recent: true,
2270 }
2271 });
2272
2273 let file_matches = visible_worktrees.into_iter().flat_map(|worktree| {
2274 let worktree = worktree.read(cx);
2275 let path_prefix: Arc<RelPath> = if include_root_name {
2276 worktree.root_name().into()
2277 } else {
2278 RelPath::empty_arc()
2279 };
2280 worktree.entries(false, 0).map(move |entry| FileMatch {
2281 mat: PathMatch {
2282 score: 0.,
2283 positions: Vec::new(),
2284 worktree_id: worktree.id().to_usize(),
2285 path: entry.path.clone(),
2286 path_prefix: path_prefix.clone(),
2287 distance_to_relative_ancestor: 0,
2288 is_dir: entry.is_dir(),
2289 },
2290 is_recent: false,
2291 })
2292 });
2293
2294 Task::ready(recent_matches.chain(file_matches).collect())
2295 } else {
2296 let workspace = workspace.read(cx);
2297 let relative_to = workspace
2298 .recent_navigation_history_iter(cx)
2299 .next()
2300 .map(|(path, _)| path.path);
2301 let worktrees = workspace.visible_worktrees(cx).collect::<Vec<_>>();
2302 let include_root_name = worktrees.len() > 1;
2303 let candidate_sets = worktrees
2304 .into_iter()
2305 .map(|worktree| {
2306 let worktree = worktree.read(cx);
2307
2308 PathMatchCandidateSet {
2309 snapshot: worktree.snapshot(),
2310 include_ignored: worktree.root_entry().is_some_and(|entry| entry.is_ignored),
2311 include_root_name,
2312 candidates: project::Candidates::Entries,
2313 }
2314 })
2315 .collect::<Vec<_>>();
2316
2317 let executor = cx.background_executor().clone();
2318 cx.foreground_executor().spawn(async move {
2319 fuzzy::match_path_sets(
2320 candidate_sets.as_slice(),
2321 query.as_str(),
2322 &relative_to,
2323 false,
2324 100,
2325 &cancellation_flag,
2326 executor,
2327 )
2328 .await
2329 .into_iter()
2330 .map(|mat| FileMatch {
2331 mat,
2332 is_recent: false,
2333 })
2334 .collect::<Vec<_>>()
2335 })
2336 }
2337}
2338
2339pub(crate) fn search_symbols(
2340 query: String,
2341 cancellation_flag: Arc<AtomicBool>,
2342 workspace: &Entity<Workspace>,
2343 cx: &mut App,
2344) -> Task<Vec<SymbolMatch>> {
2345 let symbols_task = workspace.update(cx, |workspace, cx| {
2346 workspace
2347 .project()
2348 .update(cx, |project, cx| project.symbols(&query, cx))
2349 });
2350 let project = workspace.read(cx).project().clone();
2351 cx.spawn(async move |cx| {
2352 let Some(symbols) = symbols_task.await.log_err() else {
2353 return Vec::new();
2354 };
2355 let (visible_match_candidates, external_match_candidates): (Vec<_>, Vec<_>) = project
2356 .update(cx, |project, cx| {
2357 symbols
2358 .iter()
2359 .enumerate()
2360 .map(|(id, symbol)| StringMatchCandidate::new(id, symbol.label.filter_text()))
2361 .partition(|candidate| match &symbols[candidate.id].path {
2362 SymbolLocation::InProject(project_path) => project
2363 .entry_for_path(project_path, cx)
2364 .is_some_and(|e| !e.is_ignored),
2365 SymbolLocation::OutsideProject { .. } => false,
2366 })
2367 });
2368 // Try to support rust-analyzer's path based symbols feature which
2369 // allows to search by rust path syntax, in that case we only want to
2370 // filter names by the last segment
2371 // Ideally this was a first class LSP feature (rich queries)
2372 let query = query
2373 .rsplit_once("::")
2374 .map_or(&*query, |(_, suffix)| suffix)
2375 .to_owned();
2376 // Note if you make changes to this filtering below, also change `project_symbols::ProjectSymbolsDelegate::filter`
2377 const MAX_MATCHES: usize = 100;
2378 let mut visible_matches = cx.foreground_executor().block_on(fuzzy::match_strings(
2379 &visible_match_candidates,
2380 &query,
2381 false,
2382 true,
2383 MAX_MATCHES,
2384 &cancellation_flag,
2385 cx.background_executor().clone(),
2386 ));
2387 let mut external_matches = cx.foreground_executor().block_on(fuzzy::match_strings(
2388 &external_match_candidates,
2389 &query,
2390 false,
2391 true,
2392 MAX_MATCHES - visible_matches.len().min(MAX_MATCHES),
2393 &cancellation_flag,
2394 cx.background_executor().clone(),
2395 ));
2396 let sort_key_for_match = |mat: &StringMatch| {
2397 let symbol = &symbols[mat.candidate_id];
2398 (Reverse(OrderedFloat(mat.score)), symbol.label.filter_text())
2399 };
2400
2401 visible_matches.sort_unstable_by_key(sort_key_for_match);
2402 external_matches.sort_unstable_by_key(sort_key_for_match);
2403 let mut matches = visible_matches;
2404 matches.append(&mut external_matches);
2405
2406 matches
2407 .into_iter()
2408 .map(|mut mat| {
2409 let symbol = symbols[mat.candidate_id].clone();
2410 let filter_start = symbol.label.filter_range.start;
2411 for position in &mut mat.positions {
2412 *position += filter_start;
2413 }
2414 SymbolMatch { symbol }
2415 })
2416 .collect()
2417 })
2418}
2419
2420fn collect_session_matches(cx: &App) -> Vec<SessionMatch> {
2421 let Some(store) = ThreadMetadataStore::try_global(cx) else {
2422 return Vec::new();
2423 };
2424 let mut entries: Vec<&ThreadMetadata> = store
2425 .read(cx)
2426 .entries()
2427 .filter(|t| !t.archived && t.agent_id == *agent::OMEGA_AGENT_ID)
2428 .collect();
2429 entries.sort_by_key(|t| Reverse(t.updated_at));
2430 entries
2431 .into_iter()
2432 .map(|metadata| {
2433 let info = acp_thread::AgentSessionInfo::from(metadata);
2434 SessionMatch {
2435 session_id: info.session_id,
2436 title: session_title(info.title),
2437 }
2438 })
2439 .collect()
2440}
2441
2442fn filter_sessions_by_query(
2443 query: String,
2444 cancellation_flag: Arc<AtomicBool>,
2445 sessions: Vec<SessionMatch>,
2446 cx: &mut App,
2447) -> Task<Vec<SessionMatch>> {
2448 if query.is_empty() {
2449 return Task::ready(sessions);
2450 }
2451 let executor = cx.background_executor().clone();
2452 cx.background_spawn(async move {
2453 filter_sessions(query, cancellation_flag, sessions, executor).await
2454 })
2455}
2456
2457async fn filter_sessions(
2458 query: String,
2459 cancellation_flag: Arc<AtomicBool>,
2460 sessions: Vec<SessionMatch>,
2461 executor: BackgroundExecutor,
2462) -> Vec<SessionMatch> {
2463 let titles = sessions
2464 .iter()
2465 .map(|session| session.title.clone())
2466 .collect::<Vec<_>>();
2467 let candidates = titles
2468 .iter()
2469 .enumerate()
2470 .map(|(id, title)| StringMatchCandidate::new(id, title.as_ref()))
2471 .collect::<Vec<_>>();
2472 let matches = fuzzy::match_strings(
2473 &candidates,
2474 &query,
2475 false,
2476 true,
2477 100,
2478 &cancellation_flag,
2479 executor,
2480 )
2481 .await;
2482
2483 matches
2484 .into_iter()
2485 .map(|mat| sessions[mat.candidate_id].clone())
2486 .collect()
2487}
2488
2489pub(crate) fn search_skills(
2490 query: String,
2491 cancellation_flag: Arc<AtomicBool>,
2492 skills: Vec<AvailableSkill>,
2493 cx: &mut App,
2494) -> Task<Vec<AvailableSkill>> {
2495 if skills.is_empty() {
2496 return Task::ready(Vec::new());
2497 }
2498 let executor = cx.background_executor().clone();
2499 cx.background_spawn(async move {
2500 let candidates = skills
2501 .iter()
2502 .enumerate()
2503 .map(|(id, skill)| StringMatchCandidate::new(id, &skill.name))
2504 .collect::<Vec<_>>();
2505 let matches = fuzzy::match_strings(
2506 &candidates,
2507 &query,
2508 false,
2509 true,
2510 100,
2511 &cancellation_flag,
2512 executor,
2513 )
2514 .await;
2515 matches
2516 .into_iter()
2517 .map(|mat| skills[mat.candidate_id].clone())
2518 .collect()
2519 })
2520}
2521
2522pub struct SymbolMatch {
2523 pub symbol: Symbol,
2524}
2525
2526pub struct FileMatch {
2527 pub mat: PathMatch,
2528 pub is_recent: bool,
2529}
2530
2531pub fn extract_file_name_and_directory(
2532 path: &RelPath,
2533 path_prefix: &RelPath,
2534 path_style: PathStyle,
2535) -> (SharedString, Option<SharedString>) {
2536 // If path is empty, this means we're matching with the root directory itself
2537 // so we use the path_prefix as the name
2538 if path.is_empty() && !path_prefix.is_empty() {
2539 return (path_prefix.display(path_style).to_string().into(), None);
2540 }
2541
2542 let full_path = path_prefix.join(path);
2543 let file_name = full_path.file_name().unwrap_or_default();
2544 let display_path = full_path.display(path_style);
2545 let (directory, file_name) = display_path.split_at(display_path.len() - file_name.len());
2546 (
2547 file_name.to_string().into(),
2548 Some(SharedString::new(directory)).filter(|dir| !dir.is_empty()),
2549 )
2550}
2551
2552fn build_slash_command_label(
2553 command: &AvailableCommand,
2554 source_highlight_id: Option<HighlightId>,
2555) -> CodeLabel {
2556 build_slash_item_label(&command.name, command.source.as_ref(), source_highlight_id)
2557}
2558
2559/// Build the autocomplete-popup label for a slash menu item, appending
2560/// the item origin after the name when one is present and non-empty.
2561/// The suffix is styled with the muted `variable` highlight and excluded
2562/// from the fuzzy filter range so typing the source doesn't match the entry.
2563fn build_slash_item_label(
2564 name: &Arc<str>,
2565 source: Option<&SharedString>,
2566 source_highlight_id: Option<HighlightId>,
2567) -> CodeLabel {
2568 let source = source.filter(|source| !source.is_empty());
2569 let Some(source) = source else {
2570 return CodeLabel::plain(name.to_string(), None);
2571 };
2572 let mut builder = CodeLabelBuilder::default();
2573 builder.push_str(name, None);
2574 builder.push_str(" ", None);
2575 builder.push_str(source, source_highlight_id);
2576 // The filter range defaults to the entire label after `build()`,
2577 // which would let the source text participate in fuzzy filtering.
2578 // Slash commands are matched up-front in `search_slash_commands`
2579 // against the command name, and the editor doesn't re-filter
2580 // (`filter_completions()` is false), so this is mostly defensive
2581 // — but it keeps the displayed filter consistent with what we
2582 // actually matched against.
2583 builder.respan_filter_range(Some(name));
2584 builder.build()
2585}
2586
2587fn build_code_label_for_path(
2588 file: &str,
2589 directory: Option<&str>,
2590 line_number: Option<u32>,
2591 label_max_chars: usize,
2592 cx: &App,
2593) -> CodeLabel {
2594 let variable_highlight_id = cx
2595 .theme()
2596 .syntax()
2597 .highlight_id("variable")
2598 .map(HighlightId::new);
2599 let mut label = CodeLabelBuilder::default();
2600
2601 label.push_str(file, None);
2602 label.push_str(" ", None);
2603
2604 if let Some(directory) = directory {
2605 let file_name_chars = file.chars().count();
2606 // Account for: file_name + space (ellipsis is handled by truncate_and_remove_front)
2607 let directory_max_chars = label_max_chars
2608 .saturating_sub(file_name_chars)
2609 .saturating_sub(1);
2610 let truncated_directory = truncate_and_remove_front(directory, directory_max_chars.max(5));
2611 label.push_str(&truncated_directory, variable_highlight_id);
2612 }
2613 if let Some(line_number) = line_number {
2614 label.push_str(&format!(" L{}", line_number), variable_highlight_id);
2615 }
2616 label.build()
2617}
2618
2619fn terminal_view_selection(terminal_view: &Entity<TerminalView>, cx: &App) -> Option<String> {
2620 terminal_view
2621 .read(cx)
2622 .terminal()
2623 .read(cx)
2624 .last_content
2625 .selection_text
2626 .clone()
2627 .filter(|text| !text.is_empty())
2628}
2629
2630fn editor_selection_ranges(
2631 editor: &Entity<Editor>,
2632 include_current_line: bool,
2633 cx: &mut App,
2634) -> Vec<(Entity<Buffer>, Range<text::Anchor>)> {
2635 editor.update(cx, |editor, cx| {
2636 let selections = editor.selections.all_adjusted(&editor.display_snapshot(cx));
2637
2638 let multi_buffer = editor.buffer().read(cx);
2639 let multi_buffer_snapshot = multi_buffer.snapshot(cx);
2640
2641 let non_empty_rows: collections::HashSet<u32> = selections
2642 .iter()
2643 .filter(|s| !s.is_empty())
2644 .flat_map(|s| s.start.row..=s.end.row)
2645 .collect();
2646
2647 let mut seen_current_line_rows = collections::HashSet::default();
2648 let mut results = Vec::new();
2649
2650 for s in selections {
2651 if s.is_empty() {
2652 if !include_current_line
2653 || non_empty_rows.contains(&s.start.row)
2654 || !seen_current_line_rows.insert(s.start.row)
2655 {
2656 continue;
2657 }
2658 let Some((buffer, anchor)) = multi_buffer.text_anchor_for_position(s.start, cx)
2659 else {
2660 continue;
2661 };
2662 let buffer_snapshot = buffer.read(cx).snapshot();
2663 let row = anchor.to_point(&buffer_snapshot).row;
2664 let line_start = text::Point::new(row, 0);
2665 let line_end = text::Point::new(row, buffer_snapshot.line_len(row));
2666 let start = buffer_snapshot.anchor_after(line_start);
2667 let end = buffer_snapshot.anchor_before(line_end);
2668 if start.to_offset(&buffer_snapshot) == end.to_offset(&buffer_snapshot) {
2669 continue;
2670 }
2671 results.push((buffer, start..end));
2672 } else {
2673 let mb_start = multi_buffer_snapshot.anchor_after(s.start);
2674 let mb_end = multi_buffer_snapshot.anchor_before(s.end);
2675 let Some((start_buffer, start)) =
2676 multi_buffer.text_anchor_for_position(mb_start, cx)
2677 else {
2678 continue;
2679 };
2680 let Some((end_buffer, end)) = multi_buffer.text_anchor_for_position(mb_end, cx)
2681 else {
2682 continue;
2683 };
2684 if start_buffer != end_buffer {
2685 continue;
2686 }
2687 let buffer_snapshot = start_buffer.read(cx).snapshot();
2688 if start.to_offset(&buffer_snapshot) == end.to_offset(&buffer_snapshot) {
2689 continue;
2690 }
2691 results.push((start_buffer, start..end));
2692 }
2693 }
2694
2695 results
2696 })
2697}
2698
2699type ConfirmCallback = Arc<dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync>;
2700
2701fn completion_text_for_editor_selections(
2702 source_range: Range<Anchor>,
2703 editor: WeakEntity<Editor>,
2704 mention_set: WeakEntity<MentionSet>,
2705 editor_selections: Vec<(Entity<Buffer>, Range<text::Anchor>)>,
2706) -> (String, ConfirmCallback) {
2707 const EDITOR_PLACEHOLDER: &str = "selection ";
2708
2709 let selections = editor_selections
2710 .into_iter()
2711 .enumerate()
2712 .map(|(ix, (buffer, range))| {
2713 (
2714 buffer,
2715 range,
2716 (EDITOR_PLACEHOLDER.len() * ix)..(EDITOR_PLACEHOLDER.len() * (ix + 1) - 1),
2717 )
2718 })
2719 .collect::<Vec<_>>();
2720
2721 let new_text = EDITOR_PLACEHOLDER.repeat(selections.len());
2722
2723 let callback: ConfirmCallback = Arc::new({
2724 move |_: CompletionIntent, window: &mut Window, cx: &mut App| {
2725 let editor = editor.clone();
2726 let selections = selections.clone();
2727 let mention_set = mention_set.clone();
2728 let source_range = source_range.clone();
2729 window.defer(cx, move |window, cx| {
2730 if let Some(editor) = editor.upgrade()
2731 && !selections.is_empty()
2732 {
2733 mention_set
2734 .update(cx, |store, cx| {
2735 store.confirm_mention_for_selection(
2736 source_range.clone(),
2737 selections,
2738 editor.clone(),
2739 window,
2740 cx,
2741 )
2742 })
2743 .ok();
2744 }
2745 });
2746 false
2747 }
2748 });
2749
2750 (new_text, callback)
2751}
2752
2753fn completion_text_for_terminal_selections(
2754 source_range: Range<Anchor>,
2755 editor: WeakEntity<Editor>,
2756 mention_set: WeakEntity<MentionSet>,
2757 terminal_selections: Vec<String>,
2758) -> (String, ConfirmCallback) {
2759 const TERMINAL_PLACEHOLDER: &str = "terminal ";
2760
2761 let mut new_text = String::new();
2762 let terminal_ranges: Vec<(String, std::ops::Range<usize>)> = terminal_selections
2763 .into_iter()
2764 .map(|text| {
2765 let start = new_text.len();
2766 new_text.push_str(TERMINAL_PLACEHOLDER);
2767 (text, start..(new_text.len() - 1))
2768 })
2769 .collect();
2770
2771 let callback: ConfirmCallback = Arc::new({
2772 move |_: CompletionIntent, window: &mut Window, cx: &mut App| {
2773 let editor = editor.clone();
2774 let mention_set = mention_set.clone();
2775 let source_range = source_range.clone();
2776 let terminal_ranges = terminal_ranges.clone();
2777 window.defer(cx, move |window, cx| {
2778 let Some(editor) = editor.upgrade() else {
2779 return;
2780 };
2781 for (terminal_text, terminal_range) in terminal_ranges {
2782 let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx);
2783 let Some(start) = snapshot.anchor_in_excerpt(source_range.start) else {
2784 return;
2785 };
2786 let offset = start.to_offset(&snapshot);
2787
2788 let line_count = terminal_text.lines().count() as u32;
2789 let mention_uri = MentionUri::TerminalSelection { line_count };
2790 let range = snapshot.anchor_after(offset + terminal_range.start)
2791 ..snapshot.anchor_after(offset + terminal_range.end);
2792
2793 let crease = crate::mention_set::crease_for_mention(
2794 mention_uri.name().into(),
2795 mention_uri.icon_path(cx),
2796 None,
2797 range,
2798 editor.downgrade(),
2799 );
2800
2801 let Some(crease_id) = editor.update(cx, |editor, cx| {
2802 let crease_ids = editor.insert_creases(vec![crease.clone()], cx);
2803 editor.fold_creases(vec![crease], false, window, cx);
2804 crease_ids.first().copied()
2805 }) else {
2806 log::error!("insert_creases returned no ids for terminal selection");
2807 continue;
2808 };
2809
2810 mention_set
2811 .update(cx, |mention_set, cx| {
2812 mention_set.insert_mention(
2813 crease_id,
2814 mention_uri.clone(),
2815 Task::ready(Ok(crate::mention_set::Mention::Text {
2816 content: terminal_text,
2817 tracked_buffers: vec![],
2818 }))
2819 .shared(),
2820 None,
2821 cx,
2822 );
2823 })
2824 .ok();
2825 }
2826 });
2827 false
2828 }
2829 });
2830
2831 (new_text, callback)
2832}
2833
2834#[cfg(test)]
2835mod tests {
2836 use super::*;
2837 use gpui::TestAppContext;
2838
2839 #[test]
2840 fn test_prompt_completion_parse() {
2841 let supported_modes = vec![PromptContextType::File, PromptContextType::Symbol];
2842
2843 assert_eq!(
2844 PromptCompletion::try_parse("/", 0, &supported_modes),
2845 Some(PromptCompletion::SlashCommand(SlashCommandCompletion {
2846 source_range: 0..1,
2847 command: None,
2848 argument: None,
2849 }))
2850 );
2851
2852 assert_eq!(
2853 PromptCompletion::try_parse("@", 0, &supported_modes),
2854 Some(PromptCompletion::Mention(MentionCompletion {
2855 source_range: 0..1,
2856 mode: None,
2857 argument: None,
2858 }))
2859 );
2860
2861 assert_eq!(
2862 PromptCompletion::try_parse("/test @file", 0, &supported_modes),
2863 Some(PromptCompletion::Mention(MentionCompletion {
2864 source_range: 6..11,
2865 mode: Some(PromptContextType::File),
2866 argument: None,
2867 }))
2868 );
2869 }
2870
2871 #[test]
2872 fn test_slash_command_completion_parse() {
2873 assert_eq!(
2874 SlashCommandCompletion::try_parse("/", 0),
2875 Some(SlashCommandCompletion {
2876 source_range: 0..1,
2877 command: None,
2878 argument: None,
2879 })
2880 );
2881
2882 assert_eq!(
2883 SlashCommandCompletion::try_parse("/help", 0),
2884 Some(SlashCommandCompletion {
2885 source_range: 0..5,
2886 command: Some("help".to_string()),
2887 argument: None,
2888 })
2889 );
2890
2891 assert_eq!(
2892 SlashCommandCompletion::try_parse("/help ", 0),
2893 Some(SlashCommandCompletion {
2894 source_range: 0..5,
2895 command: Some("help".to_string()),
2896 argument: None,
2897 })
2898 );
2899
2900 assert_eq!(
2901 SlashCommandCompletion::try_parse("/help arg1", 0),
2902 Some(SlashCommandCompletion {
2903 source_range: 0..10,
2904 command: Some("help".to_string()),
2905 argument: Some("arg1".to_string()),
2906 })
2907 );
2908
2909 assert_eq!(
2910 SlashCommandCompletion::try_parse("/help arg1 arg2", 0),
2911 Some(SlashCommandCompletion {
2912 source_range: 0..15,
2913 command: Some("help".to_string()),
2914 argument: Some("arg1 arg2".to_string()),
2915 })
2916 );
2917
2918 assert_eq!(
2919 SlashCommandCompletion::try_parse("/拿不到命令 拿不到命令 ", 0),
2920 Some(SlashCommandCompletion {
2921 source_range: 0..30,
2922 command: Some("拿不到命令".to_string()),
2923 argument: Some("拿不到命令".to_string()),
2924 })
2925 );
2926
2927 assert_eq!(SlashCommandCompletion::try_parse("Lorem Ipsum", 0), None);
2928
2929 assert_eq!(
2930 SlashCommandCompletion::try_parse("Lorem /", 0),
2931 Some(SlashCommandCompletion {
2932 source_range: 6..7,
2933 command: None,
2934 argument: None,
2935 })
2936 );
2937
2938 assert_eq!(
2939 SlashCommandCompletion::try_parse("Lorem /help", 0),
2940 Some(SlashCommandCompletion {
2941 source_range: 6..11,
2942 command: Some("help".to_string()),
2943 argument: None,
2944 })
2945 );
2946
2947 assert_eq!(
2948 SlashCommandCompletion::try_parse("Lorem /help /test", 0),
2949 Some(SlashCommandCompletion {
2950 source_range: 12..17,
2951 command: Some("test".to_string()),
2952 argument: None,
2953 })
2954 );
2955
2956 assert_eq!(
2957 SlashCommandCompletion::try_parse("/help", 10),
2958 Some(SlashCommandCompletion {
2959 source_range: 10..15,
2960 command: Some("help".to_string()),
2961 argument: None,
2962 })
2963 );
2964
2965 assert_eq!(SlashCommandCompletion::try_parse("Lorem/", 0), None);
2966
2967 assert_eq!(SlashCommandCompletion::try_parse("/ ", 0), None);
2968 }
2969
2970 #[test]
2971 fn test_section_headers_visible_until_argument() {
2972 // Section headers stay visible while the user narrows the command name
2973 // (`/`, `/comp`, `/compact `) and only disappear once they start typing
2974 // the command's argument, where category grouping no longer applies.
2975 let show_section_headers = |input: &str| {
2976 SlashCommandCompletion::try_parse(input, 0)
2977 .unwrap()
2978 .argument
2979 .is_none()
2980 };
2981
2982 assert!(show_section_headers("/"));
2983 assert!(show_section_headers("/comp"));
2984 assert!(show_section_headers("/compact"));
2985 assert!(show_section_headers("/compact "));
2986 assert!(!show_section_headers("/compact now"));
2987 }
2988
2989 #[test]
2990 fn test_group_by_relevance_floats_best_group_and_keeps_groups_contiguous() {
2991 // Items arrive in fuzzy-score order (best first). The group containing
2992 // the best match floats to the top, groups stay contiguous, and the
2993 // within-group order is preserved.
2994 let mut items = [
2995 ("compact", 1u32), // best match, group 1
2996 ("skill-a", 0u32), // group 0
2997 ("deploy", 2u32), // group 2
2998 ("skill-b", 0u32), // group 0 (after skill-a in score order)
2999 ("native-b", 1u32), // group 1 (after compact)
3000 ];
3001 group_by_relevance(&mut items, |(_, key)| *key);
3002 let order: Vec<&str> = items.iter().map(|(name, _)| *name).collect();
3003 assert_eq!(
3004 order,
3005 vec!["compact", "native-b", "skill-a", "skill-b", "deploy"]
3006 );
3007
3008 // When the best match is a skill, the skill group leads instead.
3009 let mut items = [("skill-a", 0u32), ("compact", 1u32)];
3010 group_by_relevance(&mut items, |(_, key)| *key);
3011 let order: Vec<&str> = items.iter().map(|(name, _)| *name).collect();
3012 assert_eq!(order, vec!["skill-a", "compact"]);
3013 }
3014
3015 #[test]
3016 fn test_mention_completion_parse() {
3017 let supported_modes = vec![PromptContextType::File, PromptContextType::Symbol];
3018 let supported_modes_with_diagnostics = vec![
3019 PromptContextType::File,
3020 PromptContextType::Symbol,
3021 PromptContextType::Diagnostics,
3022 ];
3023
3024 assert_eq!(
3025 MentionCompletion::try_parse("Lorem Ipsum", 0, &supported_modes),
3026 None
3027 );
3028
3029 assert_eq!(
3030 MentionCompletion::try_parse("Lorem @", 0, &supported_modes),
3031 Some(MentionCompletion {
3032 source_range: 6..7,
3033 mode: None,
3034 argument: None,
3035 })
3036 );
3037
3038 assert_eq!(
3039 MentionCompletion::try_parse("Lorem @file", 0, &supported_modes),
3040 Some(MentionCompletion {
3041 source_range: 6..11,
3042 mode: Some(PromptContextType::File),
3043 argument: None,
3044 })
3045 );
3046
3047 assert_eq!(
3048 MentionCompletion::try_parse("Lorem @file ", 0, &supported_modes),
3049 Some(MentionCompletion {
3050 source_range: 6..12,
3051 mode: Some(PromptContextType::File),
3052 argument: None,
3053 })
3054 );
3055
3056 assert_eq!(
3057 MentionCompletion::try_parse("Lorem @file main.rs", 0, &supported_modes),
3058 Some(MentionCompletion {
3059 source_range: 6..19,
3060 mode: Some(PromptContextType::File),
3061 argument: Some("main.rs".to_string()),
3062 })
3063 );
3064
3065 assert_eq!(
3066 MentionCompletion::try_parse("Lorem @file main.rs ", 0, &supported_modes),
3067 Some(MentionCompletion {
3068 source_range: 6..19,
3069 mode: Some(PromptContextType::File),
3070 argument: Some("main.rs".to_string()),
3071 })
3072 );
3073
3074 assert_eq!(
3075 MentionCompletion::try_parse("Lorem @file main.rs Ipsum", 0, &supported_modes),
3076 Some(MentionCompletion {
3077 source_range: 6..19,
3078 mode: Some(PromptContextType::File),
3079 argument: Some("main.rs".to_string()),
3080 })
3081 );
3082
3083 assert_eq!(
3084 MentionCompletion::try_parse("Lorem @main", 0, &supported_modes),
3085 Some(MentionCompletion {
3086 source_range: 6..11,
3087 mode: None,
3088 argument: Some("main".to_string()),
3089 })
3090 );
3091
3092 assert_eq!(
3093 MentionCompletion::try_parse("Lorem @main ", 0, &supported_modes),
3094 Some(MentionCompletion {
3095 source_range: 6..12,
3096 mode: None,
3097 argument: Some("main".to_string()),
3098 })
3099 );
3100
3101 assert_eq!(
3102 MentionCompletion::try_parse("Lorem @main m", 0, &supported_modes),
3103 None
3104 );
3105
3106 assert_eq!(
3107 MentionCompletion::try_parse("test@", 0, &supported_modes),
3108 None
3109 );
3110
3111 // Allowed non-file mentions
3112
3113 assert_eq!(
3114 MentionCompletion::try_parse("Lorem @symbol main", 0, &supported_modes),
3115 Some(MentionCompletion {
3116 source_range: 6..18,
3117 mode: Some(PromptContextType::Symbol),
3118 argument: Some("main".to_string()),
3119 })
3120 );
3121
3122 assert_eq!(
3123 MentionCompletion::try_parse(
3124 "Lorem @symbol agent_ui::completion_provider",
3125 0,
3126 &supported_modes
3127 ),
3128 Some(MentionCompletion {
3129 source_range: 6..43,
3130 mode: Some(PromptContextType::Symbol),
3131 argument: Some("agent_ui::completion_provider".to_string()),
3132 })
3133 );
3134
3135 assert_eq!(
3136 MentionCompletion::try_parse(
3137 "Lorem @diagnostics",
3138 0,
3139 &supported_modes_with_diagnostics
3140 ),
3141 Some(MentionCompletion {
3142 source_range: 6..18,
3143 mode: Some(PromptContextType::Diagnostics),
3144 argument: None,
3145 })
3146 );
3147
3148 // Disallowed non-file mentions
3149 assert_eq!(
3150 MentionCompletion::try_parse("Lorem @symbol main", 0, &[PromptContextType::File]),
3151 None
3152 );
3153
3154 assert_eq!(
3155 MentionCompletion::try_parse("Lorem@symbol", 0, &supported_modes),
3156 None,
3157 "Should not parse mention inside word"
3158 );
3159
3160 assert_eq!(
3161 MentionCompletion::try_parse("Lorem @ file", 0, &supported_modes),
3162 None,
3163 "Should not parse with a space after @"
3164 );
3165
3166 assert_eq!(
3167 MentionCompletion::try_parse("@ file", 0, &supported_modes),
3168 None,
3169 "Should not parse with a space after @ at the start of the line"
3170 );
3171
3172 assert_eq!(
3173 MentionCompletion::try_parse(
3174 "@fetch https://www.npmjs.com/package/@matterport/sdk",
3175 0,
3176 &[PromptContextType::Fetch]
3177 ),
3178 Some(MentionCompletion {
3179 source_range: 0..52,
3180 mode: Some(PromptContextType::Fetch),
3181 argument: Some("https://www.npmjs.com/package/@matterport/sdk".to_string()),
3182 }),
3183 "Should handle URLs with @ in the path"
3184 );
3185
3186 assert_eq!(
3187 MentionCompletion::try_parse(
3188 "@fetch https://example.com/@org/@repo/file",
3189 0,
3190 &[PromptContextType::Fetch]
3191 ),
3192 Some(MentionCompletion {
3193 source_range: 0..42,
3194 mode: Some(PromptContextType::Fetch),
3195 argument: Some("https://example.com/@org/@repo/file".to_string()),
3196 }),
3197 "Should handle URLs with multiple @ characters"
3198 );
3199
3200 assert_eq!(
3201 MentionCompletion::try_parse(
3202 "@fetch https://example.com/@",
3203 0,
3204 &[PromptContextType::Fetch]
3205 ),
3206 Some(MentionCompletion {
3207 source_range: 0..28,
3208 mode: Some(PromptContextType::Fetch),
3209 argument: Some("https://example.com/@".to_string()),
3210 }),
3211 "Should parse URL ending with @ (even if URL is incomplete)"
3212 );
3213
3214 // Bracketed mentions: opening brackets count as a boundary before '@' so
3215 // typing `(@`, `[@`, or `{@` still opens the completion menu.
3216
3217 assert_eq!(
3218 MentionCompletion::try_parse("(@", 0, &supported_modes),
3219 Some(MentionCompletion {
3220 source_range: 1..2,
3221 mode: None,
3222 argument: None,
3223 }),
3224 "Should parse mention immediately after '('"
3225 );
3226
3227 assert_eq!(
3228 MentionCompletion::try_parse("[@", 0, &supported_modes),
3229 Some(MentionCompletion {
3230 source_range: 1..2,
3231 mode: None,
3232 argument: None,
3233 }),
3234 "Should parse mention immediately after '['"
3235 );
3236
3237 assert_eq!(
3238 MentionCompletion::try_parse("{@", 0, &supported_modes),
3239 Some(MentionCompletion {
3240 source_range: 1..2,
3241 mode: None,
3242 argument: None,
3243 }),
3244 "Should parse mention immediately after '{{'"
3245 );
3246 }
3247
3248 #[gpui::test]
3249 async fn test_filter_sessions_by_query(cx: &mut TestAppContext) {
3250 let alpha = SessionMatch {
3251 session_id: acp::SessionId::new("session-alpha"),
3252 title: "Alpha Session".into(),
3253 };
3254 let beta = SessionMatch {
3255 session_id: acp::SessionId::new("session-beta"),
3256 title: "Beta Session".into(),
3257 };
3258
3259 let sessions = vec![alpha.clone(), beta];
3260
3261 let task = {
3262 let mut app = cx.app.borrow_mut();
3263 filter_sessions_by_query(
3264 "Alpha".into(),
3265 Arc::new(AtomicBool::default()),
3266 sessions,
3267 &mut app,
3268 )
3269 };
3270
3271 let results = task.await;
3272 assert_eq!(results.len(), 1);
3273 assert_eq!(results[0].session_id, alpha.session_id);
3274 }
3275
3276 #[gpui::test]
3277 async fn test_search_files_path_distance_ordering(cx: &mut TestAppContext) {
3278 use project::Project;
3279 use serde_json::json;
3280 use util::{path, rel_path::rel_path};
3281 use workspace::{AppState, MultiWorkspace};
3282
3283 let app_state = cx.update(|cx| {
3284 let state = AppState::test(cx);
3285 theme_settings::init(theme::LoadThemes::JustBase, cx);
3286 editor::init(cx);
3287 state
3288 });
3289
3290 app_state
3291 .fs
3292 .as_fake()
3293 .insert_tree(
3294 path!("/root"),
3295 json!({
3296 "dir1": { "a.txt": "" },
3297 "dir2": {
3298 "a.txt": "",
3299 "b.txt": ""
3300 }
3301 }),
3302 )
3303 .await;
3304
3305 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
3306 let (multi_workspace, cx) =
3307 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3308 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3309
3310 let worktree_id = cx.read(|cx| {
3311 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
3312 assert_eq!(worktrees.len(), 1);
3313 worktrees[0].read(cx).id()
3314 });
3315
3316 // Open a file in dir2 to create navigation history.
3317 // When searching for "a.txt", dir2/a.txt should be sorted first because
3318 // it is closer to the most recently opened file (dir2/b.txt).
3319 let b_path = ProjectPath {
3320 worktree_id,
3321 path: rel_path("dir2/b.txt").into(),
3322 };
3323 workspace
3324 .update_in(cx, |workspace, window, cx| {
3325 workspace.open_path(b_path, None, true, window, cx)
3326 })
3327 .await
3328 .unwrap();
3329
3330 let results = cx
3331 .update(|_window, cx| {
3332 search_files(
3333 "a.txt".into(),
3334 Arc::new(AtomicBool::default()),
3335 &workspace,
3336 cx,
3337 )
3338 })
3339 .await;
3340
3341 assert_eq!(results.len(), 2, "expected 2 matching files");
3342 assert_eq!(
3343 results[0].mat.path.as_ref(),
3344 rel_path("dir2/a.txt"),
3345 "dir2/a.txt should be first because it's closer to the recently opened dir2/b.txt"
3346 );
3347 assert_eq!(
3348 results[1].mat.path.as_ref(),
3349 rel_path("dir1/a.txt"),
3350 "dir1/a.txt should be second"
3351 );
3352 }
3353
3354 #[gpui::test]
3355 async fn test_source_read_selection_editor_whole_line(cx: &mut TestAppContext) {
3356 use editor::Editor;
3357 use project::Project;
3358 use serde_json::json;
3359 use text::ToOffset as _;
3360 use util::path;
3361 use workspace::{AppState, MultiWorkspace};
3362
3363 crate::conversation_view::tests::init_test(cx);
3364
3365 let app_state = cx.update(AppState::test);
3366
3367 app_state
3368 .fs
3369 .as_fake()
3370 .insert_tree(path!("/root"), json!({ "a.txt": "" }))
3371 .await;
3372
3373 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
3374 let (multi_workspace, cx) =
3375 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
3376 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3377
3378 let buffer = cx.new(|cx| language::Buffer::local("abc\ndef\nghi", cx));
3379 let editor =
3380 cx.new_window_entity(|window, cx| Editor::for_buffer(buffer.clone(), None, window, cx));
3381
3382 editor.update_in(cx, |editor, window, cx| {
3383 editor.change_selections(Default::default(), window, cx, |selections| {
3384 selections.select_ranges([text::Point::new(1, 1)..text::Point::new(1, 1)]);
3385 });
3386 });
3387
3388 let source = AgentContextSource::Editor(editor.downgrade());
3389
3390 workspace.update(cx, |workspace, cx| {
3391 let selection = source
3392 .read_selection(workspace, true, cx)
3393 .expect("editor source with cursor on a line should yield a selection");
3394 assert!(
3395 matches!(selection, AgentContextSelection::Editor(_)),
3396 "expected Editor variant"
3397 );
3398 if let AgentContextSelection::Editor(ranges) = selection {
3399 assert_eq!(
3400 ranges.len(),
3401 1,
3402 "expected exactly one range for whole-line fallback"
3403 );
3404 let (range_buffer, range) = &ranges[0];
3405 let snapshot = range_buffer.read(cx).snapshot();
3406 let start_offset = range.start.to_offset(&snapshot);
3407 let end_offset = range.end.to_offset(&snapshot);
3408 assert_eq!(
3409 &snapshot.text()[start_offset..end_offset],
3410 "def",
3411 "whole-line fallback should capture the current row"
3412 );
3413 }
3414
3415 // With include_current_line = false and no non-empty selection, the
3416 // fallback is suppressed and read_selection should return None.
3417 assert!(source.read_selection(workspace, false, cx).is_none());
3418 });
3419 }
3420}
3421