agent: Fuzzy match on paths and symbols when typing `@` (#28357)

Bennet Bo Fenner created

Release Notes:

- agent: Improve fuzzy matching when using @-mentions

Change summary

crates/agent/src/context_picker/completion_provider.rs   | 557 +++++----
crates/agent/src/context_picker/file_context_picker.rs   |  67 
crates/agent/src/context_picker/symbol_context_picker.rs |  59 
crates/agent/src/context_picker/thread_context_picker.rs |  27 
crates/agent/src/message_editor.rs                       |  25 
5 files changed, 411 insertions(+), 324 deletions(-)

Detailed changes

crates/agent/src/context_picker/completion_provider.rs 🔗

@@ -18,16 +18,133 @@ use text::{Anchor, ToPoint};
 use ui::prelude::*;
 use workspace::Workspace;
 
-use crate::context::AssistantContext;
+use crate::context_picker::file_context_picker::search_files;
+use crate::context_picker::symbol_context_picker::search_symbols;
 use crate::context_store::ContextStore;
 use crate::thread_store::ThreadStore;
 
 use super::fetch_context_picker::fetch_url_content;
-use super::thread_context_picker::ThreadContextEntry;
+use super::file_context_picker::FileMatch;
+use super::symbol_context_picker::SymbolMatch;
+use super::thread_context_picker::{ThreadContextEntry, ThreadMatch, search_threads};
 use super::{
-    ContextPickerMode, MentionLink, recent_context_picker_entries, supported_context_picker_modes,
+    ContextPickerMode, MentionLink, RecentEntry, recent_context_picker_entries,
+    supported_context_picker_modes,
 };
 
+pub(crate) enum Match {
+    Symbol(SymbolMatch),
+    File(FileMatch),
+    Thread(ThreadMatch),
+    Fetch(SharedString),
+    Mode(ContextPickerMode),
+}
+
+fn search(
+    mode: Option<ContextPickerMode>,
+    query: String,
+    cancellation_flag: Arc<AtomicBool>,
+    recent_entries: Vec<RecentEntry>,
+    thread_store: Option<WeakEntity<ThreadStore>>,
+    workspace: Entity<Workspace>,
+    cx: &mut App,
+) -> Task<Vec<Match>> {
+    match mode {
+        Some(ContextPickerMode::File) => {
+            let search_files_task =
+                search_files(query.clone(), cancellation_flag.clone(), &workspace, cx);
+            cx.background_spawn(async move {
+                search_files_task
+                    .await
+                    .into_iter()
+                    .map(Match::File)
+                    .collect()
+            })
+        }
+        Some(ContextPickerMode::Symbol) => {
+            let search_symbols_task =
+                search_symbols(query.clone(), cancellation_flag.clone(), &workspace, cx);
+            cx.background_spawn(async move {
+                search_symbols_task
+                    .await
+                    .into_iter()
+                    .map(Match::Symbol)
+                    .collect()
+            })
+        }
+        Some(ContextPickerMode::Thread) => {
+            if let Some(thread_store) = thread_store.as_ref().and_then(|t| t.upgrade()) {
+                let search_threads_task =
+                    search_threads(query.clone(), cancellation_flag.clone(), thread_store, cx);
+                cx.background_spawn(async move {
+                    search_threads_task
+                        .await
+                        .into_iter()
+                        .map(Match::Thread)
+                        .collect()
+                })
+            } else {
+                Task::ready(Vec::new())
+            }
+        }
+        Some(ContextPickerMode::Fetch) => {
+            if !query.is_empty() {
+                Task::ready(vec![Match::Fetch(query.into())])
+            } else {
+                Task::ready(Vec::new())
+            }
+        }
+        None => {
+            if query.is_empty() {
+                let mut matches = recent_entries
+                    .into_iter()
+                    .map(|entry| match entry {
+                        super::RecentEntry::File {
+                            project_path,
+                            path_prefix,
+                        } => Match::File(FileMatch {
+                            mat: fuzzy::PathMatch {
+                                score: 1.,
+                                positions: Vec::new(),
+                                worktree_id: project_path.worktree_id.to_usize(),
+                                path: project_path.path,
+                                path_prefix,
+                                is_dir: false,
+                                distance_to_relative_ancestor: 0,
+                            },
+                            is_recent: true,
+                        }),
+                        super::RecentEntry::Thread(thread_context_entry) => {
+                            Match::Thread(ThreadMatch {
+                                thread: thread_context_entry,
+                                is_recent: true,
+                            })
+                        }
+                    })
+                    .collect::<Vec<_>>();
+
+                matches.extend(
+                    supported_context_picker_modes(&thread_store)
+                        .into_iter()
+                        .map(Match::Mode),
+                );
+
+                Task::ready(matches)
+            } else {
+                let search_files_task =
+                    search_files(query.clone(), cancellation_flag.clone(), &workspace, cx);
+                cx.background_spawn(async move {
+                    search_files_task
+                        .await
+                        .into_iter()
+                        .map(Match::File)
+                        .collect()
+                })
+            }
+        }
+    }
+}
+
 pub struct ContextPickerCompletionProvider {
     workspace: WeakEntity<Workspace>,
     context_store: WeakEntity<ContextStore>,
@@ -50,97 +167,20 @@ impl ContextPickerCompletionProvider {
         }
     }
 
-    fn default_completions(
-        excerpt_id: ExcerptId,
-        source_range: Range<Anchor>,
-        context_store: Entity<ContextStore>,
-        thread_store: Option<WeakEntity<ThreadStore>>,
-        editor: Entity<Editor>,
-        workspace: Entity<Workspace>,
-        cx: &App,
-    ) -> Vec<Completion> {
-        let mut completions = Vec::new();
-
-        completions.extend(
-            recent_context_picker_entries(
-                context_store.clone(),
-                thread_store.clone(),
-                workspace.clone(),
-                cx,
-            )
-            .iter()
-            .filter_map(|entry| match entry {
-                super::RecentEntry::File {
-                    project_path,
-                    path_prefix,
-                } => Some(Self::completion_for_path(
-                    project_path.clone(),
-                    path_prefix,
-                    true,
-                    false,
-                    excerpt_id,
-                    source_range.clone(),
-                    editor.clone(),
-                    context_store.clone(),
-                    cx,
-                )),
-                super::RecentEntry::Thread(thread_context_entry) => {
-                    let thread_store = thread_store
-                        .as_ref()
-                        .and_then(|thread_store| thread_store.upgrade())?;
-                    Some(Self::completion_for_thread(
-                        thread_context_entry.clone(),
-                        excerpt_id,
-                        source_range.clone(),
-                        true,
-                        editor.clone(),
-                        context_store.clone(),
-                        thread_store,
-                    ))
-                }
-            }),
-        );
-
-        completions.extend(
-            supported_context_picker_modes(&thread_store)
-                .iter()
-                .map(|mode| {
-                    Completion {
-                        replace_range: source_range.clone(),
-                        new_text: format!("@{} ", mode.mention_prefix()),
-                        label: CodeLabel::plain(mode.label().to_string(), None),
-                        icon_path: Some(mode.icon().path().into()),
-                        documentation: None,
-                        source: project::CompletionSource::Custom,
-                        insert_text_mode: None,
-                        // This ensures that when a user accepts this completion, the
-                        // completion menu will still be shown after "@category " is
-                        // inserted
-                        confirm: Some(Arc::new(|_, _, _| true)),
-                    }
-                }),
-        );
-        completions
-    }
-
-    fn build_code_label_for_full_path(
-        file_name: &str,
-        directory: Option<&str>,
-        cx: &App,
-    ) -> CodeLabel {
-        let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
-        let mut label = CodeLabel::default();
-
-        label.push_str(&file_name, None);
-        label.push_str(" ", None);
-
-        if let Some(directory) = directory {
-            label.push_str(&directory, comment_id);
+    fn completion_for_mode(source_range: Range<Anchor>, mode: ContextPickerMode) -> Completion {
+        Completion {
+            replace_range: source_range.clone(),
+            new_text: format!("@{} ", mode.mention_prefix()),
+            label: CodeLabel::plain(mode.label().to_string(), None),
+            icon_path: Some(mode.icon().path().into()),
+            documentation: None,
+            source: project::CompletionSource::Custom,
+            insert_text_mode: None,
+            // This ensures that when a user accepts this completion, the
+            // completion menu will still be shown after "@category " is
+            // inserted
+            confirm: Some(Arc::new(|_, _, _| true)),
         }
-
-        label.filter_range = 0..label.text().len();
-
-        label
     }
 
     fn completion_for_thread(
@@ -261,11 +301,8 @@ impl ContextPickerCompletionProvider {
             path_prefix,
         );
 
-        let label = Self::build_code_label_for_full_path(
-            &file_name,
-            directory.as_ref().map(|s| s.as_ref()),
-            cx,
-        );
+        let label =
+            build_code_label_for_full_path(&file_name, directory.as_ref().map(|s| s.as_ref()), cx);
         let full_path = if let Some(directory) = directory {
             format!("{}{}", directory, file_name)
         } else {
@@ -382,6 +419,22 @@ impl ContextPickerCompletionProvider {
     }
 }
 
+fn build_code_label_for_full_path(file_name: &str, directory: Option<&str>, cx: &App) -> CodeLabel {
+    let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
+    let mut label = CodeLabel::default();
+
+    label.push_str(&file_name, None);
+    label.push_str(" ", None);
+
+    if let Some(directory) = directory {
+        label.push_str(&directory, comment_id);
+    }
+
+    label.filter_range = 0..label.text().len();
+
+    label
+}
+
 impl CompletionProvider for ContextPickerCompletionProvider {
     fn completions(
         &self,
@@ -404,10 +457,9 @@ impl CompletionProvider for ContextPickerCompletionProvider {
             return Task::ready(Ok(None));
         };
 
-        let Some(workspace) = self.workspace.upgrade() else {
-            return Task::ready(Ok(None));
-        };
-        let Some(context_store) = self.context_store.upgrade() else {
+        let Some((workspace, context_store)) =
+            self.workspace.upgrade().zip(self.context_store.upgrade())
+        else {
             return Task::ready(Ok(None));
         };
 
@@ -419,154 +471,89 @@ impl CompletionProvider for ContextPickerCompletionProvider {
         let editor = self.editor.clone();
         let http_client = workspace.read(cx).client().http_client().clone();
 
+        let MentionCompletion { mode, argument, .. } = state;
+        let query = argument.unwrap_or_else(|| "".to_string());
+
+        let recent_entries = recent_context_picker_entries(
+            context_store.clone(),
+            thread_store.clone(),
+            workspace.clone(),
+            cx,
+        );
+
+        let search_task = search(
+            mode,
+            query,
+            Arc::<AtomicBool>::default(),
+            recent_entries,
+            thread_store.clone(),
+            workspace.clone(),
+            cx,
+        );
+
         cx.spawn(async move |_, cx| {
-            let mut completions = Vec::new();
-
-            let MentionCompletion { mode, argument, .. } = state;
-
-            let query = argument.unwrap_or_else(|| "".to_string());
-            match mode {
-                Some(ContextPickerMode::File) => {
-                    let path_matches = cx
-                        .update(|cx| {
-                            super::file_context_picker::search_paths(
-                                query,
-                                Arc::<AtomicBool>::default(),
-                                &workspace,
-                                cx,
-                            )
-                        })?
-                        .await;
-
-                    if let Some(editor) = editor.upgrade() {
-                        completions.reserve(path_matches.len());
-                        cx.update(|cx| {
-                            completions.extend(path_matches.iter().map(|mat| {
-                                Self::completion_for_path(
-                                    ProjectPath {
-                                        worktree_id: WorktreeId::from_usize(mat.worktree_id),
-                                        path: mat.path.clone(),
-                                    },
-                                    &mat.path_prefix,
-                                    false,
-                                    mat.is_dir,
-                                    excerpt_id,
-                                    source_range.clone(),
-                                    editor.clone(),
-                                    context_store.clone(),
-                                    cx,
-                                )
-                            }));
-                        })?;
-                    }
-                }
-                Some(ContextPickerMode::Symbol) => {
-                    if let Some(editor) = editor.upgrade() {
-                        let symbol_matches = cx
-                            .update(|cx| {
-                                super::symbol_context_picker::search_symbols(
-                                    query,
-                                    Arc::new(AtomicBool::default()),
-                                    &workspace,
-                                    cx,
-                                )
-                            })?
-                            .await?;
-                        cx.update(|cx| {
-                            completions.extend(symbol_matches.into_iter().filter_map(
-                                |(_, symbol)| {
-                                    Self::completion_for_symbol(
-                                        symbol,
-                                        excerpt_id,
-                                        source_range.clone(),
-                                        editor.clone(),
-                                        context_store.clone(),
-                                        workspace.clone(),
-                                        cx,
-                                    )
+            let matches = search_task.await;
+            let Some(editor) = editor.upgrade() else {
+                return Ok(None);
+            };
+
+            Ok(Some(cx.update(|cx| {
+                matches
+                    .into_iter()
+                    .filter_map(|mat| match mat {
+                        Match::File(FileMatch { mat, is_recent }) => {
+                            Some(Self::completion_for_path(
+                                ProjectPath {
+                                    worktree_id: WorktreeId::from_usize(mat.worktree_id),
+                                    path: mat.path.clone(),
                                 },
-                            ));
-                        })?;
-                    }
-                }
-                Some(ContextPickerMode::Fetch) => {
-                    if let Some(editor) = editor.upgrade() {
-                        if !query.is_empty() {
-                            completions.push(Self::completion_for_fetch(
-                                source_range.clone(),
-                                query.into(),
+                                &mat.path_prefix,
+                                is_recent,
+                                mat.is_dir,
                                 excerpt_id,
+                                source_range.clone(),
                                 editor.clone(),
                                 context_store.clone(),
-                                http_client.clone(),
-                            ));
+                                cx,
+                            ))
                         }
-
-                        context_store.update(cx, |store, _| {
-                            let urls = store.context().iter().filter_map(|context| {
-                                if let AssistantContext::FetchedUrl(context) = context {
-                                    Some(context.url.clone())
-                                } else {
-                                    None
-                                }
-                            });
-                            for url in urls {
-                                completions.push(Self::completion_for_fetch(
-                                    source_range.clone(),
-                                    url,
-                                    excerpt_id,
-                                    editor.clone(),
-                                    context_store.clone(),
-                                    http_client.clone(),
-                                ));
-                            }
-                        })?;
-                    }
-                }
-                Some(ContextPickerMode::Thread) => {
-                    if let Some((thread_store, editor)) = thread_store
-                        .and_then(|thread_store| thread_store.upgrade())
-                        .zip(editor.upgrade())
-                    {
-                        let threads = cx
-                            .update(|cx| {
-                                super::thread_context_picker::search_threads(
-                                    query,
-                                    thread_store.clone(),
-                                    cx,
-                                )
-                            })?
-                            .await;
-                        for thread in threads {
-                            completions.push(Self::completion_for_thread(
-                                thread.clone(),
+                        Match::Symbol(SymbolMatch { symbol, .. }) => Self::completion_for_symbol(
+                            symbol,
+                            excerpt_id,
+                            source_range.clone(),
+                            editor.clone(),
+                            context_store.clone(),
+                            workspace.clone(),
+                            cx,
+                        ),
+                        Match::Thread(ThreadMatch {
+                            thread, is_recent, ..
+                        }) => {
+                            let thread_store = thread_store.as_ref().and_then(|t| t.upgrade())?;
+                            Some(Self::completion_for_thread(
+                                thread,
                                 excerpt_id,
                                 source_range.clone(),
-                                false,
+                                is_recent,
                                 editor.clone(),
                                 context_store.clone(),
-                                thread_store.clone(),
-                            ));
+                                thread_store,
+                            ))
                         }
-                    }
-                }
-                None => {
-                    cx.update(|cx| {
-                        if let Some(editor) = editor.upgrade() {
-                            completions.extend(Self::default_completions(
-                                excerpt_id,
-                                source_range.clone(),
-                                context_store.clone(),
-                                thread_store.clone(),
-                                editor,
-                                workspace.clone(),
-                                cx,
-                            ));
+                        Match::Fetch(url) => Some(Self::completion_for_fetch(
+                            source_range.clone(),
+                            url,
+                            excerpt_id,
+                            editor.clone(),
+                            context_store.clone(),
+                            http_client.clone(),
+                        )),
+                        Match::Mode(mode) => {
+                            Some(Self::completion_for_mode(source_range.clone(), mode))
                         }
-                    })?;
-                }
-            }
-            Ok(Some(completions))
+                    })
+                    .collect()
+            })?))
         })
     }
 
@@ -676,7 +663,12 @@ impl MentionCompletion {
         let mut end = last_mention_start + 1;
         if let Some(mode_text) = parts.next() {
             end += mode_text.len();
-            mode = ContextPickerMode::try_from(mode_text).ok();
+
+            if let Some(parsed_mode) = ContextPickerMode::try_from(mode_text).ok() {
+                mode = Some(parsed_mode);
+            } else {
+                argument = Some(mode_text.to_string());
+            }
             match rest_of_line[mode_text.len()..].find(|c: char| !c.is_whitespace()) {
                 Some(whitespace_count) => {
                     if let Some(argument_text) = parts.next() {
@@ -702,13 +694,13 @@ impl MentionCompletion {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use gpui::{Focusable, TestAppContext, VisualTestContext};
+    use gpui::{EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext};
     use project::{Project, ProjectPath};
     use serde_json::json;
     use settings::SettingsStore;
-    use std::{ops::Deref, path::PathBuf};
+    use std::ops::Deref;
     use util::{path, separator};
-    use workspace::AppState;
+    use workspace::{AppState, Item};
 
     #[test]
     fn test_mention_completion_parse() {
@@ -768,9 +760,42 @@ mod tests {
             })
         );
 
+        assert_eq!(
+            MentionCompletion::try_parse("Lorem @main", 0),
+            Some(MentionCompletion {
+                source_range: 6..11,
+                mode: None,
+                argument: Some("main".to_string()),
+            })
+        );
+
         assert_eq!(MentionCompletion::try_parse("test@", 0), None);
     }
 
+    struct AtMentionEditor(Entity<Editor>);
+
+    impl Item for AtMentionEditor {
+        type Event = ();
+
+        fn include_in_nav_history() -> bool {
+            false
+        }
+    }
+
+    impl EventEmitter<()> for AtMentionEditor {}
+
+    impl Focusable for AtMentionEditor {
+        fn focus_handle(&self, cx: &App) -> FocusHandle {
+            self.0.read(cx).focus_handle(cx).clone()
+        }
+    }
+
+    impl Render for AtMentionEditor {
+        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+            self.0.clone().into_any_element()
+        }
+    }
+
     #[gpui::test]
     async fn test_context_completion_provider(cx: &mut TestAppContext) {
         init_test(cx);
@@ -846,25 +871,27 @@ mod tests {
                 .unwrap();
         }
 
-        let item = workspace
-            .update_in(&mut cx, |workspace, window, cx| {
-                workspace.open_path(
-                    ProjectPath {
-                        worktree_id,
-                        path: PathBuf::from("editor").into(),
-                    },
+        let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
+            let editor = cx.new(|cx| {
+                Editor::new(
+                    editor::EditorMode::Full,
+                    multi_buffer::MultiBuffer::build_simple("", cx),
                     None,
-                    true,
                     window,
                     cx,
                 )
-            })
-            .await
-            .expect("Could not open test file");
-
-        let editor = cx.update(|_, cx| {
-            item.act_as::<Editor>(cx)
-                .expect("Opened test file wasn't an editor")
+            });
+            workspace.active_pane().update(cx, |pane, cx| {
+                pane.add_item(
+                    Box::new(cx.new(|_| AtMentionEditor(editor.clone()))),
+                    true,
+                    true,
+                    None,
+                    window,
+                    cx,
+                );
+            });
+            editor
         });
 
         let context_store = cx.new(|_| ContextStore::new(project.downgrade(), None));
@@ -895,10 +922,10 @@ mod tests {
             assert_eq!(
                 current_completion_labels(editor),
                 &[
-                    "editor dir/",
                     "seven.txt dir/b/",
                     "six.txt dir/b/",
                     "five.txt dir/b/",
+                    "four.txt dir/a/",
                     "Files & Directories",
                     "Symbols",
                     "Fetch"
@@ -993,14 +1020,14 @@ mod tests {
         editor.update(&mut cx, |editor, cx| {
             assert_eq!(
                 editor.text(cx),
-                "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@editor](@file:dir/editor)"
+                "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@seven.txt](@file:dir/b/seven.txt)"
             );
             assert!(!editor.has_visible_completions_menu());
             assert_eq!(
                 crease_ranges(editor, cx),
                 vec![
                     Point::new(0, 6)..Point::new(0, 37),
-                    Point::new(0, 44)..Point::new(0, 71)
+                    Point::new(0, 44)..Point::new(0, 79)
                 ]
             );
         });
@@ -1010,14 +1037,14 @@ mod tests {
         editor.update(&mut cx, |editor, cx| {
             assert_eq!(
                 editor.text(cx),
-                "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@editor](@file:dir/editor)\n@"
+                "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@seven.txt](@file:dir/b/seven.txt)\n@"
             );
             assert!(editor.has_visible_completions_menu());
             assert_eq!(
                 crease_ranges(editor, cx),
                 vec![
                     Point::new(0, 6)..Point::new(0, 37),
-                    Point::new(0, 44)..Point::new(0, 71)
+                    Point::new(0, 44)..Point::new(0, 79)
                 ]
             );
         });
@@ -1031,15 +1058,15 @@ mod tests {
         editor.update(&mut cx, |editor, cx| {
             assert_eq!(
                 editor.text(cx),
-                "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@editor](@file:dir/editor)\n[@seven.txt](@file:dir/b/seven.txt)"
+                "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@seven.txt](@file:dir/b/seven.txt)\n[@six.txt](@file:dir/b/six.txt)"
             );
             assert!(!editor.has_visible_completions_menu());
             assert_eq!(
                 crease_ranges(editor, cx),
                 vec![
                     Point::new(0, 6)..Point::new(0, 37),
-                    Point::new(0, 44)..Point::new(0, 71),
-                    Point::new(1, 0)..Point::new(1, 35)
+                    Point::new(0, 44)..Point::new(0, 79),
+                    Point::new(1, 0)..Point::new(1, 31)
                 ]
             );
         });

crates/agent/src/context_picker/file_context_picker.rs 🔗

@@ -58,7 +58,7 @@ pub struct FileContextPickerDelegate {
     workspace: WeakEntity<Workspace>,
     context_store: WeakEntity<ContextStore>,
     confirm_behavior: ConfirmBehavior,
-    matches: Vec<PathMatch>,
+    matches: Vec<FileMatch>,
     selected_index: usize,
 }
 
@@ -114,7 +114,7 @@ impl PickerDelegate for FileContextPickerDelegate {
             return Task::ready(());
         };
 
-        let search_task = search_paths(query, Arc::<AtomicBool>::default(), &workspace, cx);
+        let search_task = search_files(query, Arc::<AtomicBool>::default(), &workspace, cx);
 
         cx.spawn_in(window, async move |this, cx| {
             // TODO: This should be probably be run in the background.
@@ -128,7 +128,7 @@ impl PickerDelegate for FileContextPickerDelegate {
     }
 
     fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
-        let Some(mat) = self.matches.get(self.selected_index) else {
+        let Some(FileMatch { mat, .. }) = self.matches.get(self.selected_index) else {
             return;
         };
 
@@ -181,7 +181,7 @@ impl PickerDelegate for FileContextPickerDelegate {
         _window: &mut Window,
         cx: &mut Context<Picker<Self>>,
     ) -> Option<Self::ListItem> {
-        let path_match = &self.matches[ix];
+        let FileMatch { mat, .. } = &self.matches[ix];
 
         Some(
             ListItem::new(ix)
@@ -189,9 +189,9 @@ impl PickerDelegate for FileContextPickerDelegate {
                 .toggle_state(selected)
                 .child(render_file_context_entry(
                     ElementId::NamedInteger("file-ctx-picker".into(), ix),
-                    &path_match.path,
-                    &path_match.path_prefix,
-                    path_match.is_dir,
+                    &mat.path,
+                    &mat.path_prefix,
+                    mat.is_dir,
                     self.context_store.clone(),
                     cx,
                 )),
@@ -199,12 +199,17 @@ impl PickerDelegate for FileContextPickerDelegate {
     }
 }
 
-pub(crate) fn search_paths(
+pub struct FileMatch {
+    pub mat: PathMatch,
+    pub is_recent: bool,
+}
+
+pub(crate) fn search_files(
     query: String,
     cancellation_flag: Arc<AtomicBool>,
     workspace: &Entity<Workspace>,
     cx: &App,
-) -> Task<Vec<PathMatch>> {
+) -> Task<Vec<FileMatch>> {
     if query.is_empty() {
         let workspace = workspace.read(cx);
         let project = workspace.project().read(cx);
@@ -213,28 +218,34 @@ pub(crate) fn search_paths(
             .into_iter()
             .filter_map(|(project_path, _)| {
                 let worktree = project.worktree_for_id(project_path.worktree_id, cx)?;
-                Some(PathMatch {
-                    score: 0.,
-                    positions: Vec::new(),
-                    worktree_id: project_path.worktree_id.to_usize(),
-                    path: project_path.path,
-                    path_prefix: worktree.read(cx).root_name().into(),
-                    distance_to_relative_ancestor: 0,
-                    is_dir: false,
+                Some(FileMatch {
+                    mat: PathMatch {
+                        score: 0.,
+                        positions: Vec::new(),
+                        worktree_id: project_path.worktree_id.to_usize(),
+                        path: project_path.path,
+                        path_prefix: worktree.read(cx).root_name().into(),
+                        distance_to_relative_ancestor: 0,
+                        is_dir: false,
+                    },
+                    is_recent: true,
                 })
             });
 
         let file_matches = project.worktrees(cx).flat_map(|worktree| {
             let worktree = worktree.read(cx);
             let path_prefix: Arc<str> = worktree.root_name().into();
-            worktree.entries(false, 0).map(move |entry| PathMatch {
-                score: 0.,
-                positions: Vec::new(),
-                worktree_id: worktree.id().to_usize(),
-                path: entry.path.clone(),
-                path_prefix: path_prefix.clone(),
-                distance_to_relative_ancestor: 0,
-                is_dir: entry.is_dir(),
+            worktree.entries(false, 0).map(move |entry| FileMatch {
+                mat: PathMatch {
+                    score: 0.,
+                    positions: Vec::new(),
+                    worktree_id: worktree.id().to_usize(),
+                    path: entry.path.clone(),
+                    path_prefix: path_prefix.clone(),
+                    distance_to_relative_ancestor: 0,
+                    is_dir: entry.is_dir(),
+                },
+                is_recent: false,
             })
         });
 
@@ -269,6 +280,12 @@ pub(crate) fn search_paths(
                 executor,
             )
             .await
+            .into_iter()
+            .map(|mat| FileMatch {
+                mat,
+                is_recent: false,
+            })
+            .collect::<Vec<_>>()
         })
     }
 }

crates/agent/src/context_picker/symbol_context_picker.rs 🔗

@@ -2,7 +2,7 @@ use std::cmp::Reverse;
 use std::sync::Arc;
 use std::sync::atomic::AtomicBool;
 
-use anyhow::{Context as _, Result};
+use anyhow::Result;
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
     App, AppContext, DismissEvent, Entity, FocusHandle, Focusable, Stateful, Task, WeakEntity,
@@ -119,11 +119,7 @@ impl PickerDelegate for SymbolContextPickerDelegate {
         let search_task = search_symbols(query, Arc::<AtomicBool>::default(), &workspace, cx);
         let context_store = self.context_store.clone();
         cx.spawn_in(window, async move |this, cx| {
-            let symbols = search_task
-                .await
-                .context("Failed to load symbols")
-                .log_err()
-                .unwrap_or_default();
+            let symbols = search_task.await;
 
             let symbol_entries = context_store
                 .read_with(cx, |context_store, cx| {
@@ -285,12 +281,16 @@ fn find_matching_symbol(symbol: &Symbol, candidates: &[DocumentSymbol]) -> Optio
     }
 }
 
+pub struct SymbolMatch {
+    pub symbol: Symbol,
+}
+
 pub(crate) fn search_symbols(
     query: String,
     cancellation_flag: Arc<AtomicBool>,
     workspace: &Entity<Workspace>,
     cx: &mut App,
-) -> Task<Result<Vec<(StringMatch, Symbol)>>> {
+) -> Task<Vec<SymbolMatch>> {
     let symbols_task = workspace.update(cx, |workspace, cx| {
         workspace
             .project()
@@ -298,19 +298,28 @@ pub(crate) fn search_symbols(
     });
     let project = workspace.read(cx).project().clone();
     cx.spawn(async move |cx| {
-        let symbols = symbols_task.await?;
-        let (visible_match_candidates, external_match_candidates): (Vec<_>, Vec<_>) = project
-            .update(cx, |project, cx| {
-                symbols
-                    .iter()
-                    .enumerate()
-                    .map(|(id, symbol)| StringMatchCandidate::new(id, &symbol.label.filter_text()))
-                    .partition(|candidate| {
-                        project
-                            .entry_for_path(&symbols[candidate.id].path, cx)
-                            .map_or(false, |e| !e.is_ignored)
-                    })
-            })?;
+        let Some(symbols) = symbols_task.await.log_err() else {
+            return Vec::new();
+        };
+        let Some((visible_match_candidates, external_match_candidates)): Option<(Vec<_>, Vec<_>)> =
+            project
+                .update(cx, |project, cx| {
+                    symbols
+                        .iter()
+                        .enumerate()
+                        .map(|(id, symbol)| {
+                            StringMatchCandidate::new(id, &symbol.label.filter_text())
+                        })
+                        .partition(|candidate| {
+                            project
+                                .entry_for_path(&symbols[candidate.id].path, cx)
+                                .map_or(false, |e| !e.is_ignored)
+                        })
+                })
+                .log_err()
+        else {
+            return Vec::new();
+        };
 
         const MAX_MATCHES: usize = 100;
         let mut visible_matches = cx.background_executor().block(fuzzy::match_strings(
@@ -339,7 +348,7 @@ pub(crate) fn search_symbols(
         let mut matches = visible_matches;
         matches.append(&mut external_matches);
 
-        Ok(matches
+        matches
             .into_iter()
             .map(|mut mat| {
                 let symbol = symbols[mat.candidate_id].clone();
@@ -347,19 +356,19 @@ pub(crate) fn search_symbols(
                 for position in &mut mat.positions {
                     *position += filter_start;
                 }
-                (mat, symbol)
+                SymbolMatch { symbol }
             })
-            .collect())
+            .collect()
     })
 }
 
 fn compute_symbol_entries(
-    symbols: Vec<(StringMatch, Symbol)>,
+    symbols: Vec<SymbolMatch>,
     context_store: &ContextStore,
     cx: &App,
 ) -> Vec<SymbolEntry> {
     let mut symbol_entries = Vec::with_capacity(symbols.len());
-    for (_, symbol) in symbols {
+    for SymbolMatch { symbol, .. } in symbols {
         let symbols_for_path = context_store.included_symbols_by_path().get(&symbol.path);
         let is_included = if let Some(symbols_for_path) = symbols_for_path {
             let mut is_included = false;

crates/agent/src/context_picker/thread_context_picker.rs 🔗

@@ -1,4 +1,5 @@
 use std::sync::Arc;
+use std::sync::atomic::AtomicBool;
 
 use fuzzy::StringMatchCandidate;
 use gpui::{App, DismissEvent, Entity, FocusHandle, Focusable, Task, WeakEntity};
@@ -114,11 +115,11 @@ impl PickerDelegate for ThreadContextPickerDelegate {
             return Task::ready(());
         };
 
-        let search_task = search_threads(query, threads, cx);
+        let search_task = search_threads(query, Arc::new(AtomicBool::default()), threads, cx);
         cx.spawn_in(window, async move |this, cx| {
             let matches = search_task.await;
             this.update(cx, |this, cx| {
-                this.delegate.matches = matches;
+                this.delegate.matches = matches.into_iter().map(|mat| mat.thread).collect();
                 this.delegate.selected_index = 0;
                 cx.notify();
             })
@@ -217,11 +218,18 @@ pub fn render_thread_context_entry(
         })
 }
 
+#[derive(Clone)]
+pub struct ThreadMatch {
+    pub thread: ThreadContextEntry,
+    pub is_recent: bool,
+}
+
 pub(crate) fn search_threads(
     query: String,
+    cancellation_flag: Arc<AtomicBool>,
     thread_store: Entity<ThreadStore>,
     cx: &mut App,
-) -> Task<Vec<ThreadContextEntry>> {
+) -> Task<Vec<ThreadMatch>> {
     let threads = thread_store.update(cx, |this, _cx| {
         this.threads()
             .into_iter()
@@ -236,6 +244,12 @@ pub(crate) fn search_threads(
     cx.background_spawn(async move {
         if query.is_empty() {
             threads
+                .into_iter()
+                .map(|thread| ThreadMatch {
+                    thread,
+                    is_recent: false,
+                })
+                .collect()
         } else {
             let candidates = threads
                 .iter()
@@ -247,14 +261,17 @@ pub(crate) fn search_threads(
                 &query,
                 false,
                 100,
-                &Default::default(),
+                &cancellation_flag,
                 executor,
             )
             .await;
 
             matches
                 .into_iter()
-                .map(|mat| threads[mat.candidate_id].clone())
+                .map(|mat| ThreadMatch {
+                    thread: threads[mat.candidate_id].clone(),
+                    is_recent: false,
+                })
                 .collect()
         }
     })

crates/agent/src/message_editor.rs 🔗

@@ -3,14 +3,16 @@ use std::sync::Arc;
 use crate::assistant_model_selector::ModelType;
 use collections::HashSet;
 use editor::actions::MoveUp;
-use editor::{ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorStyle};
+use editor::{
+    ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorStyle, MultiBuffer,
+};
 use file_icons::FileIcons;
 use fs::Fs;
 use gpui::{
     Animation, AnimationExt, App, DismissEvent, Entity, Focusable, Subscription, TextStyle,
     WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between,
 };
-use language::Buffer;
+use language::{Buffer, Language};
 use language_model::{ConfiguredModel, LanguageModelRegistry};
 use language_model_selector::ToggleModelSelector;
 use multi_buffer;
@@ -66,8 +68,24 @@ impl MessageEditor {
         let inline_context_picker_menu_handle = PopoverMenuHandle::default();
         let model_selector_menu_handle = PopoverMenuHandle::default();
 
+        let language = Language::new(
+            language::LanguageConfig {
+                completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
+                ..Default::default()
+            },
+            None,
+        );
+
         let editor = cx.new(|cx| {
-            let mut editor = Editor::auto_height(10, window, cx);
+            let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
+            let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+            let mut editor = Editor::new(
+                editor::EditorMode::AutoHeight { max_lines: 10 },
+                buffer,
+                None,
+                window,
+                cx,
+            );
             editor.set_placeholder_text("Ask anything, @ to mention, ↑ to select", cx);
             editor.set_show_indent_guides(false, cx);
             editor.set_context_menu_options(ContextMenuOptions {
@@ -75,7 +93,6 @@ impl MessageEditor {
                 max_entries_visible: 12,
                 placement: Some(ContextMenuPlacement::Above),
             });
-
             editor
         });