1use std::cell::RefCell;
2use std::cmp::Reverse;
3use std::ops::Range;
4use std::path::PathBuf;
5use std::rc::Rc;
6use std::sync::Arc;
7use std::sync::atomic::AtomicBool;
8
9use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest, MentionUri};
10use agent::ThreadStore;
11use anyhow::Result;
12use editor::{
13 CompletionProvider, Editor, ExcerptId, code_context_menus::COMPLETION_MENU_MAX_WIDTH,
14};
15use fuzzy::{PathMatch, StringMatch, StringMatchCandidate};
16use gpui::{App, BackgroundExecutor, Entity, SharedString, Task, WeakEntity};
17use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId};
18use lsp::CompletionContext;
19use ordered_float::OrderedFloat;
20use project::lsp_store::{CompletionDocumentation, SymbolLocation};
21use project::{
22 Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse,
23 PathMatchCandidateSet, Project, ProjectPath, Symbol, WorktreeId,
24};
25use prompt_store::{PromptStore, UserPromptId};
26use rope::Point;
27use text::{Anchor, ToPoint as _};
28use ui::prelude::*;
29use util::ResultExt as _;
30use util::paths::PathStyle;
31use util::rel_path::RelPath;
32use util::truncate_and_remove_front;
33use workspace::Workspace;
34
35use crate::AgentPanel;
36use crate::mention_set::MentionSet;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub(crate) enum PromptContextEntry {
40 Mode(PromptContextType),
41 Action(PromptContextAction),
42}
43
44impl PromptContextEntry {
45 pub fn keyword(&self) -> &'static str {
46 match self {
47 Self::Mode(mode) => mode.keyword(),
48 Self::Action(action) => action.keyword(),
49 }
50 }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub(crate) enum PromptContextType {
55 File,
56 Symbol,
57 Fetch,
58 Thread,
59 Rules,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub(crate) enum PromptContextAction {
64 AddSelections,
65}
66
67impl PromptContextAction {
68 pub fn keyword(&self) -> &'static str {
69 match self {
70 Self::AddSelections => "selection",
71 }
72 }
73
74 pub fn label(&self) -> &'static str {
75 match self {
76 Self::AddSelections => "Selection",
77 }
78 }
79
80 pub fn icon(&self) -> IconName {
81 match self {
82 Self::AddSelections => IconName::Reader,
83 }
84 }
85}
86
87impl TryFrom<&str> for PromptContextType {
88 type Error = String;
89
90 fn try_from(value: &str) -> Result<Self, Self::Error> {
91 match value {
92 "file" => Ok(Self::File),
93 "symbol" => Ok(Self::Symbol),
94 "fetch" => Ok(Self::Fetch),
95 "thread" => Ok(Self::Thread),
96 "rule" => Ok(Self::Rules),
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 }
111 }
112
113 pub fn label(&self) -> &'static str {
114 match self {
115 Self::File => "Files & Directories",
116 Self::Symbol => "Symbols",
117 Self::Fetch => "Fetch",
118 Self::Thread => "Threads",
119 Self::Rules => "Rules",
120 }
121 }
122
123 pub fn icon(&self) -> IconName {
124 match self {
125 Self::File => IconName::File,
126 Self::Symbol => IconName::Code,
127 Self::Fetch => IconName::ToolWeb,
128 Self::Thread => IconName::Thread,
129 Self::Rules => IconName::Reader,
130 }
131 }
132}
133
134pub(crate) enum Match {
135 File(FileMatch),
136 Symbol(SymbolMatch),
137 Thread(AgentSessionInfo),
138 RecentThread(AgentSessionInfo),
139 Fetch(SharedString),
140 Rules(RulesContextEntry),
141 Entry(EntryMatch),
142}
143
144impl Match {
145 pub fn score(&self) -> f64 {
146 match self {
147 Match::File(file) => file.mat.score,
148 Match::Entry(mode) => mode.mat.as_ref().map(|mat| mat.score).unwrap_or(1.),
149 Match::Thread(_) => 1.,
150 Match::RecentThread(_) => 1.,
151 Match::Symbol(_) => 1.,
152 Match::Rules(_) => 1.,
153 Match::Fetch(_) => 1.,
154 }
155 }
156}
157
158pub struct EntryMatch {
159 mat: Option<StringMatch>,
160 entry: PromptContextEntry,
161}
162
163fn session_title(session: &AgentSessionInfo) -> SharedString {
164 session
165 .title
166 .clone()
167 .filter(|title| !title.is_empty())
168 .unwrap_or_else(|| SharedString::new_static("New Thread"))
169}
170
171#[derive(Debug, Clone)]
172pub struct RulesContextEntry {
173 pub prompt_id: UserPromptId,
174 pub title: SharedString,
175}
176
177#[derive(Debug, Clone)]
178pub struct AvailableCommand {
179 pub name: Arc<str>,
180 pub description: Arc<str>,
181 pub requires_argument: bool,
182}
183
184pub trait PromptCompletionProviderDelegate: Send + Sync + 'static {
185 fn supports_context(&self, mode: PromptContextType, cx: &App) -> bool {
186 self.supported_modes(cx).contains(&mode)
187 }
188 fn supported_modes(&self, cx: &App) -> Vec<PromptContextType>;
189 fn supports_images(&self, cx: &App) -> bool;
190
191 fn available_commands(&self, cx: &App) -> Vec<AvailableCommand>;
192 fn confirm_command(&self, cx: &mut App);
193}
194
195pub struct PromptCompletionProvider<T: PromptCompletionProviderDelegate> {
196 source: Arc<T>,
197 editor: WeakEntity<Editor>,
198 mention_set: Entity<MentionSet>,
199 thread_store: Option<Entity<ThreadStore>>,
200 session_list: Rc<RefCell<Option<Rc<dyn AgentSessionList>>>>,
201 prompt_store: Option<Entity<PromptStore>>,
202 workspace: WeakEntity<Workspace>,
203}
204
205impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
206 pub fn new(
207 source: T,
208 editor: WeakEntity<Editor>,
209 mention_set: Entity<MentionSet>,
210 thread_store: Option<Entity<ThreadStore>>,
211 session_list: Rc<RefCell<Option<Rc<dyn AgentSessionList>>>>,
212 prompt_store: Option<Entity<PromptStore>>,
213 workspace: WeakEntity<Workspace>,
214 ) -> Self {
215 Self {
216 source: Arc::new(source),
217 editor,
218 mention_set,
219 workspace,
220 thread_store,
221 session_list,
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 search_slash_commands(&self, query: String, cx: &mut App) -> Task<Vec<AvailableCommand>> {
592 let commands = self.source.available_commands(cx);
593 if commands.is_empty() {
594 return Task::ready(Vec::new());
595 }
596
597 cx.spawn(async move |cx| {
598 let candidates = commands
599 .iter()
600 .enumerate()
601 .map(|(id, command)| StringMatchCandidate::new(id, &command.name))
602 .collect::<Vec<_>>();
603
604 let matches = fuzzy::match_strings(
605 &candidates,
606 &query,
607 false,
608 true,
609 100,
610 &Arc::new(AtomicBool::default()),
611 cx.background_executor().clone(),
612 )
613 .await;
614
615 matches
616 .into_iter()
617 .map(|mat| commands[mat.candidate_id].clone())
618 .collect()
619 })
620 }
621
622 fn search_mentions(
623 &self,
624 mode: Option<PromptContextType>,
625 query: String,
626 cancellation_flag: Arc<AtomicBool>,
627 cx: &mut App,
628 ) -> Task<Vec<Match>> {
629 let Some(workspace) = self.workspace.upgrade() else {
630 return Task::ready(Vec::default());
631 };
632 match mode {
633 Some(PromptContextType::File) => {
634 let search_files_task = search_files(query, cancellation_flag, &workspace, cx);
635 cx.background_spawn(async move {
636 search_files_task
637 .await
638 .into_iter()
639 .map(Match::File)
640 .collect()
641 })
642 }
643
644 Some(PromptContextType::Symbol) => {
645 let search_symbols_task = search_symbols(query, cancellation_flag, &workspace, cx);
646 cx.background_spawn(async move {
647 search_symbols_task
648 .await
649 .into_iter()
650 .map(Match::Symbol)
651 .collect()
652 })
653 }
654
655 Some(PromptContextType::Thread) => {
656 if let Some(session_list) = self.session_list.borrow().clone() {
657 let search_sessions_task =
658 search_sessions(query, cancellation_flag, session_list, cx);
659 cx.spawn(async move |_cx| {
660 search_sessions_task
661 .await
662 .into_iter()
663 .map(Match::Thread)
664 .collect()
665 })
666 } else if let Some(thread_store) = self.thread_store.as_ref() {
667 let search_threads_task =
668 search_threads(query, cancellation_flag, thread_store, cx);
669 cx.background_spawn(async move {
670 search_threads_task
671 .await
672 .into_iter()
673 .map(Match::Thread)
674 .collect()
675 })
676 } else {
677 Task::ready(Vec::new())
678 }
679 }
680
681 Some(PromptContextType::Fetch) => {
682 if !query.is_empty() {
683 Task::ready(vec![Match::Fetch(query.into())])
684 } else {
685 Task::ready(Vec::new())
686 }
687 }
688
689 Some(PromptContextType::Rules) => {
690 if let Some(prompt_store) = self.prompt_store.as_ref() {
691 let search_rules_task =
692 search_rules(query, cancellation_flag, prompt_store, cx);
693 cx.background_spawn(async move {
694 search_rules_task
695 .await
696 .into_iter()
697 .map(Match::Rules)
698 .collect::<Vec<_>>()
699 })
700 } else {
701 Task::ready(Vec::new())
702 }
703 }
704
705 None if query.is_empty() => {
706 let recent_task = self.recent_context_picker_entries(&workspace, cx);
707 let entries = self
708 .available_context_picker_entries(&workspace, cx)
709 .into_iter()
710 .map(|mode| {
711 Match::Entry(EntryMatch {
712 entry: mode,
713 mat: None,
714 })
715 })
716 .collect::<Vec<_>>();
717
718 cx.spawn(async move |_cx| {
719 let mut matches = recent_task.await;
720 matches.extend(entries);
721 matches
722 })
723 }
724 None => {
725 let executor = cx.background_executor().clone();
726
727 let search_files_task =
728 search_files(query.clone(), cancellation_flag, &workspace, cx);
729
730 let entries = self.available_context_picker_entries(&workspace, cx);
731 let entry_candidates = entries
732 .iter()
733 .enumerate()
734 .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword()))
735 .collect::<Vec<_>>();
736
737 cx.background_spawn(async move {
738 let mut matches = search_files_task
739 .await
740 .into_iter()
741 .map(Match::File)
742 .collect::<Vec<_>>();
743
744 let entry_matches = fuzzy::match_strings(
745 &entry_candidates,
746 &query,
747 false,
748 true,
749 100,
750 &Arc::new(AtomicBool::default()),
751 executor,
752 )
753 .await;
754
755 matches.extend(entry_matches.into_iter().map(|mat| {
756 Match::Entry(EntryMatch {
757 entry: entries[mat.candidate_id],
758 mat: Some(mat),
759 })
760 }));
761
762 matches.sort_by(|a, b| {
763 b.score()
764 .partial_cmp(&a.score())
765 .unwrap_or(std::cmp::Ordering::Equal)
766 });
767
768 matches
769 })
770 }
771 }
772 }
773
774 fn recent_context_picker_entries(
775 &self,
776 workspace: &Entity<Workspace>,
777 cx: &mut App,
778 ) -> Task<Vec<Match>> {
779 let mut recent = Vec::with_capacity(6);
780
781 let mut mentions = self
782 .mention_set
783 .read_with(cx, |store, _cx| store.mentions());
784 let workspace = workspace.read(cx);
785 let project = workspace.project().read(cx);
786 let include_root_name = workspace.visible_worktrees(cx).count() > 1;
787
788 if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx)
789 && let Some(thread) = agent_panel.read(cx).active_agent_thread(cx)
790 {
791 let thread = thread.read(cx);
792 mentions.insert(MentionUri::Thread {
793 id: thread.session_id().clone(),
794 name: thread.title().into(),
795 });
796 }
797
798 recent.extend(
799 workspace
800 .recent_navigation_history_iter(cx)
801 .filter(|(_, abs_path)| {
802 abs_path.as_ref().is_none_or(|path| {
803 !mentions.contains(&MentionUri::File {
804 abs_path: path.clone(),
805 })
806 })
807 })
808 .take(4)
809 .filter_map(|(project_path, _)| {
810 project
811 .worktree_for_id(project_path.worktree_id, cx)
812 .map(|worktree| {
813 let path_prefix = if include_root_name {
814 worktree.read(cx).root_name().into()
815 } else {
816 RelPath::empty().into()
817 };
818 Match::File(FileMatch {
819 mat: fuzzy::PathMatch {
820 score: 1.,
821 positions: Vec::new(),
822 worktree_id: project_path.worktree_id.to_usize(),
823 path: project_path.path,
824 path_prefix,
825 is_dir: false,
826 distance_to_relative_ancestor: 0,
827 },
828 is_recent: true,
829 })
830 })
831 }),
832 );
833
834 if !self.source.supports_context(PromptContextType::Thread, cx) {
835 return Task::ready(recent);
836 }
837
838 if let Some(session_list) = self.session_list.borrow().clone() {
839 let task = session_list.list_sessions(AgentSessionListRequest::default(), cx);
840 return cx.spawn(async move |_cx| {
841 let sessions = match task.await {
842 Ok(response) => response.sessions,
843 Err(error) => {
844 log::error!("Failed to load recent sessions: {error:#}");
845 return recent;
846 }
847 };
848
849 const RECENT_COUNT: usize = 2;
850 let threads = sessions
851 .into_iter()
852 .filter(|session| {
853 let uri = MentionUri::Thread {
854 id: session.session_id.clone(),
855 name: session_title(session).to_string(),
856 };
857 !mentions.contains(&uri)
858 })
859 .take(RECENT_COUNT)
860 .collect::<Vec<_>>();
861
862 recent.extend(threads.into_iter().map(Match::RecentThread));
863 recent
864 });
865 }
866
867 let Some(thread_store) = self.thread_store.as_ref() else {
868 return Task::ready(recent);
869 };
870
871 const RECENT_COUNT: usize = 2;
872 let threads = thread_store
873 .read(cx)
874 .entries()
875 .map(thread_metadata_to_session_info)
876 .filter(|thread| {
877 let uri = MentionUri::Thread {
878 id: thread.session_id.clone(),
879 name: session_title(thread).to_string(),
880 };
881 !mentions.contains(&uri)
882 })
883 .take(RECENT_COUNT)
884 .collect::<Vec<_>>();
885
886 recent.extend(threads.into_iter().map(Match::RecentThread));
887
888 Task::ready(recent)
889 }
890
891 fn available_context_picker_entries(
892 &self,
893 workspace: &Entity<Workspace>,
894 cx: &mut App,
895 ) -> Vec<PromptContextEntry> {
896 let mut entries = vec![
897 PromptContextEntry::Mode(PromptContextType::File),
898 PromptContextEntry::Mode(PromptContextType::Symbol),
899 ];
900
901 if self.source.supports_context(PromptContextType::Thread, cx) {
902 entries.push(PromptContextEntry::Mode(PromptContextType::Thread));
903 }
904
905 let has_selection = workspace
906 .read(cx)
907 .active_item(cx)
908 .and_then(|item| item.downcast::<Editor>())
909 .is_some_and(|editor| {
910 editor.update(cx, |editor, cx| {
911 editor.has_non_empty_selection(&editor.display_snapshot(cx))
912 })
913 });
914 if has_selection {
915 entries.push(PromptContextEntry::Action(
916 PromptContextAction::AddSelections,
917 ));
918 }
919
920 if self.prompt_store.is_some() && self.source.supports_context(PromptContextType::Rules, cx)
921 {
922 entries.push(PromptContextEntry::Mode(PromptContextType::Rules));
923 }
924
925 if self.source.supports_context(PromptContextType::Fetch, cx) {
926 entries.push(PromptContextEntry::Mode(PromptContextType::Fetch));
927 }
928
929 entries
930 }
931}
932
933impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletionProvider<T> {
934 fn completions(
935 &self,
936 _excerpt_id: ExcerptId,
937 buffer: &Entity<Buffer>,
938 buffer_position: Anchor,
939 _trigger: CompletionContext,
940 window: &mut Window,
941 cx: &mut Context<Editor>,
942 ) -> Task<Result<Vec<CompletionResponse>>> {
943 let state = buffer.update(cx, |buffer, cx| {
944 let position = buffer_position.to_point(buffer);
945 let line_start = Point::new(position.row, 0);
946 let offset_to_line = buffer.point_to_offset(line_start);
947 let mut lines = buffer.text_for_range(line_start..position).lines();
948 let line = lines.next()?;
949 PromptCompletion::try_parse(line, offset_to_line, &self.source.supported_modes(cx))
950 });
951 let Some(state) = state else {
952 return Task::ready(Ok(Vec::new()));
953 };
954
955 let Some(workspace) = self.workspace.upgrade() else {
956 return Task::ready(Ok(Vec::new()));
957 };
958
959 let project = workspace.read(cx).project().clone();
960 let snapshot = buffer.read(cx).snapshot();
961 let source_range = snapshot.anchor_before(state.source_range().start)
962 ..snapshot.anchor_after(state.source_range().end);
963
964 let source = self.source.clone();
965 let editor = self.editor.clone();
966 let mention_set = self.mention_set.downgrade();
967 match state {
968 PromptCompletion::SlashCommand(SlashCommandCompletion {
969 command, argument, ..
970 }) => {
971 let search_task = self.search_slash_commands(command.unwrap_or_default(), cx);
972 cx.background_spawn(async move {
973 let completions = search_task
974 .await
975 .into_iter()
976 .map(|command| {
977 let new_text = if let Some(argument) = argument.as_ref() {
978 format!("/{} {}", command.name, argument)
979 } else {
980 format!("/{} ", command.name)
981 };
982
983 let is_missing_argument =
984 command.requires_argument && argument.is_none();
985 Completion {
986 replace_range: source_range.clone(),
987 new_text,
988 label: CodeLabel::plain(command.name.to_string(), None),
989 documentation: Some(CompletionDocumentation::MultiLinePlainText(
990 command.description.into(),
991 )),
992 source: project::CompletionSource::Custom,
993 icon_path: None,
994 match_start: None,
995 snippet_deduplication_key: None,
996 insert_text_mode: None,
997 confirm: Some(Arc::new({
998 let source = source.clone();
999 move |intent, _window, cx| {
1000 if !is_missing_argument {
1001 cx.defer({
1002 let source = source.clone();
1003 move |cx| match intent {
1004 CompletionIntent::Complete
1005 | CompletionIntent::CompleteWithInsert
1006 | CompletionIntent::CompleteWithReplace => {
1007 source.confirm_command(cx);
1008 }
1009 CompletionIntent::Compose => {}
1010 }
1011 });
1012 }
1013 false
1014 }
1015 })),
1016 }
1017 })
1018 .collect();
1019
1020 Ok(vec![CompletionResponse {
1021 completions,
1022 display_options: CompletionDisplayOptions {
1023 dynamic_width: true,
1024 },
1025 // Since this does its own filtering (see `filter_completions()` returns false),
1026 // there is no benefit to computing whether this set of completions is incomplete.
1027 is_incomplete: true,
1028 }])
1029 })
1030 }
1031 PromptCompletion::Mention(MentionCompletion { mode, argument, .. }) => {
1032 let query = argument.unwrap_or_default();
1033 let search_task =
1034 self.search_mentions(mode, query, Arc::<AtomicBool>::default(), cx);
1035
1036 // Calculate maximum characters available for the full label (file_name + space + directory)
1037 // based on maximum menu width after accounting for padding, spacing, and icon width
1038 let label_max_chars = {
1039 // Base06 left padding + Base06 gap + Base06 right padding + icon width
1040 let used_pixels = DynamicSpacing::Base06.px(cx) * 3.0
1041 + IconSize::XSmall.rems() * window.rem_size();
1042
1043 let style = window.text_style();
1044 let font_id = window.text_system().resolve_font(&style.font());
1045 let font_size = TextSize::Small.rems(cx).to_pixels(window.rem_size());
1046
1047 // Fallback em_width of 10px matches file_finder.rs fallback for TextSize::Small
1048 let em_width = cx
1049 .text_system()
1050 .em_width(font_id, font_size)
1051 .unwrap_or(px(10.0));
1052
1053 // Calculate available pixels for text (file_name + directory)
1054 // Using max width since dynamic_width allows the menu to expand up to this
1055 let available_pixels = COMPLETION_MENU_MAX_WIDTH - used_pixels;
1056
1057 // Convert to character count (total available for file_name + directory)
1058 (f32::from(available_pixels) / f32::from(em_width)) as usize
1059 };
1060
1061 cx.spawn(async move |_, cx| {
1062 let matches = search_task.await;
1063
1064 let completions = cx.update(|cx| {
1065 matches
1066 .into_iter()
1067 .filter_map(|mat| match mat {
1068 Match::File(FileMatch { mat, is_recent }) => {
1069 let project_path = ProjectPath {
1070 worktree_id: WorktreeId::from_usize(mat.worktree_id),
1071 path: mat.path.clone(),
1072 };
1073
1074 // If path is empty, this means we're matching with the root directory itself
1075 // so we use the path_prefix as the name
1076 let path_prefix = if mat.path.is_empty() {
1077 project
1078 .read(cx)
1079 .worktree_for_id(project_path.worktree_id, cx)
1080 .map(|wt| wt.read(cx).root_name().into())
1081 .unwrap_or_else(|| mat.path_prefix.clone())
1082 } else {
1083 mat.path_prefix.clone()
1084 };
1085
1086 Self::completion_for_path(
1087 project_path,
1088 &path_prefix,
1089 is_recent,
1090 mat.is_dir,
1091 source_range.clone(),
1092 source.clone(),
1093 editor.clone(),
1094 mention_set.clone(),
1095 workspace.clone(),
1096 project.clone(),
1097 label_max_chars,
1098 cx,
1099 )
1100 }
1101
1102 Match::Symbol(SymbolMatch { symbol, .. }) => {
1103 Self::completion_for_symbol(
1104 symbol,
1105 source_range.clone(),
1106 source.clone(),
1107 editor.clone(),
1108 mention_set.clone(),
1109 workspace.clone(),
1110 label_max_chars,
1111 cx,
1112 )
1113 }
1114
1115 Match::Thread(thread) => Some(Self::completion_for_thread(
1116 thread,
1117 source_range.clone(),
1118 false,
1119 source.clone(),
1120 editor.clone(),
1121 mention_set.clone(),
1122 workspace.clone(),
1123 cx,
1124 )),
1125
1126 Match::RecentThread(thread) => Some(Self::completion_for_thread(
1127 thread,
1128 source_range.clone(),
1129 true,
1130 source.clone(),
1131 editor.clone(),
1132 mention_set.clone(),
1133 workspace.clone(),
1134 cx,
1135 )),
1136
1137 Match::Rules(user_rules) => Some(Self::completion_for_rules(
1138 user_rules,
1139 source_range.clone(),
1140 source.clone(),
1141 editor.clone(),
1142 mention_set.clone(),
1143 workspace.clone(),
1144 cx,
1145 )),
1146
1147 Match::Fetch(url) => Self::completion_for_fetch(
1148 source_range.clone(),
1149 url,
1150 source.clone(),
1151 editor.clone(),
1152 mention_set.clone(),
1153 workspace.clone(),
1154 cx,
1155 ),
1156
1157 Match::Entry(EntryMatch { entry, .. }) => {
1158 Self::completion_for_entry(
1159 entry,
1160 source_range.clone(),
1161 editor.clone(),
1162 mention_set.clone(),
1163 &workspace,
1164 cx,
1165 )
1166 }
1167 })
1168 .collect::<Vec<_>>()
1169 });
1170
1171 Ok(vec![CompletionResponse {
1172 completions,
1173 display_options: CompletionDisplayOptions {
1174 dynamic_width: true,
1175 },
1176 // Since this does its own filtering (see `filter_completions()` returns false),
1177 // there is no benefit to computing whether this set of completions is incomplete.
1178 is_incomplete: true,
1179 }])
1180 })
1181 }
1182 }
1183 }
1184
1185 fn is_completion_trigger(
1186 &self,
1187 buffer: &Entity<language::Buffer>,
1188 position: language::Anchor,
1189 _text: &str,
1190 _trigger_in_words: bool,
1191 cx: &mut Context<Editor>,
1192 ) -> bool {
1193 let buffer = buffer.read(cx);
1194 let position = position.to_point(buffer);
1195 let line_start = Point::new(position.row, 0);
1196 let offset_to_line = buffer.point_to_offset(line_start);
1197 let mut lines = buffer.text_for_range(line_start..position).lines();
1198 if let Some(line) = lines.next() {
1199 PromptCompletion::try_parse(line, offset_to_line, &self.source.supported_modes(cx))
1200 .filter(|completion| {
1201 // Right now we don't support completing arguments of slash commands
1202 let is_slash_command_with_argument = matches!(
1203 completion,
1204 PromptCompletion::SlashCommand(SlashCommandCompletion {
1205 argument: Some(_),
1206 ..
1207 })
1208 );
1209 !is_slash_command_with_argument
1210 })
1211 .map(|completion| {
1212 completion.source_range().start <= offset_to_line + position.column as usize
1213 && completion.source_range().end
1214 >= offset_to_line + position.column as usize
1215 })
1216 .unwrap_or(false)
1217 } else {
1218 false
1219 }
1220 }
1221
1222 fn sort_completions(&self) -> bool {
1223 false
1224 }
1225
1226 fn filter_completions(&self) -> bool {
1227 false
1228 }
1229}
1230
1231fn confirm_completion_callback<T: PromptCompletionProviderDelegate>(
1232 crease_text: SharedString,
1233 start: Anchor,
1234 content_len: usize,
1235 mention_uri: MentionUri,
1236 source: Arc<T>,
1237 editor: WeakEntity<Editor>,
1238 mention_set: WeakEntity<MentionSet>,
1239 workspace: Entity<Workspace>,
1240) -> Arc<dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync> {
1241 Arc::new(move |_, window, cx| {
1242 let source = source.clone();
1243 let editor = editor.clone();
1244 let mention_set = mention_set.clone();
1245 let crease_text = crease_text.clone();
1246 let mention_uri = mention_uri.clone();
1247 let workspace = workspace.clone();
1248 window.defer(cx, move |window, cx| {
1249 if let Some(editor) = editor.upgrade() {
1250 mention_set
1251 .clone()
1252 .update(cx, |mention_set, cx| {
1253 mention_set
1254 .confirm_mention_completion(
1255 crease_text,
1256 start,
1257 content_len,
1258 mention_uri,
1259 source.supports_images(cx),
1260 editor,
1261 &workspace,
1262 window,
1263 cx,
1264 )
1265 .detach();
1266 })
1267 .ok();
1268 }
1269 });
1270 false
1271 })
1272}
1273
1274#[derive(Debug, PartialEq)]
1275enum PromptCompletion {
1276 SlashCommand(SlashCommandCompletion),
1277 Mention(MentionCompletion),
1278}
1279
1280impl PromptCompletion {
1281 fn source_range(&self) -> Range<usize> {
1282 match self {
1283 Self::SlashCommand(completion) => completion.source_range.clone(),
1284 Self::Mention(completion) => completion.source_range.clone(),
1285 }
1286 }
1287
1288 fn try_parse(
1289 line: &str,
1290 offset_to_line: usize,
1291 supported_modes: &[PromptContextType],
1292 ) -> Option<Self> {
1293 if line.contains('@') {
1294 if let Some(mention) =
1295 MentionCompletion::try_parse(line, offset_to_line, supported_modes)
1296 {
1297 return Some(Self::Mention(mention));
1298 }
1299 }
1300 SlashCommandCompletion::try_parse(line, offset_to_line).map(Self::SlashCommand)
1301 }
1302}
1303
1304#[derive(Debug, Default, PartialEq)]
1305pub struct SlashCommandCompletion {
1306 pub source_range: Range<usize>,
1307 pub command: Option<String>,
1308 pub argument: Option<String>,
1309}
1310
1311impl SlashCommandCompletion {
1312 pub fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
1313 // If we decide to support commands that are not at the beginning of the prompt, we can remove this check
1314 if !line.starts_with('/') || offset_to_line != 0 {
1315 return None;
1316 }
1317
1318 let (prefix, last_command) = line.rsplit_once('/')?;
1319 if prefix.chars().last().is_some_and(|c| !c.is_whitespace())
1320 || last_command.starts_with(char::is_whitespace)
1321 {
1322 return None;
1323 }
1324
1325 let mut argument = None;
1326 let mut command = None;
1327 if let Some((command_text, args)) = last_command.split_once(char::is_whitespace) {
1328 if !args.is_empty() {
1329 argument = Some(args.trim_end().to_string());
1330 }
1331 command = Some(command_text.to_string());
1332 } else if !last_command.is_empty() {
1333 command = Some(last_command.to_string());
1334 };
1335
1336 Some(Self {
1337 source_range: prefix.len() + offset_to_line
1338 ..line
1339 .rfind(|c: char| !c.is_whitespace())
1340 .unwrap_or_else(|| line.len())
1341 + 1
1342 + offset_to_line,
1343 command,
1344 argument,
1345 })
1346 }
1347}
1348
1349#[derive(Debug, Default, PartialEq)]
1350struct MentionCompletion {
1351 source_range: Range<usize>,
1352 mode: Option<PromptContextType>,
1353 argument: Option<String>,
1354}
1355
1356impl MentionCompletion {
1357 fn try_parse(
1358 line: &str,
1359 offset_to_line: usize,
1360 supported_modes: &[PromptContextType],
1361 ) -> Option<Self> {
1362 let last_mention_start = line.rfind('@')?;
1363
1364 // No whitespace immediately after '@'
1365 if line[last_mention_start + 1..]
1366 .chars()
1367 .next()
1368 .is_some_and(|c| c.is_whitespace())
1369 {
1370 return None;
1371 }
1372
1373 // Must be a word boundary before '@'
1374 if last_mention_start > 0
1375 && line[..last_mention_start]
1376 .chars()
1377 .last()
1378 .is_some_and(|c| !c.is_whitespace())
1379 {
1380 return None;
1381 }
1382
1383 let rest_of_line = &line[last_mention_start + 1..];
1384
1385 let mut mode = None;
1386 let mut argument = None;
1387
1388 let mut parts = rest_of_line.split_whitespace();
1389 let mut end = last_mention_start + 1;
1390
1391 if let Some(mode_text) = parts.next() {
1392 // Safe since we check no leading whitespace above
1393 end += mode_text.len();
1394
1395 if let Some(parsed_mode) = PromptContextType::try_from(mode_text).ok()
1396 && supported_modes.contains(&parsed_mode)
1397 {
1398 mode = Some(parsed_mode);
1399 } else {
1400 argument = Some(mode_text.to_string());
1401 }
1402 match rest_of_line[mode_text.len()..].find(|c: char| !c.is_whitespace()) {
1403 Some(whitespace_count) => {
1404 if let Some(argument_text) = parts.next() {
1405 // If mode wasn't recognized but we have an argument, don't suggest completions
1406 // (e.g. '@something word')
1407 if mode.is_none() && !argument_text.is_empty() {
1408 return None;
1409 }
1410
1411 argument = Some(argument_text.to_string());
1412 end += whitespace_count + argument_text.len();
1413 }
1414 }
1415 None => {
1416 // Rest of line is entirely whitespace
1417 end += rest_of_line.len() - mode_text.len();
1418 }
1419 }
1420 }
1421
1422 Some(Self {
1423 source_range: last_mention_start + offset_to_line..end + offset_to_line,
1424 mode,
1425 argument,
1426 })
1427 }
1428}
1429
1430pub(crate) fn search_files(
1431 query: String,
1432 cancellation_flag: Arc<AtomicBool>,
1433 workspace: &Entity<Workspace>,
1434 cx: &App,
1435) -> Task<Vec<FileMatch>> {
1436 if query.is_empty() {
1437 let workspace = workspace.read(cx);
1438 let project = workspace.project().read(cx);
1439 let visible_worktrees = workspace.visible_worktrees(cx).collect::<Vec<_>>();
1440 let include_root_name = visible_worktrees.len() > 1;
1441
1442 let recent_matches = workspace
1443 .recent_navigation_history(Some(10), cx)
1444 .into_iter()
1445 .map(|(project_path, _)| {
1446 let path_prefix = if include_root_name {
1447 project
1448 .worktree_for_id(project_path.worktree_id, cx)
1449 .map(|wt| wt.read(cx).root_name().into())
1450 .unwrap_or_else(|| RelPath::empty().into())
1451 } else {
1452 RelPath::empty().into()
1453 };
1454
1455 FileMatch {
1456 mat: PathMatch {
1457 score: 0.,
1458 positions: Vec::new(),
1459 worktree_id: project_path.worktree_id.to_usize(),
1460 path: project_path.path,
1461 path_prefix,
1462 distance_to_relative_ancestor: 0,
1463 is_dir: false,
1464 },
1465 is_recent: true,
1466 }
1467 });
1468
1469 let file_matches = visible_worktrees.into_iter().flat_map(|worktree| {
1470 let worktree = worktree.read(cx);
1471 let path_prefix: Arc<RelPath> = if include_root_name {
1472 worktree.root_name().into()
1473 } else {
1474 RelPath::empty().into()
1475 };
1476 worktree.entries(false, 0).map(move |entry| FileMatch {
1477 mat: PathMatch {
1478 score: 0.,
1479 positions: Vec::new(),
1480 worktree_id: worktree.id().to_usize(),
1481 path: entry.path.clone(),
1482 path_prefix: path_prefix.clone(),
1483 distance_to_relative_ancestor: 0,
1484 is_dir: entry.is_dir(),
1485 },
1486 is_recent: false,
1487 })
1488 });
1489
1490 Task::ready(recent_matches.chain(file_matches).collect())
1491 } else {
1492 let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
1493 let include_root_name = worktrees.len() > 1;
1494 let candidate_sets = worktrees
1495 .into_iter()
1496 .map(|worktree| {
1497 let worktree = worktree.read(cx);
1498
1499 PathMatchCandidateSet {
1500 snapshot: worktree.snapshot(),
1501 include_ignored: worktree.root_entry().is_some_and(|entry| entry.is_ignored),
1502 include_root_name,
1503 candidates: project::Candidates::Entries,
1504 }
1505 })
1506 .collect::<Vec<_>>();
1507
1508 let executor = cx.background_executor().clone();
1509 cx.foreground_executor().spawn(async move {
1510 fuzzy::match_path_sets(
1511 candidate_sets.as_slice(),
1512 query.as_str(),
1513 &None,
1514 false,
1515 100,
1516 &cancellation_flag,
1517 executor,
1518 )
1519 .await
1520 .into_iter()
1521 .map(|mat| FileMatch {
1522 mat,
1523 is_recent: false,
1524 })
1525 .collect::<Vec<_>>()
1526 })
1527 }
1528}
1529
1530pub(crate) fn search_symbols(
1531 query: String,
1532 cancellation_flag: Arc<AtomicBool>,
1533 workspace: &Entity<Workspace>,
1534 cx: &mut App,
1535) -> Task<Vec<SymbolMatch>> {
1536 let symbols_task = workspace.update(cx, |workspace, cx| {
1537 workspace
1538 .project()
1539 .update(cx, |project, cx| project.symbols(&query, cx))
1540 });
1541 let project = workspace.read(cx).project().clone();
1542 cx.spawn(async move |cx| {
1543 let Some(symbols) = symbols_task.await.log_err() else {
1544 return Vec::new();
1545 };
1546 let (visible_match_candidates, external_match_candidates): (Vec<_>, Vec<_>) = project
1547 .update(cx, |project, cx| {
1548 symbols
1549 .iter()
1550 .enumerate()
1551 .map(|(id, symbol)| StringMatchCandidate::new(id, symbol.label.filter_text()))
1552 .partition(|candidate| match &symbols[candidate.id].path {
1553 SymbolLocation::InProject(project_path) => project
1554 .entry_for_path(project_path, cx)
1555 .is_some_and(|e| !e.is_ignored),
1556 SymbolLocation::OutsideProject { .. } => false,
1557 })
1558 });
1559
1560 const MAX_MATCHES: usize = 100;
1561 let mut visible_matches = cx.background_executor().block(fuzzy::match_strings(
1562 &visible_match_candidates,
1563 &query,
1564 false,
1565 true,
1566 MAX_MATCHES,
1567 &cancellation_flag,
1568 cx.background_executor().clone(),
1569 ));
1570 let mut external_matches = cx.background_executor().block(fuzzy::match_strings(
1571 &external_match_candidates,
1572 &query,
1573 false,
1574 true,
1575 MAX_MATCHES - visible_matches.len().min(MAX_MATCHES),
1576 &cancellation_flag,
1577 cx.background_executor().clone(),
1578 ));
1579 let sort_key_for_match = |mat: &StringMatch| {
1580 let symbol = &symbols[mat.candidate_id];
1581 (Reverse(OrderedFloat(mat.score)), symbol.label.filter_text())
1582 };
1583
1584 visible_matches.sort_unstable_by_key(sort_key_for_match);
1585 external_matches.sort_unstable_by_key(sort_key_for_match);
1586 let mut matches = visible_matches;
1587 matches.append(&mut external_matches);
1588
1589 matches
1590 .into_iter()
1591 .map(|mut mat| {
1592 let symbol = symbols[mat.candidate_id].clone();
1593 let filter_start = symbol.label.filter_range.start;
1594 for position in &mut mat.positions {
1595 *position += filter_start;
1596 }
1597 SymbolMatch { symbol }
1598 })
1599 .collect()
1600 })
1601}
1602
1603pub(crate) fn search_threads(
1604 query: String,
1605 cancellation_flag: Arc<AtomicBool>,
1606 thread_store: &Entity<ThreadStore>,
1607 cx: &mut App,
1608) -> Task<Vec<AgentSessionInfo>> {
1609 let sessions = thread_store
1610 .read(cx)
1611 .entries()
1612 .map(thread_metadata_to_session_info)
1613 .collect::<Vec<_>>();
1614 if query.is_empty() {
1615 return Task::ready(sessions);
1616 }
1617
1618 let executor = cx.background_executor().clone();
1619 cx.background_spawn(async move {
1620 filter_sessions(query, cancellation_flag, sessions, executor).await
1621 })
1622}
1623
1624pub(crate) fn search_sessions(
1625 query: String,
1626 cancellation_flag: Arc<AtomicBool>,
1627 session_list: Rc<dyn AgentSessionList>,
1628 cx: &mut App,
1629) -> Task<Vec<AgentSessionInfo>> {
1630 let task = session_list.list_sessions(AgentSessionListRequest::default(), cx);
1631 let executor = cx.background_executor().clone();
1632 cx.spawn(async move |_cx| {
1633 let sessions = match task.await {
1634 Ok(response) => response.sessions,
1635 Err(error) => {
1636 log::error!("Failed to list sessions: {error:#}");
1637 return Vec::new();
1638 }
1639 };
1640 if query.is_empty() {
1641 return sessions;
1642 }
1643 filter_sessions(query, cancellation_flag, sessions, executor).await
1644 })
1645}
1646
1647async fn filter_sessions(
1648 query: String,
1649 cancellation_flag: Arc<AtomicBool>,
1650 sessions: Vec<AgentSessionInfo>,
1651 executor: BackgroundExecutor,
1652) -> Vec<AgentSessionInfo> {
1653 let titles = sessions.iter().map(session_title).collect::<Vec<_>>();
1654 let candidates = titles
1655 .iter()
1656 .enumerate()
1657 .map(|(id, title)| StringMatchCandidate::new(id, title.as_ref()))
1658 .collect::<Vec<_>>();
1659 let matches = fuzzy::match_strings(
1660 &candidates,
1661 &query,
1662 false,
1663 true,
1664 100,
1665 &cancellation_flag,
1666 executor,
1667 )
1668 .await;
1669
1670 matches
1671 .into_iter()
1672 .map(|mat| sessions[mat.candidate_id].clone())
1673 .collect()
1674}
1675
1676fn thread_metadata_to_session_info(entry: agent::DbThreadMetadata) -> AgentSessionInfo {
1677 AgentSessionInfo {
1678 session_id: entry.id,
1679 cwd: None,
1680 title: Some(entry.title),
1681 updated_at: Some(entry.updated_at),
1682 meta: None,
1683 }
1684}
1685
1686pub(crate) fn search_rules(
1687 query: String,
1688 cancellation_flag: Arc<AtomicBool>,
1689 prompt_store: &Entity<PromptStore>,
1690 cx: &mut App,
1691) -> Task<Vec<RulesContextEntry>> {
1692 let search_task = prompt_store.read(cx).search(query, cancellation_flag, cx);
1693 cx.background_spawn(async move {
1694 search_task
1695 .await
1696 .into_iter()
1697 .flat_map(|metadata| {
1698 // Default prompts are filtered out as they are automatically included.
1699 if metadata.default {
1700 None
1701 } else {
1702 Some(RulesContextEntry {
1703 prompt_id: metadata.id.as_user()?,
1704 title: metadata.title?,
1705 })
1706 }
1707 })
1708 .collect::<Vec<_>>()
1709 })
1710}
1711
1712pub struct SymbolMatch {
1713 pub symbol: Symbol,
1714}
1715
1716pub struct FileMatch {
1717 pub mat: PathMatch,
1718 pub is_recent: bool,
1719}
1720
1721pub fn extract_file_name_and_directory(
1722 path: &RelPath,
1723 path_prefix: &RelPath,
1724 path_style: PathStyle,
1725) -> (SharedString, Option<SharedString>) {
1726 // If path is empty, this means we're matching with the root directory itself
1727 // so we use the path_prefix as the name
1728 if path.is_empty() && !path_prefix.is_empty() {
1729 return (path_prefix.display(path_style).to_string().into(), None);
1730 }
1731
1732 let full_path = path_prefix.join(path);
1733 let file_name = full_path.file_name().unwrap_or_default();
1734 let display_path = full_path.display(path_style);
1735 let (directory, file_name) = display_path.split_at(display_path.len() - file_name.len());
1736 (
1737 file_name.to_string().into(),
1738 Some(SharedString::new(directory)).filter(|dir| !dir.is_empty()),
1739 )
1740}
1741
1742fn build_code_label_for_path(
1743 file: &str,
1744 directory: Option<&str>,
1745 line_number: Option<u32>,
1746 label_max_chars: usize,
1747 cx: &App,
1748) -> CodeLabel {
1749 let variable_highlight_id = cx
1750 .theme()
1751 .syntax()
1752 .highlight_id("variable")
1753 .map(HighlightId);
1754 let mut label = CodeLabelBuilder::default();
1755
1756 label.push_str(file, None);
1757 label.push_str(" ", None);
1758
1759 if let Some(directory) = directory {
1760 let file_name_chars = file.chars().count();
1761 // Account for: file_name + space (ellipsis is handled by truncate_and_remove_front)
1762 let directory_max_chars = label_max_chars
1763 .saturating_sub(file_name_chars)
1764 .saturating_sub(1);
1765 let truncated_directory = truncate_and_remove_front(directory, directory_max_chars.max(5));
1766 label.push_str(&truncated_directory, variable_highlight_id);
1767 }
1768 if let Some(line_number) = line_number {
1769 label.push_str(&format!(" L{}", line_number), variable_highlight_id);
1770 }
1771 label.build()
1772}
1773
1774fn selection_ranges(
1775 workspace: &Entity<Workspace>,
1776 cx: &mut App,
1777) -> Vec<(Entity<Buffer>, Range<text::Anchor>)> {
1778 let Some(editor) = workspace
1779 .read(cx)
1780 .active_item(cx)
1781 .and_then(|item| item.act_as::<Editor>(cx))
1782 else {
1783 return Vec::new();
1784 };
1785
1786 editor.update(cx, |editor, cx| {
1787 let selections = editor.selections.all_adjusted(&editor.display_snapshot(cx));
1788
1789 let buffer = editor.buffer().clone().read(cx);
1790 let snapshot = buffer.snapshot(cx);
1791
1792 selections
1793 .into_iter()
1794 .map(|s| snapshot.anchor_after(s.start)..snapshot.anchor_before(s.end))
1795 .flat_map(|range| {
1796 let (start_buffer, start) = buffer.text_anchor_for_position(range.start, cx)?;
1797 let (end_buffer, end) = buffer.text_anchor_for_position(range.end, cx)?;
1798 if start_buffer != end_buffer {
1799 return None;
1800 }
1801 Some((start_buffer, start..end))
1802 })
1803 .collect::<Vec<_>>()
1804 })
1805}
1806
1807#[cfg(test)]
1808mod tests {
1809 use super::*;
1810 use acp_thread::AgentSessionListResponse;
1811 use gpui::TestAppContext;
1812 use std::{any::Any, rc::Rc};
1813
1814 #[test]
1815 fn test_prompt_completion_parse() {
1816 let supported_modes = vec![PromptContextType::File, PromptContextType::Symbol];
1817
1818 assert_eq!(
1819 PromptCompletion::try_parse("/", 0, &supported_modes),
1820 Some(PromptCompletion::SlashCommand(SlashCommandCompletion {
1821 source_range: 0..1,
1822 command: None,
1823 argument: None,
1824 }))
1825 );
1826
1827 assert_eq!(
1828 PromptCompletion::try_parse("@", 0, &supported_modes),
1829 Some(PromptCompletion::Mention(MentionCompletion {
1830 source_range: 0..1,
1831 mode: None,
1832 argument: None,
1833 }))
1834 );
1835
1836 assert_eq!(
1837 PromptCompletion::try_parse("/test @file", 0, &supported_modes),
1838 Some(PromptCompletion::Mention(MentionCompletion {
1839 source_range: 6..11,
1840 mode: Some(PromptContextType::File),
1841 argument: None,
1842 }))
1843 );
1844 }
1845
1846 #[test]
1847 fn test_slash_command_completion_parse() {
1848 assert_eq!(
1849 SlashCommandCompletion::try_parse("/", 0),
1850 Some(SlashCommandCompletion {
1851 source_range: 0..1,
1852 command: None,
1853 argument: None,
1854 })
1855 );
1856
1857 assert_eq!(
1858 SlashCommandCompletion::try_parse("/help", 0),
1859 Some(SlashCommandCompletion {
1860 source_range: 0..5,
1861 command: Some("help".to_string()),
1862 argument: None,
1863 })
1864 );
1865
1866 assert_eq!(
1867 SlashCommandCompletion::try_parse("/help ", 0),
1868 Some(SlashCommandCompletion {
1869 source_range: 0..5,
1870 command: Some("help".to_string()),
1871 argument: None,
1872 })
1873 );
1874
1875 assert_eq!(
1876 SlashCommandCompletion::try_parse("/help arg1", 0),
1877 Some(SlashCommandCompletion {
1878 source_range: 0..10,
1879 command: Some("help".to_string()),
1880 argument: Some("arg1".to_string()),
1881 })
1882 );
1883
1884 assert_eq!(
1885 SlashCommandCompletion::try_parse("/help arg1 arg2", 0),
1886 Some(SlashCommandCompletion {
1887 source_range: 0..15,
1888 command: Some("help".to_string()),
1889 argument: Some("arg1 arg2".to_string()),
1890 })
1891 );
1892
1893 assert_eq!(
1894 SlashCommandCompletion::try_parse("/拿不到命令 拿不到命令 ", 0),
1895 Some(SlashCommandCompletion {
1896 source_range: 0..30,
1897 command: Some("拿不到命令".to_string()),
1898 argument: Some("拿不到命令".to_string()),
1899 })
1900 );
1901
1902 assert_eq!(SlashCommandCompletion::try_parse("Lorem Ipsum", 0), None);
1903
1904 assert_eq!(SlashCommandCompletion::try_parse("Lorem /", 0), None);
1905
1906 assert_eq!(SlashCommandCompletion::try_parse("Lorem /help", 0), None);
1907
1908 assert_eq!(SlashCommandCompletion::try_parse("Lorem/", 0), None);
1909
1910 assert_eq!(SlashCommandCompletion::try_parse("/ ", 0), None);
1911 }
1912
1913 #[test]
1914 fn test_mention_completion_parse() {
1915 let supported_modes = vec![PromptContextType::File, PromptContextType::Symbol];
1916
1917 assert_eq!(
1918 MentionCompletion::try_parse("Lorem Ipsum", 0, &supported_modes),
1919 None
1920 );
1921
1922 assert_eq!(
1923 MentionCompletion::try_parse("Lorem @", 0, &supported_modes),
1924 Some(MentionCompletion {
1925 source_range: 6..7,
1926 mode: None,
1927 argument: None,
1928 })
1929 );
1930
1931 assert_eq!(
1932 MentionCompletion::try_parse("Lorem @file", 0, &supported_modes),
1933 Some(MentionCompletion {
1934 source_range: 6..11,
1935 mode: Some(PromptContextType::File),
1936 argument: None,
1937 })
1938 );
1939
1940 assert_eq!(
1941 MentionCompletion::try_parse("Lorem @file ", 0, &supported_modes),
1942 Some(MentionCompletion {
1943 source_range: 6..12,
1944 mode: Some(PromptContextType::File),
1945 argument: None,
1946 })
1947 );
1948
1949 assert_eq!(
1950 MentionCompletion::try_parse("Lorem @file main.rs", 0, &supported_modes),
1951 Some(MentionCompletion {
1952 source_range: 6..19,
1953 mode: Some(PromptContextType::File),
1954 argument: Some("main.rs".to_string()),
1955 })
1956 );
1957
1958 assert_eq!(
1959 MentionCompletion::try_parse("Lorem @file main.rs ", 0, &supported_modes),
1960 Some(MentionCompletion {
1961 source_range: 6..19,
1962 mode: Some(PromptContextType::File),
1963 argument: Some("main.rs".to_string()),
1964 })
1965 );
1966
1967 assert_eq!(
1968 MentionCompletion::try_parse("Lorem @file main.rs Ipsum", 0, &supported_modes),
1969 Some(MentionCompletion {
1970 source_range: 6..19,
1971 mode: Some(PromptContextType::File),
1972 argument: Some("main.rs".to_string()),
1973 })
1974 );
1975
1976 assert_eq!(
1977 MentionCompletion::try_parse("Lorem @main", 0, &supported_modes),
1978 Some(MentionCompletion {
1979 source_range: 6..11,
1980 mode: None,
1981 argument: Some("main".to_string()),
1982 })
1983 );
1984
1985 assert_eq!(
1986 MentionCompletion::try_parse("Lorem @main ", 0, &supported_modes),
1987 Some(MentionCompletion {
1988 source_range: 6..12,
1989 mode: None,
1990 argument: Some("main".to_string()),
1991 })
1992 );
1993
1994 assert_eq!(
1995 MentionCompletion::try_parse("Lorem @main m", 0, &supported_modes),
1996 None
1997 );
1998
1999 assert_eq!(
2000 MentionCompletion::try_parse("test@", 0, &supported_modes),
2001 None
2002 );
2003
2004 // Allowed non-file mentions
2005
2006 assert_eq!(
2007 MentionCompletion::try_parse("Lorem @symbol main", 0, &supported_modes),
2008 Some(MentionCompletion {
2009 source_range: 6..18,
2010 mode: Some(PromptContextType::Symbol),
2011 argument: Some("main".to_string()),
2012 })
2013 );
2014
2015 // Disallowed non-file mentions
2016 assert_eq!(
2017 MentionCompletion::try_parse("Lorem @symbol main", 0, &[PromptContextType::File]),
2018 None
2019 );
2020
2021 assert_eq!(
2022 MentionCompletion::try_parse("Lorem@symbol", 0, &supported_modes),
2023 None,
2024 "Should not parse mention inside word"
2025 );
2026
2027 assert_eq!(
2028 MentionCompletion::try_parse("Lorem @ file", 0, &supported_modes),
2029 None,
2030 "Should not parse with a space after @"
2031 );
2032
2033 assert_eq!(
2034 MentionCompletion::try_parse("@ file", 0, &supported_modes),
2035 None,
2036 "Should not parse with a space after @ at the start of the line"
2037 );
2038 }
2039
2040 #[gpui::test]
2041 async fn test_search_sessions_filters_results(cx: &mut TestAppContext) {
2042 #[derive(Clone)]
2043 struct StubSessionList {
2044 sessions: Vec<AgentSessionInfo>,
2045 }
2046
2047 impl AgentSessionList for StubSessionList {
2048 fn list_sessions(
2049 &self,
2050 _request: AgentSessionListRequest,
2051 _cx: &mut App,
2052 ) -> Task<anyhow::Result<AgentSessionListResponse>> {
2053 Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone())))
2054 }
2055
2056 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
2057 self
2058 }
2059 }
2060
2061 let mut alpha = AgentSessionInfo::new("session-alpha");
2062 alpha.title = Some("Alpha Session".into());
2063 let mut beta = AgentSessionInfo::new("session-beta");
2064 beta.title = Some("Beta Session".into());
2065
2066 let session_list: Rc<dyn AgentSessionList> = Rc::new(StubSessionList {
2067 sessions: vec![alpha.clone(), beta],
2068 });
2069
2070 let task = {
2071 let mut app = cx.app.borrow_mut();
2072 search_sessions(
2073 "Alpha".into(),
2074 Arc::new(AtomicBool::default()),
2075 session_list,
2076 &mut app,
2077 )
2078 };
2079
2080 let results = task.await;
2081 assert_eq!(results.len(), 1);
2082 assert_eq!(results[0].session_id, alpha.session_id);
2083 }
2084}