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