Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T01:50:49.244Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

completion_provider.rs

3421 lines · 126.2 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.