1use std::cmp::Reverse;
2use std::ops::Range;
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::sync::atomic::AtomicBool;
6
7use acp_thread::MentionUri;
8use agent::{HistoryEntry, HistoryStore};
9use anyhow::Result;
10use editor::{CompletionProvider, Editor, ExcerptId};
11use fuzzy::{PathMatch, StringMatch, StringMatchCandidate};
12use gpui::{App, Entity, Task, WeakEntity};
13use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId};
14use lsp::CompletionContext;
15use ordered_float::OrderedFloat;
16use project::lsp_store::{CompletionDocumentation, SymbolLocation};
17use project::{
18 Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse,
19 PathMatchCandidateSet, Project, ProjectPath, Symbol, WorktreeId,
20};
21use prompt_store::{PromptId, PromptStore, UserPromptId};
22use rope::Point;
23use text::{Anchor, ToPoint as _};
24use ui::prelude::*;
25use util::ResultExt as _;
26use util::paths::PathStyle;
27use util::rel_path::RelPath;
28use workspace::Workspace;
29
30use crate::AgentPanel;
31use crate::mention_set::MentionSet;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub(crate) enum PromptContextEntry {
35 Mode(PromptContextType),
36 Action(PromptContextAction),
37}
38
39impl PromptContextEntry {
40 pub fn keyword(&self) -> &'static str {
41 match self {
42 Self::Mode(mode) => mode.keyword(),
43 Self::Action(action) => action.keyword(),
44 }
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub(crate) enum PromptContextType {
50 File,
51 Symbol,
52 Fetch,
53 Thread,
54 Rules,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub(crate) enum PromptContextAction {
59 AddSelections,
60}
61
62impl PromptContextAction {
63 pub fn keyword(&self) -> &'static str {
64 match self {
65 Self::AddSelections => "selection",
66 }
67 }
68
69 pub fn label(&self) -> &'static str {
70 match self {
71 Self::AddSelections => "Selection",
72 }
73 }
74
75 pub fn icon(&self) -> IconName {
76 match self {
77 Self::AddSelections => IconName::Reader,
78 }
79 }
80}
81
82impl TryFrom<&str> for PromptContextType {
83 type Error = String;
84
85 fn try_from(value: &str) -> Result<Self, Self::Error> {
86 match value {
87 "file" => Ok(Self::File),
88 "symbol" => Ok(Self::Symbol),
89 "fetch" => Ok(Self::Fetch),
90 "thread" => Ok(Self::Thread),
91 "rule" => Ok(Self::Rules),
92 _ => Err(format!("Invalid context picker mode: {}", value)),
93 }
94 }
95}
96
97impl PromptContextType {
98 pub fn keyword(&self) -> &'static str {
99 match self {
100 Self::File => "file",
101 Self::Symbol => "symbol",
102 Self::Fetch => "fetch",
103 Self::Thread => "thread",
104 Self::Rules => "rule",
105 }
106 }
107
108 pub fn label(&self) -> &'static str {
109 match self {
110 Self::File => "Files & Directories",
111 Self::Symbol => "Symbols",
112 Self::Fetch => "Fetch",
113 Self::Thread => "Threads",
114 Self::Rules => "Rules",
115 }
116 }
117
118 pub fn icon(&self) -> IconName {
119 match self {
120 Self::File => IconName::File,
121 Self::Symbol => IconName::Code,
122 Self::Fetch => IconName::ToolWeb,
123 Self::Thread => IconName::Thread,
124 Self::Rules => IconName::Reader,
125 }
126 }
127}
128
129pub(crate) enum Match {
130 File(FileMatch),
131 Symbol(SymbolMatch),
132 Thread(HistoryEntry),
133 RecentThread(HistoryEntry),
134 Fetch(SharedString),
135 Rules(RulesContextEntry),
136 Entry(EntryMatch),
137}
138
139impl Match {
140 pub fn score(&self) -> f64 {
141 match self {
142 Match::File(file) => file.mat.score,
143 Match::Entry(mode) => mode.mat.as_ref().map(|mat| mat.score).unwrap_or(1.),
144 Match::Thread(_) => 1.,
145 Match::RecentThread(_) => 1.,
146 Match::Symbol(_) => 1.,
147 Match::Rules(_) => 1.,
148 Match::Fetch(_) => 1.,
149 }
150 }
151}
152
153pub struct EntryMatch {
154 mat: Option<StringMatch>,
155 entry: PromptContextEntry,
156}
157
158#[derive(Debug, Clone)]
159pub struct RulesContextEntry {
160 pub prompt_id: UserPromptId,
161 pub title: SharedString,
162}
163
164#[derive(Debug, Clone)]
165pub struct AvailableCommand {
166 pub name: Arc<str>,
167 pub description: Arc<str>,
168 pub requires_argument: bool,
169}
170
171pub trait PromptCompletionProviderDelegate: Send + Sync + 'static {
172 fn supports_context(&self, mode: PromptContextType, cx: &App) -> bool {
173 self.supported_modes(cx).contains(&mode)
174 }
175 fn supported_modes(&self, cx: &App) -> Vec<PromptContextType>;
176 fn supports_images(&self, cx: &App) -> bool;
177
178 fn available_commands(&self, cx: &App) -> Vec<AvailableCommand>;
179 fn confirm_command(&self, cx: &mut App);
180}
181
182pub struct PromptCompletionProvider<T: PromptCompletionProviderDelegate> {
183 source: Arc<T>,
184 editor: WeakEntity<Editor>,
185 mention_set: Entity<MentionSet>,
186 history_store: Entity<HistoryStore>,
187 prompt_store: Option<Entity<PromptStore>>,
188 workspace: WeakEntity<Workspace>,
189}
190
191impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
192 pub fn new(
193 source: T,
194 editor: WeakEntity<Editor>,
195 mention_set: Entity<MentionSet>,
196 history_store: Entity<HistoryStore>,
197 prompt_store: Option<Entity<PromptStore>>,
198 workspace: WeakEntity<Workspace>,
199 ) -> Self {
200 Self {
201 source: Arc::new(source),
202 editor,
203 mention_set,
204 workspace,
205 history_store,
206 prompt_store,
207 }
208 }
209
210 fn completion_for_entry(
211 entry: PromptContextEntry,
212 source_range: Range<Anchor>,
213 editor: WeakEntity<Editor>,
214 mention_set: WeakEntity<MentionSet>,
215 workspace: &Entity<Workspace>,
216 cx: &mut App,
217 ) -> Option<Completion> {
218 match entry {
219 PromptContextEntry::Mode(mode) => Some(Completion {
220 replace_range: source_range,
221 new_text: format!("@{} ", mode.keyword()),
222 label: CodeLabel::plain(mode.label().to_string(), None),
223 icon_path: Some(mode.icon().path().into()),
224 documentation: None,
225 source: project::CompletionSource::Custom,
226 match_start: None,
227 snippet_deduplication_key: None,
228 insert_text_mode: None,
229 // This ensures that when a user accepts this completion, the
230 // completion menu will still be shown after "@category " is
231 // inserted
232 confirm: Some(Arc::new(|_, _, _| true)),
233 }),
234 PromptContextEntry::Action(action) => Self::completion_for_action(
235 action,
236 source_range,
237 editor,
238 mention_set,
239 workspace,
240 cx,
241 ),
242 }
243 }
244
245 fn completion_for_thread(
246 thread_entry: HistoryEntry,
247 source_range: Range<Anchor>,
248 recent: bool,
249 source: Arc<T>,
250 editor: WeakEntity<Editor>,
251 mention_set: WeakEntity<MentionSet>,
252 workspace: Entity<Workspace>,
253 cx: &mut App,
254 ) -> Completion {
255 let uri = thread_entry.mention_uri();
256
257 let icon_for_completion = if recent {
258 IconName::HistoryRerun.path().into()
259 } else {
260 uri.icon_path(cx)
261 };
262
263 let new_text = format!("{} ", uri.as_link());
264
265 let new_text_len = new_text.len();
266 Completion {
267 replace_range: source_range.clone(),
268 new_text,
269 label: CodeLabel::plain(thread_entry.title().to_string(), None),
270 documentation: None,
271 insert_text_mode: None,
272 source: project::CompletionSource::Custom,
273 match_start: None,
274 snippet_deduplication_key: None,
275 icon_path: Some(icon_for_completion),
276 confirm: Some(confirm_completion_callback(
277 thread_entry.title().clone(),
278 source_range.start,
279 new_text_len - 1,
280 uri,
281 source,
282 editor,
283 mention_set,
284 workspace,
285 )),
286 }
287 }
288
289 fn completion_for_rules(
290 rule: RulesContextEntry,
291 source_range: Range<Anchor>,
292 source: Arc<T>,
293 editor: WeakEntity<Editor>,
294 mention_set: WeakEntity<MentionSet>,
295 workspace: Entity<Workspace>,
296 cx: &mut App,
297 ) -> Completion {
298 let uri = MentionUri::Rule {
299 id: rule.prompt_id.into(),
300 name: rule.title.to_string(),
301 };
302 let new_text = format!("{} ", uri.as_link());
303 let new_text_len = new_text.len();
304 let icon_path = uri.icon_path(cx);
305 Completion {
306 replace_range: source_range.clone(),
307 new_text,
308 label: CodeLabel::plain(rule.title.to_string(), None),
309 documentation: None,
310 insert_text_mode: None,
311 source: project::CompletionSource::Custom,
312 match_start: None,
313 snippet_deduplication_key: None,
314 icon_path: Some(icon_path),
315 confirm: Some(confirm_completion_callback(
316 rule.title,
317 source_range.start,
318 new_text_len - 1,
319 uri,
320 source,
321 editor,
322 mention_set,
323 workspace,
324 )),
325 }
326 }
327
328 pub(crate) fn completion_for_path(
329 project_path: ProjectPath,
330 path_prefix: &RelPath,
331 is_recent: bool,
332 is_directory: bool,
333 source_range: Range<Anchor>,
334 source: Arc<T>,
335 editor: WeakEntity<Editor>,
336 mention_set: WeakEntity<MentionSet>,
337 workspace: Entity<Workspace>,
338 project: Entity<Project>,
339 cx: &mut App,
340 ) -> Option<Completion> {
341 let path_style = project.read(cx).path_style(cx);
342 let (file_name, directory) =
343 extract_file_name_and_directory(&project_path.path, path_prefix, path_style);
344
345 let label =
346 build_code_label_for_path(&file_name, directory.as_ref().map(|s| s.as_ref()), None, cx);
347
348 let abs_path = project.read(cx).absolute_path(&project_path, cx)?;
349
350 let uri = if is_directory {
351 MentionUri::Directory { abs_path }
352 } else {
353 MentionUri::File { abs_path }
354 };
355
356 let crease_icon_path = uri.icon_path(cx);
357 let completion_icon_path = if is_recent {
358 IconName::HistoryRerun.path().into()
359 } else {
360 crease_icon_path
361 };
362
363 let new_text = format!("{} ", uri.as_link());
364 let new_text_len = new_text.len();
365 Some(Completion {
366 replace_range: source_range.clone(),
367 new_text,
368 label,
369 documentation: None,
370 source: project::CompletionSource::Custom,
371 icon_path: Some(completion_icon_path),
372 match_start: None,
373 snippet_deduplication_key: None,
374 insert_text_mode: None,
375 confirm: Some(confirm_completion_callback(
376 file_name,
377 source_range.start,
378 new_text_len - 1,
379 uri,
380 source,
381 editor,
382 mention_set,
383 workspace,
384 )),
385 })
386 }
387
388 fn completion_for_symbol(
389 symbol: Symbol,
390 source_range: Range<Anchor>,
391 source: Arc<T>,
392 editor: WeakEntity<Editor>,
393 mention_set: WeakEntity<MentionSet>,
394 workspace: Entity<Workspace>,
395 cx: &mut App,
396 ) -> Option<Completion> {
397 let project = workspace.read(cx).project().clone();
398
399 let (abs_path, file_name) = match &symbol.path {
400 SymbolLocation::InProject(project_path) => (
401 project.read(cx).absolute_path(&project_path, cx)?,
402 project_path.path.file_name()?.to_string().into(),
403 ),
404 SymbolLocation::OutsideProject {
405 abs_path,
406 signature: _,
407 } => (
408 PathBuf::from(abs_path.as_ref()),
409 abs_path.file_name().map(|f| f.to_string_lossy())?,
410 ),
411 };
412
413 let label = build_code_label_for_path(
414 &symbol.name,
415 Some(&file_name),
416 Some(symbol.range.start.0.row + 1),
417 cx,
418 );
419
420 let uri = MentionUri::Symbol {
421 abs_path,
422 name: symbol.name.clone(),
423 line_range: symbol.range.start.0.row..=symbol.range.end.0.row,
424 };
425 let new_text = format!("{} ", uri.as_link());
426 let new_text_len = new_text.len();
427 let icon_path = uri.icon_path(cx);
428 Some(Completion {
429 replace_range: source_range.clone(),
430 new_text,
431 label,
432 documentation: None,
433 source: project::CompletionSource::Custom,
434 icon_path: Some(icon_path),
435 match_start: None,
436 snippet_deduplication_key: None,
437 insert_text_mode: None,
438 confirm: Some(confirm_completion_callback(
439 symbol.name.into(),
440 source_range.start,
441 new_text_len - 1,
442 uri,
443 source,
444 editor,
445 mention_set,
446 workspace,
447 )),
448 })
449 }
450
451 fn completion_for_fetch(
452 source_range: Range<Anchor>,
453 url_to_fetch: SharedString,
454 source: Arc<T>,
455 editor: WeakEntity<Editor>,
456 mention_set: WeakEntity<MentionSet>,
457 workspace: Entity<Workspace>,
458 cx: &mut App,
459 ) -> Option<Completion> {
460 let new_text = format!("@fetch {} ", url_to_fetch);
461 let url_to_fetch = url::Url::parse(url_to_fetch.as_ref())
462 .or_else(|_| url::Url::parse(&format!("https://{url_to_fetch}")))
463 .ok()?;
464 let mention_uri = MentionUri::Fetch {
465 url: url_to_fetch.clone(),
466 };
467 let icon_path = mention_uri.icon_path(cx);
468 Some(Completion {
469 replace_range: source_range.clone(),
470 new_text: new_text.clone(),
471 label: CodeLabel::plain(url_to_fetch.to_string(), None),
472 documentation: None,
473 source: project::CompletionSource::Custom,
474 icon_path: Some(icon_path),
475 match_start: None,
476 snippet_deduplication_key: None,
477 insert_text_mode: None,
478 confirm: Some(confirm_completion_callback(
479 url_to_fetch.to_string().into(),
480 source_range.start,
481 new_text.len() - 1,
482 mention_uri,
483 source,
484 editor,
485 mention_set,
486 workspace,
487 )),
488 })
489 }
490
491 pub(crate) fn completion_for_action(
492 action: PromptContextAction,
493 source_range: Range<Anchor>,
494 editor: WeakEntity<Editor>,
495 mention_set: WeakEntity<MentionSet>,
496 workspace: &Entity<Workspace>,
497 cx: &mut App,
498 ) -> Option<Completion> {
499 let (new_text, on_action) = match action {
500 PromptContextAction::AddSelections => {
501 const PLACEHOLDER: &str = "selection ";
502 let selections = selection_ranges(workspace, cx)
503 .into_iter()
504 .enumerate()
505 .map(|(ix, (buffer, range))| {
506 (
507 buffer,
508 range,
509 (PLACEHOLDER.len() * ix)..(PLACEHOLDER.len() * (ix + 1) - 1),
510 )
511 })
512 .collect::<Vec<_>>();
513
514 let new_text: String = PLACEHOLDER.repeat(selections.len());
515
516 let callback = Arc::new({
517 let source_range = source_range.clone();
518 move |_, window: &mut Window, cx: &mut App| {
519 let editor = editor.clone();
520 let selections = selections.clone();
521 let mention_set = mention_set.clone();
522 let source_range = source_range.clone();
523 window.defer(cx, move |window, cx| {
524 if let Some(editor) = editor.upgrade() {
525 mention_set
526 .update(cx, |store, cx| {
527 store.confirm_mention_for_selection(
528 source_range,
529 selections,
530 editor,
531 window,
532 cx,
533 )
534 })
535 .ok();
536 }
537 });
538 false
539 }
540 });
541
542 (new_text, callback)
543 }
544 };
545
546 Some(Completion {
547 replace_range: source_range,
548 new_text,
549 label: CodeLabel::plain(action.label().to_string(), None),
550 icon_path: Some(action.icon().path().into()),
551 documentation: None,
552 source: project::CompletionSource::Custom,
553 match_start: None,
554 snippet_deduplication_key: None,
555 insert_text_mode: None,
556 // This ensures that when a user accepts this completion, the
557 // completion menu will still be shown after "@category " is
558 // inserted
559 confirm: Some(on_action),
560 })
561 }
562
563 fn search_slash_commands(&self, query: String, cx: &mut App) -> Task<Vec<AvailableCommand>> {
564 let commands = self.source.available_commands(cx);
565 if commands.is_empty() {
566 return Task::ready(Vec::new());
567 }
568
569 cx.spawn(async move |cx| {
570 let candidates = commands
571 .iter()
572 .enumerate()
573 .map(|(id, command)| StringMatchCandidate::new(id, &command.name))
574 .collect::<Vec<_>>();
575
576 let matches = fuzzy::match_strings(
577 &candidates,
578 &query,
579 false,
580 true,
581 100,
582 &Arc::new(AtomicBool::default()),
583 cx.background_executor().clone(),
584 )
585 .await;
586
587 matches
588 .into_iter()
589 .map(|mat| commands[mat.candidate_id].clone())
590 .collect()
591 })
592 }
593
594 fn search_mentions(
595 &self,
596 mode: Option<PromptContextType>,
597 query: String,
598 cancellation_flag: Arc<AtomicBool>,
599 cx: &mut App,
600 ) -> Task<Vec<Match>> {
601 let Some(workspace) = self.workspace.upgrade() else {
602 return Task::ready(Vec::default());
603 };
604 match mode {
605 Some(PromptContextType::File) => {
606 let search_files_task = search_files(query, cancellation_flag, &workspace, cx);
607 cx.background_spawn(async move {
608 search_files_task
609 .await
610 .into_iter()
611 .map(Match::File)
612 .collect()
613 })
614 }
615
616 Some(PromptContextType::Symbol) => {
617 let search_symbols_task = search_symbols(query, cancellation_flag, &workspace, cx);
618 cx.background_spawn(async move {
619 search_symbols_task
620 .await
621 .into_iter()
622 .map(Match::Symbol)
623 .collect()
624 })
625 }
626
627 Some(PromptContextType::Thread) => {
628 let search_threads_task =
629 search_threads(query, cancellation_flag, &self.history_store, cx);
630 cx.background_spawn(async move {
631 search_threads_task
632 .await
633 .into_iter()
634 .map(Match::Thread)
635 .collect()
636 })
637 }
638
639 Some(PromptContextType::Fetch) => {
640 if !query.is_empty() {
641 Task::ready(vec![Match::Fetch(query.into())])
642 } else {
643 Task::ready(Vec::new())
644 }
645 }
646
647 Some(PromptContextType::Rules) => {
648 if let Some(prompt_store) = self.prompt_store.as_ref() {
649 let search_rules_task =
650 search_rules(query, cancellation_flag, prompt_store, cx);
651 cx.background_spawn(async move {
652 search_rules_task
653 .await
654 .into_iter()
655 .map(Match::Rules)
656 .collect::<Vec<_>>()
657 })
658 } else {
659 Task::ready(Vec::new())
660 }
661 }
662
663 None if query.is_empty() => {
664 let mut matches = self.recent_context_picker_entries(&workspace, cx);
665
666 matches.extend(
667 self.available_context_picker_entries(&workspace, cx)
668 .into_iter()
669 .map(|mode| {
670 Match::Entry(EntryMatch {
671 entry: mode,
672 mat: None,
673 })
674 }),
675 );
676
677 Task::ready(matches)
678 }
679 None => {
680 let executor = cx.background_executor().clone();
681
682 let search_files_task =
683 search_files(query.clone(), cancellation_flag, &workspace, cx);
684
685 let entries = self.available_context_picker_entries(&workspace, cx);
686 let entry_candidates = entries
687 .iter()
688 .enumerate()
689 .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword()))
690 .collect::<Vec<_>>();
691
692 cx.background_spawn(async move {
693 let mut matches = search_files_task
694 .await
695 .into_iter()
696 .map(Match::File)
697 .collect::<Vec<_>>();
698
699 let entry_matches = fuzzy::match_strings(
700 &entry_candidates,
701 &query,
702 false,
703 true,
704 100,
705 &Arc::new(AtomicBool::default()),
706 executor,
707 )
708 .await;
709
710 matches.extend(entry_matches.into_iter().map(|mat| {
711 Match::Entry(EntryMatch {
712 entry: entries[mat.candidate_id],
713 mat: Some(mat),
714 })
715 }));
716
717 matches.sort_by(|a, b| {
718 b.score()
719 .partial_cmp(&a.score())
720 .unwrap_or(std::cmp::Ordering::Equal)
721 });
722
723 matches
724 })
725 }
726 }
727 }
728
729 fn recent_context_picker_entries(
730 &self,
731 workspace: &Entity<Workspace>,
732 cx: &mut App,
733 ) -> Vec<Match> {
734 let mut recent = Vec::with_capacity(6);
735
736 let mut mentions = self
737 .mention_set
738 .read_with(cx, |store, _cx| store.mentions());
739 let workspace = workspace.read(cx);
740 let project = workspace.project().read(cx);
741 let include_root_name = workspace.visible_worktrees(cx).count() > 1;
742
743 if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx)
744 && let Some(thread) = agent_panel.read(cx).active_agent_thread(cx)
745 {
746 let thread = thread.read(cx);
747 mentions.insert(MentionUri::Thread {
748 id: thread.session_id().clone(),
749 name: thread.title().into(),
750 });
751 }
752
753 recent.extend(
754 workspace
755 .recent_navigation_history_iter(cx)
756 .filter(|(_, abs_path)| {
757 abs_path.as_ref().is_none_or(|path| {
758 !mentions.contains(&MentionUri::File {
759 abs_path: path.clone(),
760 })
761 })
762 })
763 .take(4)
764 .filter_map(|(project_path, _)| {
765 project
766 .worktree_for_id(project_path.worktree_id, cx)
767 .map(|worktree| {
768 let path_prefix = if include_root_name {
769 worktree.read(cx).root_name().into()
770 } else {
771 RelPath::empty().into()
772 };
773 Match::File(FileMatch {
774 mat: fuzzy::PathMatch {
775 score: 1.,
776 positions: Vec::new(),
777 worktree_id: project_path.worktree_id.to_usize(),
778 path: project_path.path,
779 path_prefix,
780 is_dir: false,
781 distance_to_relative_ancestor: 0,
782 },
783 is_recent: true,
784 })
785 })
786 }),
787 );
788
789 if self.source.supports_context(PromptContextType::Thread, cx) {
790 const RECENT_COUNT: usize = 2;
791 let threads = self
792 .history_store
793 .read(cx)
794 .recently_opened_entries(cx)
795 .into_iter()
796 .filter(|thread| !mentions.contains(&thread.mention_uri()))
797 .take(RECENT_COUNT)
798 .collect::<Vec<_>>();
799
800 recent.extend(threads.into_iter().map(Match::RecentThread));
801 }
802
803 recent
804 }
805
806 fn available_context_picker_entries(
807 &self,
808 workspace: &Entity<Workspace>,
809 cx: &mut App,
810 ) -> Vec<PromptContextEntry> {
811 let mut entries = vec![
812 PromptContextEntry::Mode(PromptContextType::File),
813 PromptContextEntry::Mode(PromptContextType::Symbol),
814 ];
815
816 if self.source.supports_context(PromptContextType::Thread, cx) {
817 entries.push(PromptContextEntry::Mode(PromptContextType::Thread));
818 }
819
820 let has_selection = workspace
821 .read(cx)
822 .active_item(cx)
823 .and_then(|item| item.downcast::<Editor>())
824 .is_some_and(|editor| {
825 editor.update(cx, |editor, cx| {
826 editor.has_non_empty_selection(&editor.display_snapshot(cx))
827 })
828 });
829 if has_selection {
830 entries.push(PromptContextEntry::Action(
831 PromptContextAction::AddSelections,
832 ));
833 }
834
835 if self.prompt_store.is_some() && self.source.supports_context(PromptContextType::Rules, cx)
836 {
837 entries.push(PromptContextEntry::Mode(PromptContextType::Rules));
838 }
839
840 if self.source.supports_context(PromptContextType::Fetch, cx) {
841 entries.push(PromptContextEntry::Mode(PromptContextType::Fetch));
842 }
843
844 entries
845 }
846}
847
848impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletionProvider<T> {
849 fn completions(
850 &self,
851 _excerpt_id: ExcerptId,
852 buffer: &Entity<Buffer>,
853 buffer_position: Anchor,
854 _trigger: CompletionContext,
855 _window: &mut Window,
856 cx: &mut Context<Editor>,
857 ) -> Task<Result<Vec<CompletionResponse>>> {
858 let state = buffer.update(cx, |buffer, cx| {
859 let position = buffer_position.to_point(buffer);
860 let line_start = Point::new(position.row, 0);
861 let offset_to_line = buffer.point_to_offset(line_start);
862 let mut lines = buffer.text_for_range(line_start..position).lines();
863 let line = lines.next()?;
864 PromptCompletion::try_parse(line, offset_to_line, &self.source.supported_modes(cx))
865 });
866 let Some(state) = state else {
867 return Task::ready(Ok(Vec::new()));
868 };
869
870 let Some(workspace) = self.workspace.upgrade() else {
871 return Task::ready(Ok(Vec::new()));
872 };
873
874 let project = workspace.read(cx).project().clone();
875 let snapshot = buffer.read(cx).snapshot();
876 let source_range = snapshot.anchor_before(state.source_range().start)
877 ..snapshot.anchor_after(state.source_range().end);
878
879 let source = self.source.clone();
880 let editor = self.editor.clone();
881 let mention_set = self.mention_set.downgrade();
882 match state {
883 PromptCompletion::SlashCommand(SlashCommandCompletion {
884 command, argument, ..
885 }) => {
886 let search_task = self.search_slash_commands(command.unwrap_or_default(), cx);
887 cx.background_spawn(async move {
888 let completions = search_task
889 .await
890 .into_iter()
891 .map(|command| {
892 let new_text = if let Some(argument) = argument.as_ref() {
893 format!("/{} {}", command.name, argument)
894 } else {
895 format!("/{} ", command.name)
896 };
897
898 let is_missing_argument =
899 command.requires_argument && argument.is_none();
900 Completion {
901 replace_range: source_range.clone(),
902 new_text,
903 label: CodeLabel::plain(command.name.to_string(), None),
904 documentation: Some(CompletionDocumentation::MultiLinePlainText(
905 command.description.into(),
906 )),
907 source: project::CompletionSource::Custom,
908 icon_path: None,
909 match_start: None,
910 snippet_deduplication_key: None,
911 insert_text_mode: None,
912 confirm: Some(Arc::new({
913 let source = source.clone();
914 move |intent, _window, cx| {
915 if !is_missing_argument {
916 cx.defer({
917 let source = source.clone();
918 move |cx| match intent {
919 CompletionIntent::Complete
920 | CompletionIntent::CompleteWithInsert
921 | CompletionIntent::CompleteWithReplace => {
922 source.confirm_command(cx);
923 }
924 CompletionIntent::Compose => {}
925 }
926 });
927 }
928 false
929 }
930 })),
931 }
932 })
933 .collect();
934
935 Ok(vec![CompletionResponse {
936 completions,
937 display_options: CompletionDisplayOptions {
938 dynamic_width: true,
939 },
940 // Since this does its own filtering (see `filter_completions()` returns false),
941 // there is no benefit to computing whether this set of completions is incomplete.
942 is_incomplete: true,
943 }])
944 })
945 }
946 PromptCompletion::Mention(MentionCompletion { mode, argument, .. }) => {
947 let query = argument.unwrap_or_default();
948 let search_task =
949 self.search_mentions(mode, query, Arc::<AtomicBool>::default(), cx);
950
951 cx.spawn(async move |_, cx| {
952 let matches = search_task.await;
953
954 let completions = cx.update(|cx| {
955 matches
956 .into_iter()
957 .filter_map(|mat| match mat {
958 Match::File(FileMatch { mat, is_recent }) => {
959 let project_path = ProjectPath {
960 worktree_id: WorktreeId::from_usize(mat.worktree_id),
961 path: mat.path.clone(),
962 };
963
964 // If path is empty, this means we're matching with the root directory itself
965 // so we use the path_prefix as the name
966 let path_prefix = if mat.path.is_empty() {
967 project
968 .read(cx)
969 .worktree_for_id(project_path.worktree_id, cx)
970 .map(|wt| wt.read(cx).root_name().into())
971 .unwrap_or_else(|| mat.path_prefix.clone())
972 } else {
973 mat.path_prefix.clone()
974 };
975
976 Self::completion_for_path(
977 project_path,
978 &path_prefix,
979 is_recent,
980 mat.is_dir,
981 source_range.clone(),
982 source.clone(),
983 editor.clone(),
984 mention_set.clone(),
985 workspace.clone(),
986 project.clone(),
987 cx,
988 )
989 }
990
991 Match::Symbol(SymbolMatch { symbol, .. }) => {
992 Self::completion_for_symbol(
993 symbol,
994 source_range.clone(),
995 source.clone(),
996 editor.clone(),
997 mention_set.clone(),
998 workspace.clone(),
999 cx,
1000 )
1001 }
1002
1003 Match::Thread(thread) => Some(Self::completion_for_thread(
1004 thread,
1005 source_range.clone(),
1006 false,
1007 source.clone(),
1008 editor.clone(),
1009 mention_set.clone(),
1010 workspace.clone(),
1011 cx,
1012 )),
1013
1014 Match::RecentThread(thread) => Some(Self::completion_for_thread(
1015 thread,
1016 source_range.clone(),
1017 true,
1018 source.clone(),
1019 editor.clone(),
1020 mention_set.clone(),
1021 workspace.clone(),
1022 cx,
1023 )),
1024
1025 Match::Rules(user_rules) => Some(Self::completion_for_rules(
1026 user_rules,
1027 source_range.clone(),
1028 source.clone(),
1029 editor.clone(),
1030 mention_set.clone(),
1031 workspace.clone(),
1032 cx,
1033 )),
1034
1035 Match::Fetch(url) => Self::completion_for_fetch(
1036 source_range.clone(),
1037 url,
1038 source.clone(),
1039 editor.clone(),
1040 mention_set.clone(),
1041 workspace.clone(),
1042 cx,
1043 ),
1044
1045 Match::Entry(EntryMatch { entry, .. }) => {
1046 Self::completion_for_entry(
1047 entry,
1048 source_range.clone(),
1049 editor.clone(),
1050 mention_set.clone(),
1051 &workspace,
1052 cx,
1053 )
1054 }
1055 })
1056 .collect()
1057 })?;
1058
1059 Ok(vec![CompletionResponse {
1060 completions,
1061 display_options: CompletionDisplayOptions {
1062 dynamic_width: true,
1063 },
1064 // Since this does its own filtering (see `filter_completions()` returns false),
1065 // there is no benefit to computing whether this set of completions is incomplete.
1066 is_incomplete: true,
1067 }])
1068 })
1069 }
1070 }
1071 }
1072
1073 fn is_completion_trigger(
1074 &self,
1075 buffer: &Entity<language::Buffer>,
1076 position: language::Anchor,
1077 _text: &str,
1078 _trigger_in_words: bool,
1079 _menu_is_open: bool,
1080 cx: &mut Context<Editor>,
1081 ) -> bool {
1082 let buffer = buffer.read(cx);
1083 let position = position.to_point(buffer);
1084 let line_start = Point::new(position.row, 0);
1085 let offset_to_line = buffer.point_to_offset(line_start);
1086 let mut lines = buffer.text_for_range(line_start..position).lines();
1087 if let Some(line) = lines.next() {
1088 PromptCompletion::try_parse(line, offset_to_line, &self.source.supported_modes(cx))
1089 .filter(|completion| {
1090 // Right now we don't support completing arguments of slash commands
1091 let is_slash_command_with_argument = matches!(
1092 completion,
1093 PromptCompletion::SlashCommand(SlashCommandCompletion {
1094 argument: Some(_),
1095 ..
1096 })
1097 );
1098 !is_slash_command_with_argument
1099 })
1100 .map(|completion| {
1101 completion.source_range().start <= offset_to_line + position.column as usize
1102 && completion.source_range().end
1103 >= offset_to_line + position.column as usize
1104 })
1105 .unwrap_or(false)
1106 } else {
1107 false
1108 }
1109 }
1110
1111 fn sort_completions(&self) -> bool {
1112 false
1113 }
1114
1115 fn filter_completions(&self) -> bool {
1116 false
1117 }
1118}
1119
1120fn confirm_completion_callback<T: PromptCompletionProviderDelegate>(
1121 crease_text: SharedString,
1122 start: Anchor,
1123 content_len: usize,
1124 mention_uri: MentionUri,
1125 source: Arc<T>,
1126 editor: WeakEntity<Editor>,
1127 mention_set: WeakEntity<MentionSet>,
1128 workspace: Entity<Workspace>,
1129) -> Arc<dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync> {
1130 Arc::new(move |_, window, cx| {
1131 let source = source.clone();
1132 let editor = editor.clone();
1133 let mention_set = mention_set.clone();
1134 let crease_text = crease_text.clone();
1135 let mention_uri = mention_uri.clone();
1136 let workspace = workspace.clone();
1137 window.defer(cx, move |window, cx| {
1138 if let Some(editor) = editor.upgrade() {
1139 mention_set
1140 .clone()
1141 .update(cx, |mention_set, cx| {
1142 mention_set
1143 .confirm_mention_completion(
1144 crease_text,
1145 start,
1146 content_len,
1147 mention_uri,
1148 source.supports_images(cx),
1149 editor,
1150 &workspace,
1151 window,
1152 cx,
1153 )
1154 .detach();
1155 })
1156 .ok();
1157 }
1158 });
1159 false
1160 })
1161}
1162
1163#[derive(Debug, PartialEq)]
1164enum PromptCompletion {
1165 SlashCommand(SlashCommandCompletion),
1166 Mention(MentionCompletion),
1167}
1168
1169impl PromptCompletion {
1170 fn source_range(&self) -> Range<usize> {
1171 match self {
1172 Self::SlashCommand(completion) => completion.source_range.clone(),
1173 Self::Mention(completion) => completion.source_range.clone(),
1174 }
1175 }
1176
1177 fn try_parse(
1178 line: &str,
1179 offset_to_line: usize,
1180 supported_modes: &[PromptContextType],
1181 ) -> Option<Self> {
1182 if line.contains('@') {
1183 if let Some(mention) =
1184 MentionCompletion::try_parse(line, offset_to_line, supported_modes)
1185 {
1186 return Some(Self::Mention(mention));
1187 }
1188 }
1189 SlashCommandCompletion::try_parse(line, offset_to_line).map(Self::SlashCommand)
1190 }
1191}
1192
1193#[derive(Debug, Default, PartialEq)]
1194pub struct SlashCommandCompletion {
1195 pub source_range: Range<usize>,
1196 pub command: Option<String>,
1197 pub argument: Option<String>,
1198}
1199
1200impl SlashCommandCompletion {
1201 pub fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
1202 // If we decide to support commands that are not at the beginning of the prompt, we can remove this check
1203 if !line.starts_with('/') || offset_to_line != 0 {
1204 return None;
1205 }
1206
1207 let (prefix, last_command) = line.rsplit_once('/')?;
1208 if prefix.chars().last().is_some_and(|c| !c.is_whitespace())
1209 || last_command.starts_with(char::is_whitespace)
1210 {
1211 return None;
1212 }
1213
1214 let mut argument = None;
1215 let mut command = None;
1216 if let Some((command_text, args)) = last_command.split_once(char::is_whitespace) {
1217 if !args.is_empty() {
1218 argument = Some(args.trim_end().to_string());
1219 }
1220 command = Some(command_text.to_string());
1221 } else if !last_command.is_empty() {
1222 command = Some(last_command.to_string());
1223 };
1224
1225 Some(Self {
1226 source_range: prefix.len() + offset_to_line
1227 ..line
1228 .rfind(|c: char| !c.is_whitespace())
1229 .unwrap_or_else(|| line.len())
1230 + 1
1231 + offset_to_line,
1232 command,
1233 argument,
1234 })
1235 }
1236}
1237
1238#[derive(Debug, Default, PartialEq)]
1239struct MentionCompletion {
1240 source_range: Range<usize>,
1241 mode: Option<PromptContextType>,
1242 argument: Option<String>,
1243}
1244
1245impl MentionCompletion {
1246 fn try_parse(
1247 line: &str,
1248 offset_to_line: usize,
1249 supported_modes: &[PromptContextType],
1250 ) -> Option<Self> {
1251 let last_mention_start = line.rfind('@')?;
1252
1253 // No whitespace immediately after '@'
1254 if line[last_mention_start + 1..]
1255 .chars()
1256 .next()
1257 .is_some_and(|c| c.is_whitespace())
1258 {
1259 return None;
1260 }
1261
1262 // Must be a word boundary before '@'
1263 if last_mention_start > 0
1264 && line[..last_mention_start]
1265 .chars()
1266 .last()
1267 .is_some_and(|c| !c.is_whitespace())
1268 {
1269 return None;
1270 }
1271
1272 let rest_of_line = &line[last_mention_start + 1..];
1273
1274 let mut mode = None;
1275 let mut argument = None;
1276
1277 let mut parts = rest_of_line.split_whitespace();
1278 let mut end = last_mention_start + 1;
1279
1280 if let Some(mode_text) = parts.next() {
1281 // Safe since we check no leading whitespace above
1282 end += mode_text.len();
1283
1284 if let Some(parsed_mode) = PromptContextType::try_from(mode_text).ok()
1285 && supported_modes.contains(&parsed_mode)
1286 {
1287 mode = Some(parsed_mode);
1288 } else {
1289 argument = Some(mode_text.to_string());
1290 }
1291 match rest_of_line[mode_text.len()..].find(|c: char| !c.is_whitespace()) {
1292 Some(whitespace_count) => {
1293 if let Some(argument_text) = parts.next() {
1294 // If mode wasn't recognized but we have an argument, don't suggest completions
1295 // (e.g. '@something word')
1296 if mode.is_none() && !argument_text.is_empty() {
1297 return None;
1298 }
1299
1300 argument = Some(argument_text.to_string());
1301 end += whitespace_count + argument_text.len();
1302 }
1303 }
1304 None => {
1305 // Rest of line is entirely whitespace
1306 end += rest_of_line.len() - mode_text.len();
1307 }
1308 }
1309 }
1310
1311 Some(Self {
1312 source_range: last_mention_start + offset_to_line..end + offset_to_line,
1313 mode,
1314 argument,
1315 })
1316 }
1317}
1318
1319pub(crate) fn search_files(
1320 query: String,
1321 cancellation_flag: Arc<AtomicBool>,
1322 workspace: &Entity<Workspace>,
1323 cx: &App,
1324) -> Task<Vec<FileMatch>> {
1325 if query.is_empty() {
1326 let workspace = workspace.read(cx);
1327 let project = workspace.project().read(cx);
1328 let visible_worktrees = workspace.visible_worktrees(cx).collect::<Vec<_>>();
1329 let include_root_name = visible_worktrees.len() > 1;
1330
1331 let recent_matches = workspace
1332 .recent_navigation_history(Some(10), cx)
1333 .into_iter()
1334 .map(|(project_path, _)| {
1335 let path_prefix = if include_root_name {
1336 project
1337 .worktree_for_id(project_path.worktree_id, cx)
1338 .map(|wt| wt.read(cx).root_name().into())
1339 .unwrap_or_else(|| RelPath::empty().into())
1340 } else {
1341 RelPath::empty().into()
1342 };
1343
1344 FileMatch {
1345 mat: PathMatch {
1346 score: 0.,
1347 positions: Vec::new(),
1348 worktree_id: project_path.worktree_id.to_usize(),
1349 path: project_path.path,
1350 path_prefix,
1351 distance_to_relative_ancestor: 0,
1352 is_dir: false,
1353 },
1354 is_recent: true,
1355 }
1356 });
1357
1358 let file_matches = visible_worktrees.into_iter().flat_map(|worktree| {
1359 let worktree = worktree.read(cx);
1360 let path_prefix: Arc<RelPath> = if include_root_name {
1361 worktree.root_name().into()
1362 } else {
1363 RelPath::empty().into()
1364 };
1365 worktree.entries(false, 0).map(move |entry| FileMatch {
1366 mat: PathMatch {
1367 score: 0.,
1368 positions: Vec::new(),
1369 worktree_id: worktree.id().to_usize(),
1370 path: entry.path.clone(),
1371 path_prefix: path_prefix.clone(),
1372 distance_to_relative_ancestor: 0,
1373 is_dir: entry.is_dir(),
1374 },
1375 is_recent: false,
1376 })
1377 });
1378
1379 Task::ready(recent_matches.chain(file_matches).collect())
1380 } else {
1381 let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
1382 let include_root_name = worktrees.len() > 1;
1383 let candidate_sets = worktrees
1384 .into_iter()
1385 .map(|worktree| {
1386 let worktree = worktree.read(cx);
1387
1388 PathMatchCandidateSet {
1389 snapshot: worktree.snapshot(),
1390 include_ignored: worktree.root_entry().is_some_and(|entry| entry.is_ignored),
1391 include_root_name,
1392 candidates: project::Candidates::Entries,
1393 }
1394 })
1395 .collect::<Vec<_>>();
1396
1397 let executor = cx.background_executor().clone();
1398 cx.foreground_executor().spawn(async move {
1399 fuzzy::match_path_sets(
1400 candidate_sets.as_slice(),
1401 query.as_str(),
1402 &None,
1403 false,
1404 100,
1405 &cancellation_flag,
1406 executor,
1407 )
1408 .await
1409 .into_iter()
1410 .map(|mat| FileMatch {
1411 mat,
1412 is_recent: false,
1413 })
1414 .collect::<Vec<_>>()
1415 })
1416 }
1417}
1418
1419pub(crate) fn search_symbols(
1420 query: String,
1421 cancellation_flag: Arc<AtomicBool>,
1422 workspace: &Entity<Workspace>,
1423 cx: &mut App,
1424) -> Task<Vec<SymbolMatch>> {
1425 let symbols_task = workspace.update(cx, |workspace, cx| {
1426 workspace
1427 .project()
1428 .update(cx, |project, cx| project.symbols(&query, cx))
1429 });
1430 let project = workspace.read(cx).project().clone();
1431 cx.spawn(async move |cx| {
1432 let Some(symbols) = symbols_task.await.log_err() else {
1433 return Vec::new();
1434 };
1435 let Some((visible_match_candidates, external_match_candidates)): Option<(Vec<_>, Vec<_>)> =
1436 project
1437 .update(cx, |project, cx| {
1438 symbols
1439 .iter()
1440 .enumerate()
1441 .map(|(id, symbol)| {
1442 StringMatchCandidate::new(id, symbol.label.filter_text())
1443 })
1444 .partition(|candidate| match &symbols[candidate.id].path {
1445 SymbolLocation::InProject(project_path) => project
1446 .entry_for_path(project_path, cx)
1447 .is_some_and(|e| !e.is_ignored),
1448 SymbolLocation::OutsideProject { .. } => false,
1449 })
1450 })
1451 .log_err()
1452 else {
1453 return Vec::new();
1454 };
1455
1456 const MAX_MATCHES: usize = 100;
1457 let mut visible_matches = cx.background_executor().block(fuzzy::match_strings(
1458 &visible_match_candidates,
1459 &query,
1460 false,
1461 true,
1462 MAX_MATCHES,
1463 &cancellation_flag,
1464 cx.background_executor().clone(),
1465 ));
1466 let mut external_matches = cx.background_executor().block(fuzzy::match_strings(
1467 &external_match_candidates,
1468 &query,
1469 false,
1470 true,
1471 MAX_MATCHES - visible_matches.len().min(MAX_MATCHES),
1472 &cancellation_flag,
1473 cx.background_executor().clone(),
1474 ));
1475 let sort_key_for_match = |mat: &StringMatch| {
1476 let symbol = &symbols[mat.candidate_id];
1477 (Reverse(OrderedFloat(mat.score)), symbol.label.filter_text())
1478 };
1479
1480 visible_matches.sort_unstable_by_key(sort_key_for_match);
1481 external_matches.sort_unstable_by_key(sort_key_for_match);
1482 let mut matches = visible_matches;
1483 matches.append(&mut external_matches);
1484
1485 matches
1486 .into_iter()
1487 .map(|mut mat| {
1488 let symbol = symbols[mat.candidate_id].clone();
1489 let filter_start = symbol.label.filter_range.start;
1490 for position in &mut mat.positions {
1491 *position += filter_start;
1492 }
1493 SymbolMatch { symbol }
1494 })
1495 .collect()
1496 })
1497}
1498
1499pub(crate) fn search_threads(
1500 query: String,
1501 cancellation_flag: Arc<AtomicBool>,
1502 thread_store: &Entity<HistoryStore>,
1503 cx: &mut App,
1504) -> Task<Vec<HistoryEntry>> {
1505 let threads = thread_store.read(cx).entries().collect();
1506 if query.is_empty() {
1507 return Task::ready(threads);
1508 }
1509
1510 let executor = cx.background_executor().clone();
1511 cx.background_spawn(async move {
1512 let candidates = threads
1513 .iter()
1514 .enumerate()
1515 .map(|(id, thread)| StringMatchCandidate::new(id, thread.title()))
1516 .collect::<Vec<_>>();
1517 let matches = fuzzy::match_strings(
1518 &candidates,
1519 &query,
1520 false,
1521 true,
1522 100,
1523 &cancellation_flag,
1524 executor,
1525 )
1526 .await;
1527
1528 matches
1529 .into_iter()
1530 .map(|mat| threads[mat.candidate_id].clone())
1531 .collect()
1532 })
1533}
1534
1535pub(crate) fn search_rules(
1536 query: String,
1537 cancellation_flag: Arc<AtomicBool>,
1538 prompt_store: &Entity<PromptStore>,
1539 cx: &mut App,
1540) -> Task<Vec<RulesContextEntry>> {
1541 let search_task = prompt_store.read(cx).search(query, cancellation_flag, cx);
1542 cx.background_spawn(async move {
1543 search_task
1544 .await
1545 .into_iter()
1546 .flat_map(|metadata| {
1547 // Default prompts are filtered out as they are automatically included.
1548 if metadata.default {
1549 None
1550 } else {
1551 match metadata.id {
1552 PromptId::EditWorkflow => None,
1553 PromptId::User { uuid } => Some(RulesContextEntry {
1554 prompt_id: uuid,
1555 title: metadata.title?,
1556 }),
1557 }
1558 }
1559 })
1560 .collect::<Vec<_>>()
1561 })
1562}
1563
1564pub struct SymbolMatch {
1565 pub symbol: Symbol,
1566}
1567
1568pub struct FileMatch {
1569 pub mat: PathMatch,
1570 pub is_recent: bool,
1571}
1572
1573pub fn extract_file_name_and_directory(
1574 path: &RelPath,
1575 path_prefix: &RelPath,
1576 path_style: PathStyle,
1577) -> (SharedString, Option<SharedString>) {
1578 // If path is empty, this means we're matching with the root directory itself
1579 // so we use the path_prefix as the name
1580 if path.is_empty() && !path_prefix.is_empty() {
1581 return (path_prefix.display(path_style).to_string().into(), None);
1582 }
1583
1584 let full_path = path_prefix.join(path);
1585 let file_name = full_path.file_name().unwrap_or_default();
1586 let display_path = full_path.display(path_style);
1587 let (directory, file_name) = display_path.split_at(display_path.len() - file_name.len());
1588 (
1589 file_name.to_string().into(),
1590 Some(SharedString::new(directory)).filter(|dir| !dir.is_empty()),
1591 )
1592}
1593
1594fn build_code_label_for_path(
1595 file: &str,
1596 directory: Option<&str>,
1597 line_number: Option<u32>,
1598 cx: &App,
1599) -> CodeLabel {
1600 let variable_highlight_id = cx
1601 .theme()
1602 .syntax()
1603 .highlight_id("variable")
1604 .map(HighlightId);
1605 let mut label = CodeLabelBuilder::default();
1606
1607 label.push_str(file, None);
1608 label.push_str(" ", None);
1609
1610 if let Some(directory) = directory {
1611 label.push_str(directory, variable_highlight_id);
1612 }
1613 if let Some(line_number) = line_number {
1614 label.push_str(&format!(" L{}", line_number), variable_highlight_id);
1615 }
1616 label.build()
1617}
1618
1619fn selection_ranges(
1620 workspace: &Entity<Workspace>,
1621 cx: &mut App,
1622) -> Vec<(Entity<Buffer>, Range<text::Anchor>)> {
1623 let Some(editor) = workspace
1624 .read(cx)
1625 .active_item(cx)
1626 .and_then(|item| item.act_as::<Editor>(cx))
1627 else {
1628 return Vec::new();
1629 };
1630
1631 editor.update(cx, |editor, cx| {
1632 let selections = editor.selections.all_adjusted(&editor.display_snapshot(cx));
1633
1634 let buffer = editor.buffer().clone().read(cx);
1635 let snapshot = buffer.snapshot(cx);
1636
1637 selections
1638 .into_iter()
1639 .map(|s| snapshot.anchor_after(s.start)..snapshot.anchor_before(s.end))
1640 .flat_map(|range| {
1641 let (start_buffer, start) = buffer.text_anchor_for_position(range.start, cx)?;
1642 let (end_buffer, end) = buffer.text_anchor_for_position(range.end, cx)?;
1643 if start_buffer != end_buffer {
1644 return None;
1645 }
1646 Some((start_buffer, start..end))
1647 })
1648 .collect::<Vec<_>>()
1649 })
1650}
1651
1652#[cfg(test)]
1653mod tests {
1654 use super::*;
1655
1656 #[test]
1657 fn test_prompt_completion_parse() {
1658 let supported_modes = vec![PromptContextType::File, PromptContextType::Symbol];
1659
1660 assert_eq!(
1661 PromptCompletion::try_parse("/", 0, &supported_modes),
1662 Some(PromptCompletion::SlashCommand(SlashCommandCompletion {
1663 source_range: 0..1,
1664 command: None,
1665 argument: None,
1666 }))
1667 );
1668
1669 assert_eq!(
1670 PromptCompletion::try_parse("@", 0, &supported_modes),
1671 Some(PromptCompletion::Mention(MentionCompletion {
1672 source_range: 0..1,
1673 mode: None,
1674 argument: None,
1675 }))
1676 );
1677
1678 assert_eq!(
1679 PromptCompletion::try_parse("/test @file", 0, &supported_modes),
1680 Some(PromptCompletion::Mention(MentionCompletion {
1681 source_range: 6..11,
1682 mode: Some(PromptContextType::File),
1683 argument: None,
1684 }))
1685 );
1686 }
1687
1688 #[test]
1689 fn test_slash_command_completion_parse() {
1690 assert_eq!(
1691 SlashCommandCompletion::try_parse("/", 0),
1692 Some(SlashCommandCompletion {
1693 source_range: 0..1,
1694 command: None,
1695 argument: None,
1696 })
1697 );
1698
1699 assert_eq!(
1700 SlashCommandCompletion::try_parse("/help", 0),
1701 Some(SlashCommandCompletion {
1702 source_range: 0..5,
1703 command: Some("help".to_string()),
1704 argument: None,
1705 })
1706 );
1707
1708 assert_eq!(
1709 SlashCommandCompletion::try_parse("/help ", 0),
1710 Some(SlashCommandCompletion {
1711 source_range: 0..5,
1712 command: Some("help".to_string()),
1713 argument: None,
1714 })
1715 );
1716
1717 assert_eq!(
1718 SlashCommandCompletion::try_parse("/help arg1", 0),
1719 Some(SlashCommandCompletion {
1720 source_range: 0..10,
1721 command: Some("help".to_string()),
1722 argument: Some("arg1".to_string()),
1723 })
1724 );
1725
1726 assert_eq!(
1727 SlashCommandCompletion::try_parse("/help arg1 arg2", 0),
1728 Some(SlashCommandCompletion {
1729 source_range: 0..15,
1730 command: Some("help".to_string()),
1731 argument: Some("arg1 arg2".to_string()),
1732 })
1733 );
1734
1735 assert_eq!(
1736 SlashCommandCompletion::try_parse("/拿不到命令 拿不到命令 ", 0),
1737 Some(SlashCommandCompletion {
1738 source_range: 0..30,
1739 command: Some("拿不到命令".to_string()),
1740 argument: Some("拿不到命令".to_string()),
1741 })
1742 );
1743
1744 assert_eq!(SlashCommandCompletion::try_parse("Lorem Ipsum", 0), None);
1745
1746 assert_eq!(SlashCommandCompletion::try_parse("Lorem /", 0), None);
1747
1748 assert_eq!(SlashCommandCompletion::try_parse("Lorem /help", 0), None);
1749
1750 assert_eq!(SlashCommandCompletion::try_parse("Lorem/", 0), None);
1751
1752 assert_eq!(SlashCommandCompletion::try_parse("/ ", 0), None);
1753 }
1754
1755 #[test]
1756 fn test_mention_completion_parse() {
1757 let supported_modes = vec![PromptContextType::File, PromptContextType::Symbol];
1758
1759 assert_eq!(
1760 MentionCompletion::try_parse("Lorem Ipsum", 0, &supported_modes),
1761 None
1762 );
1763
1764 assert_eq!(
1765 MentionCompletion::try_parse("Lorem @", 0, &supported_modes),
1766 Some(MentionCompletion {
1767 source_range: 6..7,
1768 mode: None,
1769 argument: None,
1770 })
1771 );
1772
1773 assert_eq!(
1774 MentionCompletion::try_parse("Lorem @file", 0, &supported_modes),
1775 Some(MentionCompletion {
1776 source_range: 6..11,
1777 mode: Some(PromptContextType::File),
1778 argument: None,
1779 })
1780 );
1781
1782 assert_eq!(
1783 MentionCompletion::try_parse("Lorem @file ", 0, &supported_modes),
1784 Some(MentionCompletion {
1785 source_range: 6..12,
1786 mode: Some(PromptContextType::File),
1787 argument: None,
1788 })
1789 );
1790
1791 assert_eq!(
1792 MentionCompletion::try_parse("Lorem @file main.rs", 0, &supported_modes),
1793 Some(MentionCompletion {
1794 source_range: 6..19,
1795 mode: Some(PromptContextType::File),
1796 argument: Some("main.rs".to_string()),
1797 })
1798 );
1799
1800 assert_eq!(
1801 MentionCompletion::try_parse("Lorem @file main.rs ", 0, &supported_modes),
1802 Some(MentionCompletion {
1803 source_range: 6..19,
1804 mode: Some(PromptContextType::File),
1805 argument: Some("main.rs".to_string()),
1806 })
1807 );
1808
1809 assert_eq!(
1810 MentionCompletion::try_parse("Lorem @file main.rs Ipsum", 0, &supported_modes),
1811 Some(MentionCompletion {
1812 source_range: 6..19,
1813 mode: Some(PromptContextType::File),
1814 argument: Some("main.rs".to_string()),
1815 })
1816 );
1817
1818 assert_eq!(
1819 MentionCompletion::try_parse("Lorem @main", 0, &supported_modes),
1820 Some(MentionCompletion {
1821 source_range: 6..11,
1822 mode: None,
1823 argument: Some("main".to_string()),
1824 })
1825 );
1826
1827 assert_eq!(
1828 MentionCompletion::try_parse("Lorem @main ", 0, &supported_modes),
1829 Some(MentionCompletion {
1830 source_range: 6..12,
1831 mode: None,
1832 argument: Some("main".to_string()),
1833 })
1834 );
1835
1836 assert_eq!(
1837 MentionCompletion::try_parse("Lorem @main m", 0, &supported_modes),
1838 None
1839 );
1840
1841 assert_eq!(
1842 MentionCompletion::try_parse("test@", 0, &supported_modes),
1843 None
1844 );
1845
1846 // Allowed non-file mentions
1847
1848 assert_eq!(
1849 MentionCompletion::try_parse("Lorem @symbol main", 0, &supported_modes),
1850 Some(MentionCompletion {
1851 source_range: 6..18,
1852 mode: Some(PromptContextType::Symbol),
1853 argument: Some("main".to_string()),
1854 })
1855 );
1856
1857 // Disallowed non-file mentions
1858 assert_eq!(
1859 MentionCompletion::try_parse("Lorem @symbol main", 0, &[PromptContextType::File]),
1860 None
1861 );
1862
1863 assert_eq!(
1864 MentionCompletion::try_parse("Lorem@symbol", 0, &supported_modes),
1865 None,
1866 "Should not parse mention inside word"
1867 );
1868
1869 assert_eq!(
1870 MentionCompletion::try_parse("Lorem @ file", 0, &supported_modes),
1871 None,
1872 "Should not parse with a space after @"
1873 );
1874
1875 assert_eq!(
1876 MentionCompletion::try_parse("@ file", 0, &supported_modes),
1877 None,
1878 "Should not parse with a space after @ at the start of the line"
1879 );
1880 }
1881}