1use std::cell::{Cell, RefCell};
2use std::ops::Range;
3use std::rc::Rc;
4use std::sync::Arc;
5use std::sync::atomic::AtomicBool;
6
7use acp_thread::MentionUri;
8use agent_client_protocol as acp;
9use agent2::{HistoryEntry, HistoryStore};
10use anyhow::Result;
11use editor::{CompletionProvider, Editor, ExcerptId};
12use fuzzy::{StringMatch, StringMatchCandidate};
13use gpui::{App, Entity, Task, WeakEntity};
14use language::{Buffer, CodeLabel, HighlightId};
15use lsp::CompletionContext;
16use project::lsp_store::CompletionDocumentation;
17use project::{
18 Completion, CompletionIntent, CompletionResponse, Project, ProjectPath, Symbol, WorktreeId,
19};
20use prompt_store::PromptStore;
21use rope::Point;
22use text::{Anchor, ToPoint as _};
23use ui::prelude::*;
24use workspace::Workspace;
25
26use crate::AgentPanel;
27use crate::acp::message_editor::{MessageEditor, MessageEditorEvent};
28use crate::context_picker::file_context_picker::{FileMatch, search_files};
29use crate::context_picker::rules_context_picker::{RulesContextEntry, search_rules};
30use crate::context_picker::symbol_context_picker::SymbolMatch;
31use crate::context_picker::symbol_context_picker::search_symbols;
32use crate::context_picker::{
33 ContextPickerAction, ContextPickerEntry, ContextPickerMode, selection_ranges,
34};
35
36pub(crate) enum Match {
37 File(FileMatch),
38 Symbol(SymbolMatch),
39 Thread(HistoryEntry),
40 RecentThread(HistoryEntry),
41 Fetch(SharedString),
42 Rules(RulesContextEntry),
43 Entry(EntryMatch),
44}
45
46pub struct EntryMatch {
47 mat: Option<StringMatch>,
48 entry: ContextPickerEntry,
49}
50
51impl Match {
52 pub fn score(&self) -> f64 {
53 match self {
54 Match::File(file) => file.mat.score,
55 Match::Entry(mode) => mode.mat.as_ref().map(|mat| mat.score).unwrap_or(1.),
56 Match::Thread(_) => 1.,
57 Match::RecentThread(_) => 1.,
58 Match::Symbol(_) => 1.,
59 Match::Rules(_) => 1.,
60 Match::Fetch(_) => 1.,
61 }
62 }
63}
64
65pub struct ContextPickerCompletionProvider {
66 message_editor: WeakEntity<MessageEditor>,
67 workspace: WeakEntity<Workspace>,
68 history_store: Entity<HistoryStore>,
69 prompt_store: Option<Entity<PromptStore>>,
70 prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
71 available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
72}
73
74impl ContextPickerCompletionProvider {
75 pub fn new(
76 message_editor: WeakEntity<MessageEditor>,
77 workspace: WeakEntity<Workspace>,
78 history_store: Entity<HistoryStore>,
79 prompt_store: Option<Entity<PromptStore>>,
80 prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
81 available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
82 ) -> Self {
83 Self {
84 message_editor,
85 workspace,
86 history_store,
87 prompt_store,
88 prompt_capabilities,
89 available_commands,
90 }
91 }
92
93 fn completion_for_entry(
94 entry: ContextPickerEntry,
95 source_range: Range<Anchor>,
96 message_editor: WeakEntity<MessageEditor>,
97 workspace: &Entity<Workspace>,
98 cx: &mut App,
99 ) -> Option<Completion> {
100 match entry {
101 ContextPickerEntry::Mode(mode) => Some(Completion {
102 replace_range: source_range,
103 new_text: format!("@{} ", mode.keyword()),
104 label: CodeLabel::plain(mode.label().to_string(), None),
105 icon_path: Some(mode.icon().path().into()),
106 documentation: None,
107 source: project::CompletionSource::Custom,
108 insert_text_mode: None,
109 // This ensures that when a user accepts this completion, the
110 // completion menu will still be shown after "@category " is
111 // inserted
112 confirm: Some(Arc::new(|_, _, _| true)),
113 }),
114 ContextPickerEntry::Action(action) => {
115 Self::completion_for_action(action, source_range, message_editor, workspace, cx)
116 }
117 }
118 }
119
120 fn completion_for_thread(
121 thread_entry: HistoryEntry,
122 source_range: Range<Anchor>,
123 recent: bool,
124 editor: WeakEntity<MessageEditor>,
125 cx: &mut App,
126 ) -> Completion {
127 let uri = thread_entry.mention_uri();
128
129 let icon_for_completion = if recent {
130 IconName::HistoryRerun.path().into()
131 } else {
132 uri.icon_path(cx)
133 };
134
135 let new_text = format!("{} ", uri.as_link());
136
137 let new_text_len = new_text.len();
138 Completion {
139 replace_range: source_range.clone(),
140 new_text,
141 label: CodeLabel::plain(thread_entry.title().to_string(), None),
142 documentation: None,
143 insert_text_mode: None,
144 source: project::CompletionSource::Custom,
145 icon_path: Some(icon_for_completion),
146 confirm: Some(confirm_completion_callback(
147 thread_entry.title().clone(),
148 source_range.start,
149 new_text_len - 1,
150 editor,
151 uri,
152 )),
153 }
154 }
155
156 fn completion_for_rules(
157 rule: RulesContextEntry,
158 source_range: Range<Anchor>,
159 editor: WeakEntity<MessageEditor>,
160 cx: &mut App,
161 ) -> Completion {
162 let uri = MentionUri::Rule {
163 id: rule.prompt_id.into(),
164 name: rule.title.to_string(),
165 };
166 let new_text = format!("{} ", uri.as_link());
167 let new_text_len = new_text.len();
168 let icon_path = uri.icon_path(cx);
169 Completion {
170 replace_range: source_range.clone(),
171 new_text,
172 label: CodeLabel::plain(rule.title.to_string(), None),
173 documentation: None,
174 insert_text_mode: None,
175 source: project::CompletionSource::Custom,
176 icon_path: Some(icon_path),
177 confirm: Some(confirm_completion_callback(
178 rule.title,
179 source_range.start,
180 new_text_len - 1,
181 editor,
182 uri,
183 )),
184 }
185 }
186
187 pub(crate) fn completion_for_path(
188 project_path: ProjectPath,
189 path_prefix: &str,
190 is_recent: bool,
191 is_directory: bool,
192 source_range: Range<Anchor>,
193 message_editor: WeakEntity<MessageEditor>,
194 project: Entity<Project>,
195 cx: &mut App,
196 ) -> Option<Completion> {
197 let (file_name, directory) =
198 crate::context_picker::file_context_picker::extract_file_name_and_directory(
199 &project_path.path,
200 path_prefix,
201 );
202
203 let label =
204 build_code_label_for_full_path(&file_name, directory.as_ref().map(|s| s.as_ref()), cx);
205
206 let abs_path = project.read(cx).absolute_path(&project_path, cx)?;
207
208 let uri = if is_directory {
209 MentionUri::Directory { abs_path }
210 } else {
211 MentionUri::File { abs_path }
212 };
213
214 let crease_icon_path = uri.icon_path(cx);
215 let completion_icon_path = if is_recent {
216 IconName::HistoryRerun.path().into()
217 } else {
218 crease_icon_path
219 };
220
221 let new_text = format!("{} ", uri.as_link());
222 let new_text_len = new_text.len();
223 Some(Completion {
224 replace_range: source_range.clone(),
225 new_text,
226 label,
227 documentation: None,
228 source: project::CompletionSource::Custom,
229 icon_path: Some(completion_icon_path),
230 insert_text_mode: None,
231 confirm: Some(confirm_completion_callback(
232 file_name,
233 source_range.start,
234 new_text_len - 1,
235 message_editor,
236 uri,
237 )),
238 })
239 }
240
241 fn completion_for_symbol(
242 symbol: Symbol,
243 source_range: Range<Anchor>,
244 message_editor: WeakEntity<MessageEditor>,
245 workspace: Entity<Workspace>,
246 cx: &mut App,
247 ) -> Option<Completion> {
248 let project = workspace.read(cx).project().clone();
249
250 let label = CodeLabel::plain(symbol.name.clone(), None);
251
252 let abs_path = project.read(cx).absolute_path(&symbol.path, cx)?;
253 let uri = MentionUri::Symbol {
254 abs_path,
255 name: symbol.name.clone(),
256 line_range: symbol.range.start.0.row..=symbol.range.end.0.row,
257 };
258 let new_text = format!("{} ", uri.as_link());
259 let new_text_len = new_text.len();
260 let icon_path = uri.icon_path(cx);
261 Some(Completion {
262 replace_range: source_range.clone(),
263 new_text,
264 label,
265 documentation: None,
266 source: project::CompletionSource::Custom,
267 icon_path: Some(icon_path),
268 insert_text_mode: None,
269 confirm: Some(confirm_completion_callback(
270 symbol.name.into(),
271 source_range.start,
272 new_text_len - 1,
273 message_editor,
274 uri,
275 )),
276 })
277 }
278
279 fn completion_for_fetch(
280 source_range: Range<Anchor>,
281 url_to_fetch: SharedString,
282 message_editor: WeakEntity<MessageEditor>,
283 cx: &mut App,
284 ) -> Option<Completion> {
285 let new_text = format!("@fetch {} ", url_to_fetch);
286 let url_to_fetch = url::Url::parse(url_to_fetch.as_ref())
287 .or_else(|_| url::Url::parse(&format!("https://{url_to_fetch}")))
288 .ok()?;
289 let mention_uri = MentionUri::Fetch {
290 url: url_to_fetch.clone(),
291 };
292 let icon_path = mention_uri.icon_path(cx);
293 Some(Completion {
294 replace_range: source_range.clone(),
295 new_text: new_text.clone(),
296 label: CodeLabel::plain(url_to_fetch.to_string(), None),
297 documentation: None,
298 source: project::CompletionSource::Custom,
299 icon_path: Some(icon_path),
300 insert_text_mode: None,
301 confirm: Some(confirm_completion_callback(
302 url_to_fetch.to_string().into(),
303 source_range.start,
304 new_text.len() - 1,
305 message_editor,
306 mention_uri,
307 )),
308 })
309 }
310
311 pub(crate) fn completion_for_action(
312 action: ContextPickerAction,
313 source_range: Range<Anchor>,
314 message_editor: WeakEntity<MessageEditor>,
315 workspace: &Entity<Workspace>,
316 cx: &mut App,
317 ) -> Option<Completion> {
318 let (new_text, on_action) = match action {
319 ContextPickerAction::AddSelections => {
320 const PLACEHOLDER: &str = "selection ";
321 let selections = selection_ranges(workspace, cx)
322 .into_iter()
323 .enumerate()
324 .map(|(ix, (buffer, range))| {
325 (
326 buffer,
327 range,
328 (PLACEHOLDER.len() * ix)..(PLACEHOLDER.len() * (ix + 1) - 1),
329 )
330 })
331 .collect::<Vec<_>>();
332
333 let new_text: String = PLACEHOLDER.repeat(selections.len());
334
335 let callback = Arc::new({
336 let source_range = source_range.clone();
337 move |_, window: &mut Window, cx: &mut App| {
338 let selections = selections.clone();
339 let message_editor = message_editor.clone();
340 let source_range = source_range.clone();
341 window.defer(cx, move |window, cx| {
342 message_editor
343 .update(cx, |message_editor, cx| {
344 message_editor.confirm_mention_for_selection(
345 source_range,
346 selections,
347 window,
348 cx,
349 )
350 })
351 .ok();
352 });
353 false
354 }
355 });
356
357 (new_text, callback)
358 }
359 };
360
361 Some(Completion {
362 replace_range: source_range,
363 new_text,
364 label: CodeLabel::plain(action.label().to_string(), None),
365 icon_path: Some(action.icon().path().into()),
366 documentation: None,
367 source: project::CompletionSource::Custom,
368 insert_text_mode: None,
369 // This ensures that when a user accepts this completion, the
370 // completion menu will still be shown after "@category " is
371 // inserted
372 confirm: Some(on_action),
373 })
374 }
375
376 fn search_slash_commands(
377 &self,
378 query: String,
379 cx: &mut App,
380 ) -> Task<Vec<acp::AvailableCommand>> {
381 let commands = self.available_commands.borrow().clone();
382 if commands.is_empty() {
383 return Task::ready(Vec::new());
384 }
385
386 cx.spawn(async move |cx| {
387 let candidates = commands
388 .iter()
389 .enumerate()
390 .map(|(id, command)| StringMatchCandidate::new(id, &command.name))
391 .collect::<Vec<_>>();
392
393 let matches = fuzzy::match_strings(
394 &candidates,
395 &query,
396 false,
397 true,
398 100,
399 &Arc::new(AtomicBool::default()),
400 cx.background_executor().clone(),
401 )
402 .await;
403
404 matches
405 .into_iter()
406 .map(|mat| commands[mat.candidate_id].clone())
407 .collect()
408 })
409 }
410
411 fn search_mentions(
412 &self,
413 mode: Option<ContextPickerMode>,
414 query: String,
415 cancellation_flag: Arc<AtomicBool>,
416 cx: &mut App,
417 ) -> Task<Vec<Match>> {
418 let Some(workspace) = self.workspace.upgrade() else {
419 return Task::ready(Vec::default());
420 };
421 match mode {
422 Some(ContextPickerMode::File) => {
423 let search_files_task = search_files(query, cancellation_flag, &workspace, cx);
424 cx.background_spawn(async move {
425 search_files_task
426 .await
427 .into_iter()
428 .map(Match::File)
429 .collect()
430 })
431 }
432
433 Some(ContextPickerMode::Symbol) => {
434 let search_symbols_task = search_symbols(query, cancellation_flag, &workspace, cx);
435 cx.background_spawn(async move {
436 search_symbols_task
437 .await
438 .into_iter()
439 .map(Match::Symbol)
440 .collect()
441 })
442 }
443
444 Some(ContextPickerMode::Thread) => {
445 let search_threads_task =
446 search_threads(query, cancellation_flag, &self.history_store, cx);
447 cx.background_spawn(async move {
448 search_threads_task
449 .await
450 .into_iter()
451 .map(Match::Thread)
452 .collect()
453 })
454 }
455
456 Some(ContextPickerMode::Fetch) => {
457 if !query.is_empty() {
458 Task::ready(vec![Match::Fetch(query.into())])
459 } else {
460 Task::ready(Vec::new())
461 }
462 }
463
464 Some(ContextPickerMode::Rules) => {
465 if let Some(prompt_store) = self.prompt_store.as_ref() {
466 let search_rules_task =
467 search_rules(query, cancellation_flag, prompt_store, cx);
468 cx.background_spawn(async move {
469 search_rules_task
470 .await
471 .into_iter()
472 .map(Match::Rules)
473 .collect::<Vec<_>>()
474 })
475 } else {
476 Task::ready(Vec::new())
477 }
478 }
479
480 None if query.is_empty() => {
481 let mut matches = self.recent_context_picker_entries(&workspace, cx);
482
483 matches.extend(
484 self.available_context_picker_entries(&workspace, cx)
485 .into_iter()
486 .map(|mode| {
487 Match::Entry(EntryMatch {
488 entry: mode,
489 mat: None,
490 })
491 }),
492 );
493
494 Task::ready(matches)
495 }
496 None => {
497 let executor = cx.background_executor().clone();
498
499 let search_files_task =
500 search_files(query.clone(), cancellation_flag, &workspace, cx);
501
502 let entries = self.available_context_picker_entries(&workspace, cx);
503 let entry_candidates = entries
504 .iter()
505 .enumerate()
506 .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword()))
507 .collect::<Vec<_>>();
508
509 cx.background_spawn(async move {
510 let mut matches = search_files_task
511 .await
512 .into_iter()
513 .map(Match::File)
514 .collect::<Vec<_>>();
515
516 let entry_matches = fuzzy::match_strings(
517 &entry_candidates,
518 &query,
519 false,
520 true,
521 100,
522 &Arc::new(AtomicBool::default()),
523 executor,
524 )
525 .await;
526
527 matches.extend(entry_matches.into_iter().map(|mat| {
528 Match::Entry(EntryMatch {
529 entry: entries[mat.candidate_id],
530 mat: Some(mat),
531 })
532 }));
533
534 matches.sort_by(|a, b| {
535 b.score()
536 .partial_cmp(&a.score())
537 .unwrap_or(std::cmp::Ordering::Equal)
538 });
539
540 matches
541 })
542 }
543 }
544 }
545
546 fn recent_context_picker_entries(
547 &self,
548 workspace: &Entity<Workspace>,
549 cx: &mut App,
550 ) -> Vec<Match> {
551 let mut recent = Vec::with_capacity(6);
552
553 let mut mentions = self
554 .message_editor
555 .read_with(cx, |message_editor, _cx| message_editor.mentions())
556 .unwrap_or_default();
557 let workspace = workspace.read(cx);
558 let project = workspace.project().read(cx);
559
560 if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx)
561 && let Some(thread) = agent_panel.read(cx).active_agent_thread(cx)
562 {
563 let thread = thread.read(cx);
564 mentions.insert(MentionUri::Thread {
565 id: thread.session_id().clone(),
566 name: thread.title().into(),
567 });
568 }
569
570 recent.extend(
571 workspace
572 .recent_navigation_history_iter(cx)
573 .filter(|(_, abs_path)| {
574 abs_path.as_ref().is_none_or(|path| {
575 !mentions.contains(&MentionUri::File {
576 abs_path: path.clone(),
577 })
578 })
579 })
580 .take(4)
581 .filter_map(|(project_path, _)| {
582 project
583 .worktree_for_id(project_path.worktree_id, cx)
584 .map(|worktree| {
585 let path_prefix = worktree.read(cx).root_name().into();
586 Match::File(FileMatch {
587 mat: fuzzy::PathMatch {
588 score: 1.,
589 positions: Vec::new(),
590 worktree_id: project_path.worktree_id.to_usize(),
591 path: project_path.path,
592 path_prefix,
593 is_dir: false,
594 distance_to_relative_ancestor: 0,
595 },
596 is_recent: true,
597 })
598 })
599 }),
600 );
601
602 if self.prompt_capabilities.get().embedded_context {
603 const RECENT_COUNT: usize = 2;
604 let threads = self
605 .history_store
606 .read(cx)
607 .recently_opened_entries(cx)
608 .into_iter()
609 .filter(|thread| !mentions.contains(&thread.mention_uri()))
610 .take(RECENT_COUNT)
611 .collect::<Vec<_>>();
612
613 recent.extend(threads.into_iter().map(Match::RecentThread));
614 }
615
616 recent
617 }
618
619 fn available_context_picker_entries(
620 &self,
621 workspace: &Entity<Workspace>,
622 cx: &mut App,
623 ) -> Vec<ContextPickerEntry> {
624 let embedded_context = self.prompt_capabilities.get().embedded_context;
625 let mut entries = if embedded_context {
626 vec![
627 ContextPickerEntry::Mode(ContextPickerMode::File),
628 ContextPickerEntry::Mode(ContextPickerMode::Symbol),
629 ContextPickerEntry::Mode(ContextPickerMode::Thread),
630 ]
631 } else {
632 // File is always available, but we don't need a mode entry
633 vec![]
634 };
635
636 let has_selection = workspace
637 .read(cx)
638 .active_item(cx)
639 .and_then(|item| item.downcast::<Editor>())
640 .is_some_and(|editor| {
641 editor.update(cx, |editor, cx| editor.has_non_empty_selection(cx))
642 });
643 if has_selection {
644 entries.push(ContextPickerEntry::Action(
645 ContextPickerAction::AddSelections,
646 ));
647 }
648
649 if embedded_context {
650 if self.prompt_store.is_some() {
651 entries.push(ContextPickerEntry::Mode(ContextPickerMode::Rules));
652 }
653
654 entries.push(ContextPickerEntry::Mode(ContextPickerMode::Fetch));
655 }
656
657 entries
658 }
659}
660
661fn build_code_label_for_full_path(file_name: &str, directory: Option<&str>, cx: &App) -> CodeLabel {
662 let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
663 let mut label = CodeLabel::default();
664
665 label.push_str(file_name, None);
666 label.push_str(" ", None);
667
668 if let Some(directory) = directory {
669 label.push_str(directory, comment_id);
670 }
671
672 label.filter_range = 0..label.text().len();
673
674 label
675}
676
677impl CompletionProvider for ContextPickerCompletionProvider {
678 fn completions(
679 &self,
680 _excerpt_id: ExcerptId,
681 buffer: &Entity<Buffer>,
682 buffer_position: Anchor,
683 _trigger: CompletionContext,
684 _window: &mut Window,
685 cx: &mut Context<Editor>,
686 ) -> Task<Result<Vec<CompletionResponse>>> {
687 let state = buffer.update(cx, |buffer, _cx| {
688 let position = buffer_position.to_point(buffer);
689 let line_start = Point::new(position.row, 0);
690 let offset_to_line = buffer.point_to_offset(line_start);
691 let mut lines = buffer.text_for_range(line_start..position).lines();
692 let line = lines.next()?;
693 ContextCompletion::try_parse(
694 line,
695 offset_to_line,
696 self.prompt_capabilities.get().embedded_context,
697 )
698 });
699 let Some(state) = state else {
700 return Task::ready(Ok(Vec::new()));
701 };
702
703 let Some(workspace) = self.workspace.upgrade() else {
704 return Task::ready(Ok(Vec::new()));
705 };
706
707 let project = workspace.read(cx).project().clone();
708 let snapshot = buffer.read(cx).snapshot();
709 let source_range = snapshot.anchor_before(state.source_range().start)
710 ..snapshot.anchor_after(state.source_range().end);
711
712 let editor = self.message_editor.clone();
713
714 match state {
715 ContextCompletion::SlashCommand(SlashCommandCompletion {
716 command, argument, ..
717 }) => {
718 let search_task = self.search_slash_commands(command.unwrap_or_default(), cx);
719 cx.background_spawn(async move {
720 let completions = search_task
721 .await
722 .into_iter()
723 .map(|command| {
724 let new_text = if let Some(argument) = argument.as_ref() {
725 format!("/{} {}", command.name, argument)
726 } else {
727 format!("/{} ", command.name)
728 };
729
730 let is_missing_argument = argument.is_none() && command.input.is_some();
731 Completion {
732 replace_range: source_range.clone(),
733 new_text,
734 label: CodeLabel::plain(command.name.to_string(), None),
735 documentation: Some(CompletionDocumentation::SingleLine(
736 command.description.into(),
737 )),
738 source: project::CompletionSource::Custom,
739 icon_path: None,
740 insert_text_mode: None,
741 confirm: Some(Arc::new({
742 let editor = editor.clone();
743 move |intent, _window, cx| {
744 if !is_missing_argument {
745 cx.defer({
746 let editor = editor.clone();
747 move |cx| {
748 editor
749 .update(cx, |_editor, cx| {
750 match intent {
751 CompletionIntent::Complete
752 | CompletionIntent::CompleteWithInsert
753 | CompletionIntent::CompleteWithReplace => {
754 if !is_missing_argument {
755 cx.emit(MessageEditorEvent::Send);
756 }
757 }
758 CompletionIntent::Compose => {}
759 }
760 })
761 .ok();
762 }
763 });
764 }
765 is_missing_argument
766 }
767 })),
768 }
769 })
770 .collect();
771
772 Ok(vec![CompletionResponse {
773 completions,
774 // Since this does its own filtering (see `filter_completions()` returns false),
775 // there is no benefit to computing whether this set of completions is incomplete.
776 is_incomplete: true,
777 }])
778 })
779 }
780 ContextCompletion::Mention(MentionCompletion { mode, argument, .. }) => {
781 let query = argument.unwrap_or_default();
782 let search_task =
783 self.search_mentions(mode, query, Arc::<AtomicBool>::default(), cx);
784
785 cx.spawn(async move |_, cx| {
786 let matches = search_task.await;
787
788 let completions = cx.update(|cx| {
789 matches
790 .into_iter()
791 .filter_map(|mat| match mat {
792 Match::File(FileMatch { mat, is_recent }) => {
793 let project_path = ProjectPath {
794 worktree_id: WorktreeId::from_usize(mat.worktree_id),
795 path: mat.path.clone(),
796 };
797
798 Self::completion_for_path(
799 project_path,
800 &mat.path_prefix,
801 is_recent,
802 mat.is_dir,
803 source_range.clone(),
804 editor.clone(),
805 project.clone(),
806 cx,
807 )
808 }
809
810 Match::Symbol(SymbolMatch { symbol, .. }) => {
811 Self::completion_for_symbol(
812 symbol,
813 source_range.clone(),
814 editor.clone(),
815 workspace.clone(),
816 cx,
817 )
818 }
819
820 Match::Thread(thread) => Some(Self::completion_for_thread(
821 thread,
822 source_range.clone(),
823 false,
824 editor.clone(),
825 cx,
826 )),
827
828 Match::RecentThread(thread) => Some(Self::completion_for_thread(
829 thread,
830 source_range.clone(),
831 true,
832 editor.clone(),
833 cx,
834 )),
835
836 Match::Rules(user_rules) => Some(Self::completion_for_rules(
837 user_rules,
838 source_range.clone(),
839 editor.clone(),
840 cx,
841 )),
842
843 Match::Fetch(url) => Self::completion_for_fetch(
844 source_range.clone(),
845 url,
846 editor.clone(),
847 cx,
848 ),
849
850 Match::Entry(EntryMatch { entry, .. }) => {
851 Self::completion_for_entry(
852 entry,
853 source_range.clone(),
854 editor.clone(),
855 &workspace,
856 cx,
857 )
858 }
859 })
860 .collect()
861 })?;
862
863 Ok(vec![CompletionResponse {
864 completions,
865 // Since this does its own filtering (see `filter_completions()` returns false),
866 // there is no benefit to computing whether this set of completions is incomplete.
867 is_incomplete: true,
868 }])
869 })
870 }
871 }
872 }
873
874 fn is_completion_trigger(
875 &self,
876 buffer: &Entity<language::Buffer>,
877 position: language::Anchor,
878 _text: &str,
879 _trigger_in_words: bool,
880 _menu_is_open: bool,
881 cx: &mut Context<Editor>,
882 ) -> bool {
883 let buffer = buffer.read(cx);
884 let position = position.to_point(buffer);
885 let line_start = Point::new(position.row, 0);
886 let offset_to_line = buffer.point_to_offset(line_start);
887 let mut lines = buffer.text_for_range(line_start..position).lines();
888 if let Some(line) = lines.next() {
889 ContextCompletion::try_parse(
890 line,
891 offset_to_line,
892 self.prompt_capabilities.get().embedded_context,
893 )
894 .map(|completion| {
895 completion.source_range().start <= offset_to_line + position.column as usize
896 && completion.source_range().end >= offset_to_line + position.column as usize
897 })
898 .unwrap_or(false)
899 } else {
900 false
901 }
902 }
903
904 fn sort_completions(&self) -> bool {
905 false
906 }
907
908 fn filter_completions(&self) -> bool {
909 false
910 }
911}
912
913pub(crate) fn search_threads(
914 query: String,
915 cancellation_flag: Arc<AtomicBool>,
916 history_store: &Entity<HistoryStore>,
917 cx: &mut App,
918) -> Task<Vec<HistoryEntry>> {
919 let threads = history_store.read(cx).entries().collect();
920 if query.is_empty() {
921 return Task::ready(threads);
922 }
923
924 let executor = cx.background_executor().clone();
925 cx.background_spawn(async move {
926 let candidates = threads
927 .iter()
928 .enumerate()
929 .map(|(id, thread)| StringMatchCandidate::new(id, thread.title()))
930 .collect::<Vec<_>>();
931 let matches = fuzzy::match_strings(
932 &candidates,
933 &query,
934 false,
935 true,
936 100,
937 &cancellation_flag,
938 executor,
939 )
940 .await;
941
942 matches
943 .into_iter()
944 .map(|mat| threads[mat.candidate_id].clone())
945 .collect()
946 })
947}
948
949fn confirm_completion_callback(
950 crease_text: SharedString,
951 start: Anchor,
952 content_len: usize,
953 message_editor: WeakEntity<MessageEditor>,
954 mention_uri: MentionUri,
955) -> Arc<dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync> {
956 Arc::new(move |_, window, cx| {
957 let message_editor = message_editor.clone();
958 let crease_text = crease_text.clone();
959 let mention_uri = mention_uri.clone();
960 window.defer(cx, move |window, cx| {
961 message_editor
962 .clone()
963 .update(cx, |message_editor, cx| {
964 message_editor
965 .confirm_mention_completion(
966 crease_text,
967 start,
968 content_len,
969 mention_uri,
970 window,
971 cx,
972 )
973 .detach();
974 })
975 .ok();
976 });
977 false
978 })
979}
980
981enum ContextCompletion {
982 SlashCommand(SlashCommandCompletion),
983 Mention(MentionCompletion),
984}
985
986impl ContextCompletion {
987 fn source_range(&self) -> Range<usize> {
988 match self {
989 Self::SlashCommand(completion) => completion.source_range.clone(),
990 Self::Mention(completion) => completion.source_range.clone(),
991 }
992 }
993
994 fn try_parse(line: &str, offset_to_line: usize, allow_non_file_mentions: bool) -> Option<Self> {
995 if let Some(command) = SlashCommandCompletion::try_parse(line, offset_to_line) {
996 Some(Self::SlashCommand(command))
997 } else if let Some(mention) =
998 MentionCompletion::try_parse(allow_non_file_mentions, line, offset_to_line)
999 {
1000 Some(Self::Mention(mention))
1001 } else {
1002 None
1003 }
1004 }
1005}
1006
1007#[derive(Debug, Default, PartialEq)]
1008struct SlashCommandCompletion {
1009 source_range: Range<usize>,
1010 command: Option<String>,
1011 argument: Option<String>,
1012}
1013
1014impl SlashCommandCompletion {
1015 fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
1016 // If we decide to support commands that are not at the beginning of the prompt, we can remove this check
1017 if !line.starts_with('/') || offset_to_line != 0 {
1018 return None;
1019 }
1020
1021 let last_command_start = line.rfind('/')?;
1022 if last_command_start >= line.len() {
1023 return Some(Self::default());
1024 }
1025 if last_command_start > 0
1026 && line
1027 .chars()
1028 .nth(last_command_start - 1)
1029 .is_some_and(|c| !c.is_whitespace())
1030 {
1031 return None;
1032 }
1033
1034 let rest_of_line = &line[last_command_start + 1..];
1035
1036 let mut command = None;
1037 let mut argument = None;
1038 let mut end = last_command_start + 1;
1039
1040 if let Some(command_text) = rest_of_line.split_whitespace().next() {
1041 command = Some(command_text.to_string());
1042 end += command_text.len();
1043
1044 // Find the start of arguments after the command
1045 if let Some(args_start) =
1046 rest_of_line[command_text.len()..].find(|c: char| !c.is_whitespace())
1047 {
1048 let args = &rest_of_line[command_text.len() + args_start..].trim_end();
1049 if !args.is_empty() {
1050 argument = Some(args.to_string());
1051 end += args.len() + 1;
1052 }
1053 }
1054 }
1055
1056 Some(Self {
1057 source_range: last_command_start + offset_to_line..end + offset_to_line,
1058 command,
1059 argument,
1060 })
1061 }
1062}
1063
1064#[derive(Debug, Default, PartialEq)]
1065struct MentionCompletion {
1066 source_range: Range<usize>,
1067 mode: Option<ContextPickerMode>,
1068 argument: Option<String>,
1069}
1070
1071impl MentionCompletion {
1072 fn try_parse(allow_non_file_mentions: bool, line: &str, offset_to_line: usize) -> Option<Self> {
1073 let last_mention_start = line.rfind('@')?;
1074 if last_mention_start >= line.len() {
1075 return Some(Self::default());
1076 }
1077 if last_mention_start > 0
1078 && line
1079 .chars()
1080 .nth(last_mention_start - 1)
1081 .is_some_and(|c| !c.is_whitespace())
1082 {
1083 return None;
1084 }
1085
1086 let rest_of_line = &line[last_mention_start + 1..];
1087
1088 let mut mode = None;
1089 let mut argument = None;
1090
1091 let mut parts = rest_of_line.split_whitespace();
1092 let mut end = last_mention_start + 1;
1093 if let Some(mode_text) = parts.next() {
1094 end += mode_text.len();
1095
1096 if let Some(parsed_mode) = ContextPickerMode::try_from(mode_text).ok()
1097 && (allow_non_file_mentions || matches!(parsed_mode, ContextPickerMode::File))
1098 {
1099 mode = Some(parsed_mode);
1100 } else {
1101 argument = Some(mode_text.to_string());
1102 }
1103 match rest_of_line[mode_text.len()..].find(|c: char| !c.is_whitespace()) {
1104 Some(whitespace_count) => {
1105 if let Some(argument_text) = parts.next() {
1106 argument = Some(argument_text.to_string());
1107 end += whitespace_count + argument_text.len();
1108 }
1109 }
1110 None => {
1111 // Rest of line is entirely whitespace
1112 end += rest_of_line.len() - mode_text.len();
1113 }
1114 }
1115 }
1116
1117 Some(Self {
1118 source_range: last_mention_start + offset_to_line..end + offset_to_line,
1119 mode,
1120 argument,
1121 })
1122 }
1123}
1124
1125#[cfg(test)]
1126mod tests {
1127 use super::*;
1128
1129 #[test]
1130 fn test_slash_command_completion_parse() {
1131 assert_eq!(
1132 SlashCommandCompletion::try_parse("/", 0),
1133 Some(SlashCommandCompletion {
1134 source_range: 0..1,
1135 command: None,
1136 argument: None,
1137 })
1138 );
1139
1140 assert_eq!(
1141 SlashCommandCompletion::try_parse("/help", 0),
1142 Some(SlashCommandCompletion {
1143 source_range: 0..5,
1144 command: Some("help".to_string()),
1145 argument: None,
1146 })
1147 );
1148
1149 assert_eq!(
1150 SlashCommandCompletion::try_parse("/help ", 0),
1151 Some(SlashCommandCompletion {
1152 source_range: 0..5,
1153 command: Some("help".to_string()),
1154 argument: None,
1155 })
1156 );
1157
1158 assert_eq!(
1159 SlashCommandCompletion::try_parse("/help arg1", 0),
1160 Some(SlashCommandCompletion {
1161 source_range: 0..10,
1162 command: Some("help".to_string()),
1163 argument: Some("arg1".to_string()),
1164 })
1165 );
1166
1167 assert_eq!(
1168 SlashCommandCompletion::try_parse("/help arg1 arg2", 0),
1169 Some(SlashCommandCompletion {
1170 source_range: 0..15,
1171 command: Some("help".to_string()),
1172 argument: Some("arg1 arg2".to_string()),
1173 })
1174 );
1175
1176 assert_eq!(SlashCommandCompletion::try_parse("Lorem Ipsum", 0), None);
1177
1178 assert_eq!(SlashCommandCompletion::try_parse("Lorem /", 0), None);
1179
1180 assert_eq!(SlashCommandCompletion::try_parse("Lorem /help", 0), None);
1181
1182 assert_eq!(SlashCommandCompletion::try_parse("Lorem/", 0), None);
1183 }
1184
1185 #[test]
1186 fn test_mention_completion_parse() {
1187 assert_eq!(MentionCompletion::try_parse(true, "Lorem Ipsum", 0), None);
1188
1189 assert_eq!(
1190 MentionCompletion::try_parse(true, "Lorem @", 0),
1191 Some(MentionCompletion {
1192 source_range: 6..7,
1193 mode: None,
1194 argument: None,
1195 })
1196 );
1197
1198 assert_eq!(
1199 MentionCompletion::try_parse(true, "Lorem @file", 0),
1200 Some(MentionCompletion {
1201 source_range: 6..11,
1202 mode: Some(ContextPickerMode::File),
1203 argument: None,
1204 })
1205 );
1206
1207 assert_eq!(
1208 MentionCompletion::try_parse(true, "Lorem @file ", 0),
1209 Some(MentionCompletion {
1210 source_range: 6..12,
1211 mode: Some(ContextPickerMode::File),
1212 argument: None,
1213 })
1214 );
1215
1216 assert_eq!(
1217 MentionCompletion::try_parse(true, "Lorem @file main.rs", 0),
1218 Some(MentionCompletion {
1219 source_range: 6..19,
1220 mode: Some(ContextPickerMode::File),
1221 argument: Some("main.rs".to_string()),
1222 })
1223 );
1224
1225 assert_eq!(
1226 MentionCompletion::try_parse(true, "Lorem @file main.rs ", 0),
1227 Some(MentionCompletion {
1228 source_range: 6..19,
1229 mode: Some(ContextPickerMode::File),
1230 argument: Some("main.rs".to_string()),
1231 })
1232 );
1233
1234 assert_eq!(
1235 MentionCompletion::try_parse(true, "Lorem @file main.rs Ipsum", 0),
1236 Some(MentionCompletion {
1237 source_range: 6..19,
1238 mode: Some(ContextPickerMode::File),
1239 argument: Some("main.rs".to_string()),
1240 })
1241 );
1242
1243 assert_eq!(
1244 MentionCompletion::try_parse(true, "Lorem @main", 0),
1245 Some(MentionCompletion {
1246 source_range: 6..11,
1247 mode: None,
1248 argument: Some("main".to_string()),
1249 })
1250 );
1251
1252 assert_eq!(MentionCompletion::try_parse(true, "test@", 0), None);
1253
1254 // Allowed non-file mentions
1255
1256 assert_eq!(
1257 MentionCompletion::try_parse(true, "Lorem @symbol main", 0),
1258 Some(MentionCompletion {
1259 source_range: 6..18,
1260 mode: Some(ContextPickerMode::Symbol),
1261 argument: Some("main".to_string()),
1262 })
1263 );
1264
1265 // Disallowed non-file mentions
1266
1267 assert_eq!(
1268 MentionCompletion::try_parse(false, "Lorem @symbol main", 0),
1269 Some(MentionCompletion {
1270 source_range: 6..18,
1271 mode: None,
1272 argument: Some("main".to_string()),
1273 })
1274 );
1275 }
1276}