agent: Support inserting selections as context via `@selection` (#29045)

Bennet Bo Fenner created

WIP

Release Notes:

- N/A

Change summary

crates/agent/src/active_thread.rs                      |  28 
crates/agent/src/assistant_panel.rs                    |   4 
crates/agent/src/context.rs                            |  24 
crates/agent/src/context_picker.rs                     | 342 +++++++++--
crates/agent/src/context_picker/completion_provider.rs | 200 +++++-
crates/agent/src/context_store.rs                      |  48 
crates/agent/src/message_editor.rs                     |   2 
crates/agent/src/thread.rs                             |   4 
crates/agent/src/ui/context_pill.rs                    |  78 +-
crates/editor/src/editor.rs                            |   7 
10 files changed, 547 insertions(+), 190 deletions(-)

Detailed changes

crates/agent/src/active_thread.rs 🔗

@@ -670,6 +670,26 @@ fn open_markdown_link(
                 })
                 .detach_and_log_err(cx);
         }
+        Some(MentionLink::Selection(path, line_range)) => {
+            let open_task = workspace.update(cx, |workspace, cx| {
+                workspace.open_path(path, None, true, window, cx)
+            });
+            window
+                .spawn(cx, async move |cx| {
+                    let active_editor = open_task
+                        .await?
+                        .downcast::<Editor>()
+                        .context("Item is not an editor")?;
+                    active_editor.update_in(cx, |editor, window, cx| {
+                        editor.change_selections(Some(Autoscroll::center()), window, cx, |s| {
+                            s.select_ranges([Point::new(line_range.start as u32, 0)
+                                ..Point::new(line_range.start as u32, 0)])
+                        });
+                        anyhow::Ok(())
+                    })
+                })
+                .detach_and_log_err(cx);
+        }
         Some(MentionLink::Thread(thread_id)) => workspace.update(cx, |workspace, cx| {
             if let Some(panel) = workspace.panel::<AssistantPanel>(cx) {
                 panel.update(cx, |panel, cx| {
@@ -3309,15 +3329,15 @@ pub(crate) fn open_context(
                     .detach();
             }
         }
-        AssistantContext::Excerpt(excerpt_context) => {
-            if let Some(project_path) = excerpt_context
+        AssistantContext::Selection(selection_context) => {
+            if let Some(project_path) = selection_context
                 .context_buffer
                 .buffer
                 .read(cx)
                 .project_path(cx)
             {
-                let snapshot = excerpt_context.context_buffer.buffer.read(cx).snapshot();
-                let target_position = excerpt_context.range.start.to_point(&snapshot);
+                let snapshot = selection_context.context_buffer.buffer.read(cx).snapshot();
+                let target_position = selection_context.range.start.to_point(&snapshot);
 
                 open_editor_at_position(project_path, target_position, &workspace, window, cx)
                     .detach();

crates/agent/src/assistant_panel.rs 🔗

@@ -1951,7 +1951,9 @@ impl AssistantPanelDelegate for ConcreteAssistantPanelDelegate {
                                 .collect::<Vec<_>>();
 
                             for (buffer, range) in selection_ranges {
-                                store.add_excerpt(range, buffer, cx).detach_and_log_err(cx);
+                                store
+                                    .add_selection(buffer, range, cx)
+                                    .detach_and_log_err(cx);
                             }
                         })
                     })

crates/agent/src/context.rs 🔗

@@ -33,7 +33,7 @@ pub enum ContextKind {
     File,
     Directory,
     Symbol,
-    Excerpt,
+    Selection,
     FetchedUrl,
     Thread,
     Rules,
@@ -46,7 +46,7 @@ impl ContextKind {
             ContextKind::File => IconName::File,
             ContextKind::Directory => IconName::Folder,
             ContextKind::Symbol => IconName::Code,
-            ContextKind::Excerpt => IconName::Code,
+            ContextKind::Selection => IconName::Context,
             ContextKind::FetchedUrl => IconName::Globe,
             ContextKind::Thread => IconName::MessageBubbles,
             ContextKind::Rules => RULES_ICON,
@@ -62,7 +62,7 @@ pub enum AssistantContext {
     Symbol(SymbolContext),
     FetchedUrl(FetchedUrlContext),
     Thread(ThreadContext),
-    Excerpt(ExcerptContext),
+    Selection(SelectionContext),
     Rules(RulesContext),
     Image(ImageContext),
 }
@@ -75,7 +75,7 @@ impl AssistantContext {
             Self::Symbol(symbol) => symbol.id,
             Self::FetchedUrl(url) => url.id,
             Self::Thread(thread) => thread.id,
-            Self::Excerpt(excerpt) => excerpt.id,
+            Self::Selection(selection) => selection.id,
             Self::Rules(rules) => rules.id,
             Self::Image(image) => image.id,
         }
@@ -220,7 +220,7 @@ pub struct ContextSymbolId {
 }
 
 #[derive(Debug, Clone)]
-pub struct ExcerptContext {
+pub struct SelectionContext {
     pub id: ContextId,
     pub range: Range<Anchor>,
     pub line_range: Range<Point>,
@@ -243,7 +243,7 @@ pub fn format_context_as_string<'a>(
     let mut file_context = Vec::new();
     let mut directory_context = Vec::new();
     let mut symbol_context = Vec::new();
-    let mut excerpt_context = Vec::new();
+    let mut selection_context = Vec::new();
     let mut fetch_context = Vec::new();
     let mut thread_context = Vec::new();
     let mut rules_context = Vec::new();
@@ -253,7 +253,7 @@ pub fn format_context_as_string<'a>(
             AssistantContext::File(context) => file_context.push(context),
             AssistantContext::Directory(context) => directory_context.push(context),
             AssistantContext::Symbol(context) => symbol_context.push(context),
-            AssistantContext::Excerpt(context) => excerpt_context.push(context),
+            AssistantContext::Selection(context) => selection_context.push(context),
             AssistantContext::FetchedUrl(context) => fetch_context.push(context),
             AssistantContext::Thread(context) => thread_context.push(context),
             AssistantContext::Rules(context) => rules_context.push(context),
@@ -264,7 +264,7 @@ pub fn format_context_as_string<'a>(
     if file_context.is_empty()
         && directory_context.is_empty()
         && symbol_context.is_empty()
-        && excerpt_context.is_empty()
+        && selection_context.is_empty()
         && fetch_context.is_empty()
         && thread_context.is_empty()
         && rules_context.is_empty()
@@ -303,13 +303,13 @@ pub fn format_context_as_string<'a>(
         result.push_str("</symbols>\n");
     }
 
-    if !excerpt_context.is_empty() {
-        result.push_str("<excerpts>\n");
-        for context in excerpt_context {
+    if !selection_context.is_empty() {
+        result.push_str("<selections>\n");
+        for context in selection_context {
             result.push_str(&context.context_buffer.text);
             result.push('\n');
         }
-        result.push_str("</excerpts>\n");
+        result.push_str("</selections>\n");
     }
 
     if !fetch_context.is_empty() {

crates/agent/src/context_picker.rs 🔗

@@ -17,6 +17,7 @@ use gpui::{
     App, DismissEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, Subscription, Task,
     WeakEntity,
 };
+use language::Buffer;
 use multi_buffer::MultiBufferRow;
 use project::{Entry, ProjectPath};
 use prompt_store::UserPromptId;
@@ -40,6 +41,35 @@ use crate::context_store::ContextStore;
 use crate::thread::ThreadId;
 use crate::thread_store::ThreadStore;
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ContextPickerEntry {
+    Mode(ContextPickerMode),
+    Action(ContextPickerAction),
+}
+
+impl ContextPickerEntry {
+    pub fn keyword(&self) -> &'static str {
+        match self {
+            Self::Mode(mode) => mode.keyword(),
+            Self::Action(action) => action.keyword(),
+        }
+    }
+
+    pub fn label(&self) -> &'static str {
+        match self {
+            Self::Mode(mode) => mode.label(),
+            Self::Action(action) => action.label(),
+        }
+    }
+
+    pub fn icon(&self) -> IconName {
+        match self {
+            Self::Mode(mode) => mode.icon(),
+            Self::Action(action) => action.icon(),
+        }
+    }
+}
+
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 enum ContextPickerMode {
     File,
@@ -49,6 +79,31 @@ enum ContextPickerMode {
     Rules,
 }
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ContextPickerAction {
+    AddSelections,
+}
+
+impl ContextPickerAction {
+    pub fn keyword(&self) -> &'static str {
+        match self {
+            Self::AddSelections => "selection",
+        }
+    }
+
+    pub fn label(&self) -> &'static str {
+        match self {
+            Self::AddSelections => "Selection",
+        }
+    }
+
+    pub fn icon(&self) -> IconName {
+        match self {
+            Self::AddSelections => IconName::Context,
+        }
+    }
+}
+
 impl TryFrom<&str> for ContextPickerMode {
     type Error = String;
 
@@ -65,7 +120,7 @@ impl TryFrom<&str> for ContextPickerMode {
 }
 
 impl ContextPickerMode {
-    pub fn mention_prefix(&self) -> &'static str {
+    pub fn keyword(&self) -> &'static str {
         match self {
             Self::File => "file",
             Self::Symbol => "symbol",
@@ -167,7 +222,13 @@ impl ContextPicker {
                 .enumerate()
                 .map(|(ix, entry)| self.recent_menu_item(context_picker.clone(), ix, entry));
 
-            let modes = supported_context_picker_modes(&self.thread_store);
+            let entries = self
+                .workspace
+                .upgrade()
+                .map(|workspace| {
+                    available_context_picker_entries(&self.thread_store, &workspace, cx)
+                })
+                .unwrap_or_default();
 
             menu.when(has_recent, |menu| {
                 menu.custom_row(|_, _| {
@@ -183,15 +244,15 @@ impl ContextPicker {
             })
             .extend(recent_entries)
             .when(has_recent, |menu| menu.separator())
-            .extend(modes.into_iter().map(|mode| {
+            .extend(entries.into_iter().map(|entry| {
                 let context_picker = context_picker.clone();
 
-                ContextMenuEntry::new(mode.label())
-                    .icon(mode.icon())
+                ContextMenuEntry::new(entry.label())
+                    .icon(entry.icon())
                     .icon_size(IconSize::XSmall)
                     .icon_color(Color::Muted)
                     .handler(move |window, cx| {
-                        context_picker.update(cx, |this, cx| this.select_mode(mode, window, cx))
+                        context_picker.update(cx, |this, cx| this.select_entry(entry, window, cx))
                     })
             }))
             .keep_open_on_confirm()
@@ -210,74 +271,87 @@ impl ContextPicker {
         self.thread_store.is_some()
     }
 
-    fn select_mode(
+    fn select_entry(
         &mut self,
-        mode: ContextPickerMode,
+        entry: ContextPickerEntry,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
         let context_picker = cx.entity().downgrade();
 
-        match mode {
-            ContextPickerMode::File => {
-                self.mode = ContextPickerState::File(cx.new(|cx| {
-                    FileContextPicker::new(
-                        context_picker.clone(),
-                        self.workspace.clone(),
-                        self.context_store.clone(),
-                        window,
-                        cx,
-                    )
-                }));
-            }
-            ContextPickerMode::Symbol => {
-                self.mode = ContextPickerState::Symbol(cx.new(|cx| {
-                    SymbolContextPicker::new(
-                        context_picker.clone(),
-                        self.workspace.clone(),
-                        self.context_store.clone(),
-                        window,
-                        cx,
-                    )
-                }));
-            }
-            ContextPickerMode::Fetch => {
-                self.mode = ContextPickerState::Fetch(cx.new(|cx| {
-                    FetchContextPicker::new(
-                        context_picker.clone(),
-                        self.workspace.clone(),
-                        self.context_store.clone(),
-                        window,
-                        cx,
-                    )
-                }));
-            }
-            ContextPickerMode::Thread => {
-                if let Some(thread_store) = self.thread_store.as_ref() {
-                    self.mode = ContextPickerState::Thread(cx.new(|cx| {
-                        ThreadContextPicker::new(
-                            thread_store.clone(),
+        match entry {
+            ContextPickerEntry::Mode(mode) => match mode {
+                ContextPickerMode::File => {
+                    self.mode = ContextPickerState::File(cx.new(|cx| {
+                        FileContextPicker::new(
                             context_picker.clone(),
+                            self.workspace.clone(),
                             self.context_store.clone(),
                             window,
                             cx,
                         )
                     }));
                 }
-            }
-            ContextPickerMode::Rules => {
-                if let Some(thread_store) = self.thread_store.as_ref() {
-                    self.mode = ContextPickerState::Rules(cx.new(|cx| {
-                        RulesContextPicker::new(
-                            thread_store.clone(),
+                ContextPickerMode::Symbol => {
+                    self.mode = ContextPickerState::Symbol(cx.new(|cx| {
+                        SymbolContextPicker::new(
                             context_picker.clone(),
+                            self.workspace.clone(),
                             self.context_store.clone(),
                             window,
                             cx,
                         )
                     }));
                 }
-            }
+                ContextPickerMode::Rules => {
+                    if let Some(thread_store) = self.thread_store.as_ref() {
+                        self.mode = ContextPickerState::Rules(cx.new(|cx| {
+                            RulesContextPicker::new(
+                                thread_store.clone(),
+                                context_picker.clone(),
+                                self.context_store.clone(),
+                                window,
+                                cx,
+                            )
+                        }));
+                    }
+                }
+                ContextPickerMode::Fetch => {
+                    self.mode = ContextPickerState::Fetch(cx.new(|cx| {
+                        FetchContextPicker::new(
+                            context_picker.clone(),
+                            self.workspace.clone(),
+                            self.context_store.clone(),
+                            window,
+                            cx,
+                        )
+                    }));
+                }
+                ContextPickerMode::Thread => {
+                    if let Some(thread_store) = self.thread_store.as_ref() {
+                        self.mode = ContextPickerState::Thread(cx.new(|cx| {
+                            ThreadContextPicker::new(
+                                thread_store.clone(),
+                                context_picker.clone(),
+                                self.context_store.clone(),
+                                window,
+                                cx,
+                            )
+                        }));
+                    }
+                }
+            },
+            ContextPickerEntry::Action(action) => match action {
+                ContextPickerAction::AddSelections => {
+                    if let Some((context_store, workspace)) =
+                        self.context_store.upgrade().zip(self.workspace.upgrade())
+                    {
+                        add_selections_as_context(&context_store, &workspace, cx);
+                    }
+
+                    cx.emit(DismissEvent);
+                }
+            },
         }
 
         cx.notify();
@@ -451,19 +525,37 @@ enum RecentEntry {
     Thread(ThreadContextEntry),
 }
 
-fn supported_context_picker_modes(
+fn available_context_picker_entries(
     thread_store: &Option<WeakEntity<ThreadStore>>,
-) -> Vec<ContextPickerMode> {
-    let mut modes = vec![
-        ContextPickerMode::File,
-        ContextPickerMode::Symbol,
-        ContextPickerMode::Fetch,
+    workspace: &Entity<Workspace>,
+    cx: &mut App,
+) -> Vec<ContextPickerEntry> {
+    let mut entries = vec![
+        ContextPickerEntry::Mode(ContextPickerMode::File),
+        ContextPickerEntry::Mode(ContextPickerMode::Symbol),
     ];
+
+    let has_selection = workspace
+        .read(cx)
+        .active_item(cx)
+        .and_then(|item| item.downcast::<Editor>())
+        .map_or(false, |editor| {
+            editor.update(cx, |editor, cx| editor.has_non_empty_selection(cx))
+        });
+    if has_selection {
+        entries.push(ContextPickerEntry::Action(
+            ContextPickerAction::AddSelections,
+        ));
+    }
+
     if thread_store.is_some() {
-        modes.push(ContextPickerMode::Thread);
-        modes.push(ContextPickerMode::Rules);
+        entries.push(ContextPickerEntry::Mode(ContextPickerMode::Thread));
+        entries.push(ContextPickerEntry::Mode(ContextPickerMode::Rules));
     }
-    modes
+
+    entries.push(ContextPickerEntry::Mode(ContextPickerMode::Fetch));
+
+    entries
 }
 
 fn recent_context_picker_entries(
@@ -522,6 +614,54 @@ fn recent_context_picker_entries(
     recent
 }
 
+fn add_selections_as_context(
+    context_store: &Entity<ContextStore>,
+    workspace: &Entity<Workspace>,
+    cx: &mut App,
+) {
+    let selection_ranges = selection_ranges(workspace, cx);
+    context_store.update(cx, |context_store, cx| {
+        for (buffer, range) in selection_ranges {
+            context_store
+                .add_selection(buffer, range, cx)
+                .detach_and_log_err(cx);
+        }
+    })
+}
+
+fn selection_ranges(
+    workspace: &Entity<Workspace>,
+    cx: &mut App,
+) -> Vec<(Entity<Buffer>, Range<text::Anchor>)> {
+    let Some(editor) = workspace
+        .read(cx)
+        .active_item(cx)
+        .and_then(|item| item.act_as::<Editor>(cx))
+    else {
+        return Vec::new();
+    };
+
+    editor.update(cx, |editor, cx| {
+        let selections = editor.selections.all_adjusted(cx);
+
+        let buffer = editor.buffer().clone().read(cx);
+        let snapshot = buffer.snapshot(cx);
+
+        selections
+            .into_iter()
+            .map(|s| snapshot.anchor_after(s.start)..snapshot.anchor_before(s.end))
+            .flat_map(|range| {
+                let (start_buffer, start) = buffer.text_anchor_for_position(range.start, cx)?;
+                let (end_buffer, end) = buffer.text_anchor_for_position(range.end, cx)?;
+                if start_buffer != end_buffer {
+                    return None;
+                }
+                Some((start_buffer, start..end))
+            })
+            .collect::<Vec<_>>()
+    })
+}
+
 pub(crate) fn insert_fold_for_mention(
     excerpt_id: ExcerptId,
     crease_start: text::Anchor,
@@ -541,24 +681,11 @@ pub(crate) fn insert_fold_for_mention(
         let start = start.bias_right(&snapshot);
         let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
 
-        let placeholder = FoldPlaceholder {
-            render: render_fold_icon_button(
-                crease_icon_path,
-                crease_label,
-                editor_entity.downgrade(),
-            ),
-            merge_adjacent: false,
-            ..Default::default()
-        };
-
-        let render_trailer =
-            move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
-
-        let crease = Crease::inline(
+        let crease = crease_for_mention(
+            crease_label,
+            crease_icon_path,
             start..end,
-            placeholder.clone(),
-            fold_toggle("mention"),
-            render_trailer,
+            editor_entity.downgrade(),
         );
 
         editor.display_map.update(cx, |display_map, cx| {
@@ -567,6 +694,29 @@ pub(crate) fn insert_fold_for_mention(
     });
 }
 
+pub fn crease_for_mention(
+    label: SharedString,
+    icon_path: SharedString,
+    range: Range<Anchor>,
+    editor_entity: WeakEntity<Editor>,
+) -> Crease<Anchor> {
+    let placeholder = FoldPlaceholder {
+        render: render_fold_icon_button(icon_path, label, editor_entity),
+        merge_adjacent: false,
+        ..Default::default()
+    };
+
+    let render_trailer = move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
+
+    let crease = Crease::inline(
+        range,
+        placeholder.clone(),
+        fold_toggle("mention"),
+        render_trailer,
+    );
+    crease
+}
+
 fn render_fold_icon_button(
     icon_path: SharedString,
     label: SharedString,
@@ -655,6 +805,7 @@ fn fold_toggle(
 pub enum MentionLink {
     File(ProjectPath, Entry),
     Symbol(ProjectPath, String),
+    Selection(ProjectPath, Range<usize>),
     Fetch(String),
     Thread(ThreadId),
     Rules(UserPromptId),
@@ -663,6 +814,7 @@ pub enum MentionLink {
 impl MentionLink {
     const FILE: &str = "@file";
     const SYMBOL: &str = "@symbol";
+    const SELECTION: &str = "@selection";
     const THREAD: &str = "@thread";
     const FETCH: &str = "@fetch";
     const RULES: &str = "@rules";
@@ -672,8 +824,9 @@ impl MentionLink {
     pub fn is_valid(url: &str) -> bool {
         url.starts_with(Self::FILE)
             || url.starts_with(Self::SYMBOL)
-            || url.starts_with(Self::THREAD)
             || url.starts_with(Self::FETCH)
+            || url.starts_with(Self::SELECTION)
+            || url.starts_with(Self::THREAD)
             || url.starts_with(Self::RULES)
     }
 
@@ -691,6 +844,19 @@ impl MentionLink {
         )
     }
 
+    pub fn for_selection(file_name: &str, full_path: &str, line_range: Range<usize>) -> String {
+        format!(
+            "[@{} ({}-{})]({}:{}:{}-{})",
+            file_name,
+            line_range.start,
+            line_range.end,
+            Self::SELECTION,
+            full_path,
+            line_range.start,
+            line_range.end
+        )
+    }
+
     pub fn for_thread(thread: &ThreadContextEntry) -> String {
         format!("[@{}]({}:{})", thread.summary, Self::THREAD, thread.id)
     }
@@ -739,6 +905,20 @@ impl MentionLink {
                 let project_path = extract_project_path_from_link(path, workspace, cx)?;
                 Some(MentionLink::Symbol(project_path, symbol.to_string()))
             }
+            Self::SELECTION => {
+                let (path, line_args) = argument.split_once(Self::SEPARATOR)?;
+                let project_path = extract_project_path_from_link(path, workspace, cx)?;
+
+                let line_range = {
+                    let (start, end) = line_args
+                        .trim_start_matches('(')
+                        .trim_end_matches(')')
+                        .split_once('-')?;
+                    start.parse::<usize>().ok()?..end.parse::<usize>().ok()?
+                };
+
+                Some(MentionLink::Selection(project_path, line_range))
+            }
             Self::THREAD => {
                 let thread_id = ThreadId::from(argument);
                 Some(MentionLink::Thread(thread_id))

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

@@ -1,22 +1,23 @@
 use std::cell::RefCell;
 use std::ops::Range;
-use std::path::Path;
+use std::path::{Path, PathBuf};
 use std::rc::Rc;
 use std::sync::Arc;
 use std::sync::atomic::AtomicBool;
 
 use anyhow::Result;
-use editor::{CompletionProvider, Editor, ExcerptId};
+use editor::{CompletionProvider, Editor, ExcerptId, ToOffset as _};
 use file_icons::FileIcons;
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{App, Entity, Task, WeakEntity};
 use http_client::HttpClientWithUrl;
+use itertools::Itertools;
 use language::{Buffer, CodeLabel, HighlightId};
 use lsp::CompletionContext;
 use project::{Completion, CompletionIntent, ProjectPath, Symbol, WorktreeId};
 use prompt_store::PromptId;
 use rope::Point;
-use text::{Anchor, ToPoint};
+use text::{Anchor, OffsetRangeExt, ToPoint};
 use ui::prelude::*;
 use workspace::Workspace;
 
@@ -32,8 +33,8 @@ use super::rules_context_picker::{RulesContextEntry, search_rules};
 use super::symbol_context_picker::SymbolMatch;
 use super::thread_context_picker::{ThreadContextEntry, ThreadMatch, search_threads};
 use super::{
-    ContextPickerMode, MentionLink, RecentEntry, recent_context_picker_entries,
-    supported_context_picker_modes,
+    ContextPickerAction, ContextPickerEntry, ContextPickerMode, MentionLink, RecentEntry,
+    available_context_picker_entries, recent_context_picker_entries, selection_ranges,
 };
 
 pub(crate) enum Match {
@@ -42,19 +43,19 @@ pub(crate) enum Match {
     Thread(ThreadMatch),
     Fetch(SharedString),
     Rules(RulesContextEntry),
-    Mode(ModeMatch),
+    Entry(EntryMatch),
 }
 
-pub struct ModeMatch {
+pub struct EntryMatch {
     mat: Option<StringMatch>,
-    mode: ContextPickerMode,
+    entry: ContextPickerEntry,
 }
 
 impl Match {
     pub fn score(&self) -> f64 {
         match self {
             Match::File(file) => file.mat.score,
-            Match::Mode(mode) => mode.mat.as_ref().map(|mat| mat.score).unwrap_or(1.),
+            Match::Entry(mode) => mode.mat.as_ref().map(|mat| mat.score).unwrap_or(1.),
             Match::Thread(_) => 1.,
             Match::Symbol(_) => 1.,
             Match::Fetch(_) => 1.,
@@ -162,9 +163,14 @@ fn search(
                     .collect::<Vec<_>>();
 
                 matches.extend(
-                    supported_context_picker_modes(&thread_store)
+                    available_context_picker_entries(&thread_store, &workspace, cx)
                         .into_iter()
-                        .map(|mode| Match::Mode(ModeMatch { mode, mat: None })),
+                        .map(|mode| {
+                            Match::Entry(EntryMatch {
+                                entry: mode,
+                                mat: None,
+                            })
+                        }),
                 );
 
                 Task::ready(matches)
@@ -174,11 +180,11 @@ fn search(
                 let search_files_task =
                     search_files(query.clone(), cancellation_flag.clone(), &workspace, cx);
 
-                let modes = supported_context_picker_modes(&thread_store);
-                let mode_candidates = modes
+                let entries = available_context_picker_entries(&thread_store, &workspace, cx);
+                let entry_candidates = entries
                     .iter()
                     .enumerate()
-                    .map(|(ix, mode)| StringMatchCandidate::new(ix, mode.mention_prefix()))
+                    .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword()))
                     .collect::<Vec<_>>();
 
                 cx.background_spawn(async move {
@@ -188,8 +194,8 @@ fn search(
                         .map(Match::File)
                         .collect::<Vec<_>>();
 
-                    let mode_matches = fuzzy::match_strings(
-                        &mode_candidates,
+                    let entry_matches = fuzzy::match_strings(
+                        &entry_candidates,
                         &query,
                         false,
                         100,
@@ -198,9 +204,9 @@ fn search(
                     )
                     .await;
 
-                    matches.extend(mode_matches.into_iter().map(|mat| {
-                        Match::Mode(ModeMatch {
-                            mode: modes[mat.candidate_id],
+                    matches.extend(entry_matches.into_iter().map(|mat| {
+                        Match::Entry(EntryMatch {
+                            entry: entries[mat.candidate_id],
                             mat: Some(mat),
                         })
                     }));
@@ -240,19 +246,137 @@ impl ContextPickerCompletionProvider {
         }
     }
 
-    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)),
+    fn completion_for_entry(
+        entry: ContextPickerEntry,
+        excerpt_id: ExcerptId,
+        source_range: Range<Anchor>,
+        editor: Entity<Editor>,
+        context_store: Entity<ContextStore>,
+        workspace: &Entity<Workspace>,
+        cx: &mut App,
+    ) -> Option<Completion> {
+        match entry {
+            ContextPickerEntry::Mode(mode) => Some(Completion {
+                replace_range: source_range.clone(),
+                new_text: format!("@{} ", mode.keyword()),
+                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)),
+            }),
+            ContextPickerEntry::Action(action) => {
+                let (new_text, on_action) = match action {
+                    ContextPickerAction::AddSelections => {
+                        let selections = selection_ranges(workspace, cx);
+
+                        let selection_infos = selections
+                            .iter()
+                            .map(|(buffer, range)| {
+                                let full_path = buffer
+                                    .read(cx)
+                                    .file()
+                                    .map(|file| file.full_path(cx))
+                                    .unwrap_or_else(|| PathBuf::from("untitled"));
+                                let file_name = full_path
+                                    .file_name()
+                                    .unwrap_or_default()
+                                    .to_string_lossy()
+                                    .to_string();
+                                let line_range = range.to_point(&buffer.read(cx).snapshot());
+
+                                let link = MentionLink::for_selection(
+                                    &file_name,
+                                    &full_path.to_string_lossy(),
+                                    line_range.start.row as usize..line_range.end.row as usize,
+                                );
+                                (file_name, link, line_range)
+                            })
+                            .collect::<Vec<_>>();
+
+                        let new_text = selection_infos.iter().map(|(_, link, _)| link).join(" ");
+
+                        let callback = Arc::new({
+                            let context_store = context_store.clone();
+                            let selections = selections.clone();
+                            let selection_infos = selection_infos.clone();
+                            move |_, _: &mut Window, cx: &mut App| {
+                                context_store.update(cx, |context_store, cx| {
+                                    for (buffer, range) in &selections {
+                                        context_store
+                                            .add_selection(buffer.clone(), range.clone(), cx)
+                                            .detach_and_log_err(cx)
+                                    }
+                                });
+
+                                let editor = editor.clone();
+                                let selection_infos = selection_infos.clone();
+                                cx.defer(move |cx| {
+                                    let mut current_offset = 0;
+                                    for (file_name, link, line_range) in selection_infos.iter() {
+                                        let snapshot =
+                                            editor.read(cx).buffer().read(cx).snapshot(cx);
+                                        let Some(start) = snapshot
+                                            .anchor_in_excerpt(excerpt_id, source_range.start)
+                                        else {
+                                            return;
+                                        };
+
+                                        let offset = start.to_offset(&snapshot) + current_offset;
+                                        let text_len = link.len();
+
+                                        let range = snapshot.anchor_after(offset)
+                                            ..snapshot.anchor_after(offset + text_len);
+
+                                        let crease = super::crease_for_mention(
+                                            format!(
+                                                "{} ({}-{})",
+                                                file_name,
+                                                line_range.start.row + 1,
+                                                line_range.end.row + 1
+                                            )
+                                            .into(),
+                                            IconName::Context.path().into(),
+                                            range,
+                                            editor.downgrade(),
+                                        );
+
+                                        editor.update(cx, |editor, cx| {
+                                            editor.display_map.update(cx, |display_map, cx| {
+                                                display_map.fold(vec![crease], cx);
+                                            });
+                                        });
+
+                                        current_offset += text_len + 1;
+                                    }
+                                });
+
+                                false
+                            }
+                        });
+
+                        (new_text, callback)
+                    }
+                };
+
+                Some(Completion {
+                    replace_range: source_range.clone(),
+                    new_text,
+                    label: CodeLabel::plain(action.label().to_string(), None),
+                    icon_path: Some(action.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(on_action),
+                })
+            }
         }
     }
 
@@ -686,9 +810,15 @@ impl CompletionProvider for ContextPickerCompletionProvider {
                             context_store.clone(),
                             http_client.clone(),
                         )),
-                        Match::Mode(ModeMatch { mode, .. }) => {
-                            Some(Self::completion_for_mode(source_range.clone(), mode))
-                        }
+                        Match::Entry(EntryMatch { entry, .. }) => Self::completion_for_entry(
+                            entry,
+                            excerpt_id,
+                            source_range.clone(),
+                            editor.clone(),
+                            context_store.clone(),
+                            &workspace,
+                            cx,
+                        ),
                     })
                     .collect()
             })?))

crates/agent/src/context_store.rs 🔗

@@ -18,7 +18,7 @@ use util::{ResultExt as _, maybe};
 use crate::ThreadStore;
 use crate::context::{
     AssistantContext, ContextBuffer, ContextId, ContextSymbol, ContextSymbolId, DirectoryContext,
-    ExcerptContext, FetchedUrlContext, FileContext, ImageContext, RulesContext, SymbolContext,
+    FetchedUrlContext, FileContext, ImageContext, RulesContext, SelectionContext, SymbolContext,
     ThreadContext,
 };
 use crate::context_strip::SuggestedContext;
@@ -476,10 +476,10 @@ impl ContextStore {
         })
     }
 
-    pub fn add_excerpt(
+    pub fn add_selection(
         &mut self,
-        range: Range<Anchor>,
         buffer: Entity<Buffer>,
+        range: Range<Anchor>,
         cx: &mut Context<ContextStore>,
     ) -> Task<Result<()>> {
         cx.spawn(async move |this, cx| {
@@ -490,14 +490,14 @@ impl ContextStore {
             let context_buffer = context_buffer_task.await;
 
             this.update(cx, |this, cx| {
-                this.insert_excerpt(context_buffer, range, line_range, cx)
+                this.insert_selection(context_buffer, range, line_range, cx)
             })?;
 
             anyhow::Ok(())
         })
     }
 
-    fn insert_excerpt(
+    fn insert_selection(
         &mut self,
         context_buffer: ContextBuffer,
         range: Range<Anchor>,
@@ -505,12 +505,13 @@ impl ContextStore {
         cx: &mut Context<Self>,
     ) {
         let id = self.next_context_id.post_inc();
-        self.context.push(AssistantContext::Excerpt(ExcerptContext {
-            id,
-            range,
-            line_range,
-            context_buffer,
-        }));
+        self.context
+            .push(AssistantContext::Selection(SelectionContext {
+                id,
+                range,
+                line_range,
+                context_buffer,
+            }));
         cx.notify();
     }
 
@@ -563,7 +564,7 @@ impl ContextStore {
                 self.symbol_buffers.remove(&symbol.context_symbol.id);
                 self.symbols.retain(|_, context_id| *context_id != id);
             }
-            AssistantContext::Excerpt(_) => {}
+            AssistantContext::Selection(_) => {}
             AssistantContext::FetchedUrl(_) => {
                 self.fetched_urls.retain(|_, context_id| *context_id != id);
             }
@@ -699,7 +700,7 @@ impl ContextStore {
                 }
                 AssistantContext::Directory(_)
                 | AssistantContext::Symbol(_)
-                | AssistantContext::Excerpt(_)
+                | AssistantContext::Selection(_)
                 | AssistantContext::FetchedUrl(_)
                 | AssistantContext::Thread(_)
                 | AssistantContext::Rules(_)
@@ -914,13 +915,13 @@ pub fn refresh_context_store_text(
                         return refresh_symbol_text(context_store, symbol_context, cx);
                     }
                 }
-                AssistantContext::Excerpt(excerpt_context) => {
+                AssistantContext::Selection(selection_context) => {
                     // TODO: Should refresh if the path has changed, as it's in the text.
                     if changed_buffers.is_empty()
-                        || changed_buffers.contains(&excerpt_context.context_buffer.buffer)
+                        || changed_buffers.contains(&selection_context.context_buffer.buffer)
                     {
                         let context_store = context_store.clone();
-                        return refresh_excerpt_text(context_store, excerpt_context, cx);
+                        return refresh_selection_text(context_store, selection_context, cx);
                     }
                 }
                 AssistantContext::Thread(thread_context) => {
@@ -1042,26 +1043,27 @@ fn refresh_symbol_text(
     }
 }
 
-fn refresh_excerpt_text(
+fn refresh_selection_text(
     context_store: Entity<ContextStore>,
-    excerpt_context: &ExcerptContext,
+    selection_context: &SelectionContext,
     cx: &App,
 ) -> Option<Task<()>> {
-    let id = excerpt_context.id;
-    let range = excerpt_context.range.clone();
-    let task = refresh_context_excerpt(&excerpt_context.context_buffer, range.clone(), cx);
+    let id = selection_context.id;
+    let range = selection_context.range.clone();
+    let task = refresh_context_excerpt(&selection_context.context_buffer, range.clone(), cx);
     if let Some(task) = task {
         Some(cx.spawn(async move |cx| {
             let (line_range, context_buffer) = task.await;
             context_store
                 .update(cx, |context_store, _| {
-                    let new_excerpt_context = ExcerptContext {
+                    let new_selection_context = SelectionContext {
                         id,
                         range,
                         line_range,
                         context_buffer,
                     };
-                    context_store.replace_context(AssistantContext::Excerpt(new_excerpt_context));
+                    context_store
+                        .replace_context(AssistantContext::Selection(new_selection_context));
                 })
                 .ok();
         }))

crates/agent/src/message_editor.rs 🔗

@@ -298,7 +298,7 @@ impl MessageEditor {
                         .filter(|ctx| {
                             matches!(
                                 ctx,
-                                AssistantContext::Excerpt(_) | AssistantContext::Image(_)
+                                AssistantContext::Selection(_) | AssistantContext::Image(_)
                             )
                         })
                         .map(|ctx| ctx.id())

crates/agent/src/thread.rs 🔗

@@ -780,9 +780,9 @@ impl Thread {
                                 cx,
                             );
                         }
-                        AssistantContext::Excerpt(excerpt_context) => {
+                        AssistantContext::Selection(selection_context) => {
                             log.buffer_added_as_context(
-                                excerpt_context.context_buffer.buffer.clone(),
+                                selection_context.context_buffer.buffer.clone(),
                                 cx,
                             );
                         }

crates/agent/src/ui/context_pill.rs 🔗

@@ -3,7 +3,7 @@ use std::{rc::Rc, time::Duration};
 
 use file_icons::FileIcons;
 use futures::FutureExt;
-use gpui::{Animation, AnimationExt as _, AnyView, Image, MouseButton, pulsating_between};
+use gpui::{Animation, AnimationExt as _, Image, MouseButton, pulsating_between};
 use gpui::{ClickEvent, Task};
 use language_model::LanguageModelImage;
 use ui::{IconButtonShape, Tooltip, prelude::*, tooltip_container};
@@ -168,11 +168,16 @@ impl RenderOnce for ContextPill {
                             .map(|element| match &context.status {
                                 ContextStatus::Ready => element
                                     .when_some(
-                                        context.show_preview.as_ref(),
-                                        |element, show_preview| {
+                                        context.render_preview.as_ref(),
+                                        |element, render_preview| {
                                             element.hoverable_tooltip({
-                                                let show_preview = show_preview.clone();
-                                                move |window, cx| show_preview(window, cx)
+                                                let render_preview = render_preview.clone();
+                                                move |_, cx| {
+                                                    cx.new(|_| ContextPillPreview {
+                                                        render_preview: render_preview.clone(),
+                                                    })
+                                                    .into()
+                                                }
                                             })
                                         },
                                     )
@@ -266,7 +271,7 @@ pub struct AddedContext {
     pub tooltip: Option<SharedString>,
     pub icon_path: Option<SharedString>,
     pub status: ContextStatus,
-    pub show_preview: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
+    pub render_preview: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
 }
 
 impl AddedContext {
@@ -292,7 +297,7 @@ impl AddedContext {
                     tooltip: Some(full_path_string),
                     icon_path: FileIcons::get_icon(&full_path, cx),
                     status: ContextStatus::Ready,
-                    show_preview: None,
+                    render_preview: None,
                 }
             }
 
@@ -323,7 +328,7 @@ impl AddedContext {
                     tooltip: Some(full_path_string),
                     icon_path: None,
                     status: ContextStatus::Ready,
-                    show_preview: None,
+                    render_preview: None,
                 }
             }
 
@@ -335,11 +340,11 @@ impl AddedContext {
                 tooltip: None,
                 icon_path: None,
                 status: ContextStatus::Ready,
-                show_preview: None,
+                render_preview: None,
             },
 
-            AssistantContext::Excerpt(excerpt_context) => {
-                let full_path = excerpt_context.context_buffer.full_path(cx);
+            AssistantContext::Selection(selection_context) => {
+                let full_path = selection_context.context_buffer.full_path(cx);
                 let mut full_path_string = full_path.to_string_lossy().into_owned();
                 let mut name = full_path
                     .file_name()
@@ -348,8 +353,8 @@ impl AddedContext {
 
                 let line_range_text = format!(
                     " ({}-{})",
-                    excerpt_context.line_range.start.row + 1,
-                    excerpt_context.line_range.end.row + 1
+                    selection_context.line_range.start.row + 1,
+                    selection_context.line_range.end.row + 1
                 );
 
                 full_path_string.push_str(&line_range_text);
@@ -361,14 +366,25 @@ impl AddedContext {
                     .map(|n| n.to_string_lossy().into_owned().into());
 
                 AddedContext {
-                    id: excerpt_context.id,
-                    kind: ContextKind::File,
+                    id: selection_context.id,
+                    kind: ContextKind::Selection,
                     name: name.into(),
                     parent,
-                    tooltip: Some(full_path_string.into()),
+                    tooltip: None,
                     icon_path: FileIcons::get_icon(&full_path, cx),
                     status: ContextStatus::Ready,
-                    show_preview: None,
+                    render_preview: Some(Rc::new({
+                        let content = selection_context.context_buffer.text.clone();
+                        move |_, cx| {
+                            div()
+                                .id("context-pill-selection-preview")
+                                .overflow_scroll()
+                                .max_w_128()
+                                .max_h_96()
+                                .child(Label::new(content.clone()).buffer_font(cx))
+                                .into_any_element()
+                        }
+                    })),
                 }
             }
 
@@ -380,7 +396,7 @@ impl AddedContext {
                 tooltip: None,
                 icon_path: None,
                 status: ContextStatus::Ready,
-                show_preview: None,
+                render_preview: None,
             },
 
             AssistantContext::Thread(thread_context) => AddedContext {
@@ -401,7 +417,7 @@ impl AddedContext {
                 } else {
                     ContextStatus::Ready
                 },
-                show_preview: None,
+                render_preview: None,
             },
 
             AssistantContext::Rules(user_rules_context) => AddedContext {
@@ -412,7 +428,7 @@ impl AddedContext {
                 tooltip: None,
                 icon_path: None,
                 status: ContextStatus::Ready,
-                show_preview: None,
+                render_preview: None,
             },
 
             AssistantContext::Image(image_context) => AddedContext {
@@ -433,13 +449,13 @@ impl AddedContext {
                 } else {
                     ContextStatus::Ready
                 },
-                show_preview: Some(Rc::new({
+                render_preview: Some(Rc::new({
                     let image = image_context.original_image.clone();
-                    move |_, cx| {
-                        cx.new(|_| ImagePreview {
-                            image: image.clone(),
-                        })
-                        .into()
+                    move |_, _| {
+                        gpui::img(image.clone())
+                            .max_w_96()
+                            .max_h_96()
+                            .into_any_element()
                     }
                 })),
             },
@@ -447,17 +463,17 @@ impl AddedContext {
     }
 }
 
-struct ImagePreview {
-    image: Arc<Image>,
+struct ContextPillPreview {
+    render_preview: Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>,
 }
 
-impl Render for ImagePreview {
+impl Render for ContextPillPreview {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        tooltip_container(window, cx, move |this, _, _| {
+        tooltip_container(window, cx, move |this, window, cx| {
             this.occlude()
                 .on_mouse_move(|_, _, cx| cx.stop_propagation())
                 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
-                .child(gpui::img(self.image.clone()).max_w_96().max_h_96())
+                .child((self.render_preview)(window, cx))
         })
     }
 }

crates/editor/src/editor.rs 🔗

@@ -3108,6 +3108,13 @@ impl Editor {
         cx.notify();
     }
 
+    pub fn has_non_empty_selection(&self, cx: &mut App) -> bool {
+        self.selections
+            .all_adjusted(cx)
+            .iter()
+            .any(|selection| !selection.is_empty())
+    }
+
     pub fn has_pending_nonempty_selection(&self) -> bool {
         let pending_nonempty_selection = match self.selections.pending_anchor() {
             Some(Selection { start, end, .. }) => start != end,