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

WIP

Release Notes:

- N/A
This commit is contained in:
Bennet Bo Fenner 2025-04-22 13:56:42 +02:00 committed by GitHub
parent 10ded0ab75
commit a5852d4537
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 548 additions and 191 deletions

View file

@ -670,6 +670,26 @@ fn open_markdown_link(
}) })
.detach_and_log_err(cx); .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| { Some(MentionLink::Thread(thread_id)) => workspace.update(cx, |workspace, cx| {
if let Some(panel) = workspace.panel::<AssistantPanel>(cx) { if let Some(panel) = workspace.panel::<AssistantPanel>(cx) {
panel.update(cx, |panel, cx| { panel.update(cx, |panel, cx| {
@ -3309,15 +3329,15 @@ pub(crate) fn open_context(
.detach(); .detach();
} }
} }
AssistantContext::Excerpt(excerpt_context) => { AssistantContext::Selection(selection_context) => {
if let Some(project_path) = excerpt_context if let Some(project_path) = selection_context
.context_buffer .context_buffer
.buffer .buffer
.read(cx) .read(cx)
.project_path(cx) .project_path(cx)
{ {
let snapshot = excerpt_context.context_buffer.buffer.read(cx).snapshot(); let snapshot = selection_context.context_buffer.buffer.read(cx).snapshot();
let target_position = excerpt_context.range.start.to_point(&snapshot); let target_position = selection_context.range.start.to_point(&snapshot);
open_editor_at_position(project_path, target_position, &workspace, window, cx) open_editor_at_position(project_path, target_position, &workspace, window, cx)
.detach(); .detach();

View file

@ -1951,7 +1951,9 @@ impl AssistantPanelDelegate for ConcreteAssistantPanelDelegate {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
for (buffer, range) in selection_ranges { 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);
} }
}) })
}) })

View file

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

View file

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

View file

@ -1,22 +1,23 @@
use std::cell::RefCell; use std::cell::RefCell;
use std::ops::Range; use std::ops::Range;
use std::path::Path; use std::path::{Path, PathBuf};
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use anyhow::Result; use anyhow::Result;
use editor::{CompletionProvider, Editor, ExcerptId}; use editor::{CompletionProvider, Editor, ExcerptId, ToOffset as _};
use file_icons::FileIcons; use file_icons::FileIcons;
use fuzzy::{StringMatch, StringMatchCandidate}; use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{App, Entity, Task, WeakEntity}; use gpui::{App, Entity, Task, WeakEntity};
use http_client::HttpClientWithUrl; use http_client::HttpClientWithUrl;
use itertools::Itertools;
use language::{Buffer, CodeLabel, HighlightId}; use language::{Buffer, CodeLabel, HighlightId};
use lsp::CompletionContext; use lsp::CompletionContext;
use project::{Completion, CompletionIntent, ProjectPath, Symbol, WorktreeId}; use project::{Completion, CompletionIntent, ProjectPath, Symbol, WorktreeId};
use prompt_store::PromptId; use prompt_store::PromptId;
use rope::Point; use rope::Point;
use text::{Anchor, ToPoint}; use text::{Anchor, OffsetRangeExt, ToPoint};
use ui::prelude::*; use ui::prelude::*;
use workspace::Workspace; use workspace::Workspace;
@ -32,8 +33,8 @@ use super::rules_context_picker::{RulesContextEntry, search_rules};
use super::symbol_context_picker::SymbolMatch; use super::symbol_context_picker::SymbolMatch;
use super::thread_context_picker::{ThreadContextEntry, ThreadMatch, search_threads}; use super::thread_context_picker::{ThreadContextEntry, ThreadMatch, search_threads};
use super::{ use super::{
ContextPickerMode, MentionLink, RecentEntry, recent_context_picker_entries, ContextPickerAction, ContextPickerEntry, ContextPickerMode, MentionLink, RecentEntry,
supported_context_picker_modes, available_context_picker_entries, recent_context_picker_entries, selection_ranges,
}; };
pub(crate) enum Match { pub(crate) enum Match {
@ -42,19 +43,19 @@ pub(crate) enum Match {
Thread(ThreadMatch), Thread(ThreadMatch),
Fetch(SharedString), Fetch(SharedString),
Rules(RulesContextEntry), Rules(RulesContextEntry),
Mode(ModeMatch), Entry(EntryMatch),
} }
pub struct ModeMatch { pub struct EntryMatch {
mat: Option<StringMatch>, mat: Option<StringMatch>,
mode: ContextPickerMode, entry: ContextPickerEntry,
} }
impl Match { impl Match {
pub fn score(&self) -> f64 { pub fn score(&self) -> f64 {
match self { match self {
Match::File(file) => file.mat.score, 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::Thread(_) => 1.,
Match::Symbol(_) => 1., Match::Symbol(_) => 1.,
Match::Fetch(_) => 1., Match::Fetch(_) => 1.,
@ -162,9 +163,14 @@ fn search(
.collect::<Vec<_>>(); .collect::<Vec<_>>();
matches.extend( matches.extend(
supported_context_picker_modes(&thread_store) available_context_picker_entries(&thread_store, &workspace, cx)
.into_iter() .into_iter()
.map(|mode| Match::Mode(ModeMatch { mode, mat: None })), .map(|mode| {
Match::Entry(EntryMatch {
entry: mode,
mat: None,
})
}),
); );
Task::ready(matches) Task::ready(matches)
@ -174,11 +180,11 @@ fn search(
let search_files_task = let search_files_task =
search_files(query.clone(), cancellation_flag.clone(), &workspace, cx); search_files(query.clone(), cancellation_flag.clone(), &workspace, cx);
let modes = supported_context_picker_modes(&thread_store); let entries = available_context_picker_entries(&thread_store, &workspace, cx);
let mode_candidates = modes let entry_candidates = entries
.iter() .iter()
.enumerate() .enumerate()
.map(|(ix, mode)| StringMatchCandidate::new(ix, mode.mention_prefix())) .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword()))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
cx.background_spawn(async move { cx.background_spawn(async move {
@ -188,8 +194,8 @@ fn search(
.map(Match::File) .map(Match::File)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mode_matches = fuzzy::match_strings( let entry_matches = fuzzy::match_strings(
&mode_candidates, &entry_candidates,
&query, &query,
false, false,
100, 100,
@ -198,9 +204,9 @@ fn search(
) )
.await; .await;
matches.extend(mode_matches.into_iter().map(|mat| { matches.extend(entry_matches.into_iter().map(|mat| {
Match::Mode(ModeMatch { Match::Entry(EntryMatch {
mode: modes[mat.candidate_id], entry: entries[mat.candidate_id],
mat: Some(mat), mat: Some(mat),
}) })
})); }));
@ -240,10 +246,19 @@ impl ContextPickerCompletionProvider {
} }
} }
fn completion_for_mode(source_range: Range<Anchor>, mode: ContextPickerMode) -> Completion { fn completion_for_entry(
Completion { 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(), replace_range: source_range.clone(),
new_text: format!("@{} ", mode.mention_prefix()), new_text: format!("@{} ", mode.keyword()),
label: CodeLabel::plain(mode.label().to_string(), None), label: CodeLabel::plain(mode.label().to_string(), None),
icon_path: Some(mode.icon().path().into()), icon_path: Some(mode.icon().path().into()),
documentation: None, documentation: None,
@ -253,6 +268,115 @@ impl ContextPickerCompletionProvider {
// completion menu will still be shown after "@category " is // completion menu will still be shown after "@category " is
// inserted // inserted
confirm: Some(Arc::new(|_, _, _| true)), 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(), context_store.clone(),
http_client.clone(), http_client.clone(),
)), )),
Match::Mode(ModeMatch { mode, .. }) => { Match::Entry(EntryMatch { entry, .. }) => Self::completion_for_entry(
Some(Self::completion_for_mode(source_range.clone(), mode)) entry,
} excerpt_id,
source_range.clone(),
editor.clone(),
context_store.clone(),
&workspace,
cx,
),
}) })
.collect() .collect()
})?)) })?))

View file

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

View file

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

View file

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

View file

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

View file

@ -3108,6 +3108,13 @@ impl Editor {
cx.notify(); 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 { pub fn has_pending_nonempty_selection(&self) -> bool {
let pending_nonempty_selection = match self.selections.pending_anchor() { let pending_nonempty_selection = match self.selections.pending_anchor() {
Some(Selection { start, end, .. }) => start != end, Some(Selection { start, end, .. }) => start != end,