1use std::cmp::Reverse;
2use std::ops::Range;
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::sync::atomic::AtomicBool;
6
7use crate::ThreadHistory;
8use acp_thread::MentionUri;
9use agent_client_protocol as acp;
10use anyhow::Result;
11use editor::{
12 CompletionProvider, Editor, ExcerptId, code_context_menus::COMPLETION_MENU_MAX_WIDTH,
13};
14use futures::FutureExt as _;
15use fuzzy::{PathMatch, StringMatch, StringMatchCandidate};
16use gpui::{App, BackgroundExecutor, Entity, SharedString, Task, WeakEntity};
17use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId};
18use lsp::CompletionContext;
19use multi_buffer::ToOffset as _;
20use ordered_float::OrderedFloat;
21use project::lsp_store::{CompletionDocumentation, SymbolLocation};
22use project::{
23 Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, DiagnosticSummary,
24 PathMatchCandidateSet, Project, ProjectPath, Symbol, WorktreeId,
25};
26use prompt_store::{PromptStore, UserPromptId};
27use rope::Point;
28use settings::{Settings, TerminalDockPosition};
29use terminal::terminal_settings::TerminalSettings;
30use terminal_view::terminal_panel::TerminalPanel;
31use text::{Anchor, ToOffset as _, ToPoint as _};
32use ui::IconName;
33use ui::prelude::*;
34use util::ResultExt as _;
35use util::paths::PathStyle;
36use util::rel_path::RelPath;
37use util::truncate_and_remove_front;
38use workspace::Workspace;
39use workspace::dock::DockPosition;
40
41use crate::AgentPanel;
42use crate::mention_set::MentionSet;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub(crate) enum PromptContextEntry {
46 Mode(PromptContextType),
47 Action(PromptContextAction),
48}
49
50impl PromptContextEntry {
51 pub fn keyword(&self) -> &'static str {
52 match self {
53 Self::Mode(mode) => mode.keyword(),
54 Self::Action(action) => action.keyword(),
55 }
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub(crate) enum PromptContextType {
61 File,
62 Symbol,
63 Fetch,
64 Thread,
65 Rules,
66 Diagnostics,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub(crate) enum PromptContextAction {
71 AddSelections,
72}
73
74impl PromptContextAction {
75 pub fn keyword(&self) -> &'static str {
76 match self {
77 Self::AddSelections => "selection",
78 }
79 }
80
81 pub fn label(&self) -> &'static str {
82 match self {
83 Self::AddSelections => "Selection",
84 }
85 }
86
87 pub fn icon(&self) -> IconName {
88 match self {
89 Self::AddSelections => IconName::Reader,
90 }
91 }
92}
93
94impl TryFrom<&str> for PromptContextType {
95 type Error = String;
96
97 fn try_from(value: &str) -> Result<Self, Self::Error> {
98 match value {
99 "file" => Ok(Self::File),
100 "symbol" => Ok(Self::Symbol),
101 "fetch" => Ok(Self::Fetch),
102 "thread" => Ok(Self::Thread),
103 "rule" => Ok(Self::Rules),
104 "diagnostics" => Ok(Self::Diagnostics),
105 _ => Err(format!("Invalid context picker mode: {}", value)),
106 }
107 }
108}
109
110impl PromptContextType {
111 pub fn keyword(&self) -> &'static str {
112 match self {
113 Self::File => "file",
114 Self::Symbol => "symbol",
115 Self::Fetch => "fetch",
116 Self::Thread => "thread",
117 Self::Rules => "rule",
118 Self::Diagnostics => "diagnostics",
119 }
120 }
121
122 pub fn label(&self) -> &'static str {
123 match self {
124 Self::File => "Files & Directories",
125 Self::Symbol => "Symbols",
126 Self::Fetch => "Fetch",
127 Self::Thread => "Threads",
128 Self::Rules => "Rules",
129 Self::Diagnostics => "Diagnostics",
130 }
131 }
132
133 pub fn icon(&self) -> IconName {
134 match self {
135 Self::File => IconName::File,
136 Self::Symbol => IconName::Code,
137 Self::Fetch => IconName::ToolWeb,
138 Self::Thread => IconName::Thread,
139 Self::Rules => IconName::Reader,
140 Self::Diagnostics => IconName::Warning,
141 }
142 }
143}
144
145pub(crate) enum Match {
146 File(FileMatch),
147 Symbol(SymbolMatch),
148 Thread(SessionMatch),
149 RecentThread(SessionMatch),
150 Fetch(SharedString),
151 Rules(RulesContextEntry),
152 Entry(EntryMatch),
153}
154
155impl Match {
156 pub fn score(&self) -> f64 {
157 match self {
158 Match::File(file) => file.mat.score,
159 Match::Entry(mode) => mode.mat.as_ref().map(|mat| mat.score).unwrap_or(1.),
160 Match::Thread(_) => 1.,
161 Match::RecentThread(_) => 1.,
162 Match::Symbol(_) => 1.,
163 Match::Rules(_) => 1.,
164 Match::Fetch(_) => 1.,
165 }
166 }
167}
168
169#[derive(Debug, Clone)]
170pub struct SessionMatch {
171 session_id: acp::SessionId,
172 title: SharedString,
173}
174
175pub struct EntryMatch {
176 mat: Option<StringMatch>,
177 entry: PromptContextEntry,
178}
179
180fn session_title(title: Option<SharedString>) -> SharedString {
181 title
182 .filter(|title| !title.is_empty())
183 .unwrap_or_else(|| SharedString::new_static("New Thread"))
184}
185
186#[derive(Debug, Clone)]
187pub struct RulesContextEntry {
188 pub prompt_id: UserPromptId,
189 pub title: SharedString,
190}
191
192#[derive(Debug, Clone)]
193pub struct AvailableCommand {
194 pub name: Arc<str>,
195 pub description: Arc<str>,
196 pub requires_argument: bool,
197}
198
199pub trait PromptCompletionProviderDelegate: Send + Sync + 'static {
200 fn supports_context(&self, mode: PromptContextType, cx: &App) -> bool {
201 self.supported_modes(cx).contains(&mode)
202 }
203 fn supported_modes(&self, cx: &App) -> Vec<PromptContextType>;
204 fn supports_images(&self, cx: &App) -> bool;
205
206 fn available_commands(&self, cx: &App) -> Vec<AvailableCommand>;
207 fn confirm_command(&self, cx: &mut App);
208}
209
210pub struct PromptCompletionProvider<T: PromptCompletionProviderDelegate> {
211 source: Arc<T>,
212 editor: WeakEntity<Editor>,
213 mention_set: Entity<MentionSet>,
214 history: WeakEntity<ThreadHistory>,
215 prompt_store: Option<Entity<PromptStore>>,
216 workspace: WeakEntity<Workspace>,
217}
218
219impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
220 pub fn new(
221 source: T,
222 editor: WeakEntity<Editor>,
223 mention_set: Entity<MentionSet>,
224 history: WeakEntity<ThreadHistory>,
225 prompt_store: Option<Entity<PromptStore>>,
226 workspace: WeakEntity<Workspace>,
227 ) -> Self {
228 Self {
229 source: Arc::new(source),
230 editor,
231 mention_set,
232 workspace,
233 history,
234 prompt_store,
235 }
236 }
237
238 fn completion_for_entry(
239 entry: PromptContextEntry,
240 source_range: Range<Anchor>,
241 editor: WeakEntity<Editor>,
242 mention_set: WeakEntity<MentionSet>,
243 workspace: &Entity<Workspace>,
244 cx: &mut App,
245 ) -> Option<Completion> {
246 match entry {
247 PromptContextEntry::Mode(mode) => Some(Completion {
248 replace_range: source_range,
249 new_text: format!("@{} ", mode.keyword()),
250 label: CodeLabel::plain(mode.label().to_string(), None),
251 icon_path: Some(mode.icon().path().into()),
252 documentation: None,
253 source: project::CompletionSource::Custom,
254 match_start: None,
255 snippet_deduplication_key: None,
256 insert_text_mode: None,
257 // This ensures that when a user accepts this completion, the
258 // completion menu will still be shown after "@category " is
259 // inserted
260 confirm: Some(Arc::new(|_, _, _| true)),
261 }),
262 PromptContextEntry::Action(action) => Self::completion_for_action(
263 action,
264 source_range,
265 editor,
266 mention_set,
267 workspace,
268 cx,
269 ),
270 }
271 }
272
273 fn completion_for_thread(
274 session_id: acp::SessionId,
275 title: Option<SharedString>,
276 source_range: Range<Anchor>,
277 recent: bool,
278 source: Arc<T>,
279 editor: WeakEntity<Editor>,
280 mention_set: WeakEntity<MentionSet>,
281 workspace: Entity<Workspace>,
282 cx: &mut App,
283 ) -> Completion {
284 let title = session_title(title);
285 let uri = MentionUri::Thread {
286 id: session_id,
287 name: title.to_string(),
288 };
289
290 let icon_for_completion = if recent {
291 IconName::HistoryRerun.path().into()
292 } else {
293 uri.icon_path(cx)
294 };
295
296 let new_text = format!("{} ", uri.as_link());
297
298 let new_text_len = new_text.len();
299 Completion {
300 replace_range: source_range.clone(),
301 new_text,
302 label: CodeLabel::plain(title.to_string(), None),
303 documentation: None,
304 insert_text_mode: None,
305 source: project::CompletionSource::Custom,
306 match_start: None,
307 snippet_deduplication_key: None,
308 icon_path: Some(icon_for_completion),
309 confirm: Some(confirm_completion_callback(
310 title,
311 source_range.start,
312 new_text_len - 1,
313 uri,
314 source,
315 editor,
316 mention_set,
317 workspace,
318 )),
319 }
320 }
321
322 fn completion_for_rules(
323 rule: RulesContextEntry,
324 source_range: Range<Anchor>,
325 source: Arc<T>,
326 editor: WeakEntity<Editor>,
327 mention_set: WeakEntity<MentionSet>,
328 workspace: Entity<Workspace>,
329 cx: &mut App,
330 ) -> Completion {
331 let uri = MentionUri::Rule {
332 id: rule.prompt_id.into(),
333 name: rule.title.to_string(),
334 };
335 let new_text = format!("{} ", uri.as_link());
336 let new_text_len = new_text.len();
337 let icon_path = uri.icon_path(cx);
338 Completion {
339 replace_range: source_range.clone(),
340 new_text,
341 label: CodeLabel::plain(rule.title.to_string(), None),
342 documentation: None,
343 insert_text_mode: None,
344 source: project::CompletionSource::Custom,
345 match_start: None,
346 snippet_deduplication_key: None,
347 icon_path: Some(icon_path),
348 confirm: Some(confirm_completion_callback(
349 rule.title,
350 source_range.start,
351 new_text_len - 1,
352 uri,
353 source,
354 editor,
355 mention_set,
356 workspace,
357 )),
358 }
359 }
360
361 pub(crate) fn completion_for_path(
362 project_path: ProjectPath,
363 path_prefix: &RelPath,
364 is_recent: bool,
365 is_directory: bool,
366 source_range: Range<Anchor>,
367 source: Arc<T>,
368 editor: WeakEntity<Editor>,
369 mention_set: WeakEntity<MentionSet>,
370 workspace: Entity<Workspace>,
371 project: Entity<Project>,
372 label_max_chars: usize,
373 cx: &mut App,
374 ) -> Option<Completion> {
375 let path_style = project.read(cx).path_style(cx);
376 let (file_name, directory) =
377 extract_file_name_and_directory(&project_path.path, path_prefix, path_style);
378
379 let label = build_code_label_for_path(
380 &file_name,
381 directory.as_ref().map(|s| s.as_ref()),
382 None,
383 label_max_chars,
384 cx,
385 );
386
387 let abs_path = project.read(cx).absolute_path(&project_path, cx)?;
388
389 let uri = if is_directory {
390 MentionUri::Directory { abs_path }
391 } else {
392 MentionUri::File { abs_path }
393 };
394
395 let crease_icon_path = uri.icon_path(cx);
396 let completion_icon_path = if is_recent {
397 IconName::HistoryRerun.path().into()
398 } else {
399 crease_icon_path
400 };
401
402 let new_text = format!("{} ", uri.as_link());
403 let new_text_len = new_text.len();
404 Some(Completion {
405 replace_range: source_range.clone(),
406 new_text,
407 label,
408 documentation: None,
409 source: project::CompletionSource::Custom,
410 icon_path: Some(completion_icon_path),
411 match_start: None,
412 snippet_deduplication_key: None,
413 insert_text_mode: None,
414 confirm: Some(confirm_completion_callback(
415 file_name,
416 source_range.start,
417 new_text_len - 1,
418 uri,
419 source,
420 editor,
421 mention_set,
422 workspace,
423 )),
424 })
425 }
426
427 fn completion_for_symbol(
428 symbol: Symbol,
429 source_range: Range<Anchor>,
430 source: Arc<T>,
431 editor: WeakEntity<Editor>,
432 mention_set: WeakEntity<MentionSet>,
433 workspace: Entity<Workspace>,
434 label_max_chars: usize,
435 cx: &mut App,
436 ) -> Option<Completion> {
437 let project = workspace.read(cx).project().clone();
438
439 let (abs_path, file_name) = match &symbol.path {
440 SymbolLocation::InProject(project_path) => (
441 project.read(cx).absolute_path(&project_path, cx)?,
442 project_path.path.file_name()?.to_string().into(),
443 ),
444 SymbolLocation::OutsideProject {
445 abs_path,
446 signature: _,
447 } => (
448 PathBuf::from(abs_path.as_ref()),
449 abs_path.file_name().map(|f| f.to_string_lossy())?,
450 ),
451 };
452
453 let label = build_code_label_for_path(
454 &symbol.name,
455 Some(&file_name),
456 Some(symbol.range.start.0.row + 1),
457 label_max_chars,
458 cx,
459 );
460
461 let uri = MentionUri::Symbol {
462 abs_path,
463 name: symbol.name.clone(),
464 line_range: symbol.range.start.0.row..=symbol.range.end.0.row,
465 };
466 let new_text = format!("{} ", uri.as_link());
467 let new_text_len = new_text.len();
468 let icon_path = uri.icon_path(cx);
469 Some(Completion {
470 replace_range: source_range.clone(),
471 new_text,
472 label,
473 documentation: None,
474 source: project::CompletionSource::Custom,
475 icon_path: Some(icon_path),
476 match_start: None,
477 snippet_deduplication_key: None,
478 insert_text_mode: None,
479 confirm: Some(confirm_completion_callback(
480 symbol.name.into(),
481 source_range.start,
482 new_text_len - 1,
483 uri,
484 source,
485 editor,
486 mention_set,
487 workspace,
488 )),
489 })
490 }
491
492 fn completion_for_fetch(
493 source_range: Range<Anchor>,
494 url_to_fetch: SharedString,
495 source: Arc<T>,
496 editor: WeakEntity<Editor>,
497 mention_set: WeakEntity<MentionSet>,
498 workspace: Entity<Workspace>,
499 cx: &mut App,
500 ) -> Option<Completion> {
501 let new_text = format!("@fetch {} ", url_to_fetch);
502 let url_to_fetch = url::Url::parse(url_to_fetch.as_ref())
503 .or_else(|_| url::Url::parse(&format!("https://{url_to_fetch}")))
504 .ok()?;
505 let mention_uri = MentionUri::Fetch {
506 url: url_to_fetch.clone(),
507 };
508 let icon_path = mention_uri.icon_path(cx);
509 Some(Completion {
510 replace_range: source_range.clone(),
511 new_text: new_text.clone(),
512 label: CodeLabel::plain(url_to_fetch.to_string(), None),
513 documentation: None,
514 source: project::CompletionSource::Custom,
515 icon_path: Some(icon_path),
516 match_start: None,
517 snippet_deduplication_key: None,
518 insert_text_mode: None,
519 confirm: Some(confirm_completion_callback(
520 url_to_fetch.to_string().into(),
521 source_range.start,
522 new_text.len() - 1,
523 mention_uri,
524 source,
525 editor,
526 mention_set,
527 workspace,
528 )),
529 })
530 }
531
532 pub(crate) fn completion_for_action(
533 action: PromptContextAction,
534 source_range: Range<Anchor>,
535 editor: WeakEntity<Editor>,
536 mention_set: WeakEntity<MentionSet>,
537 workspace: &Entity<Workspace>,
538 cx: &mut App,
539 ) -> Option<Completion> {
540 let (new_text, on_action) = match action {
541 PromptContextAction::AddSelections => {
542 // Collect non-empty editor selections
543 let editor_selections: Vec<_> = selection_ranges(workspace, cx)
544 .into_iter()
545 .filter(|(buffer, range)| {
546 let snapshot = buffer.read(cx).snapshot();
547 range.start.to_offset(&snapshot) != range.end.to_offset(&snapshot)
548 })
549 .collect();
550
551 // Collect terminal selections from all terminal views if the terminal panel is visible
552 let terminal_selections: Vec<String> =
553 terminal_selections_if_panel_open(workspace, cx);
554
555 const EDITOR_PLACEHOLDER: &str = "selection ";
556 const TERMINAL_PLACEHOLDER: &str = "terminal ";
557
558 let selections = editor_selections
559 .into_iter()
560 .enumerate()
561 .map(|(ix, (buffer, range))| {
562 (
563 buffer,
564 range,
565 (EDITOR_PLACEHOLDER.len() * ix)
566 ..(EDITOR_PLACEHOLDER.len() * (ix + 1) - 1),
567 )
568 })
569 .collect::<Vec<_>>();
570
571 let mut new_text: String = EDITOR_PLACEHOLDER.repeat(selections.len());
572
573 // Add terminal placeholders for each terminal selection
574 let terminal_ranges: Vec<(String, std::ops::Range<usize>)> = terminal_selections
575 .into_iter()
576 .map(|text| {
577 let start = new_text.len();
578 new_text.push_str(TERMINAL_PLACEHOLDER);
579 (text, start..(new_text.len() - 1))
580 })
581 .collect();
582
583 let callback = Arc::new({
584 let source_range = source_range.clone();
585 move |_: CompletionIntent, window: &mut Window, cx: &mut App| {
586 let editor = editor.clone();
587 let selections = selections.clone();
588 let mention_set = mention_set.clone();
589 let source_range = source_range.clone();
590 let terminal_ranges = terminal_ranges.clone();
591 window.defer(cx, move |window, cx| {
592 if let Some(editor) = editor.upgrade() {
593 // Insert editor selections
594 if !selections.is_empty() {
595 mention_set
596 .update(cx, |store, cx| {
597 store.confirm_mention_for_selection(
598 source_range.clone(),
599 selections,
600 editor.clone(),
601 window,
602 cx,
603 )
604 })
605 .ok();
606 }
607
608 // Insert terminal selections
609 for (terminal_text, terminal_range) in terminal_ranges {
610 let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx);
611 let Some(start) =
612 snapshot.as_singleton_anchor(source_range.start)
613 else {
614 return;
615 };
616 let offset = start.to_offset(&snapshot);
617
618 let line_count = terminal_text.lines().count() as u32;
619 let mention_uri = MentionUri::TerminalSelection { line_count };
620 let range = snapshot.anchor_after(offset + terminal_range.start)
621 ..snapshot.anchor_after(offset + terminal_range.end);
622
623 let crease = crate::mention_set::crease_for_mention(
624 mention_uri.name().into(),
625 mention_uri.icon_path(cx),
626 None,
627 range,
628 editor.downgrade(),
629 );
630
631 let crease_id = editor.update(cx, |editor, cx| {
632 let crease_ids =
633 editor.insert_creases(vec![crease.clone()], cx);
634 editor.fold_creases(vec![crease], false, window, cx);
635 crease_ids.first().copied().unwrap()
636 });
637
638 mention_set
639 .update(cx, |mention_set, _| {
640 mention_set.insert_mention(
641 crease_id,
642 mention_uri.clone(),
643 gpui::Task::ready(Ok(
644 crate::mention_set::Mention::Text {
645 content: terminal_text,
646 tracked_buffers: vec![],
647 },
648 ))
649 .shared(),
650 );
651 })
652 .ok();
653 }
654 }
655 });
656 false
657 }
658 });
659
660 (
661 new_text,
662 callback
663 as Arc<
664 dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync,
665 >,
666 )
667 }
668 };
669
670 Some(Completion {
671 replace_range: source_range,
672 new_text,
673 label: CodeLabel::plain(action.label().to_string(), None),
674 icon_path: Some(action.icon().path().into()),
675 documentation: None,
676 source: project::CompletionSource::Custom,
677 match_start: None,
678 snippet_deduplication_key: None,
679 insert_text_mode: None,
680 // This ensures that when a user accepts this completion, the
681 // completion menu will still be shown after "@category " is
682 // inserted
683 confirm: Some(on_action),
684 })
685 }
686
687 fn completion_for_diagnostics(
688 source_range: Range<Anchor>,
689 source: Arc<T>,
690 editor: WeakEntity<Editor>,
691 mention_set: WeakEntity<MentionSet>,
692 workspace: Entity<Workspace>,
693 cx: &mut App,
694 ) -> Vec<Completion> {
695 let summary = workspace
696 .read(cx)
697 .project()
698 .read(cx)
699 .diagnostic_summary(false, cx);
700 if summary.error_count == 0 && summary.warning_count == 0 {
701 return Vec::new();
702 }
703 let icon_path = MentionUri::Diagnostics {
704 include_errors: true,
705 include_warnings: false,
706 }
707 .icon_path(cx);
708
709 let mut completions = Vec::new();
710
711 let cases = [
712 (summary.error_count > 0, true, false),
713 (summary.warning_count > 0, false, true),
714 (
715 summary.error_count > 0 && summary.warning_count > 0,
716 true,
717 true,
718 ),
719 ];
720
721 for (condition, include_errors, include_warnings) in cases {
722 if condition {
723 completions.push(Self::build_diagnostics_completion(
724 diagnostics_submenu_label(summary, include_errors, include_warnings),
725 source_range.clone(),
726 source.clone(),
727 editor.clone(),
728 mention_set.clone(),
729 workspace.clone(),
730 icon_path.clone(),
731 include_errors,
732 include_warnings,
733 summary,
734 ));
735 }
736 }
737
738 completions
739 }
740
741 fn build_diagnostics_completion(
742 menu_label: String,
743 source_range: Range<Anchor>,
744 source: Arc<T>,
745 editor: WeakEntity<Editor>,
746 mention_set: WeakEntity<MentionSet>,
747 workspace: Entity<Workspace>,
748 icon_path: SharedString,
749 include_errors: bool,
750 include_warnings: bool,
751 summary: DiagnosticSummary,
752 ) -> Completion {
753 let uri = MentionUri::Diagnostics {
754 include_errors,
755 include_warnings,
756 };
757 let crease_text = diagnostics_crease_label(summary, include_errors, include_warnings);
758 let display_text = format!("@{}", crease_text);
759 let new_text = format!("[{}]({}) ", display_text, uri.to_uri());
760 let new_text_len = new_text.len();
761 Completion {
762 replace_range: source_range.clone(),
763 new_text,
764 label: CodeLabel::plain(menu_label, None),
765 documentation: None,
766 source: project::CompletionSource::Custom,
767 icon_path: Some(icon_path),
768 match_start: None,
769 snippet_deduplication_key: None,
770 insert_text_mode: None,
771 confirm: Some(confirm_completion_callback(
772 crease_text,
773 source_range.start,
774 new_text_len - 1,
775 uri,
776 source,
777 editor,
778 mention_set,
779 workspace,
780 )),
781 }
782 }
783
784 fn search_slash_commands(&self, query: String, cx: &mut App) -> Task<Vec<AvailableCommand>> {
785 let commands = self.source.available_commands(cx);
786 if commands.is_empty() {
787 return Task::ready(Vec::new());
788 }
789
790 cx.spawn(async move |cx| {
791 let candidates = commands
792 .iter()
793 .enumerate()
794 .map(|(id, command)| StringMatchCandidate::new(id, &command.name))
795 .collect::<Vec<_>>();
796
797 let matches = fuzzy::match_strings(
798 &candidates,
799 &query,
800 false,
801 true,
802 100,
803 &Arc::new(AtomicBool::default()),
804 cx.background_executor().clone(),
805 )
806 .await;
807
808 matches
809 .into_iter()
810 .map(|mat| commands[mat.candidate_id].clone())
811 .collect()
812 })
813 }
814
815 fn search_mentions(
816 &self,
817 mode: Option<PromptContextType>,
818 query: String,
819 cancellation_flag: Arc<AtomicBool>,
820 cx: &mut App,
821 ) -> Task<Vec<Match>> {
822 let Some(workspace) = self.workspace.upgrade() else {
823 return Task::ready(Vec::default());
824 };
825 match mode {
826 Some(PromptContextType::File) => {
827 let search_files_task = search_files(query, cancellation_flag, &workspace, cx);
828 cx.background_spawn(async move {
829 search_files_task
830 .await
831 .into_iter()
832 .map(Match::File)
833 .collect()
834 })
835 }
836
837 Some(PromptContextType::Symbol) => {
838 let search_symbols_task = search_symbols(query, cancellation_flag, &workspace, cx);
839 cx.background_spawn(async move {
840 search_symbols_task
841 .await
842 .into_iter()
843 .map(Match::Symbol)
844 .collect()
845 })
846 }
847
848 Some(PromptContextType::Thread) => {
849 if let Some(history) = self.history.upgrade() {
850 let sessions = history
851 .read(cx)
852 .sessions()
853 .iter()
854 .map(|session| SessionMatch {
855 session_id: session.session_id.clone(),
856 title: session_title(session.title.clone()),
857 })
858 .collect::<Vec<_>>();
859 let search_task =
860 filter_sessions_by_query(query, cancellation_flag, sessions, cx);
861 cx.spawn(async move |_cx| {
862 search_task.await.into_iter().map(Match::Thread).collect()
863 })
864 } else {
865 Task::ready(Vec::new())
866 }
867 }
868
869 Some(PromptContextType::Fetch) => {
870 if !query.is_empty() {
871 Task::ready(vec![Match::Fetch(query.into())])
872 } else {
873 Task::ready(Vec::new())
874 }
875 }
876
877 Some(PromptContextType::Rules) => {
878 if let Some(prompt_store) = self.prompt_store.as_ref() {
879 let search_rules_task =
880 search_rules(query, cancellation_flag, prompt_store, cx);
881 cx.background_spawn(async move {
882 search_rules_task
883 .await
884 .into_iter()
885 .map(Match::Rules)
886 .collect::<Vec<_>>()
887 })
888 } else {
889 Task::ready(Vec::new())
890 }
891 }
892
893 Some(PromptContextType::Diagnostics) => Task::ready(Vec::new()),
894
895 None if query.is_empty() => {
896 let recent_task = self.recent_context_picker_entries(&workspace, cx);
897 let entries = self
898 .available_context_picker_entries(&workspace, cx)
899 .into_iter()
900 .map(|mode| {
901 Match::Entry(EntryMatch {
902 entry: mode,
903 mat: None,
904 })
905 })
906 .collect::<Vec<_>>();
907
908 cx.spawn(async move |_cx| {
909 let mut matches = recent_task.await;
910 matches.extend(entries);
911 matches
912 })
913 }
914 None => {
915 let executor = cx.background_executor().clone();
916
917 let search_files_task =
918 search_files(query.clone(), cancellation_flag, &workspace, cx);
919
920 let entries = self.available_context_picker_entries(&workspace, cx);
921 let entry_candidates = entries
922 .iter()
923 .enumerate()
924 .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword()))
925 .collect::<Vec<_>>();
926
927 cx.background_spawn(async move {
928 let mut matches = search_files_task
929 .await
930 .into_iter()
931 .map(Match::File)
932 .collect::<Vec<_>>();
933
934 let entry_matches = fuzzy::match_strings(
935 &entry_candidates,
936 &query,
937 false,
938 true,
939 100,
940 &Arc::new(AtomicBool::default()),
941 executor,
942 )
943 .await;
944
945 matches.extend(entry_matches.into_iter().map(|mat| {
946 Match::Entry(EntryMatch {
947 entry: entries[mat.candidate_id],
948 mat: Some(mat),
949 })
950 }));
951
952 matches.sort_by(|a, b| {
953 b.score()
954 .partial_cmp(&a.score())
955 .unwrap_or(std::cmp::Ordering::Equal)
956 });
957
958 matches
959 })
960 }
961 }
962 }
963
964 fn recent_context_picker_entries(
965 &self,
966 workspace: &Entity<Workspace>,
967 cx: &mut App,
968 ) -> Task<Vec<Match>> {
969 let mut recent = Vec::with_capacity(6);
970
971 let mut mentions = self
972 .mention_set
973 .read_with(cx, |store, _cx| store.mentions());
974 let workspace = workspace.read(cx);
975 let project = workspace.project().read(cx);
976 let include_root_name = workspace.visible_worktrees(cx).count() > 1;
977
978 if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx)
979 && let Some(thread) = agent_panel.read(cx).active_agent_thread(cx)
980 {
981 let thread = thread.read(cx);
982 mentions.insert(MentionUri::Thread {
983 id: thread.session_id().clone(),
984 name: thread.title().into(),
985 });
986 }
987
988 recent.extend(
989 workspace
990 .recent_navigation_history_iter(cx)
991 .filter(|(_, abs_path)| {
992 abs_path.as_ref().is_none_or(|path| {
993 !mentions.contains(&MentionUri::File {
994 abs_path: path.clone(),
995 })
996 })
997 })
998 .take(4)
999 .filter_map(|(project_path, _)| {
1000 project
1001 .worktree_for_id(project_path.worktree_id, cx)
1002 .map(|worktree| {
1003 let path_prefix = if include_root_name {
1004 worktree.read(cx).root_name().into()
1005 } else {
1006 RelPath::empty().into()
1007 };
1008 Match::File(FileMatch {
1009 mat: fuzzy::PathMatch {
1010 score: 1.,
1011 positions: Vec::new(),
1012 worktree_id: project_path.worktree_id.to_usize(),
1013 path: project_path.path,
1014 path_prefix,
1015 is_dir: false,
1016 distance_to_relative_ancestor: 0,
1017 },
1018 is_recent: true,
1019 })
1020 })
1021 }),
1022 );
1023
1024 if !self.source.supports_context(PromptContextType::Thread, cx) {
1025 return Task::ready(recent);
1026 }
1027
1028 if let Some(history) = self.history.upgrade() {
1029 const RECENT_COUNT: usize = 2;
1030 recent.extend(
1031 history
1032 .read(cx)
1033 .sessions()
1034 .into_iter()
1035 .map(|session| SessionMatch {
1036 session_id: session.session_id.clone(),
1037 title: session_title(session.title.clone()),
1038 })
1039 .filter(|session| {
1040 let uri = MentionUri::Thread {
1041 id: session.session_id.clone(),
1042 name: session.title.to_string(),
1043 };
1044 !mentions.contains(&uri)
1045 })
1046 .take(RECENT_COUNT)
1047 .map(Match::RecentThread),
1048 );
1049 return Task::ready(recent);
1050 }
1051
1052 Task::ready(recent)
1053 }
1054
1055 fn available_context_picker_entries(
1056 &self,
1057 workspace: &Entity<Workspace>,
1058 cx: &mut App,
1059 ) -> Vec<PromptContextEntry> {
1060 let mut entries = vec![
1061 PromptContextEntry::Mode(PromptContextType::File),
1062 PromptContextEntry::Mode(PromptContextType::Symbol),
1063 ];
1064
1065 if self.source.supports_context(PromptContextType::Thread, cx) {
1066 entries.push(PromptContextEntry::Mode(PromptContextType::Thread));
1067 }
1068
1069 let has_editor_selection = workspace
1070 .read(cx)
1071 .active_item(cx)
1072 .and_then(|item| item.downcast::<Editor>())
1073 .is_some_and(|editor| {
1074 editor.update(cx, |editor, cx| {
1075 editor.has_non_empty_selection(&editor.display_snapshot(cx))
1076 })
1077 });
1078
1079 let has_terminal_selection = !terminal_selections_if_panel_open(workspace, cx).is_empty();
1080
1081 if has_editor_selection || has_terminal_selection {
1082 entries.push(PromptContextEntry::Action(
1083 PromptContextAction::AddSelections,
1084 ));
1085 }
1086
1087 if self.prompt_store.is_some() && self.source.supports_context(PromptContextType::Rules, cx)
1088 {
1089 entries.push(PromptContextEntry::Mode(PromptContextType::Rules));
1090 }
1091
1092 if self.source.supports_context(PromptContextType::Fetch, cx) {
1093 entries.push(PromptContextEntry::Mode(PromptContextType::Fetch));
1094 }
1095
1096 if self
1097 .source
1098 .supports_context(PromptContextType::Diagnostics, cx)
1099 {
1100 let summary = workspace
1101 .read(cx)
1102 .project()
1103 .read(cx)
1104 .diagnostic_summary(false, cx);
1105 if summary.error_count > 0 || summary.warning_count > 0 {
1106 entries.push(PromptContextEntry::Mode(PromptContextType::Diagnostics));
1107 }
1108 }
1109
1110 entries
1111 }
1112}
1113
1114impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletionProvider<T> {
1115 fn completions(
1116 &self,
1117 _excerpt_id: ExcerptId,
1118 buffer: &Entity<Buffer>,
1119 buffer_position: Anchor,
1120 _trigger: CompletionContext,
1121 window: &mut Window,
1122 cx: &mut Context<Editor>,
1123 ) -> Task<Result<Vec<CompletionResponse>>> {
1124 let state = buffer.update(cx, |buffer, cx| {
1125 let position = buffer_position.to_point(buffer);
1126 let line_start = Point::new(position.row, 0);
1127 let offset_to_line = buffer.point_to_offset(line_start);
1128 let mut lines = buffer.text_for_range(line_start..position).lines();
1129 let line = lines.next()?;
1130 PromptCompletion::try_parse(line, offset_to_line, &self.source.supported_modes(cx))
1131 });
1132 let Some(state) = state else {
1133 return Task::ready(Ok(Vec::new()));
1134 };
1135
1136 let Some(workspace) = self.workspace.upgrade() else {
1137 return Task::ready(Ok(Vec::new()));
1138 };
1139
1140 let project = workspace.read(cx).project().clone();
1141 let snapshot = buffer.read(cx).snapshot();
1142 let source_range = snapshot.anchor_before(state.source_range().start)
1143 ..snapshot.anchor_after(state.source_range().end);
1144
1145 let source = self.source.clone();
1146 let editor = self.editor.clone();
1147 let mention_set = self.mention_set.downgrade();
1148 match state {
1149 PromptCompletion::SlashCommand(SlashCommandCompletion {
1150 command, argument, ..
1151 }) => {
1152 let search_task = self.search_slash_commands(command.unwrap_or_default(), cx);
1153 cx.background_spawn(async move {
1154 let completions = search_task
1155 .await
1156 .into_iter()
1157 .map(|command| {
1158 let new_text = if let Some(argument) = argument.as_ref() {
1159 format!("/{} {}", command.name, argument)
1160 } else {
1161 format!("/{} ", command.name)
1162 };
1163
1164 let is_missing_argument =
1165 command.requires_argument && argument.is_none();
1166
1167 Completion {
1168 replace_range: source_range.clone(),
1169 new_text,
1170 label: CodeLabel::plain(command.name.to_string(), None),
1171 documentation: Some(CompletionDocumentation::MultiLinePlainText(
1172 command.description.into(),
1173 )),
1174 source: project::CompletionSource::Custom,
1175 icon_path: None,
1176 match_start: None,
1177 snippet_deduplication_key: None,
1178 insert_text_mode: None,
1179 confirm: Some(Arc::new({
1180 let source = source.clone();
1181 move |intent, _window, cx| {
1182 if !is_missing_argument {
1183 cx.defer({
1184 let source = source.clone();
1185 move |cx| match intent {
1186 CompletionIntent::Complete
1187 | CompletionIntent::CompleteWithInsert
1188 | CompletionIntent::CompleteWithReplace => {
1189 source.confirm_command(cx);
1190 }
1191 CompletionIntent::Compose => {}
1192 }
1193 });
1194 }
1195 false
1196 }
1197 })),
1198 }
1199 })
1200 .collect();
1201
1202 Ok(vec![CompletionResponse {
1203 completions,
1204 display_options: CompletionDisplayOptions {
1205 dynamic_width: true,
1206 },
1207 // Since this does its own filtering (see `filter_completions()` returns false),
1208 // there is no benefit to computing whether this set of completions is incomplete.
1209 is_incomplete: true,
1210 }])
1211 })
1212 }
1213 PromptCompletion::Mention(MentionCompletion { mode, argument, .. }) => {
1214 if let Some(PromptContextType::Diagnostics) = mode {
1215 if argument.is_some() {
1216 return Task::ready(Ok(Vec::new()));
1217 }
1218
1219 let completions = Self::completion_for_diagnostics(
1220 source_range.clone(),
1221 source.clone(),
1222 editor.clone(),
1223 mention_set.clone(),
1224 workspace.clone(),
1225 cx,
1226 );
1227 if !completions.is_empty() {
1228 return Task::ready(Ok(vec![CompletionResponse {
1229 completions,
1230 display_options: CompletionDisplayOptions::default(),
1231 is_incomplete: false,
1232 }]));
1233 }
1234 }
1235
1236 let query = argument.unwrap_or_default();
1237 let search_task =
1238 self.search_mentions(mode, query, Arc::<AtomicBool>::default(), cx);
1239
1240 // Calculate maximum characters available for the full label (file_name + space + directory)
1241 // based on maximum menu width after accounting for padding, spacing, and icon width
1242 let label_max_chars = {
1243 // Base06 left padding + Base06 gap + Base06 right padding + icon width
1244 let used_pixels = DynamicSpacing::Base06.px(cx) * 3.0
1245 + IconSize::XSmall.rems() * window.rem_size();
1246
1247 let style = window.text_style();
1248 let font_id = window.text_system().resolve_font(&style.font());
1249 let font_size = TextSize::Small.rems(cx).to_pixels(window.rem_size());
1250
1251 // Fallback em_width of 10px matches file_finder.rs fallback for TextSize::Small
1252 let em_width = cx
1253 .text_system()
1254 .em_width(font_id, font_size)
1255 .unwrap_or(px(10.0));
1256
1257 // Calculate available pixels for text (file_name + directory)
1258 // Using max width since dynamic_width allows the menu to expand up to this
1259 let available_pixels = COMPLETION_MENU_MAX_WIDTH - used_pixels;
1260
1261 // Convert to character count (total available for file_name + directory)
1262 (f32::from(available_pixels) / f32::from(em_width)) as usize
1263 };
1264
1265 cx.spawn(async move |_, cx| {
1266 let matches = search_task.await;
1267
1268 let completions = cx.update(|cx| {
1269 matches
1270 .into_iter()
1271 .filter_map(|mat| match mat {
1272 Match::File(FileMatch { mat, is_recent }) => {
1273 let project_path = ProjectPath {
1274 worktree_id: WorktreeId::from_usize(mat.worktree_id),
1275 path: mat.path.clone(),
1276 };
1277
1278 // If path is empty, this means we're matching with the root directory itself
1279 // so we use the path_prefix as the name
1280 let path_prefix = if mat.path.is_empty() {
1281 project
1282 .read(cx)
1283 .worktree_for_id(project_path.worktree_id, cx)
1284 .map(|wt| wt.read(cx).root_name().into())
1285 .unwrap_or_else(|| mat.path_prefix.clone())
1286 } else {
1287 mat.path_prefix.clone()
1288 };
1289
1290 Self::completion_for_path(
1291 project_path,
1292 &path_prefix,
1293 is_recent,
1294 mat.is_dir,
1295 source_range.clone(),
1296 source.clone(),
1297 editor.clone(),
1298 mention_set.clone(),
1299 workspace.clone(),
1300 project.clone(),
1301 label_max_chars,
1302 cx,
1303 )
1304 }
1305 Match::Symbol(SymbolMatch { symbol, .. }) => {
1306 Self::completion_for_symbol(
1307 symbol,
1308 source_range.clone(),
1309 source.clone(),
1310 editor.clone(),
1311 mention_set.clone(),
1312 workspace.clone(),
1313 label_max_chars,
1314 cx,
1315 )
1316 }
1317 Match::Thread(thread) => Some(Self::completion_for_thread(
1318 thread.session_id,
1319 Some(thread.title),
1320 source_range.clone(),
1321 false,
1322 source.clone(),
1323 editor.clone(),
1324 mention_set.clone(),
1325 workspace.clone(),
1326 cx,
1327 )),
1328 Match::RecentThread(thread) => Some(Self::completion_for_thread(
1329 thread.session_id,
1330 Some(thread.title),
1331 source_range.clone(),
1332 true,
1333 source.clone(),
1334 editor.clone(),
1335 mention_set.clone(),
1336 workspace.clone(),
1337 cx,
1338 )),
1339 Match::Rules(user_rules) => Some(Self::completion_for_rules(
1340 user_rules,
1341 source_range.clone(),
1342 source.clone(),
1343 editor.clone(),
1344 mention_set.clone(),
1345 workspace.clone(),
1346 cx,
1347 )),
1348 Match::Fetch(url) => Self::completion_for_fetch(
1349 source_range.clone(),
1350 url,
1351 source.clone(),
1352 editor.clone(),
1353 mention_set.clone(),
1354 workspace.clone(),
1355 cx,
1356 ),
1357 Match::Entry(EntryMatch { entry, .. }) => {
1358 Self::completion_for_entry(
1359 entry,
1360 source_range.clone(),
1361 editor.clone(),
1362 mention_set.clone(),
1363 &workspace,
1364 cx,
1365 )
1366 }
1367 })
1368 .collect::<Vec<_>>()
1369 });
1370
1371 Ok(vec![CompletionResponse {
1372 completions,
1373 display_options: CompletionDisplayOptions {
1374 dynamic_width: true,
1375 },
1376 // Since this does its own filtering (see `filter_completions()` returns false),
1377 // there is no benefit to computing whether this set of completions is incomplete.
1378 is_incomplete: true,
1379 }])
1380 })
1381 }
1382 }
1383 }
1384
1385 fn is_completion_trigger(
1386 &self,
1387 buffer: &Entity<language::Buffer>,
1388 position: language::Anchor,
1389 _text: &str,
1390 _trigger_in_words: bool,
1391 cx: &mut Context<Editor>,
1392 ) -> bool {
1393 let buffer = buffer.read(cx);
1394 let position = position.to_point(buffer);
1395 let line_start = Point::new(position.row, 0);
1396 let offset_to_line = buffer.point_to_offset(line_start);
1397 let mut lines = buffer.text_for_range(line_start..position).lines();
1398 if let Some(line) = lines.next() {
1399 PromptCompletion::try_parse(line, offset_to_line, &self.source.supported_modes(cx))
1400 .filter(|completion| {
1401 // Right now we don't support completing arguments of slash commands
1402 let is_slash_command_with_argument = matches!(
1403 completion,
1404 PromptCompletion::SlashCommand(SlashCommandCompletion {
1405 argument: Some(_),
1406 ..
1407 })
1408 );
1409 !is_slash_command_with_argument
1410 })
1411 .map(|completion| {
1412 completion.source_range().start <= offset_to_line + position.column as usize
1413 && completion.source_range().end
1414 >= offset_to_line + position.column as usize
1415 })
1416 .unwrap_or(false)
1417 } else {
1418 false
1419 }
1420 }
1421
1422 fn sort_completions(&self) -> bool {
1423 false
1424 }
1425
1426 fn filter_completions(&self) -> bool {
1427 false
1428 }
1429}
1430
1431fn confirm_completion_callback<T: PromptCompletionProviderDelegate>(
1432 crease_text: SharedString,
1433 start: Anchor,
1434 content_len: usize,
1435 mention_uri: MentionUri,
1436 source: Arc<T>,
1437 editor: WeakEntity<Editor>,
1438 mention_set: WeakEntity<MentionSet>,
1439 workspace: Entity<Workspace>,
1440) -> Arc<dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync> {
1441 Arc::new(move |_, window, cx| {
1442 let source = source.clone();
1443 let editor = editor.clone();
1444 let mention_set = mention_set.clone();
1445 let crease_text = crease_text.clone();
1446 let mention_uri = mention_uri.clone();
1447 let workspace = workspace.clone();
1448 window.defer(cx, move |window, cx| {
1449 if let Some(editor) = editor.upgrade() {
1450 mention_set
1451 .clone()
1452 .update(cx, |mention_set, cx| {
1453 mention_set
1454 .confirm_mention_completion(
1455 crease_text,
1456 start,
1457 content_len,
1458 mention_uri,
1459 source.supports_images(cx),
1460 editor,
1461 &workspace,
1462 window,
1463 cx,
1464 )
1465 .detach();
1466 })
1467 .ok();
1468 }
1469 });
1470 false
1471 })
1472}
1473
1474#[derive(Debug, PartialEq)]
1475enum PromptCompletion {
1476 SlashCommand(SlashCommandCompletion),
1477 Mention(MentionCompletion),
1478}
1479
1480impl PromptCompletion {
1481 fn source_range(&self) -> Range<usize> {
1482 match self {
1483 Self::SlashCommand(completion) => completion.source_range.clone(),
1484 Self::Mention(completion) => completion.source_range.clone(),
1485 }
1486 }
1487
1488 fn try_parse(
1489 line: &str,
1490 offset_to_line: usize,
1491 supported_modes: &[PromptContextType],
1492 ) -> Option<Self> {
1493 if line.contains('@') {
1494 if let Some(mention) =
1495 MentionCompletion::try_parse(line, offset_to_line, supported_modes)
1496 {
1497 return Some(Self::Mention(mention));
1498 }
1499 }
1500 SlashCommandCompletion::try_parse(line, offset_to_line).map(Self::SlashCommand)
1501 }
1502}
1503
1504#[derive(Debug, Default, PartialEq)]
1505pub struct SlashCommandCompletion {
1506 pub source_range: Range<usize>,
1507 pub command: Option<String>,
1508 pub argument: Option<String>,
1509}
1510
1511impl SlashCommandCompletion {
1512 pub fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
1513 // If we decide to support commands that are not at the beginning of the prompt, we can remove this check
1514 if !line.starts_with('/') || offset_to_line != 0 {
1515 return None;
1516 }
1517
1518 let (prefix, last_command) = line.rsplit_once('/')?;
1519 if prefix.chars().last().is_some_and(|c| !c.is_whitespace())
1520 || last_command.starts_with(char::is_whitespace)
1521 {
1522 return None;
1523 }
1524
1525 let mut argument = None;
1526 let mut command = None;
1527 if let Some((command_text, args)) = last_command.split_once(char::is_whitespace) {
1528 if !args.is_empty() {
1529 argument = Some(args.trim_end().to_string());
1530 }
1531 command = Some(command_text.to_string());
1532 } else if !last_command.is_empty() {
1533 command = Some(last_command.to_string());
1534 };
1535
1536 Some(Self {
1537 source_range: prefix.len() + offset_to_line
1538 ..line
1539 .rfind(|c: char| !c.is_whitespace())
1540 .unwrap_or_else(|| line.len())
1541 + 1
1542 + offset_to_line,
1543 command,
1544 argument,
1545 })
1546 }
1547}
1548
1549#[derive(Debug, Default, PartialEq)]
1550struct MentionCompletion {
1551 source_range: Range<usize>,
1552 mode: Option<PromptContextType>,
1553 argument: Option<String>,
1554}
1555
1556impl MentionCompletion {
1557 fn try_parse(
1558 line: &str,
1559 offset_to_line: usize,
1560 supported_modes: &[PromptContextType],
1561 ) -> Option<Self> {
1562 let last_mention_start = line.rfind('@')?;
1563
1564 // No whitespace immediately after '@'
1565 if line[last_mention_start + 1..]
1566 .chars()
1567 .next()
1568 .is_some_and(|c| c.is_whitespace())
1569 {
1570 return None;
1571 }
1572
1573 // Must be a word boundary before '@'
1574 if last_mention_start > 0
1575 && line[..last_mention_start]
1576 .chars()
1577 .last()
1578 .is_some_and(|c| !c.is_whitespace())
1579 {
1580 return None;
1581 }
1582
1583 let rest_of_line = &line[last_mention_start + 1..];
1584
1585 let mut mode = None;
1586 let mut argument = None;
1587
1588 let mut parts = rest_of_line.split_whitespace();
1589 let mut end = last_mention_start + 1;
1590
1591 if let Some(mode_text) = parts.next() {
1592 // Safe since we check no leading whitespace above
1593 end += mode_text.len();
1594
1595 if let Some(parsed_mode) = PromptContextType::try_from(mode_text).ok()
1596 && supported_modes.contains(&parsed_mode)
1597 {
1598 mode = Some(parsed_mode);
1599 } else {
1600 argument = Some(mode_text.to_string());
1601 }
1602 match rest_of_line[mode_text.len()..].find(|c: char| !c.is_whitespace()) {
1603 Some(whitespace_count) => {
1604 if let Some(argument_text) = parts.next() {
1605 // If mode wasn't recognized but we have an argument, don't suggest completions
1606 // (e.g. '@something word')
1607 if mode.is_none() && !argument_text.is_empty() {
1608 return None;
1609 }
1610
1611 argument = Some(argument_text.to_string());
1612 end += whitespace_count + argument_text.len();
1613 }
1614 }
1615 None => {
1616 // Rest of line is entirely whitespace
1617 end += rest_of_line.len() - mode_text.len();
1618 }
1619 }
1620 }
1621
1622 Some(Self {
1623 source_range: last_mention_start + offset_to_line..end + offset_to_line,
1624 mode,
1625 argument,
1626 })
1627 }
1628}
1629
1630fn diagnostics_label(
1631 summary: DiagnosticSummary,
1632 include_errors: bool,
1633 include_warnings: bool,
1634) -> String {
1635 let mut parts = Vec::new();
1636
1637 if include_errors && summary.error_count > 0 {
1638 parts.push(format!(
1639 "{} {}",
1640 summary.error_count,
1641 pluralize("error", summary.error_count)
1642 ));
1643 }
1644
1645 if include_warnings && summary.warning_count > 0 {
1646 parts.push(format!(
1647 "{} {}",
1648 summary.warning_count,
1649 pluralize("warning", summary.warning_count)
1650 ));
1651 }
1652
1653 if parts.is_empty() {
1654 return "Diagnostics".into();
1655 }
1656
1657 let body = if parts.len() == 2 {
1658 format!("{} and {}", parts[0], parts[1])
1659 } else {
1660 parts
1661 .pop()
1662 .expect("at least one part present after non-empty check")
1663 };
1664
1665 format!("Diagnostics: {body}")
1666}
1667
1668fn diagnostics_submenu_label(
1669 summary: DiagnosticSummary,
1670 include_errors: bool,
1671 include_warnings: bool,
1672) -> String {
1673 match (include_errors, include_warnings) {
1674 (true, true) => format!(
1675 "{} {} & {} {}",
1676 summary.error_count,
1677 pluralize("error", summary.error_count),
1678 summary.warning_count,
1679 pluralize("warning", summary.warning_count)
1680 ),
1681 (true, _) => format!(
1682 "{} {}",
1683 summary.error_count,
1684 pluralize("error", summary.error_count)
1685 ),
1686 (_, true) => format!(
1687 "{} {}",
1688 summary.warning_count,
1689 pluralize("warning", summary.warning_count)
1690 ),
1691 _ => "Diagnostics".into(),
1692 }
1693}
1694
1695fn diagnostics_crease_label(
1696 summary: DiagnosticSummary,
1697 include_errors: bool,
1698 include_warnings: bool,
1699) -> SharedString {
1700 diagnostics_label(summary, include_errors, include_warnings).into()
1701}
1702
1703fn pluralize(noun: &str, count: usize) -> String {
1704 if count == 1 {
1705 noun.to_string()
1706 } else {
1707 format!("{noun}s")
1708 }
1709}
1710
1711pub(crate) fn search_files(
1712 query: String,
1713 cancellation_flag: Arc<AtomicBool>,
1714 workspace: &Entity<Workspace>,
1715 cx: &App,
1716) -> Task<Vec<FileMatch>> {
1717 if query.is_empty() {
1718 let workspace = workspace.read(cx);
1719 let project = workspace.project().read(cx);
1720 let visible_worktrees = workspace.visible_worktrees(cx).collect::<Vec<_>>();
1721 let include_root_name = visible_worktrees.len() > 1;
1722
1723 let recent_matches = workspace
1724 .recent_navigation_history(Some(10), cx)
1725 .into_iter()
1726 .map(|(project_path, _)| {
1727 let path_prefix = if include_root_name {
1728 project
1729 .worktree_for_id(project_path.worktree_id, cx)
1730 .map(|wt| wt.read(cx).root_name().into())
1731 .unwrap_or_else(|| RelPath::empty().into())
1732 } else {
1733 RelPath::empty().into()
1734 };
1735
1736 FileMatch {
1737 mat: PathMatch {
1738 score: 0.,
1739 positions: Vec::new(),
1740 worktree_id: project_path.worktree_id.to_usize(),
1741 path: project_path.path,
1742 path_prefix,
1743 distance_to_relative_ancestor: 0,
1744 is_dir: false,
1745 },
1746 is_recent: true,
1747 }
1748 });
1749
1750 let file_matches = visible_worktrees.into_iter().flat_map(|worktree| {
1751 let worktree = worktree.read(cx);
1752 let path_prefix: Arc<RelPath> = if include_root_name {
1753 worktree.root_name().into()
1754 } else {
1755 RelPath::empty().into()
1756 };
1757 worktree.entries(false, 0).map(move |entry| FileMatch {
1758 mat: PathMatch {
1759 score: 0.,
1760 positions: Vec::new(),
1761 worktree_id: worktree.id().to_usize(),
1762 path: entry.path.clone(),
1763 path_prefix: path_prefix.clone(),
1764 distance_to_relative_ancestor: 0,
1765 is_dir: entry.is_dir(),
1766 },
1767 is_recent: false,
1768 })
1769 });
1770
1771 Task::ready(recent_matches.chain(file_matches).collect())
1772 } else {
1773 let workspace = workspace.read(cx);
1774 let relative_to = workspace
1775 .recent_navigation_history_iter(cx)
1776 .next()
1777 .map(|(path, _)| path.path);
1778 let worktrees = workspace.visible_worktrees(cx).collect::<Vec<_>>();
1779 let include_root_name = worktrees.len() > 1;
1780 let candidate_sets = worktrees
1781 .into_iter()
1782 .map(|worktree| {
1783 let worktree = worktree.read(cx);
1784
1785 PathMatchCandidateSet {
1786 snapshot: worktree.snapshot(),
1787 include_ignored: worktree.root_entry().is_some_and(|entry| entry.is_ignored),
1788 include_root_name,
1789 candidates: project::Candidates::Entries,
1790 }
1791 })
1792 .collect::<Vec<_>>();
1793
1794 let executor = cx.background_executor().clone();
1795 cx.foreground_executor().spawn(async move {
1796 fuzzy::match_path_sets(
1797 candidate_sets.as_slice(),
1798 query.as_str(),
1799 &relative_to,
1800 false,
1801 100,
1802 &cancellation_flag,
1803 executor,
1804 )
1805 .await
1806 .into_iter()
1807 .map(|mat| FileMatch {
1808 mat,
1809 is_recent: false,
1810 })
1811 .collect::<Vec<_>>()
1812 })
1813 }
1814}
1815
1816pub(crate) fn search_symbols(
1817 query: String,
1818 cancellation_flag: Arc<AtomicBool>,
1819 workspace: &Entity<Workspace>,
1820 cx: &mut App,
1821) -> Task<Vec<SymbolMatch>> {
1822 let symbols_task = workspace.update(cx, |workspace, cx| {
1823 workspace
1824 .project()
1825 .update(cx, |project, cx| project.symbols(&query, cx))
1826 });
1827 let project = workspace.read(cx).project().clone();
1828 cx.spawn(async move |cx| {
1829 let Some(symbols) = symbols_task.await.log_err() else {
1830 return Vec::new();
1831 };
1832 let (visible_match_candidates, external_match_candidates): (Vec<_>, Vec<_>) = project
1833 .update(cx, |project, cx| {
1834 symbols
1835 .iter()
1836 .enumerate()
1837 .map(|(id, symbol)| StringMatchCandidate::new(id, symbol.label.filter_text()))
1838 .partition(|candidate| match &symbols[candidate.id].path {
1839 SymbolLocation::InProject(project_path) => project
1840 .entry_for_path(project_path, cx)
1841 .is_some_and(|e| !e.is_ignored),
1842 SymbolLocation::OutsideProject { .. } => false,
1843 })
1844 });
1845 // Try to support rust-analyzer's path based symbols feature which
1846 // allows to search by rust path syntax, in that case we only want to
1847 // filter names by the last segment
1848 // Ideally this was a first class LSP feature (rich queries)
1849 let query = query
1850 .rsplit_once("::")
1851 .map_or(&*query, |(_, suffix)| suffix)
1852 .to_owned();
1853 // Note if you make changes to this filtering below, also change `project_symbols::ProjectSymbolsDelegate::filter`
1854 const MAX_MATCHES: usize = 100;
1855 let mut visible_matches = cx.foreground_executor().block_on(fuzzy::match_strings(
1856 &visible_match_candidates,
1857 &query,
1858 false,
1859 true,
1860 MAX_MATCHES,
1861 &cancellation_flag,
1862 cx.background_executor().clone(),
1863 ));
1864 let mut external_matches = cx.foreground_executor().block_on(fuzzy::match_strings(
1865 &external_match_candidates,
1866 &query,
1867 false,
1868 true,
1869 MAX_MATCHES - visible_matches.len().min(MAX_MATCHES),
1870 &cancellation_flag,
1871 cx.background_executor().clone(),
1872 ));
1873 let sort_key_for_match = |mat: &StringMatch| {
1874 let symbol = &symbols[mat.candidate_id];
1875 (Reverse(OrderedFloat(mat.score)), symbol.label.filter_text())
1876 };
1877
1878 visible_matches.sort_unstable_by_key(sort_key_for_match);
1879 external_matches.sort_unstable_by_key(sort_key_for_match);
1880 let mut matches = visible_matches;
1881 matches.append(&mut external_matches);
1882
1883 matches
1884 .into_iter()
1885 .map(|mut mat| {
1886 let symbol = symbols[mat.candidate_id].clone();
1887 let filter_start = symbol.label.filter_range.start;
1888 for position in &mut mat.positions {
1889 *position += filter_start;
1890 }
1891 SymbolMatch { symbol }
1892 })
1893 .collect()
1894 })
1895}
1896
1897fn filter_sessions_by_query(
1898 query: String,
1899 cancellation_flag: Arc<AtomicBool>,
1900 sessions: Vec<SessionMatch>,
1901 cx: &mut App,
1902) -> Task<Vec<SessionMatch>> {
1903 if query.is_empty() {
1904 return Task::ready(sessions);
1905 }
1906 let executor = cx.background_executor().clone();
1907 cx.background_spawn(async move {
1908 filter_sessions(query, cancellation_flag, sessions, executor).await
1909 })
1910}
1911
1912async fn filter_sessions(
1913 query: String,
1914 cancellation_flag: Arc<AtomicBool>,
1915 sessions: Vec<SessionMatch>,
1916 executor: BackgroundExecutor,
1917) -> Vec<SessionMatch> {
1918 let titles = sessions
1919 .iter()
1920 .map(|session| session.title.clone())
1921 .collect::<Vec<_>>();
1922 let candidates = titles
1923 .iter()
1924 .enumerate()
1925 .map(|(id, title)| StringMatchCandidate::new(id, title.as_ref()))
1926 .collect::<Vec<_>>();
1927 let matches = fuzzy::match_strings(
1928 &candidates,
1929 &query,
1930 false,
1931 true,
1932 100,
1933 &cancellation_flag,
1934 executor,
1935 )
1936 .await;
1937
1938 matches
1939 .into_iter()
1940 .map(|mat| sessions[mat.candidate_id].clone())
1941 .collect()
1942}
1943
1944pub(crate) fn search_rules(
1945 query: String,
1946 cancellation_flag: Arc<AtomicBool>,
1947 prompt_store: &Entity<PromptStore>,
1948 cx: &mut App,
1949) -> Task<Vec<RulesContextEntry>> {
1950 let search_task = prompt_store.read(cx).search(query, cancellation_flag, cx);
1951 cx.background_spawn(async move {
1952 search_task
1953 .await
1954 .into_iter()
1955 .flat_map(|metadata| {
1956 // Default prompts are filtered out as they are automatically included.
1957 if metadata.default {
1958 None
1959 } else {
1960 Some(RulesContextEntry {
1961 prompt_id: metadata.id.as_user()?,
1962 title: metadata.title?,
1963 })
1964 }
1965 })
1966 .collect::<Vec<_>>()
1967 })
1968}
1969
1970pub struct SymbolMatch {
1971 pub symbol: Symbol,
1972}
1973
1974pub struct FileMatch {
1975 pub mat: PathMatch,
1976 pub is_recent: bool,
1977}
1978
1979pub fn extract_file_name_and_directory(
1980 path: &RelPath,
1981 path_prefix: &RelPath,
1982 path_style: PathStyle,
1983) -> (SharedString, Option<SharedString>) {
1984 // If path is empty, this means we're matching with the root directory itself
1985 // so we use the path_prefix as the name
1986 if path.is_empty() && !path_prefix.is_empty() {
1987 return (path_prefix.display(path_style).to_string().into(), None);
1988 }
1989
1990 let full_path = path_prefix.join(path);
1991 let file_name = full_path.file_name().unwrap_or_default();
1992 let display_path = full_path.display(path_style);
1993 let (directory, file_name) = display_path.split_at(display_path.len() - file_name.len());
1994 (
1995 file_name.to_string().into(),
1996 Some(SharedString::new(directory)).filter(|dir| !dir.is_empty()),
1997 )
1998}
1999
2000fn build_code_label_for_path(
2001 file: &str,
2002 directory: Option<&str>,
2003 line_number: Option<u32>,
2004 label_max_chars: usize,
2005 cx: &App,
2006) -> CodeLabel {
2007 let variable_highlight_id = cx
2008 .theme()
2009 .syntax()
2010 .highlight_id("variable")
2011 .map(HighlightId);
2012 let mut label = CodeLabelBuilder::default();
2013
2014 label.push_str(file, None);
2015 label.push_str(" ", None);
2016
2017 if let Some(directory) = directory {
2018 let file_name_chars = file.chars().count();
2019 // Account for: file_name + space (ellipsis is handled by truncate_and_remove_front)
2020 let directory_max_chars = label_max_chars
2021 .saturating_sub(file_name_chars)
2022 .saturating_sub(1);
2023 let truncated_directory = truncate_and_remove_front(directory, directory_max_chars.max(5));
2024 label.push_str(&truncated_directory, variable_highlight_id);
2025 }
2026 if let Some(line_number) = line_number {
2027 label.push_str(&format!(" L{}", line_number), variable_highlight_id);
2028 }
2029 label.build()
2030}
2031
2032/// Returns terminal selections from all terminal views if the terminal panel is open.
2033fn terminal_selections_if_panel_open(workspace: &Entity<Workspace>, cx: &App) -> Vec<String> {
2034 let Some(panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
2035 return Vec::new();
2036 };
2037
2038 // Check if the dock containing this panel is open
2039 let position = match TerminalSettings::get_global(cx).dock {
2040 TerminalDockPosition::Left => DockPosition::Left,
2041 TerminalDockPosition::Bottom => DockPosition::Bottom,
2042 TerminalDockPosition::Right => DockPosition::Right,
2043 };
2044 let dock_is_open = workspace
2045 .read(cx)
2046 .dock_at_position(position)
2047 .read(cx)
2048 .is_open();
2049 if !dock_is_open {
2050 return Vec::new();
2051 }
2052
2053 panel.read(cx).terminal_selections(cx)
2054}
2055
2056fn selection_ranges(
2057 workspace: &Entity<Workspace>,
2058 cx: &mut App,
2059) -> Vec<(Entity<Buffer>, Range<text::Anchor>)> {
2060 let Some(editor) = workspace
2061 .read(cx)
2062 .active_item(cx)
2063 .and_then(|item| item.act_as::<Editor>(cx))
2064 else {
2065 return Vec::new();
2066 };
2067
2068 editor.update(cx, |editor, cx| {
2069 let selections = editor.selections.all_adjusted(&editor.display_snapshot(cx));
2070
2071 let buffer = editor.buffer().clone().read(cx);
2072 let snapshot = buffer.snapshot(cx);
2073
2074 selections
2075 .into_iter()
2076 .map(|s| {
2077 let (start, end) = if s.is_empty() {
2078 let row = multi_buffer::MultiBufferRow(s.start.row);
2079 let line_start = text::Point::new(s.start.row, 0);
2080 let line_end = text::Point::new(s.start.row, snapshot.line_len(row));
2081 (line_start, line_end)
2082 } else {
2083 (s.start, s.end)
2084 };
2085 snapshot.anchor_after(start)..snapshot.anchor_before(end)
2086 })
2087 .flat_map(|range| {
2088 let (start_buffer, start) = buffer.text_anchor_for_position(range.start, cx)?;
2089 let (end_buffer, end) = buffer.text_anchor_for_position(range.end, cx)?;
2090 if start_buffer != end_buffer {
2091 return None;
2092 }
2093 Some((start_buffer, start..end))
2094 })
2095 .collect::<Vec<_>>()
2096 })
2097}
2098
2099#[cfg(test)]
2100mod tests {
2101 use super::*;
2102 use gpui::TestAppContext;
2103
2104 #[test]
2105 fn test_prompt_completion_parse() {
2106 let supported_modes = vec![PromptContextType::File, PromptContextType::Symbol];
2107
2108 assert_eq!(
2109 PromptCompletion::try_parse("/", 0, &supported_modes),
2110 Some(PromptCompletion::SlashCommand(SlashCommandCompletion {
2111 source_range: 0..1,
2112 command: None,
2113 argument: None,
2114 }))
2115 );
2116
2117 assert_eq!(
2118 PromptCompletion::try_parse("@", 0, &supported_modes),
2119 Some(PromptCompletion::Mention(MentionCompletion {
2120 source_range: 0..1,
2121 mode: None,
2122 argument: None,
2123 }))
2124 );
2125
2126 assert_eq!(
2127 PromptCompletion::try_parse("/test @file", 0, &supported_modes),
2128 Some(PromptCompletion::Mention(MentionCompletion {
2129 source_range: 6..11,
2130 mode: Some(PromptContextType::File),
2131 argument: None,
2132 }))
2133 );
2134 }
2135
2136 #[test]
2137 fn test_slash_command_completion_parse() {
2138 assert_eq!(
2139 SlashCommandCompletion::try_parse("/", 0),
2140 Some(SlashCommandCompletion {
2141 source_range: 0..1,
2142 command: None,
2143 argument: None,
2144 })
2145 );
2146
2147 assert_eq!(
2148 SlashCommandCompletion::try_parse("/help", 0),
2149 Some(SlashCommandCompletion {
2150 source_range: 0..5,
2151 command: Some("help".to_string()),
2152 argument: None,
2153 })
2154 );
2155
2156 assert_eq!(
2157 SlashCommandCompletion::try_parse("/help ", 0),
2158 Some(SlashCommandCompletion {
2159 source_range: 0..5,
2160 command: Some("help".to_string()),
2161 argument: None,
2162 })
2163 );
2164
2165 assert_eq!(
2166 SlashCommandCompletion::try_parse("/help arg1", 0),
2167 Some(SlashCommandCompletion {
2168 source_range: 0..10,
2169 command: Some("help".to_string()),
2170 argument: Some("arg1".to_string()),
2171 })
2172 );
2173
2174 assert_eq!(
2175 SlashCommandCompletion::try_parse("/help arg1 arg2", 0),
2176 Some(SlashCommandCompletion {
2177 source_range: 0..15,
2178 command: Some("help".to_string()),
2179 argument: Some("arg1 arg2".to_string()),
2180 })
2181 );
2182
2183 assert_eq!(
2184 SlashCommandCompletion::try_parse("/拿不到命令 拿不到命令 ", 0),
2185 Some(SlashCommandCompletion {
2186 source_range: 0..30,
2187 command: Some("拿不到命令".to_string()),
2188 argument: Some("拿不到命令".to_string()),
2189 })
2190 );
2191
2192 assert_eq!(SlashCommandCompletion::try_parse("Lorem Ipsum", 0), None);
2193
2194 assert_eq!(SlashCommandCompletion::try_parse("Lorem /", 0), None);
2195
2196 assert_eq!(SlashCommandCompletion::try_parse("Lorem /help", 0), None);
2197
2198 assert_eq!(SlashCommandCompletion::try_parse("Lorem/", 0), None);
2199
2200 assert_eq!(SlashCommandCompletion::try_parse("/ ", 0), None);
2201 }
2202
2203 #[test]
2204 fn test_mention_completion_parse() {
2205 let supported_modes = vec![PromptContextType::File, PromptContextType::Symbol];
2206 let supported_modes_with_diagnostics = vec![
2207 PromptContextType::File,
2208 PromptContextType::Symbol,
2209 PromptContextType::Diagnostics,
2210 ];
2211
2212 assert_eq!(
2213 MentionCompletion::try_parse("Lorem Ipsum", 0, &supported_modes),
2214 None
2215 );
2216
2217 assert_eq!(
2218 MentionCompletion::try_parse("Lorem @", 0, &supported_modes),
2219 Some(MentionCompletion {
2220 source_range: 6..7,
2221 mode: None,
2222 argument: None,
2223 })
2224 );
2225
2226 assert_eq!(
2227 MentionCompletion::try_parse("Lorem @file", 0, &supported_modes),
2228 Some(MentionCompletion {
2229 source_range: 6..11,
2230 mode: Some(PromptContextType::File),
2231 argument: None,
2232 })
2233 );
2234
2235 assert_eq!(
2236 MentionCompletion::try_parse("Lorem @file ", 0, &supported_modes),
2237 Some(MentionCompletion {
2238 source_range: 6..12,
2239 mode: Some(PromptContextType::File),
2240 argument: None,
2241 })
2242 );
2243
2244 assert_eq!(
2245 MentionCompletion::try_parse("Lorem @file main.rs", 0, &supported_modes),
2246 Some(MentionCompletion {
2247 source_range: 6..19,
2248 mode: Some(PromptContextType::File),
2249 argument: Some("main.rs".to_string()),
2250 })
2251 );
2252
2253 assert_eq!(
2254 MentionCompletion::try_parse("Lorem @file main.rs ", 0, &supported_modes),
2255 Some(MentionCompletion {
2256 source_range: 6..19,
2257 mode: Some(PromptContextType::File),
2258 argument: Some("main.rs".to_string()),
2259 })
2260 );
2261
2262 assert_eq!(
2263 MentionCompletion::try_parse("Lorem @file main.rs Ipsum", 0, &supported_modes),
2264 Some(MentionCompletion {
2265 source_range: 6..19,
2266 mode: Some(PromptContextType::File),
2267 argument: Some("main.rs".to_string()),
2268 })
2269 );
2270
2271 assert_eq!(
2272 MentionCompletion::try_parse("Lorem @main", 0, &supported_modes),
2273 Some(MentionCompletion {
2274 source_range: 6..11,
2275 mode: None,
2276 argument: Some("main".to_string()),
2277 })
2278 );
2279
2280 assert_eq!(
2281 MentionCompletion::try_parse("Lorem @main ", 0, &supported_modes),
2282 Some(MentionCompletion {
2283 source_range: 6..12,
2284 mode: None,
2285 argument: Some("main".to_string()),
2286 })
2287 );
2288
2289 assert_eq!(
2290 MentionCompletion::try_parse("Lorem @main m", 0, &supported_modes),
2291 None
2292 );
2293
2294 assert_eq!(
2295 MentionCompletion::try_parse("test@", 0, &supported_modes),
2296 None
2297 );
2298
2299 // Allowed non-file mentions
2300
2301 assert_eq!(
2302 MentionCompletion::try_parse("Lorem @symbol main", 0, &supported_modes),
2303 Some(MentionCompletion {
2304 source_range: 6..18,
2305 mode: Some(PromptContextType::Symbol),
2306 argument: Some("main".to_string()),
2307 })
2308 );
2309
2310 assert_eq!(
2311 MentionCompletion::try_parse(
2312 "Lorem @symbol agent_ui::completion_provider",
2313 0,
2314 &supported_modes
2315 ),
2316 Some(MentionCompletion {
2317 source_range: 6..43,
2318 mode: Some(PromptContextType::Symbol),
2319 argument: Some("agent_ui::completion_provider".to_string()),
2320 })
2321 );
2322
2323 assert_eq!(
2324 MentionCompletion::try_parse(
2325 "Lorem @diagnostics",
2326 0,
2327 &supported_modes_with_diagnostics
2328 ),
2329 Some(MentionCompletion {
2330 source_range: 6..18,
2331 mode: Some(PromptContextType::Diagnostics),
2332 argument: None,
2333 })
2334 );
2335
2336 // Disallowed non-file mentions
2337 assert_eq!(
2338 MentionCompletion::try_parse("Lorem @symbol main", 0, &[PromptContextType::File]),
2339 None
2340 );
2341
2342 assert_eq!(
2343 MentionCompletion::try_parse("Lorem@symbol", 0, &supported_modes),
2344 None,
2345 "Should not parse mention inside word"
2346 );
2347
2348 assert_eq!(
2349 MentionCompletion::try_parse("Lorem @ file", 0, &supported_modes),
2350 None,
2351 "Should not parse with a space after @"
2352 );
2353
2354 assert_eq!(
2355 MentionCompletion::try_parse("@ file", 0, &supported_modes),
2356 None,
2357 "Should not parse with a space after @ at the start of the line"
2358 );
2359 }
2360
2361 #[gpui::test]
2362 async fn test_filter_sessions_by_query(cx: &mut TestAppContext) {
2363 let alpha = SessionMatch {
2364 session_id: acp::SessionId::new("session-alpha"),
2365 title: "Alpha Session".into(),
2366 };
2367 let beta = SessionMatch {
2368 session_id: acp::SessionId::new("session-beta"),
2369 title: "Beta Session".into(),
2370 };
2371
2372 let sessions = vec![alpha.clone(), beta];
2373
2374 let task = {
2375 let mut app = cx.app.borrow_mut();
2376 filter_sessions_by_query(
2377 "Alpha".into(),
2378 Arc::new(AtomicBool::default()),
2379 sessions,
2380 &mut app,
2381 )
2382 };
2383
2384 let results = task.await;
2385 assert_eq!(results.len(), 1);
2386 assert_eq!(results[0].session_id, alpha.session_id);
2387 }
2388
2389 #[gpui::test]
2390 async fn test_search_files_path_distance_ordering(cx: &mut TestAppContext) {
2391 use project::Project;
2392 use serde_json::json;
2393 use util::{path, rel_path::rel_path};
2394 use workspace::{AppState, MultiWorkspace};
2395
2396 let app_state = cx.update(|cx| {
2397 let state = AppState::test(cx);
2398 theme::init(theme::LoadThemes::JustBase, cx);
2399 editor::init(cx);
2400 state
2401 });
2402
2403 app_state
2404 .fs
2405 .as_fake()
2406 .insert_tree(
2407 path!("/root"),
2408 json!({
2409 "dir1": { "a.txt": "" },
2410 "dir2": {
2411 "a.txt": "",
2412 "b.txt": ""
2413 }
2414 }),
2415 )
2416 .await;
2417
2418 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
2419 let (multi_workspace, cx) =
2420 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
2421 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2422
2423 let worktree_id = cx.read(|cx| {
2424 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
2425 assert_eq!(worktrees.len(), 1);
2426 worktrees[0].read(cx).id()
2427 });
2428
2429 // Open a file in dir2 to create navigation history.
2430 // When searching for "a.txt", dir2/a.txt should be sorted first because
2431 // it is closer to the most recently opened file (dir2/b.txt).
2432 let b_path = ProjectPath {
2433 worktree_id,
2434 path: rel_path("dir2/b.txt").into(),
2435 };
2436 workspace
2437 .update_in(cx, |workspace, window, cx| {
2438 workspace.open_path(b_path, None, true, window, cx)
2439 })
2440 .await
2441 .unwrap();
2442
2443 let results = cx
2444 .update(|_window, cx| {
2445 search_files(
2446 "a.txt".into(),
2447 Arc::new(AtomicBool::default()),
2448 &workspace,
2449 cx,
2450 )
2451 })
2452 .await;
2453
2454 assert_eq!(results.len(), 2, "expected 2 matching files");
2455 assert_eq!(
2456 results[0].mat.path.as_ref(),
2457 rel_path("dir2/a.txt"),
2458 "dir2/a.txt should be first because it's closer to the recently opened dir2/b.txt"
2459 );
2460 assert_eq!(
2461 results[1].mat.path.as_ref(),
2462 rel_path("dir1/a.txt"),
2463 "dir1/a.txt should be second"
2464 );
2465 }
2466}