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