agent_ui: Auto-capture file context on paste (#42982)

ozzy and Bennet Bo Fenner created

Closes #42972


https://github.com/user-attachments/assets/98f2d3dc-5682-4670-b636-fa8ea2495c69

Release Notes:

- Added automatic file context detection when pasting code into the AI
agent panel. Pasted code now displays as collapsible badges showing the
file path and line numbers (e.g., "app/layout.tsx (18-25)").

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>

Change summary

crates/agent_ui/src/acp/message_editor.rs | 118 ++++++++++++++++++++++++
crates/agent_ui/src/text_thread_editor.rs |  92 +++++++++++++++++++
crates/editor/src/editor.rs               |  62 +++++++++++--
crates/vim/src/normal/yank.rs             |  14 +-
4 files changed, 268 insertions(+), 18 deletions(-)

Detailed changes

crates/agent_ui/src/acp/message_editor.rs 🔗

@@ -21,8 +21,8 @@ use editor::{
 };
 use futures::{FutureExt as _, future::join_all};
 use gpui::{
-    AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageFormat, KeyContext,
-    SharedString, Subscription, Task, TextStyle, WeakEntity,
+    AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageFormat,
+    KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity,
 };
 use language::{Buffer, Language, language_settings::InlayHintKind};
 use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Worktree};
@@ -543,6 +543,120 @@ impl MessageEditor {
     }
 
     fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
+        let editor_clipboard_selections = cx
+            .read_from_clipboard()
+            .and_then(|item| item.entries().first().cloned())
+            .and_then(|entry| match entry {
+                ClipboardEntry::String(text) => {
+                    text.metadata_json::<Vec<editor::ClipboardSelection>>()
+                }
+                _ => None,
+            });
+
+        let has_file_context = editor_clipboard_selections
+            .as_ref()
+            .is_some_and(|selections| {
+                selections
+                    .iter()
+                    .any(|sel| sel.file_path.is_some() && sel.line_range.is_some())
+            });
+
+        if has_file_context {
+            if let Some((workspace, selections)) =
+                self.workspace.upgrade().zip(editor_clipboard_selections)
+            {
+                cx.stop_propagation();
+
+                let project = workspace.read(cx).project().clone();
+                for selection in selections {
+                    if let (Some(file_path), Some(line_range)) =
+                        (selection.file_path, selection.line_range)
+                    {
+                        let crease_text =
+                            acp_thread::selection_name(Some(file_path.as_ref()), &line_range);
+
+                        let mention_uri = MentionUri::Selection {
+                            abs_path: Some(file_path.clone()),
+                            line_range: line_range.clone(),
+                        };
+
+                        let mention_text = mention_uri.as_link().to_string();
+                        let (excerpt_id, text_anchor, content_len) =
+                            self.editor.update(cx, |editor, cx| {
+                                let buffer = editor.buffer().read(cx);
+                                let snapshot = buffer.snapshot(cx);
+                                let (excerpt_id, _, buffer_snapshot) =
+                                    snapshot.as_singleton().unwrap();
+                                let start_offset = buffer_snapshot.len();
+                                let text_anchor = buffer_snapshot.anchor_before(start_offset);
+
+                                editor.insert(&mention_text, window, cx);
+                                editor.insert(" ", window, cx);
+
+                                (*excerpt_id, text_anchor, mention_text.len())
+                            });
+
+                        let Some((crease_id, tx)) = insert_crease_for_mention(
+                            excerpt_id,
+                            text_anchor,
+                            content_len,
+                            crease_text.into(),
+                            mention_uri.icon_path(cx),
+                            None,
+                            self.editor.clone(),
+                            window,
+                            cx,
+                        ) else {
+                            continue;
+                        };
+                        drop(tx);
+
+                        let mention_task = cx
+                            .spawn({
+                                let project = project.clone();
+                                async move |_, cx| {
+                                    let project_path = project
+                                        .update(cx, |project, cx| {
+                                            project.project_path_for_absolute_path(&file_path, cx)
+                                        })
+                                        .map_err(|e| e.to_string())?
+                                        .ok_or_else(|| "project path not found".to_string())?;
+
+                                    let buffer = project
+                                        .update(cx, |project, cx| {
+                                            project.open_buffer(project_path, cx)
+                                        })
+                                        .map_err(|e| e.to_string())?
+                                        .await
+                                        .map_err(|e| e.to_string())?;
+
+                                    buffer
+                                        .update(cx, |buffer, cx| {
+                                            let start = Point::new(*line_range.start(), 0)
+                                                .min(buffer.max_point());
+                                            let end = Point::new(*line_range.end() + 1, 0)
+                                                .min(buffer.max_point());
+                                            let content =
+                                                buffer.text_for_range(start..end).collect();
+                                            Mention::Text {
+                                                content,
+                                                tracked_buffers: vec![cx.entity()],
+                                            }
+                                        })
+                                        .map_err(|e| e.to_string())
+                                }
+                            })
+                            .shared();
+
+                        self.mention_set.update(cx, |mention_set, _cx| {
+                            mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task)
+                        });
+                    }
+                }
+                return;
+            }
+        }
+
         if self.prompt_capabilities.borrow().image
             && let Some(task) =
                 paste_images_as_context(self.editor.clone(), self.mention_set.clone(), window, cx)

crates/agent_ui/src/text_thread_editor.rs 🔗

@@ -1682,6 +1682,98 @@ impl TextThreadEditor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        let editor_clipboard_selections = cx
+            .read_from_clipboard()
+            .and_then(|item| item.entries().first().cloned())
+            .and_then(|entry| match entry {
+                ClipboardEntry::String(text) => {
+                    text.metadata_json::<Vec<editor::ClipboardSelection>>()
+                }
+                _ => None,
+            });
+
+        let has_file_context = editor_clipboard_selections
+            .as_ref()
+            .is_some_and(|selections| {
+                selections
+                    .iter()
+                    .any(|sel| sel.file_path.is_some() && sel.line_range.is_some())
+            });
+
+        if has_file_context {
+            if let Some(clipboard_item) = cx.read_from_clipboard() {
+                if let Some(ClipboardEntry::String(clipboard_text)) =
+                    clipboard_item.entries().first()
+                {
+                    if let Some(selections) = editor_clipboard_selections {
+                        cx.stop_propagation();
+
+                        let text = clipboard_text.text();
+                        self.editor.update(cx, |editor, cx| {
+                            let mut current_offset = 0;
+                            let weak_editor = cx.entity().downgrade();
+
+                            for selection in selections {
+                                if let (Some(file_path), Some(line_range)) =
+                                    (selection.file_path, selection.line_range)
+                                {
+                                    let selected_text =
+                                        &text[current_offset..current_offset + selection.len];
+                                    let fence = assistant_slash_commands::codeblock_fence_for_path(
+                                        file_path.to_str(),
+                                        Some(line_range.clone()),
+                                    );
+                                    let formatted_text = format!("{fence}{selected_text}\n```");
+
+                                    let insert_point = editor
+                                        .selections
+                                        .newest::<Point>(&editor.display_snapshot(cx))
+                                        .head();
+                                    let start_row = MultiBufferRow(insert_point.row);
+
+                                    editor.insert(&formatted_text, window, cx);
+
+                                    let snapshot = editor.buffer().read(cx).snapshot(cx);
+                                    let anchor_before = snapshot.anchor_after(insert_point);
+                                    let anchor_after = editor
+                                        .selections
+                                        .newest_anchor()
+                                        .head()
+                                        .bias_left(&snapshot);
+
+                                    editor.insert("\n", window, cx);
+
+                                    let crease_text = acp_thread::selection_name(
+                                        Some(file_path.as_ref()),
+                                        &line_range,
+                                    );
+
+                                    let fold_placeholder = quote_selection_fold_placeholder(
+                                        crease_text,
+                                        weak_editor.clone(),
+                                    );
+                                    let crease = Crease::inline(
+                                        anchor_before..anchor_after,
+                                        fold_placeholder,
+                                        render_quote_selection_output_toggle,
+                                        |_, _, _, _| Empty.into_any(),
+                                    );
+                                    editor.insert_creases(vec![crease], cx);
+                                    editor.fold_at(start_row, window, cx);
+
+                                    current_offset += selection.len;
+                                    if !selection.is_entire_line && current_offset < text.len() {
+                                        current_offset += 1;
+                                    }
+                                }
+                            }
+                        });
+                        return;
+                    }
+                }
+            }
+        }
+
         cx.stop_propagation();
 
         let mut images = if let Some(item) = cx.read_from_clipboard() {

crates/editor/src/editor.rs 🔗

@@ -1592,6 +1592,45 @@ pub struct ClipboardSelection {
     pub is_entire_line: bool,
     /// The indentation of the first line when this content was originally copied.
     pub first_line_indent: u32,
+    #[serde(default)]
+    pub file_path: Option<PathBuf>,
+    #[serde(default)]
+    pub line_range: Option<RangeInclusive<u32>>,
+}
+
+impl ClipboardSelection {
+    pub fn for_buffer(
+        len: usize,
+        is_entire_line: bool,
+        range: Range<Point>,
+        buffer: &MultiBufferSnapshot,
+        project: Option<&Entity<Project>>,
+        cx: &App,
+    ) -> Self {
+        let first_line_indent = buffer
+            .indent_size_for_line(MultiBufferRow(range.start.row))
+            .len;
+
+        let file_path = util::maybe!({
+            let project = project?.read(cx);
+            let file = buffer.file_at(range.start)?;
+            let project_path = ProjectPath {
+                worktree_id: file.worktree_id(cx),
+                path: file.path().clone(),
+            };
+            project.absolute_path(&project_path, cx)
+        });
+
+        let line_range = file_path.as_ref().map(|_| range.start.row..=range.end.row);
+
+        Self {
+            len,
+            is_entire_line,
+            first_line_indent,
+            file_path,
+            line_range,
+        }
+    }
 }
 
 // selections, scroll behavior, was newest selection reversed
@@ -12812,13 +12851,15 @@ impl Editor {
                     text.push_str(chunk);
                     len += chunk.len();
                 }
-                clipboard_selections.push(ClipboardSelection {
+
+                clipboard_selections.push(ClipboardSelection::for_buffer(
                     len,
                     is_entire_line,
-                    first_line_indent: buffer
-                        .indent_size_for_line(MultiBufferRow(selection.start.row))
-                        .len,
-                });
+                    selection.range(),
+                    &buffer,
+                    self.project.as_ref(),
+                    cx,
+                ));
             }
         }
 
@@ -12961,13 +13002,14 @@ impl Editor {
                         text.push('\n');
                         len += 1;
                     }
-                    clipboard_selections.push(ClipboardSelection {
+                    clipboard_selections.push(ClipboardSelection::for_buffer(
                         len,
                         is_entire_line,
-                        first_line_indent: buffer
-                            .indent_size_for_line(MultiBufferRow(trimmed_range.start.row))
-                            .len,
-                    });
+                        trimmed_range,
+                        &buffer,
+                        self.project.as_ref(),
+                        cx,
+                    ));
                 }
             }
         }

crates/vim/src/normal/yank.rs 🔗

@@ -11,7 +11,6 @@ use editor::{ClipboardSelection, Editor, SelectionEffects};
 use gpui::Context;
 use gpui::Window;
 use language::Point;
-use multi_buffer::MultiBufferRow;
 use settings::Settings;
 
 struct HighlightOnYank;
@@ -198,11 +197,14 @@ impl Vim {
                 if kind.linewise() {
                     text.push('\n');
                 }
-                clipboard_selections.push(ClipboardSelection {
-                    len: text.len() - initial_len,
-                    is_entire_line: false,
-                    first_line_indent: buffer.indent_size_for_line(MultiBufferRow(start.row)).len,
-                });
+                clipboard_selections.push(ClipboardSelection::for_buffer(
+                    text.len() - initial_len,
+                    false,
+                    start..end,
+                    &buffer,
+                    editor.project(),
+                    cx,
+                ));
             }
         }