agent: Support adding selection as context (#28964)

Bennet Bo Fenner created

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

Change summary

crates/agent/src/active_thread.rs                        |  67 ++-
crates/agent/src/assistant_panel.rs                      | 122 ++++--
crates/agent/src/context.rs                              |  25 +
crates/agent/src/context_store.rs                        | 195 ++++++++-
crates/agent/src/thread.rs                               |   6 
crates/agent/src/ui/context_pill.rs                      |  33 +
crates/assistant/src/assistant_panel.rs                  |  18 
crates/assistant_context_editor/src/context_editor.rs    |  36 +
crates/assistant_slash_commands/src/selection_command.rs | 187 ++++----
9 files changed, 494 insertions(+), 195 deletions(-)

Detailed changes

crates/agent/src/active_thread.rs 🔗

@@ -756,6 +756,10 @@ impl ActiveThread {
         this
     }
 
+    pub fn context_store(&self) -> &Entity<ContextStore> {
+        &self.context_store
+    }
+
     pub fn thread(&self) -> &Entity<Thread> {
         &self.thread
     }
@@ -3145,28 +3149,21 @@ pub(crate) fn open_context(
                     .start
                     .to_point(&snapshot);
 
-                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();
-                        }
-                    })
+                open_editor_at_position(project_path, target_position, &workspace, window, cx)
+                    .detach();
+            }
+        }
+        AssistantContext::Excerpt(excerpt_context) => {
+            if let Some(project_path) = excerpt_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);
+
+                open_editor_at_position(project_path, target_position, &workspace, window, cx)
                     .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();
+        }
+    })
+}

crates/agent/src/assistant_panel.rs 🔗

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

crates/agent/src/context.rs 🔗

@@ -4,6 +4,7 @@ use gpui::{App, Entity, SharedString};
 use language::{Buffer, File};
 use language_model::LanguageModelRequestMessage;
 use project::{ProjectPath, Worktree};
+use rope::Point;
 use serde::{Deserialize, Serialize};
 use text::{Anchor, BufferId};
 use ui::IconName;
@@ -23,6 +24,7 @@ pub enum ContextKind {
     File,
     Directory,
     Symbol,
+    Excerpt,
     FetchedUrl,
     Thread,
 }
@@ -33,6 +35,7 @@ impl ContextKind {
             ContextKind::File => IconName::File,
             ContextKind::Directory => IconName::Folder,
             ContextKind::Symbol => IconName::Code,
+            ContextKind::Excerpt => IconName::Code,
             ContextKind::FetchedUrl => IconName::Globe,
             ContextKind::Thread => IconName::MessageBubbles,
         }
@@ -46,6 +49,7 @@ pub enum AssistantContext {
     Symbol(SymbolContext),
     FetchedUrl(FetchedUrlContext),
     Thread(ThreadContext),
+    Excerpt(ExcerptContext),
 }
 
 impl AssistantContext {
@@ -56,6 +60,7 @@ impl AssistantContext {
             Self::Symbol(symbol) => symbol.id,
             Self::FetchedUrl(url) => url.id,
             Self::Thread(thread) => thread.id,
+            Self::Excerpt(excerpt) => excerpt.id,
         }
     }
 }
@@ -155,6 +160,14 @@ pub struct ContextSymbolId {
     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
 pub fn format_context_as_string<'a>(
     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 directory_context = Vec::new();
     let mut symbol_context = Vec::new();
+    let mut excerpt_context = Vec::new();
     let mut fetch_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::Directory(context) => directory_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::Thread(context) => thread_context.push(context),
         }
@@ -179,6 +194,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()
         && fetch_context.is_empty()
         && thread_context.is_empty()
     {
@@ -216,6 +232,15 @@ pub fn format_context_as_string<'a>(
         result.push_str("</symbols>\n");
     }
 
+    if !excerpt_context.is_empty() {
+        result.push_str("<excerpts>\n");
+        for context in excerpt_context {
+            result.push_str(&context.context_buffer.text);
+            result.push('\n');
+        }
+        result.push_str("</excerpts>\n");
+    }
+
     if !fetch_context.is_empty() {
         result.push_str("<fetched_urls>\n");
         for context in &fetch_context {

crates/agent/src/context_store.rs 🔗

@@ -9,14 +9,14 @@ use futures::{self, Future, FutureExt, future};
 use gpui::{App, AppContext as _, Context, Entity, SharedString, Task, WeakEntity};
 use language::{Buffer, File};
 use project::{Project, ProjectItem, ProjectPath, Worktree};
-use rope::Rope;
+use rope::{Point, Rope};
 use text::{Anchor, BufferId, OffsetRangeExt};
 use util::{ResultExt as _, maybe};
 
 use crate::ThreadStore;
 use crate::context::{
     AssistantContext, ContextBuffer, ContextId, ContextSymbol, ContextSymbolId, DirectoryContext,
-    FetchedUrlContext, FileContext, SymbolContext, ThreadContext,
+    ExcerptContext, FetchedUrlContext, FileContext, SymbolContext, ThreadContext,
 };
 use crate::context_strip::SuggestedContext;
 use crate::thread::{Thread, ThreadId};
@@ -110,7 +110,7 @@ impl ContextStore {
             }
 
             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;
 
@@ -129,7 +129,7 @@ impl ContextStore {
     ) -> Task<Result<()>> {
         cx.spawn(async move |this, cx| {
             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;
 
@@ -206,7 +206,7 @@ impl ContextStore {
                 // Skip all binary files and other non-UTF8 files
                 for buffer in buffers.into_iter().flatten() {
                     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);
                         text_tasks.push(text_task);
@@ -290,11 +290,14 @@ impl ContextStore {
             }
         }
 
-        let (buffer_info, collect_content_task) =
-            match collect_buffer_info_and_text(buffer, Some(symbol_enclosing_range.clone()), cx) {
-                Ok((buffer_info, collect_context_task)) => (buffer_info, collect_context_task),
-                Err(err) => return Task::ready(Err(err)),
-            };
+        let (buffer_info, collect_content_task) = match collect_buffer_info_and_text_for_range(
+            buffer,
+            symbol_enclosing_range.clone(),
+            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| {
             let content = collect_content_task.await;
@@ -416,6 +419,49 @@ impl ContextStore {
         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(
         &mut self,
         suggested: &SuggestedContext,
@@ -465,6 +511,7 @@ impl ContextStore {
                 self.symbol_buffers.remove(&symbol.context_symbol.id);
                 self.symbols.retain(|_, context_id| *context_id != id);
             }
+            AssistantContext::Excerpt(_) => {}
             AssistantContext::FetchedUrl(_) => {
                 self.fetched_urls.retain(|_, context_id| *context_id != id);
             }
@@ -592,6 +639,7 @@ impl ContextStore {
                 }
                 AssistantContext::Directory(_)
                 | AssistantContext::Symbol(_)
+                | AssistantContext::Excerpt(_)
                 | AssistantContext::FetchedUrl(_)
                 | 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(
     buffer: Entity<Buffer>,
-    range: Option<Range<Anchor>>,
     cx: &App,
 ) -> 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 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.
     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,
         id: buffer_ref.remote_id(),
         file: file.clone(),
         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_string = path.to_string_lossy();
     let capacity = 3
         + path_extension.map_or(0, |extension| extension.len() + 1)
         + path_string.len()
+        + line_range_text.as_ref().map_or(0, |text| text.len())
         + 1
         + content.len()
         + 5;
@@ -691,6 +776,10 @@ fn to_fenced_codeblock(path: &Path, content: Rope) -> SharedString {
     }
     buffer.push_str(&path_string);
 
+    if let Some(line_range_text) = line_range_text {
+        buffer.push_str(&line_range_text);
+    }
+
     buffer.push('\n');
     for chunk in content.chunks() {
         buffer.push_str(&chunk);
@@ -769,6 +858,14 @@ pub fn refresh_context_store_text(
                         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) => {
                     if changed_buffers.is_empty() {
                         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(
     context_store: Entity<ContextStore>,
     thread_context: &ThreadContext,
@@ -908,13 +1033,29 @@ fn refresh_context_buffer(
     let buffer = context_buffer.buffer.read(cx);
     if buffer.version.changed_since(&context_buffer.version) {
         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)))
     } else {
         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(
     context_symbol: &ContextSymbol,
     cx: &App,
@@ -922,9 +1063,9 @@ fn refresh_context_symbol(
     let buffer = context_symbol.buffer.read(cx);
     let project_path = buffer.project_path(cx)?;
     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(),
-            Some(context_symbol.enclosing_range.clone()),
+            context_symbol.enclosing_range.clone(),
             cx,
         )
         .log_err()?;

crates/agent/src/thread.rs 🔗

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

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

@@ -299,6 +299,39 @@ impl AddedContext {
                 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 {
                 id: fetched_url_context.id,
                 kind: ContextKind::FetchedUrl,

crates/assistant/src/assistant_panel.rs 🔗

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

crates/assistant_context_editor/src/context_editor.rs 🔗

@@ -8,8 +8,8 @@ use assistant_slash_commands::{
 use client::{proto, zed_urls};
 use collections::{BTreeSet, HashMap, HashSet, hash_map};
 use editor::{
-    Anchor, Editor, EditorEvent, MenuInlineCompletionsPolicy, ProposedChangeLocation,
-    ProposedChangesEditor, RowExt, ToOffset as _, ToPoint,
+    Anchor, Editor, EditorEvent, MenuInlineCompletionsPolicy, MultiBuffer, MultiBufferSnapshot,
+    ProposedChangeLocation, ProposedChangesEditor, RowExt, ToOffset as _, ToPoint,
     actions::{MoveToEndOfLine, Newline, ShowCompletions},
     display_map::{
         BlockContext, BlockId, BlockPlacement, BlockProperties, BlockStyle, Crease, CreaseMetadata,
@@ -155,7 +155,8 @@ pub trait AssistantPanelDelegate {
     fn quote_selection(
         &self,
         workspace: &mut Workspace,
-        creases: Vec<(String, String)>,
+        selection_ranges: Vec<Range<Anchor>>,
+        buffer: Entity<MultiBuffer>,
         window: &mut Window,
         cx: &mut Context<Workspace>,
     );
@@ -1800,23 +1801,42 @@ impl ContextEditor {
             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;
         };
 
-        if creases.is_empty() {
+        if selections.is_empty() {
             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,
-        creases: Vec<(String, String)>,
+        ranges: Vec<Range<Point>>,
+        snapshot: MultiBufferSnapshot,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        let creases = selections_creases(ranges, snapshot, cx);
+
         self.editor.update(cx, |editor, cx| {
             editor.insert("\n", window, cx);
             for (text, crease_title) in creases {

crates/assistant_slash_commands/src/selection_command.rs 🔗

@@ -3,10 +3,12 @@ use assistant_slash_command::{
     ArgumentCompletion, SlashCommand, SlashCommandContent, SlashCommandEvent,
     SlashCommandOutputSection, SlashCommandResult,
 };
-use editor::Editor;
+use editor::{Editor, MultiBufferSnapshot};
 use futures::StreamExt;
-use gpui::{App, Context, SharedString, Task, WeakEntity, Window};
+use gpui::{App, SharedString, Task, WeakEntity, Window};
 use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate};
+use rope::Point;
+use std::ops::Range;
 use std::sync::Arc;
 use std::sync::atomic::AtomicBool;
 use ui::IconName;
@@ -69,7 +71,22 @@ impl SlashCommand for SelectionCommand {
         let mut events = vec![];
 
         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| {
                 events.push(Err(e));
                 None
@@ -102,94 +119,82 @@ impl SlashCommand for SelectionCommand {
 }
 
 pub fn selections_creases(
-    workspace: &mut workspace::Workspace,
-    cx: &mut Context<Workspace>,
-) -> Option<Vec<(String, String)>> {
-    let editor = workspace
-        .active_item(cx)
-        .and_then(|item| item.act_as::<Editor>(cx))?;
-
-    let mut creases = vec![];
-    editor.update(cx, |editor, cx| {
-        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)
+    selection_ranges: Vec<Range<Point>>,
+    snapshot: MultiBufferSnapshot,
+    cx: &App,
+) -> Vec<(String, String)> {
+    let mut creases = Vec::new();
+    for range in selection_ranges {
+        let selected_text = snapshot.text_for_range(range.clone()).collect::<String>();
+        if selected_text.is_empty() {
+            continue;
+        }
+        let start_language = snapshot.language_at(range.start);
+        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 {
-                    format!("{}, Lines {} to {}", path.display(), start_line, end_line)
-                }
+                    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 {
-                "Quoted selection".to_string()
-            };
-            creases.push((text, crease_title));
-        }
-    });
-    Some(creases)
+                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
 }