agent: Support adding selection as context (#28964)

https://github.com/user-attachments/assets/42ebe911-3392-48f7-8583-caab285aca09

Release Notes:

- agent: Support adding selections via @selection or `assistant: Quote
selection` as context
This commit is contained in:
Bennet Bo Fenner 2025-04-17 16:55:15 +02:00 committed by GitHub
parent f07695c4cd
commit 002235d0da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 495 additions and 196 deletions

View file

@ -756,6 +756,10 @@ impl ActiveThread {
this this
} }
pub fn context_store(&self) -> &Entity<ContextStore> {
&self.context_store
}
pub fn thread(&self) -> &Entity<Thread> { pub fn thread(&self) -> &Entity<Thread> {
&self.thread &self.thread
} }
@ -3145,28 +3149,21 @@ pub(crate) fn open_context(
.start .start
.to_point(&snapshot); .to_point(&snapshot);
let open_task = workspace.update(cx, |workspace, cx| { open_editor_at_position(project_path, target_position, &workspace, window, cx)
workspace.open_path(project_path, None, true, window, cx) .detach();
}); }
window }
.spawn(cx, async move |cx| { AssistantContext::Excerpt(excerpt_context) => {
if let Some(active_editor) = open_task if let Some(project_path) = excerpt_context
.await .context_buffer
.log_err() .buffer
.and_then(|item| item.downcast::<Editor>()) .read(cx)
{ .project_path(cx)
active_editor {
.downgrade() let snapshot = excerpt_context.context_buffer.buffer.read(cx).snapshot();
.update_in(cx, |editor, window, cx| { let target_position = excerpt_context.range.start.to_point(&snapshot);
editor.go_to_singleton_buffer_point(
target_position, open_editor_at_position(project_path, target_position, &workspace, window, cx)
window,
cx,
);
})
.log_err();
}
})
.detach(); .detach();
} }
} }
@ -3187,3 +3184,29 @@ pub(crate) fn open_context(
} }
} }
} }
fn open_editor_at_position(
project_path: project::ProjectPath,
target_position: Point,
workspace: &Entity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Task<()> {
let open_task = workspace.update(cx, |workspace, cx| {
workspace.open_path(project_path, None, true, window, cx)
});
window.spawn(cx, async move |cx| {
if let Some(active_editor) = open_task
.await
.log_err()
.and_then(|item| item.downcast::<Editor>())
{
active_editor
.downgrade()
.update_in(cx, |editor, window, cx| {
editor.go_to_singleton_buffer_point(target_position, window, cx);
})
.log_err();
}
})
}

View file

@ -1,3 +1,4 @@
use std::ops::Range;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@ -12,7 +13,7 @@ use assistant_slash_command::SlashCommandWorkingSet;
use assistant_tool::ToolWorkingSet; use assistant_tool::ToolWorkingSet;
use client::zed_urls; use client::zed_urls;
use editor::{Editor, EditorEvent, MultiBuffer}; use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
use fs::Fs; use fs::Fs;
use gpui::{ use gpui::{
Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, Corner, Entity, Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, Corner, Entity,
@ -112,7 +113,9 @@ enum ActiveView {
change_title_editor: Entity<Editor>, change_title_editor: Entity<Editor>,
_subscriptions: Vec<gpui::Subscription>, _subscriptions: Vec<gpui::Subscription>,
}, },
PromptEditor, PromptEditor {
context_editor: Entity<ContextEditor>,
},
History, History,
Configuration, Configuration,
} }
@ -184,7 +187,6 @@ pub struct AssistantPanel {
message_editor: Entity<MessageEditor>, message_editor: Entity<MessageEditor>,
_active_thread_subscriptions: Vec<Subscription>, _active_thread_subscriptions: Vec<Subscription>,
context_store: Entity<assistant_context_editor::ContextStore>, context_store: Entity<assistant_context_editor::ContextStore>,
context_editor: Option<Entity<ContextEditor>>,
configuration: Option<Entity<AssistantConfiguration>>, configuration: Option<Entity<AssistantConfiguration>>,
configuration_subscription: Option<Subscription>, configuration_subscription: Option<Subscription>,
local_timezone: UtcOffset, local_timezone: UtcOffset,
@ -316,7 +318,6 @@ impl AssistantPanel {
message_editor_subscription, message_editor_subscription,
], ],
context_store, context_store,
context_editor: None,
configuration: None, configuration: None,
configuration_subscription: None, configuration_subscription: None,
local_timezone: UtcOffset::from_whole_seconds( local_timezone: UtcOffset::from_whole_seconds(
@ -453,8 +454,6 @@ impl AssistantPanel {
} }
fn new_prompt_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn new_prompt_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_active_view(ActiveView::PromptEditor, window, cx);
let context = self let context = self
.context_store .context_store
.update(cx, |context_store, cx| context_store.create(cx)); .update(cx, |context_store, cx| context_store.create(cx));
@ -462,7 +461,7 @@ impl AssistantPanel {
.log_err() .log_err()
.flatten(); .flatten();
self.context_editor = Some(cx.new(|cx| { let context_editor = cx.new(|cx| {
let mut editor = ContextEditor::for_context( let mut editor = ContextEditor::for_context(
context, context,
self.fs.clone(), self.fs.clone(),
@ -474,11 +473,16 @@ impl AssistantPanel {
); );
editor.insert_default_prompt(window, cx); editor.insert_default_prompt(window, cx);
editor editor
})); });
if let Some(context_editor) = self.context_editor.as_ref() { self.set_active_view(
context_editor.focus_handle(cx).focus(window); ActiveView::PromptEditor {
} context_editor: context_editor.clone(),
},
window,
cx,
);
context_editor.focus_handle(cx).focus(window);
} }
fn deploy_prompt_library( fn deploy_prompt_library(
@ -545,8 +549,13 @@ impl AssistantPanel {
cx, cx,
) )
}); });
this.set_active_view(ActiveView::PromptEditor, window, cx); this.set_active_view(
this.context_editor = Some(editor); ActiveView::PromptEditor {
context_editor: editor,
},
window,
cx,
);
anyhow::Ok(()) anyhow::Ok(())
})??; })??;
@ -777,8 +786,15 @@ impl AssistantPanel {
.update(cx, |this, cx| this.delete_thread(thread_id, cx)) .update(cx, |this, cx| this.delete_thread(thread_id, cx))
} }
pub(crate) fn has_active_thread(&self) -> bool {
matches!(self.active_view, ActiveView::Thread { .. })
}
pub(crate) fn active_context_editor(&self) -> Option<Entity<ContextEditor>> { pub(crate) fn active_context_editor(&self) -> Option<Entity<ContextEditor>> {
self.context_editor.clone() match &self.active_view {
ActiveView::PromptEditor { context_editor } => Some(context_editor.clone()),
_ => None,
}
} }
pub(crate) fn delete_context( pub(crate) fn delete_context(
@ -816,16 +832,10 @@ impl AssistantPanel {
impl Focusable for AssistantPanel { impl Focusable for AssistantPanel {
fn focus_handle(&self, cx: &App) -> FocusHandle { fn focus_handle(&self, cx: &App) -> FocusHandle {
match self.active_view { match &self.active_view {
ActiveView::Thread { .. } => self.message_editor.focus_handle(cx), ActiveView::Thread { .. } => self.message_editor.focus_handle(cx),
ActiveView::History => self.history.focus_handle(cx), ActiveView::History => self.history.focus_handle(cx),
ActiveView::PromptEditor => { ActiveView::PromptEditor { context_editor } => context_editor.focus_handle(cx),
if let Some(context_editor) = self.context_editor.as_ref() {
context_editor.focus_handle(cx)
} else {
cx.focus_handle()
}
}
ActiveView::Configuration => { ActiveView::Configuration => {
if let Some(configuration) = self.configuration.as_ref() { if let Some(configuration) = self.configuration.as_ref() {
configuration.focus_handle(cx) configuration.focus_handle(cx)
@ -949,15 +959,8 @@ impl AssistantPanel {
.into_any_element() .into_any_element()
} }
} }
ActiveView::PromptEditor => { ActiveView::PromptEditor { context_editor } => {
let title = self let title = SharedString::from(context_editor.read(cx).title(cx).to_string());
.context_editor
.as_ref()
.map(|context_editor| {
SharedString::from(context_editor.read(cx).title(cx).to_string())
})
.unwrap_or_else(|| SharedString::from(LOADING_SUMMARY_PLACEHOLDER));
Label::new(title).ml_2().truncate().into_any_element() Label::new(title).ml_2().truncate().into_any_element()
} }
ActiveView::History => Label::new("History").truncate().into_any_element(), ActiveView::History => Label::new("History").truncate().into_any_element(),
@ -984,7 +987,7 @@ impl AssistantPanel {
let show_token_count = match &self.active_view { let show_token_count = match &self.active_view {
ActiveView::Thread { .. } => !is_empty, ActiveView::Thread { .. } => !is_empty,
ActiveView::PromptEditor => self.context_editor.is_some(), ActiveView::PromptEditor { .. } => true,
_ => false, _ => false,
}; };
@ -1156,7 +1159,7 @@ impl AssistantPanel {
let is_waiting_to_update_token_count = message_editor.is_waiting_to_update_token_count(); let is_waiting_to_update_token_count = message_editor.is_waiting_to_update_token_count();
match self.active_view { match &self.active_view {
ActiveView::Thread { .. } => { ActiveView::Thread { .. } => {
if total_token_usage.total == 0 { if total_token_usage.total == 0 {
return None; return None;
@ -1229,9 +1232,8 @@ impl AssistantPanel {
Some(token_count) Some(token_count)
} }
ActiveView::PromptEditor => { ActiveView::PromptEditor { context_editor } => {
let editor = self.context_editor.as_ref()?; let element = render_remaining_tokens(context_editor, cx)?;
let element = render_remaining_tokens(editor, cx)?;
Some(element.into_any_element()) Some(element.into_any_element())
} }
@ -1769,7 +1771,7 @@ impl AssistantPanel {
fn key_context(&self) -> KeyContext { fn key_context(&self) -> KeyContext {
let mut key_context = KeyContext::new_with_defaults(); let mut key_context = KeyContext::new_with_defaults();
key_context.add("AgentPanel"); key_context.add("AgentPanel");
if matches!(self.active_view, ActiveView::PromptEditor) { if matches!(self.active_view, ActiveView::PromptEditor { .. }) {
key_context.add("prompt_editor"); key_context.add("prompt_editor");
} }
key_context key_context
@ -1797,13 +1799,13 @@ impl Render for AssistantPanel {
.on_action(cx.listener(Self::open_agent_diff)) .on_action(cx.listener(Self::open_agent_diff))
.on_action(cx.listener(Self::go_back)) .on_action(cx.listener(Self::go_back))
.child(self.render_toolbar(window, cx)) .child(self.render_toolbar(window, cx))
.map(|parent| match self.active_view { .map(|parent| match &self.active_view {
ActiveView::Thread { .. } => parent ActiveView::Thread { .. } => parent
.child(self.render_active_thread_or_empty_state(window, cx)) .child(self.render_active_thread_or_empty_state(window, cx))
.child(h_flex().child(self.message_editor.clone())) .child(h_flex().child(self.message_editor.clone()))
.children(self.render_last_error(cx)), .children(self.render_last_error(cx)),
ActiveView::History => parent.child(self.history.clone()), ActiveView::History => parent.child(self.history.clone()),
ActiveView::PromptEditor => parent.children(self.context_editor.clone()), ActiveView::PromptEditor { context_editor } => parent.child(context_editor.clone()),
ActiveView::Configuration => parent.children(self.configuration.clone()), ActiveView::Configuration => parent.children(self.configuration.clone()),
}) })
} }
@ -1868,7 +1870,7 @@ impl AssistantPanelDelegate for ConcreteAssistantPanelDelegate {
cx: &mut Context<Workspace>, cx: &mut Context<Workspace>,
) -> Option<Entity<ContextEditor>> { ) -> Option<Entity<ContextEditor>> {
let panel = workspace.panel::<AssistantPanel>(cx)?; let panel = workspace.panel::<AssistantPanel>(cx)?;
panel.update(cx, |panel, _cx| panel.context_editor.clone()) panel.read(cx).active_context_editor()
} }
fn open_saved_context( fn open_saved_context(
@ -1900,7 +1902,8 @@ impl AssistantPanelDelegate for ConcreteAssistantPanelDelegate {
fn quote_selection( fn quote_selection(
&self, &self,
workspace: &mut Workspace, workspace: &mut Workspace,
creases: Vec<(String, String)>, selection_ranges: Vec<Range<Anchor>>,
buffer: Entity<MultiBuffer>,
window: &mut Window, window: &mut Window,
cx: &mut Context<Workspace>, cx: &mut Context<Workspace>,
) { ) {
@ -1916,9 +1919,40 @@ impl AssistantPanelDelegate for ConcreteAssistantPanelDelegate {
// Wait to create a new context until the workspace is no longer // Wait to create a new context until the workspace is no longer
// being updated. // being updated.
cx.defer_in(window, move |panel, window, cx| { cx.defer_in(window, move |panel, window, cx| {
if let Some(context) = panel.active_context_editor() { if panel.has_active_thread() {
context.update(cx, |context, cx| context.quote_creases(creases, window, cx)); panel.thread.update(cx, |thread, cx| {
}; thread.context_store().update(cx, |store, cx| {
let buffer = buffer.read(cx);
let selection_ranges = selection_ranges
.into_iter()
.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<_>>();
for (buffer, range) in selection_ranges {
store.add_excerpt(range, buffer, cx).detach_and_log_err(cx);
}
})
})
} else if let Some(context_editor) = panel.active_context_editor() {
let snapshot = buffer.read(cx).snapshot(cx);
let selection_ranges = selection_ranges
.into_iter()
.map(|range| range.to_point(&snapshot))
.collect::<Vec<_>>();
context_editor.update(cx, |context_editor, cx| {
context_editor.quote_ranges(selection_ranges, snapshot, window, cx)
});
}
}); });
}); });
} }

View file

@ -4,6 +4,7 @@ use gpui::{App, Entity, SharedString};
use language::{Buffer, File}; use language::{Buffer, File};
use language_model::LanguageModelRequestMessage; use language_model::LanguageModelRequestMessage;
use project::{ProjectPath, Worktree}; use project::{ProjectPath, Worktree};
use rope::Point;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use text::{Anchor, BufferId}; use text::{Anchor, BufferId};
use ui::IconName; use ui::IconName;
@ -23,6 +24,7 @@ pub enum ContextKind {
File, File,
Directory, Directory,
Symbol, Symbol,
Excerpt,
FetchedUrl, FetchedUrl,
Thread, Thread,
} }
@ -33,6 +35,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::FetchedUrl => IconName::Globe, ContextKind::FetchedUrl => IconName::Globe,
ContextKind::Thread => IconName::MessageBubbles, ContextKind::Thread => IconName::MessageBubbles,
} }
@ -46,6 +49,7 @@ pub enum AssistantContext {
Symbol(SymbolContext), Symbol(SymbolContext),
FetchedUrl(FetchedUrlContext), FetchedUrl(FetchedUrlContext),
Thread(ThreadContext), Thread(ThreadContext),
Excerpt(ExcerptContext),
} }
impl AssistantContext { impl AssistantContext {
@ -56,6 +60,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,
} }
} }
} }
@ -155,6 +160,14 @@ pub struct ContextSymbolId {
pub range: Range<Anchor>, pub range: Range<Anchor>,
} }
#[derive(Debug, Clone)]
pub struct ExcerptContext {
pub id: ContextId,
pub range: Range<Anchor>,
pub line_range: Range<Point>,
pub context_buffer: ContextBuffer,
}
/// Formats a collection of contexts into a string representation /// Formats a collection of contexts into a string representation
pub fn format_context_as_string<'a>( pub fn format_context_as_string<'a>(
contexts: impl Iterator<Item = &'a AssistantContext>, contexts: impl Iterator<Item = &'a AssistantContext>,
@ -163,6 +176,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 fetch_context = Vec::new(); let mut fetch_context = Vec::new();
let mut thread_context = Vec::new(); let mut thread_context = Vec::new();
@ -171,6 +185,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::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),
} }
@ -179,6 +194,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()
&& fetch_context.is_empty() && fetch_context.is_empty()
&& thread_context.is_empty() && thread_context.is_empty()
{ {
@ -216,6 +232,15 @@ pub fn format_context_as_string<'a>(
result.push_str("</symbols>\n"); result.push_str("</symbols>\n");
} }
if !excerpt_context.is_empty() {
result.push_str("<excerpts>\n");
for context in excerpt_context {
result.push_str(&context.context_buffer.text);
result.push('\n');
}
result.push_str("</excerpts>\n");
}
if !fetch_context.is_empty() { if !fetch_context.is_empty() {
result.push_str("<fetched_urls>\n"); result.push_str("<fetched_urls>\n");
for context in &fetch_context { for context in &fetch_context {

View file

@ -9,14 +9,14 @@ use futures::{self, Future, FutureExt, future};
use gpui::{App, AppContext as _, Context, Entity, SharedString, Task, WeakEntity}; use gpui::{App, AppContext as _, Context, Entity, SharedString, Task, WeakEntity};
use language::{Buffer, File}; use language::{Buffer, File};
use project::{Project, ProjectItem, ProjectPath, Worktree}; use project::{Project, ProjectItem, ProjectPath, Worktree};
use rope::Rope; use rope::{Point, Rope};
use text::{Anchor, BufferId, OffsetRangeExt}; use text::{Anchor, BufferId, OffsetRangeExt};
use util::{ResultExt as _, maybe}; 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,
FetchedUrlContext, FileContext, SymbolContext, ThreadContext, ExcerptContext, FetchedUrlContext, FileContext, SymbolContext, ThreadContext,
}; };
use crate::context_strip::SuggestedContext; use crate::context_strip::SuggestedContext;
use crate::thread::{Thread, ThreadId}; use crate::thread::{Thread, ThreadId};
@ -110,7 +110,7 @@ impl ContextStore {
} }
let (buffer_info, text_task) = let (buffer_info, text_task) =
this.update(cx, |_, cx| collect_buffer_info_and_text(buffer, None, cx))??; this.update(cx, |_, cx| collect_buffer_info_and_text(buffer, cx))??;
let text = text_task.await; let text = text_task.await;
@ -129,7 +129,7 @@ impl ContextStore {
) -> Task<Result<()>> { ) -> Task<Result<()>> {
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
let (buffer_info, text_task) = let (buffer_info, text_task) =
this.update(cx, |_, cx| collect_buffer_info_and_text(buffer, None, cx))??; this.update(cx, |_, cx| collect_buffer_info_and_text(buffer, cx))??;
let text = text_task.await; let text = text_task.await;
@ -206,7 +206,7 @@ impl ContextStore {
// Skip all binary files and other non-UTF8 files // Skip all binary files and other non-UTF8 files
for buffer in buffers.into_iter().flatten() { for buffer in buffers.into_iter().flatten() {
if let Some((buffer_info, text_task)) = if let Some((buffer_info, text_task)) =
collect_buffer_info_and_text(buffer, None, cx).log_err() collect_buffer_info_and_text(buffer, cx).log_err()
{ {
buffer_infos.push(buffer_info); buffer_infos.push(buffer_info);
text_tasks.push(text_task); text_tasks.push(text_task);
@ -290,11 +290,14 @@ impl ContextStore {
} }
} }
let (buffer_info, collect_content_task) = let (buffer_info, collect_content_task) = match collect_buffer_info_and_text_for_range(
match collect_buffer_info_and_text(buffer, Some(symbol_enclosing_range.clone()), cx) { buffer,
Ok((buffer_info, collect_context_task)) => (buffer_info, collect_context_task), symbol_enclosing_range.clone(),
Err(err) => return Task::ready(Err(err)), cx,
}; ) {
Ok((_, buffer_info, collect_context_task)) => (buffer_info, collect_context_task),
Err(err) => return Task::ready(Err(err)),
};
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
let content = collect_content_task.await; let content = collect_content_task.await;
@ -416,6 +419,49 @@ impl ContextStore {
cx.notify(); cx.notify();
} }
pub fn add_excerpt(
&mut self,
range: Range<Anchor>,
buffer: Entity<Buffer>,
cx: &mut Context<ContextStore>,
) -> Task<Result<()>> {
cx.spawn(async move |this, cx| {
let (line_range, buffer_info, text_task) = this.update(cx, |_, cx| {
collect_buffer_info_and_text_for_range(buffer, range.clone(), cx)
})??;
let text = text_task.await;
this.update(cx, |this, cx| {
this.insert_excerpt(
make_context_buffer(buffer_info, text),
range,
line_range,
cx,
)
})?;
anyhow::Ok(())
})
}
fn insert_excerpt(
&mut self,
context_buffer: ContextBuffer,
range: Range<Anchor>,
line_range: Range<Point>,
cx: &mut Context<Self>,
) {
let id = self.next_context_id.post_inc();
self.context.push(AssistantContext::Excerpt(ExcerptContext {
id,
range,
line_range,
context_buffer,
}));
cx.notify();
}
pub fn accept_suggested_context( pub fn accept_suggested_context(
&mut self, &mut self,
suggested: &SuggestedContext, suggested: &SuggestedContext,
@ -465,6 +511,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::FetchedUrl(_) => { AssistantContext::FetchedUrl(_) => {
self.fetched_urls.retain(|_, context_id| *context_id != id); self.fetched_urls.retain(|_, context_id| *context_id != id);
} }
@ -592,6 +639,7 @@ impl ContextStore {
} }
AssistantContext::Directory(_) AssistantContext::Directory(_)
| AssistantContext::Symbol(_) | AssistantContext::Symbol(_)
| AssistantContext::Excerpt(_)
| AssistantContext::FetchedUrl(_) | AssistantContext::FetchedUrl(_)
| AssistantContext::Thread(_) => None, | AssistantContext::Thread(_) => None,
}) })
@ -643,41 +691,78 @@ fn make_context_symbol(
} }
} }
fn collect_buffer_info_and_text_for_range(
buffer: Entity<Buffer>,
range: Range<Anchor>,
cx: &App,
) -> Result<(Range<Point>, BufferInfo, Task<SharedString>)> {
let content = buffer
.read(cx)
.text_for_range(range.clone())
.collect::<Rope>();
let line_range = range.to_point(&buffer.read(cx).snapshot());
let buffer_info = collect_buffer_info(buffer, cx)?;
let full_path = buffer_info.file.full_path(cx);
let text_task = cx.background_spawn({
let line_range = line_range.clone();
async move { to_fenced_codeblock(&full_path, content, Some(line_range)) }
});
Ok((line_range, buffer_info, text_task))
}
fn collect_buffer_info_and_text( fn collect_buffer_info_and_text(
buffer: Entity<Buffer>, buffer: Entity<Buffer>,
range: Option<Range<Anchor>>,
cx: &App, cx: &App,
) -> Result<(BufferInfo, Task<SharedString>)> { ) -> Result<(BufferInfo, Task<SharedString>)> {
let content = buffer.read(cx).as_rope().clone();
let buffer_info = collect_buffer_info(buffer, cx)?;
let full_path = buffer_info.file.full_path(cx);
let text_task =
cx.background_spawn(async move { to_fenced_codeblock(&full_path, content, None) });
Ok((buffer_info, text_task))
}
fn collect_buffer_info(buffer: Entity<Buffer>, cx: &App) -> Result<BufferInfo> {
let buffer_ref = buffer.read(cx); let buffer_ref = buffer.read(cx);
let file = buffer_ref.file().context("file context must have a path")?; let file = buffer_ref.file().context("file context must have a path")?;
// Important to collect version at the same time as content so that staleness logic is correct. // Important to collect version at the same time as content so that staleness logic is correct.
let version = buffer_ref.version(); let version = buffer_ref.version();
let content = if let Some(range) = range {
buffer_ref.text_for_range(range).collect::<Rope>()
} else {
buffer_ref.as_rope().clone()
};
let buffer_info = BufferInfo { Ok(BufferInfo {
buffer, buffer,
id: buffer_ref.remote_id(), id: buffer_ref.remote_id(),
file: file.clone(), file: file.clone(),
version, version,
}; })
let full_path = file.full_path(cx);
let text_task = cx.background_spawn(async move { to_fenced_codeblock(&full_path, content) });
Ok((buffer_info, text_task))
} }
fn to_fenced_codeblock(path: &Path, content: Rope) -> SharedString { fn to_fenced_codeblock(
path: &Path,
content: Rope,
line_range: Option<Range<Point>>,
) -> SharedString {
let line_range_text = line_range.map(|range| {
if range.start.row == range.end.row {
format!(":{}", range.start.row + 1)
} else {
format!(":{}-{}", range.start.row + 1, range.end.row + 1)
}
});
let path_extension = path.extension().and_then(|ext| ext.to_str()); let path_extension = path.extension().and_then(|ext| ext.to_str());
let path_string = path.to_string_lossy(); let path_string = path.to_string_lossy();
let capacity = 3 let capacity = 3
+ path_extension.map_or(0, |extension| extension.len() + 1) + path_extension.map_or(0, |extension| extension.len() + 1)
+ path_string.len() + path_string.len()
+ line_range_text.as_ref().map_or(0, |text| text.len())
+ 1 + 1
+ content.len() + content.len()
+ 5; + 5;
@ -691,6 +776,10 @@ fn to_fenced_codeblock(path: &Path, content: Rope) -> SharedString {
} }
buffer.push_str(&path_string); buffer.push_str(&path_string);
if let Some(line_range_text) = line_range_text {
buffer.push_str(&line_range_text);
}
buffer.push('\n'); buffer.push('\n');
for chunk in content.chunks() { for chunk in content.chunks() {
buffer.push_str(&chunk); buffer.push_str(&chunk);
@ -769,6 +858,14 @@ 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) => {
if changed_buffers.is_empty()
|| changed_buffers.contains(&excerpt_context.context_buffer.buffer)
{
let context_store = context_store.clone();
return refresh_excerpt_text(context_store, excerpt_context, cx);
}
}
AssistantContext::Thread(thread_context) => { AssistantContext::Thread(thread_context) => {
if changed_buffers.is_empty() { if changed_buffers.is_empty() {
let context_store = context_store.clone(); let context_store = context_store.clone();
@ -880,6 +977,34 @@ fn refresh_symbol_text(
} }
} }
fn refresh_excerpt_text(
context_store: Entity<ContextStore>,
excerpt_context: &ExcerptContext,
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);
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 {
id,
range,
line_range,
context_buffer,
};
context_store.replace_context(AssistantContext::Excerpt(new_excerpt_context));
})
.ok();
}))
} else {
None
}
}
fn refresh_thread_text( fn refresh_thread_text(
context_store: Entity<ContextStore>, context_store: Entity<ContextStore>,
thread_context: &ThreadContext, thread_context: &ThreadContext,
@ -908,13 +1033,29 @@ fn refresh_context_buffer(
let buffer = context_buffer.buffer.read(cx); let buffer = context_buffer.buffer.read(cx);
if buffer.version.changed_since(&context_buffer.version) { if buffer.version.changed_since(&context_buffer.version) {
let (buffer_info, text_task) = let (buffer_info, text_task) =
collect_buffer_info_and_text(context_buffer.buffer.clone(), None, cx).log_err()?; collect_buffer_info_and_text(context_buffer.buffer.clone(), cx).log_err()?;
Some(text_task.map(move |text| make_context_buffer(buffer_info, text))) Some(text_task.map(move |text| make_context_buffer(buffer_info, text)))
} else { } else {
None None
} }
} }
fn refresh_context_excerpt(
context_buffer: &ContextBuffer,
range: Range<Anchor>,
cx: &App,
) -> Option<impl Future<Output = (Range<Point>, ContextBuffer)> + use<>> {
let buffer = context_buffer.buffer.read(cx);
if buffer.version.changed_since(&context_buffer.version) {
let (line_range, buffer_info, text_task) =
collect_buffer_info_and_text_for_range(context_buffer.buffer.clone(), range, cx)
.log_err()?;
Some(text_task.map(move |text| (line_range, make_context_buffer(buffer_info, text))))
} else {
None
}
}
fn refresh_context_symbol( fn refresh_context_symbol(
context_symbol: &ContextSymbol, context_symbol: &ContextSymbol,
cx: &App, cx: &App,
@ -922,9 +1063,9 @@ fn refresh_context_symbol(
let buffer = context_symbol.buffer.read(cx); let buffer = context_symbol.buffer.read(cx);
let project_path = buffer.project_path(cx)?; let project_path = buffer.project_path(cx)?;
if buffer.version.changed_since(&context_symbol.buffer_version) { if buffer.version.changed_since(&context_symbol.buffer_version) {
let (buffer_info, text_task) = collect_buffer_info_and_text( let (_, buffer_info, text_task) = collect_buffer_info_and_text_for_range(
context_symbol.buffer.clone(), context_symbol.buffer.clone(),
Some(context_symbol.enclosing_range.clone()), context_symbol.enclosing_range.clone(),
cx, cx,
) )
.log_err()?; .log_err()?;

View file

@ -725,6 +725,12 @@ impl Thread {
cx, cx,
); );
} }
AssistantContext::Excerpt(excerpt_context) => {
log.buffer_added_as_context(
excerpt_context.context_buffer.buffer.clone(),
cx,
);
}
AssistantContext::FetchedUrl(_) | AssistantContext::Thread(_) => {} AssistantContext::FetchedUrl(_) | AssistantContext::Thread(_) => {}
} }
} }

View file

@ -299,6 +299,39 @@ impl AddedContext {
summarizing: false, summarizing: false,
}, },
AssistantContext::Excerpt(excerpt_context) => {
let full_path = excerpt_context.context_buffer.file.full_path(cx);
let mut full_path_string = full_path.to_string_lossy().into_owned();
let mut name = full_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| full_path_string.clone());
let line_range_text = format!(
" ({}-{})",
excerpt_context.line_range.start.row + 1,
excerpt_context.line_range.end.row + 1
);
full_path_string.push_str(&line_range_text);
name.push_str(&line_range_text);
let parent = full_path
.parent()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned().into());
AddedContext {
id: excerpt_context.id,
kind: ContextKind::File, // Use File icon for excerpts
name: name.into(),
parent,
tooltip: Some(full_path_string.into()),
icon_path: FileIcons::get_icon(&full_path, cx),
summarizing: false,
}
}
AssistantContext::FetchedUrl(fetched_url_context) => AddedContext { AssistantContext::FetchedUrl(fetched_url_context) => AddedContext {
id: fetched_url_context.id, id: fetched_url_context.id,
kind: ContextKind::FetchedUrl, kind: ContextKind::FetchedUrl,

View file

@ -13,7 +13,7 @@ use assistant_context_editor::{
use assistant_settings::{AssistantDockPosition, AssistantSettings}; use assistant_settings::{AssistantDockPosition, AssistantSettings};
use assistant_slash_command::SlashCommandWorkingSet; use assistant_slash_command::SlashCommandWorkingSet;
use client::{Client, Status, proto}; use client::{Client, Status, proto};
use editor::{Editor, EditorEvent}; use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
use fs::Fs; use fs::Fs;
use gpui::{ use gpui::{
Action, App, AsyncWindowContext, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, Action, App, AsyncWindowContext, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable,
@ -28,9 +28,12 @@ use language_model::{
use project::Project; use project::Project;
use prompt_library::{PromptLibrary, open_prompt_library}; use prompt_library::{PromptLibrary, open_prompt_library};
use prompt_store::PromptBuilder; use prompt_store::PromptBuilder;
use search::{BufferSearchBar, buffer_search::DivRegistrar}; use search::{BufferSearchBar, buffer_search::DivRegistrar};
use settings::{Settings, update_settings_file}; use settings::{Settings, update_settings_file};
use smol::stream::StreamExt; use smol::stream::StreamExt;
use std::ops::Range;
use std::{ops::ControlFlow, path::PathBuf, sync::Arc}; use std::{ops::ControlFlow, path::PathBuf, sync::Arc};
use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
use ui::{ContextMenu, PopoverMenu, Tooltip, prelude::*}; use ui::{ContextMenu, PopoverMenu, Tooltip, prelude::*};
@ -1413,7 +1416,8 @@ impl AssistantPanelDelegate for ConcreteAssistantPanelDelegate {
fn quote_selection( fn quote_selection(
&self, &self,
workspace: &mut Workspace, workspace: &mut Workspace,
creases: Vec<(String, String)>, selection_ranges: Vec<Range<Anchor>>,
buffer: Entity<MultiBuffer>,
window: &mut Window, window: &mut Window,
cx: &mut Context<Workspace>, cx: &mut Context<Workspace>,
) { ) {
@ -1425,6 +1429,12 @@ impl AssistantPanelDelegate for ConcreteAssistantPanelDelegate {
workspace.toggle_panel_focus::<AssistantPanel>(window, cx); workspace.toggle_panel_focus::<AssistantPanel>(window, cx);
} }
let snapshot = buffer.read(cx).snapshot(cx);
let selection_ranges = selection_ranges
.into_iter()
.map(|range| range.to_point(&snapshot))
.collect::<Vec<_>>();
panel.update(cx, |_, cx| { panel.update(cx, |_, cx| {
// Wait to create a new context until the workspace is no longer // Wait to create a new context until the workspace is no longer
// being updated. // being updated.
@ -1433,7 +1443,9 @@ impl AssistantPanelDelegate for ConcreteAssistantPanelDelegate {
.active_context_editor(cx) .active_context_editor(cx)
.or_else(|| panel.new_context(window, cx)) .or_else(|| panel.new_context(window, cx))
{ {
context.update(cx, |context, cx| context.quote_creases(creases, window, cx)); context.update(cx, |context, cx| {
context.quote_ranges(selection_ranges, snapshot, window, cx)
});
}; };
}); });
}); });

View file

@ -8,8 +8,8 @@ use assistant_slash_commands::{
use client::{proto, zed_urls}; use client::{proto, zed_urls};
use collections::{BTreeSet, HashMap, HashSet, hash_map}; use collections::{BTreeSet, HashMap, HashSet, hash_map};
use editor::{ use editor::{
Anchor, Editor, EditorEvent, MenuInlineCompletionsPolicy, ProposedChangeLocation, Anchor, Editor, EditorEvent, MenuInlineCompletionsPolicy, MultiBuffer, MultiBufferSnapshot,
ProposedChangesEditor, RowExt, ToOffset as _, ToPoint, ProposedChangeLocation, ProposedChangesEditor, RowExt, ToOffset as _, ToPoint,
actions::{MoveToEndOfLine, Newline, ShowCompletions}, actions::{MoveToEndOfLine, Newline, ShowCompletions},
display_map::{ display_map::{
BlockContext, BlockId, BlockPlacement, BlockProperties, BlockStyle, Crease, CreaseMetadata, BlockContext, BlockId, BlockPlacement, BlockProperties, BlockStyle, Crease, CreaseMetadata,
@ -155,7 +155,8 @@ pub trait AssistantPanelDelegate {
fn quote_selection( fn quote_selection(
&self, &self,
workspace: &mut Workspace, workspace: &mut Workspace,
creases: Vec<(String, String)>, selection_ranges: Vec<Range<Anchor>>,
buffer: Entity<MultiBuffer>,
window: &mut Window, window: &mut Window,
cx: &mut Context<Workspace>, cx: &mut Context<Workspace>,
); );
@ -1800,23 +1801,42 @@ impl ContextEditor {
return; return;
}; };
let Some(creases) = selections_creases(workspace, cx) else { let Some((selections, buffer)) = maybe!({
let editor = workspace
.active_item(cx)
.and_then(|item| item.act_as::<Editor>(cx))?;
let buffer = editor.read(cx).buffer().clone();
let snapshot = buffer.read(cx).snapshot(cx);
let selections = editor.update(cx, |editor, cx| {
editor
.selections
.all_adjusted(cx)
.into_iter()
.map(|s| snapshot.anchor_after(s.start)..snapshot.anchor_before(s.end))
.collect::<Vec<_>>()
});
Some((selections, buffer))
}) else {
return; return;
}; };
if creases.is_empty() { if selections.is_empty() {
return; return;
} }
assistant_panel_delegate.quote_selection(workspace, creases, window, cx); assistant_panel_delegate.quote_selection(workspace, selections, buffer, window, cx);
} }
pub fn quote_creases( pub fn quote_ranges(
&mut self, &mut self,
creases: Vec<(String, String)>, ranges: Vec<Range<Point>>,
snapshot: MultiBufferSnapshot,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let creases = selections_creases(ranges, snapshot, cx);
self.editor.update(cx, |editor, cx| { self.editor.update(cx, |editor, cx| {
editor.insert("\n", window, cx); editor.insert("\n", window, cx);
for (text, crease_title) in creases { for (text, crease_title) in creases {

View file

@ -3,10 +3,12 @@ use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandContent, SlashCommandEvent, ArgumentCompletion, SlashCommand, SlashCommandContent, SlashCommandEvent,
SlashCommandOutputSection, SlashCommandResult, SlashCommandOutputSection, SlashCommandResult,
}; };
use editor::Editor; use editor::{Editor, MultiBufferSnapshot};
use futures::StreamExt; use futures::StreamExt;
use gpui::{App, Context, SharedString, Task, WeakEntity, Window}; use gpui::{App, SharedString, Task, WeakEntity, Window};
use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate}; use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate};
use rope::Point;
use std::ops::Range;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use ui::IconName; use ui::IconName;
@ -69,7 +71,22 @@ impl SlashCommand for SelectionCommand {
let mut events = vec![]; let mut events = vec![];
let Some(creases) = workspace let Some(creases) = workspace
.update(cx, selections_creases) .update(cx, |workspace, cx| {
let editor = workspace
.active_item(cx)
.and_then(|item| item.act_as::<Editor>(cx))?;
editor.update(cx, |editor, cx| {
let selection_ranges = editor
.selections
.all_adjusted(cx)
.iter()
.map(|selection| selection.range())
.collect::<Vec<_>>();
let snapshot = editor.buffer().read(cx).snapshot(cx);
Some(selections_creases(selection_ranges, snapshot, cx))
})
})
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
events.push(Err(e)); events.push(Err(e));
None None
@ -102,94 +119,82 @@ impl SlashCommand for SelectionCommand {
} }
pub fn selections_creases( pub fn selections_creases(
workspace: &mut workspace::Workspace, selection_ranges: Vec<Range<Point>>,
cx: &mut Context<Workspace>, snapshot: MultiBufferSnapshot,
) -> Option<Vec<(String, String)>> { cx: &App,
let editor = workspace ) -> Vec<(String, String)> {
.active_item(cx) let mut creases = Vec::new();
.and_then(|item| item.act_as::<Editor>(cx))?; for range in selection_ranges {
let selected_text = snapshot.text_for_range(range.clone()).collect::<String>();
let mut creases = vec![]; if selected_text.is_empty() {
editor.update(cx, |editor, cx| { continue;
let selections = editor.selections.all_adjusted(cx);
let buffer = editor.buffer().read(cx).snapshot(cx);
for selection in selections {
let range = editor::ToOffset::to_offset(&selection.start, &buffer)
..editor::ToOffset::to_offset(&selection.end, &buffer);
let selected_text = buffer.text_for_range(range.clone()).collect::<String>();
if selected_text.is_empty() {
continue;
}
let start_language = buffer.language_at(range.start);
let end_language = buffer.language_at(range.end);
let language_name = if start_language == end_language {
start_language.map(|language| language.code_fence_block_name())
} else {
None
};
let language_name = language_name.as_deref().unwrap_or("");
let filename = buffer
.file_at(selection.start)
.map(|file| file.full_path(cx));
let text = if language_name == "markdown" {
selected_text
.lines()
.map(|line| format!("> {}", line))
.collect::<Vec<_>>()
.join("\n")
} else {
let start_symbols = buffer
.symbols_containing(selection.start, None)
.map(|(_, symbols)| symbols);
let end_symbols = buffer
.symbols_containing(selection.end, None)
.map(|(_, symbols)| symbols);
let outline_text =
if let Some((start_symbols, end_symbols)) = start_symbols.zip(end_symbols) {
Some(
start_symbols
.into_iter()
.zip(end_symbols)
.take_while(|(a, b)| a == b)
.map(|(a, _)| a.text)
.collect::<Vec<_>>()
.join(" > "),
)
} else {
None
};
let line_comment_prefix = start_language
.and_then(|l| l.default_scope().line_comment_prefixes().first().cloned());
let fence = codeblock_fence_for_path(
filename.as_deref(),
Some(selection.start.row..=selection.end.row),
);
if let Some((line_comment_prefix, outline_text)) =
line_comment_prefix.zip(outline_text)
{
let breadcrumb = format!("{line_comment_prefix}Excerpt from: {outline_text}\n");
format!("{fence}{breadcrumb}{selected_text}\n```")
} else {
format!("{fence}{selected_text}\n```")
}
};
let crease_title = if let Some(path) = filename {
let start_line = selection.start.row + 1;
let end_line = selection.end.row + 1;
if start_line == end_line {
format!("{}, Line {}", path.display(), start_line)
} else {
format!("{}, Lines {} to {}", path.display(), start_line, end_line)
}
} else {
"Quoted selection".to_string()
};
creases.push((text, crease_title));
} }
}); let start_language = snapshot.language_at(range.start);
Some(creases) let end_language = snapshot.language_at(range.end);
let language_name = if start_language == end_language {
start_language.map(|language| language.code_fence_block_name())
} else {
None
};
let language_name = language_name.as_deref().unwrap_or("");
let filename = snapshot.file_at(range.start).map(|file| file.full_path(cx));
let text = if language_name == "markdown" {
selected_text
.lines()
.map(|line| format!("> {}", line))
.collect::<Vec<_>>()
.join("\n")
} else {
let start_symbols = snapshot
.symbols_containing(range.start, None)
.map(|(_, symbols)| symbols);
let end_symbols = snapshot
.symbols_containing(range.end, None)
.map(|(_, symbols)| symbols);
let outline_text =
if let Some((start_symbols, end_symbols)) = start_symbols.zip(end_symbols) {
Some(
start_symbols
.into_iter()
.zip(end_symbols)
.take_while(|(a, b)| a == b)
.map(|(a, _)| a.text)
.collect::<Vec<_>>()
.join(" > "),
)
} else {
None
};
let line_comment_prefix = start_language
.and_then(|l| l.default_scope().line_comment_prefixes().first().cloned());
let fence = codeblock_fence_for_path(
filename.as_deref(),
Some(range.start.row..=range.end.row),
);
if let Some((line_comment_prefix, outline_text)) = line_comment_prefix.zip(outline_text)
{
let breadcrumb = format!("{line_comment_prefix}Excerpt from: {outline_text}\n");
format!("{fence}{breadcrumb}{selected_text}\n```")
} else {
format!("{fence}{selected_text}\n```")
}
};
let crease_title = if let Some(path) = filename {
let start_line = range.start.row + 1;
let end_line = range.end.row + 1;
if start_line == end_line {
format!("{}, Line {}", path.display(), start_line)
} else {
format!("{}, Lines {} to {}", path.display(), start_line, end_line)
}
} else {
"Quoted selection".to_string()
};
creases.push((text, crease_title));
}
creases
} }