From a5852d453750cf075a8d97adb67365cd6fb7a462 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 22 Apr 2025 13:56:42 +0200 Subject: [PATCH] agent: Support inserting selections as context via `@selection` (#29045) WIP Release Notes: - N/A --- 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 | 344 +++++++++++++----- .../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, 548 insertions(+), 191 deletions(-) diff --git a/crates/agent/src/active_thread.rs b/crates/agent/src/active_thread.rs index 37afa2730c..ca7406bdd1 100644 --- a/crates/agent/src/active_thread.rs +++ b/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::() + .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::(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(); diff --git a/crates/agent/src/assistant_panel.rs b/crates/agent/src/assistant_panel.rs index 82c7b2be9d..ea39a827ae 100644 --- a/crates/agent/src/assistant_panel.rs +++ b/crates/agent/src/assistant_panel.rs @@ -1951,7 +1951,9 @@ impl AssistantPanelDelegate for ConcreteAssistantPanelDelegate { .collect::>(); 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); } }) }) diff --git a/crates/agent/src/context.rs b/crates/agent/src/context.rs index 321f3bebb6..cf813a1426 100644 --- a/crates/agent/src/context.rs +++ b/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, pub line_range: Range, @@ -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("\n"); } - if !excerpt_context.is_empty() { - result.push_str("\n"); - for context in excerpt_context { + if !selection_context.is_empty() { + result.push_str("\n"); + for context in selection_context { result.push_str(&context.context_buffer.text); result.push('\n'); } - result.push_str("\n"); + result.push_str("\n"); } if !fetch_context.is_empty() { diff --git a/crates/agent/src/context_picker.rs b/crates/agent/src/context_picker.rs index ce08179fb9..8e5cca941c 100644 --- a/crates/agent/src/context_picker.rs +++ b/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, ) { 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>, -) -> Vec { - let mut modes = vec![ - ContextPickerMode::File, - ContextPickerMode::Symbol, - ContextPickerMode::Fetch, + workspace: &Entity, + cx: &mut App, +) -> Vec { + let mut entries = vec![ + ContextPickerEntry::Mode(ContextPickerMode::File), + ContextPickerEntry::Mode(ContextPickerMode::Symbol), ]; - if thread_store.is_some() { - modes.push(ContextPickerMode::Thread); - modes.push(ContextPickerMode::Rules); + + let has_selection = workspace + .read(cx) + .active_item(cx) + .and_then(|item| item.downcast::()) + .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( @@ -522,6 +614,54 @@ fn recent_context_picker_entries( recent } +fn add_selections_as_context( + context_store: &Entity, + workspace: &Entity, + 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, + cx: &mut App, +) -> Vec<(Entity, Range)> { + let Some(editor) = workspace + .read(cx) + .active_item(cx) + .and_then(|item| item.act_as::(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::>() + }) +} + 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, + editor_entity: WeakEntity, +) -> Crease { + 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), 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) -> 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::().ok()?..end.parse::().ok()? + }; + + Some(MentionLink::Selection(project_path, line_range)) + } Self::THREAD => { let thread_id = ThreadId::from(argument); Some(MentionLink::Thread(thread_id)) diff --git a/crates/agent/src/context_picker/completion_provider.rs b/crates/agent/src/context_picker/completion_provider.rs index 6703e93d66..f82dab0a1b 100644 --- a/crates/agent/src/context_picker/completion_provider.rs +++ b/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, - 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::>(); 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::>(); cx.background_spawn(async move { @@ -188,8 +194,8 @@ fn search( .map(Match::File) .collect::>(); - 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, 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, + editor: Entity, + context_store: Entity, + workspace: &Entity, + cx: &mut App, + ) -> Option { + 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::>(); + + 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() })?)) diff --git a/crates/agent/src/context_store.rs b/crates/agent/src/context_store.rs index 9cb7825a19..c66cad3ef2 100644 --- a/crates/agent/src/context_store.rs +++ b/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, buffer: Entity, + range: Range, cx: &mut Context, ) -> Task> { 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, @@ -505,12 +505,13 @@ impl ContextStore { cx: &mut Context, ) { 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, - excerpt_context: &ExcerptContext, + selection_context: &SelectionContext, cx: &App, ) -> Option> { - 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(); })) diff --git a/crates/agent/src/message_editor.rs b/crates/agent/src/message_editor.rs index e6f311c924..633ad2ee54 100644 --- a/crates/agent/src/message_editor.rs +++ b/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()) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 3d1a1cc242..2e5198a462 100644 --- a/crates/agent/src/thread.rs +++ b/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, ); } diff --git a/crates/agent/src/ui/context_pill.rs b/crates/agent/src/ui/context_pill.rs index be2934e0a9..a3c6608179 100644 --- a/crates/agent/src/ui/context_pill.rs +++ b/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, pub icon_path: Option, pub status: ContextStatus, - pub show_preview: Option AnyView + 'static>>, + pub render_preview: Option 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, +struct ContextPillPreview { + render_preview: Rc AnyElement>, } -impl Render for ImagePreview { +impl Render for ContextPillPreview { fn render(&mut self, window: &mut Window, cx: &mut Context) -> 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)) }) } } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 39d26ff7d1..eb777d6349 100644 --- a/crates/editor/src/editor.rs +++ b/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,