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