From b400e4c18f98e959d1c961c3011e5c8cbfbddf01 Mon Sep 17 00:00:00 2001 From: Hamza Paracha Date: Wed, 15 Apr 2026 23:04:32 -0400 Subject: [PATCH 1/6] agent_ui: Serialize copied message mentions as links --- crates/agent_ui/src/mention_set.rs | 4 + crates/agent_ui/src/message_editor.rs | 257 +++++++++++++++++++++++++- 2 files changed, 256 insertions(+), 5 deletions(-) diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 880257e3f942bf..5d2f25e2f77dbe 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -174,6 +174,10 @@ impl MentionSet { self.mentions.values().map(|(uri, _)| uri.clone()).collect() } + pub fn mention_uri_for_crease(&self, crease_id: &CreaseId) -> Option { + self.mentions.get(crease_id).map(|(uri, _)| uri.clone()) + } + pub fn set_mentions(&mut self, mentions: HashMap) { self.mentions = mentions; } diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 3b93439b62305f..21f93c9c9e6acb 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -16,12 +16,14 @@ use anyhow::{Result, anyhow}; use editor::{ Addon, AnchorRangeExt, ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, MultiBufferSnapshot, ToOffset, - actions::Paste, code_context_menus::CodeContextMenu, scroll::Autoscroll, + actions::{Copy, Paste}, + code_context_menus::CodeContextMenu, + scroll::Autoscroll, }; use futures::{FutureExt as _, future::join_all}; use gpui::{ - AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageFormat, - KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity, + AppContext, ClipboardEntry, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, + Focusable, ImageFormat, KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity, }; use language::{Buffer, language_settings::InlayHintKind}; use parking_lot::RwLock; @@ -1189,6 +1191,16 @@ impl MessageEditor { cx.propagate(); } + fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { + let Some(text) = self.serialized_copy_text(cx) else { + cx.propagate(); + return; + }; + + cx.stop_propagation(); + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context) { let editor = self.editor.clone(); window.defer(cx, move |window, cx| { @@ -1768,6 +1780,83 @@ impl MessageEditor { editor.set_text(text, window, cx); }); } + + fn serialized_copy_text(&self, cx: &App) -> Option { + let editor = self.editor.read(cx); + let display_snapshot = editor.display_snapshot(cx); + if !editor.has_non_empty_selection(&display_snapshot) { + return None; + } + + let snapshot = editor.buffer().read(cx).snapshot(cx); + let mention_set = self.mention_set.read(cx); + let mention_ranges = display_snapshot + .crease_snapshot + .crease_items_with_offsets(&snapshot) + .into_iter() + .filter_map(|(crease_id, range)| { + mention_set.mention_uri_for_crease(&crease_id).map(|uri| { + ( + range.start.to_offset(&snapshot), + range.end.to_offset(&snapshot), + uri, + ) + }) + }) + .collect::>(); + + let mut text = String::new(); + let mut has_mentions = false; + let mut is_first = true; + + for selection in editor + .selections + .all::(&display_snapshot) + { + let mut overlapping_mentions = mention_ranges + .iter() + .filter(|(start, end, _)| *start < selection.end && selection.start < *end) + .collect::>(); + + if is_first { + is_first = false; + } else { + text.push('\n'); + } + + if overlapping_mentions.is_empty() { + text.push_str( + &snapshot + .text_for_range(selection.start..selection.end) + .collect::(), + ); + continue; + } + + has_mentions = true; + overlapping_mentions.sort_by_key(|(start, _, _)| *start); + + let mut cursor = selection.start; + for (start, end, uri) in overlapping_mentions { + if cursor < *start { + text.push_str(&snapshot.text_for_range(cursor..*start).collect::()); + } + + text.push_str(&uri.as_link().to_string()); + cursor = cursor.max(*end); + } + + if cursor < selection.end { + text.push_str( + &snapshot + .text_for_range(cursor..selection.end) + .collect::(), + ); + } + } + + has_mentions.then_some(text) + } } impl Focusable for MessageEditor { @@ -1784,6 +1873,7 @@ impl Render for MessageEditor { .on_action(cx.listener(Self::send_immediately)) .on_action(cx.listener(Self::chat_with_follow)) .on_action(cx.listener(Self::cancel)) + .capture_action(cx.listener(Self::copy)) .on_action(cx.listener(Self::paste_raw)) .capture_action(cx.listener(Self::paste)) .flex_1() @@ -1918,10 +2008,10 @@ mod tests { }; use fs::FakeFs; - use futures::StreamExt as _; + use futures::{FutureExt as _, StreamExt as _}; use gpui::{ AppContext, ClipboardEntry, ClipboardItem, Entity, EventEmitter, ExternalPaths, - FocusHandle, Focusable, TestAppContext, VisualTestContext, + FocusHandle, Focusable, Task, TestAppContext, VisualTestContext, }; use language_model::LanguageModelRegistry; use lsp::{CompletionContext, CompletionTriggerKind}; @@ -3865,6 +3955,163 @@ mod tests { ); } + #[gpui::test] + async fn test_copy_with_selection_mentions_serializes_links(cx: &mut TestAppContext) { + init_test(cx); + + let (source_message_editor, _source_editor, mut cx) = setup_paste_test_message_editor( + json!({"file.rs": "line 1\nline 2\nline 3\nline 4\n"}), + cx, + ) + .await; + + let workspace = source_message_editor.read_with(&cx, |message_editor, _| { + message_editor.workspace.upgrade().expect("workspace") + }); + let project = workspace.read_with(&cx, |workspace, _| workspace.project().clone()); + + let source_text = "selection needs work\nselection looks fine"; + let first_range = 0..9; + let second_start = "selection needs work\n".len(); + let second_range = second_start..(second_start + "selection".len()); + let first_uri = MentionUri::Selection { + abs_path: Some(path!("/project/file.rs").into()), + line_range: 0..=1, + }; + let second_uri = MentionUri::Selection { + abs_path: Some(path!("/project/file.rs").into()), + line_range: 2..=3, + }; + + source_message_editor + .update_in(&mut cx, |message_editor, window, cx| { + message_editor.set_text(source_text, window, cx); + + let snapshot = message_editor + .editor + .read(cx) + .buffer() + .read(cx) + .snapshot(cx); + for (range, uri, content) in [ + ( + first_range.clone(), + first_uri.clone(), + "line 1\nline 2\n".to_string(), + ), + ( + second_range.clone(), + second_uri.clone(), + "line 3\nline 4\n".to_string(), + ), + ] { + let Some((crease_id, tx)) = insert_crease_for_mention( + snapshot.anchor_before(MultiBufferOffset(range.start)), + range.len(), + uri.name().into(), + uri.icon_path(cx), + uri.tooltip_text(), + Some(uri.clone()), + Some(message_editor.workspace.clone()), + None, + message_editor.editor.clone(), + window, + cx, + ) else { + panic!("expected mention crease insertion"); + }; + drop(tx); + + message_editor.mention_set.update(cx, |mention_set, _cx| { + mention_set.insert_mention( + crease_id, + uri, + Task::ready(Ok(Mention::Text { + content, + tracked_buffers: Vec::new(), + })) + .shared(), + ); + }); + } + + let buffer_len = snapshot.len(); + message_editor.editor.update(cx, |editor, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([MultiBufferOffset(0)..buffer_len]); + }); + }); + }) + .unwrap(); + + let copied_text = source_message_editor.update(&mut cx, |message_editor, cx| { + message_editor + .serialized_copy_text(cx) + .expect("selection mentions should serialize") + }); + let expected_text = format!( + "{} needs work\n{} looks fine", + first_uri.as_link(), + second_uri.as_link() + ); + assert_eq!(copied_text, expected_text); + + let target_message_editor = workspace + .update_in(&mut cx, |workspace, window, cx| { + let workspace_handle = cx.weak_entity(); + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + let message_editor = cx.new(|cx| { + MessageEditor::new( + workspace_handle, + project.downgrade(), + Some(thread_store), + None, + None, + Default::default(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + max_lines: None, + min_lines: 1, + }, + window, + cx, + ) + }); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item( + Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))), + true, + true, + None, + window, + cx, + ); + }); + message_editor.read(cx).focus_handle(cx).focus(window, cx); + message_editor + }) + .unwrap(); + + cx.write_to_clipboard(ClipboardItem::new_string(copied_text)); + target_message_editor + .update_in(&mut cx, |message_editor, window, cx| { + message_editor.paste(&Paste, window, cx); + }) + .unwrap(); + cx.run_until_parked(); + + let target_text = target_message_editor.read_with(&cx, |message_editor, cx| { + message_editor.editor.read(cx).text(cx) + }); + assert_eq!(target_text, expected_text); + + let contents = mention_contents(&target_message_editor, &mut cx).await; + assert_eq!(contents.len(), 2); + assert!(contents.iter().any(|(uri, _)| uri == &first_uri)); + assert!(contents.iter().any(|(uri, _)| uri == &second_uri)); + } + #[gpui::test] async fn test_paste_mention_link_with_completion_trigger_does_not_panic( cx: &mut TestAppContext, From 219f89e65fcb2de6eeb0cb8e2ae3e8cb2c739b17 Mon Sep 17 00:00:00 2001 From: Neel Date: Thu, 23 Apr 2026 14:24:15 +0100 Subject: [PATCH 2/6] agent_ui: Fix copy serialization tests and compile errors --- crates/agent_ui/src/message_editor.rs | 388 ++++++++++++++++++-------- 1 file changed, 269 insertions(+), 119 deletions(-) diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 21f93c9c9e6acb..9a9b6caaa83a61 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -1781,9 +1781,11 @@ impl MessageEditor { }); } - fn serialized_copy_text(&self, cx: &App) -> Option { + fn serialized_copy_text(&self, cx: &mut App) -> Option { + let display_snapshot = self + .editor + .update(cx, |editor, cx| editor.display_snapshot(cx)); let editor = self.editor.read(cx); - let display_snapshot = editor.display_snapshot(cx); if !editor.has_non_empty_selection(&display_snapshot) { return None; } @@ -1813,45 +1815,36 @@ impl MessageEditor { .selections .all::(&display_snapshot) { - let mut overlapping_mentions = mention_ranges - .iter() - .filter(|(start, end, _)| *start < selection.end && selection.start < *end) - .collect::>(); - if is_first { is_first = false; } else { text.push('\n'); } - if overlapping_mentions.is_empty() { - text.push_str( - &snapshot - .text_for_range(selection.start..selection.end) - .collect::(), - ); + let mut overlapping_mentions = mention_ranges + .iter() + .filter(|(start, end, _)| *start < selection.end && selection.start < *end) + .peekable(); + + if overlapping_mentions.peek().is_none() { + text.extend(snapshot.text_for_range(selection.start..selection.end)); continue; } has_mentions = true; - overlapping_mentions.sort_by_key(|(start, _, _)| *start); let mut cursor = selection.start; for (start, end, uri) in overlapping_mentions { if cursor < *start { - text.push_str(&snapshot.text_for_range(cursor..*start).collect::()); + text.extend(snapshot.text_for_range(cursor..*start)); } - text.push_str(&uri.as_link().to_string()); - cursor = cursor.max(*end); + write!(text, "{}", uri.as_link()).unwrap(); + cursor = *end; } if cursor < selection.end { - text.push_str( - &snapshot - .text_for_range(cursor..selection.end) - .collect::(), - ); + text.extend(snapshot.text_for_range(cursor..selection.end)); } } @@ -2027,6 +2020,7 @@ mod tests { use crate::completion_provider::PromptContextType; use crate::{ conversation_view::tests::init_test, + mention_set::insert_crease_for_mention, message_editor::{Mention, MessageEditor, SessionCapabilities, parse_mention_links}, }; @@ -3983,66 +3977,69 @@ mod tests { line_range: 2..=3, }; - source_message_editor - .update_in(&mut cx, |message_editor, window, cx| { - message_editor.set_text(source_text, window, cx); + source_message_editor.update_in(&mut cx, |message_editor, window, cx| { + message_editor.set_text(source_text, window, cx); - let snapshot = message_editor - .editor - .read(cx) - .buffer() - .read(cx) - .snapshot(cx); - for (range, uri, content) in [ - ( - first_range.clone(), - first_uri.clone(), - "line 1\nline 2\n".to_string(), - ), - ( - second_range.clone(), - second_uri.clone(), - "line 3\nline 4\n".to_string(), - ), - ] { - let Some((crease_id, tx)) = insert_crease_for_mention( - snapshot.anchor_before(MultiBufferOffset(range.start)), - range.len(), - uri.name().into(), - uri.icon_path(cx), - uri.tooltip_text(), - Some(uri.clone()), - Some(message_editor.workspace.clone()), - None, - message_editor.editor.clone(), - window, - cx, - ) else { - panic!("expected mention crease insertion"); - }; - drop(tx); + let snapshot = message_editor + .editor + .read(cx) + .buffer() + .read(cx) + .snapshot(cx); + for (range, uri, content) in [ + ( + first_range.clone(), + first_uri.clone(), + "line 1\nline 2\n".to_string(), + ), + ( + second_range.clone(), + second_uri.clone(), + "line 3\nline 4\n".to_string(), + ), + ] { + let Some((crease_id, tx)) = insert_crease_for_mention( + snapshot + .anchor_to_buffer_anchor( + snapshot.anchor_before(MultiBufferOffset(range.start)), + ) + .expect("selection mention anchor should map to a buffer") + .0, + range.len(), + uri.name().into(), + uri.icon_path(cx), + uri.tooltip_text(), + Some(uri.clone()), + Some(message_editor.workspace.clone()), + None, + message_editor.editor.clone(), + window, + cx, + ) else { + panic!("expected mention crease insertion"); + }; + drop(tx); - message_editor.mention_set.update(cx, |mention_set, _cx| { - mention_set.insert_mention( - crease_id, - uri, - Task::ready(Ok(Mention::Text { - content, - tracked_buffers: Vec::new(), - })) - .shared(), - ); - }); - } + message_editor.mention_set.update(cx, |mention_set, _cx| { + mention_set.insert_mention( + crease_id, + uri, + Task::ready(Ok(Mention::Text { + content, + tracked_buffers: Vec::new(), + })) + .shared(), + ); + }); + } - let buffer_len = snapshot.len(); - message_editor.editor.update(cx, |editor, cx| { - editor.change_selections(Default::default(), window, cx, |selections| { - selections.select_ranges([MultiBufferOffset(0)..buffer_len]); - }); + let buffer_len = snapshot.len(); + message_editor.editor.update(cx, |editor, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([MultiBufferOffset(0)..buffer_len]); }); - }) - .unwrap(); + }); + }); let copied_text = source_message_editor.update(&mut cx, |message_editor, cx| { message_editor @@ -4056,49 +4053,45 @@ mod tests { ); assert_eq!(copied_text, expected_text); - let target_message_editor = workspace - .update_in(&mut cx, |workspace, window, cx| { - let workspace_handle = cx.weak_entity(); - let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let message_editor = cx.new(|cx| { - MessageEditor::new( - workspace_handle, - project.downgrade(), - Some(thread_store), - None, - None, - Default::default(), - "Test Agent".into(), - "Test", - EditorMode::AutoHeight { - max_lines: None, - min_lines: 1, - }, - window, - cx, - ) - }); - workspace.active_pane().update(cx, |pane, cx| { - pane.add_item( - Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))), - true, - true, - None, - window, - cx, - ); - }); - message_editor.read(cx).focus_handle(cx).focus(window, cx); - message_editor - }) - .unwrap(); + let target_message_editor = workspace.update_in(&mut cx, |workspace, window, cx| { + let workspace_handle = cx.weak_entity(); + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + let message_editor = cx.new(|cx| { + MessageEditor::new( + workspace_handle, + project.downgrade(), + Some(thread_store), + None, + None, + Default::default(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + max_lines: None, + min_lines: 1, + }, + window, + cx, + ) + }); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item( + Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))), + true, + true, + None, + window, + cx, + ); + }); + message_editor.read(cx).focus_handle(cx).focus(window, cx); + message_editor + }); cx.write_to_clipboard(ClipboardItem::new_string(copied_text)); - target_message_editor - .update_in(&mut cx, |message_editor, window, cx| { - message_editor.paste(&Paste, window, cx); - }) - .unwrap(); + target_message_editor.update_in(&mut cx, |message_editor, window, cx| { + message_editor.paste(&Paste, window, cx); + }); cx.run_until_parked(); let target_text = target_message_editor.read_with(&cx, |message_editor, cx| { @@ -4112,6 +4105,163 @@ mod tests { assert!(contents.iter().any(|(uri, _)| uri == &second_uri)); } + struct SelectionMentionFixture { + message_editor: Entity, + first_uri: MentionUri, + first_range: Range, + second_range: Range, + } + + async fn setup_selection_mention_fixture( + cx: &mut TestAppContext, + ) -> (SelectionMentionFixture, VisualTestContext) { + let (message_editor, _source_editor, mut cx) = setup_paste_test_message_editor( + json!({"file.rs": "line 1\nline 2\nline 3\nline 4\n"}), + cx, + ) + .await; + + let source_text = "selection needs work\nselection looks fine"; + let first_range = 0..9; + let second_start = "selection needs work\n".len(); + let second_range = second_start..(second_start + "selection".len()); + let first_uri = MentionUri::Selection { + abs_path: Some(path!("/project/file.rs").into()), + line_range: 0..=1, + }; + let second_uri = MentionUri::Selection { + abs_path: Some(path!("/project/file.rs").into()), + line_range: 2..=3, + }; + + message_editor.update_in(&mut cx, |message_editor, window, cx| { + message_editor.set_text(source_text, window, cx); + + let snapshot = message_editor + .editor + .read(cx) + .buffer() + .read(cx) + .snapshot(cx); + for (range, uri, content) in [ + ( + first_range.clone(), + first_uri.clone(), + "line 1\nline 2\n".to_string(), + ), + ( + second_range.clone(), + second_uri.clone(), + "line 3\nline 4\n".to_string(), + ), + ] { + let Some((crease_id, tx)) = insert_crease_for_mention( + snapshot + .anchor_to_buffer_anchor( + snapshot.anchor_before(MultiBufferOffset(range.start)), + ) + .expect("selection mention anchor should map to a buffer") + .0, + range.len(), + uri.name().into(), + uri.icon_path(cx), + uri.tooltip_text(), + Some(uri.clone()), + Some(message_editor.workspace.clone()), + None, + message_editor.editor.clone(), + window, + cx, + ) else { + panic!("expected mention crease insertion"); + }; + drop(tx); + + message_editor.mention_set.update(cx, |mention_set, _cx| { + mention_set.insert_mention( + crease_id, + uri, + Task::ready(Ok(Mention::Text { + content, + tracked_buffers: Vec::new(), + })) + .shared(), + ); + }); + } + }); + + ( + SelectionMentionFixture { + message_editor, + first_uri, + first_range, + second_range, + }, + cx, + ) + } + + #[gpui::test] + async fn test_serialized_copy_text_selection_covers_only_mention(cx: &mut TestAppContext) { + init_test(cx); + + let (fixture, mut cx) = setup_selection_mention_fixture(cx).await; + + fixture + .message_editor + .update_in(&mut cx, |message_editor, window, cx| { + let range = fixture.first_range.clone(); + message_editor.editor.update(cx, |editor, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([ + MultiBufferOffset(range.start)..MultiBufferOffset(range.end) + ]); + }); + }); + }); + + let copied = fixture + .message_editor + .update(&mut cx, |message_editor, cx| { + message_editor.serialized_copy_text(cx) + }); + + assert_eq!(copied, Some(fixture.first_uri.as_link().to_string())); + } + + #[gpui::test] + async fn test_serialized_copy_text_returns_none_when_mentions_outside_selection( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let (fixture, mut cx) = setup_selection_mention_fixture(cx).await; + + let between_start = fixture.first_range.end; + let between_end = fixture.second_range.start - 1; + + fixture + .message_editor + .update_in(&mut cx, |message_editor, window, cx| { + message_editor.editor.update(cx, |editor, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([ + MultiBufferOffset(between_start)..MultiBufferOffset(between_end) + ]); + }); + }); + }); + + let copied = fixture + .message_editor + .update(&mut cx, |message_editor, cx| { + message_editor.serialized_copy_text(cx) + }); + + assert_eq!(copied, None); + } + #[gpui::test] async fn test_paste_mention_link_with_completion_trigger_does_not_panic( cx: &mut TestAppContext, From 2014ed9f1343c6fab4a0c66bdb79bffea6471777 Mon Sep 17 00:00:00 2001 From: Neel Date: Thu, 23 Apr 2026 14:27:04 +0100 Subject: [PATCH 3/6] agent_ui: Handle Cut for selection mentions --- crates/agent_ui/src/message_editor.rs | 64 +++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 9a9b6caaa83a61..6fd39ef5f6f45b 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -16,7 +16,7 @@ use anyhow::{Result, anyhow}; use editor::{ Addon, AnchorRangeExt, ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, MultiBufferSnapshot, ToOffset, - actions::{Copy, Paste}, + actions::{Copy, Cut, Paste}, code_context_menus::CodeContextMenu, scroll::Autoscroll, }; @@ -1201,6 +1201,18 @@ impl MessageEditor { cx.write_to_clipboard(ClipboardItem::new_string(text)); } + fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { + let Some(text) = self.serialized_copy_text(cx) else { + cx.propagate(); + return; + }; + + cx.stop_propagation(); + cx.write_to_clipboard(ClipboardItem::new_string(text)); + self.editor + .update(cx, |editor, cx| editor.insert("", window, cx)); + } + fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context) { let editor = self.editor.clone(); window.defer(cx, move |window, cx| { @@ -1867,6 +1879,7 @@ impl Render for MessageEditor { .on_action(cx.listener(Self::chat_with_follow)) .on_action(cx.listener(Self::cancel)) .capture_action(cx.listener(Self::copy)) + .capture_action(cx.listener(Self::cut)) .on_action(cx.listener(Self::paste_raw)) .capture_action(cx.listener(Self::paste)) .flex_1() @@ -1997,7 +2010,7 @@ mod tests { use base64::Engine as _; use editor::{ AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset, SelectionEffects, - actions::Paste, + actions::{Cut, Paste}, }; use fs::FakeFs; @@ -4109,7 +4122,9 @@ mod tests { message_editor: Entity, first_uri: MentionUri, first_range: Range, + second_uri: MentionUri, second_range: Range, + buffer_len: MultiBufferOffset, } async fn setup_selection_mention_fixture( @@ -4134,7 +4149,7 @@ mod tests { line_range: 2..=3, }; - message_editor.update_in(&mut cx, |message_editor, window, cx| { + let buffer_len = message_editor.update_in(&mut cx, |message_editor, window, cx| { message_editor.set_text(source_text, window, cx); let snapshot = message_editor @@ -4189,6 +4204,8 @@ mod tests { ); }); } + + snapshot.len() }); ( @@ -4196,7 +4213,9 @@ mod tests { message_editor, first_uri, first_range, + second_uri, second_range, + buffer_len, }, cx, ) @@ -4262,6 +4281,45 @@ mod tests { assert_eq!(copied, None); } + #[gpui::test] + async fn test_cut_with_selection_mentions_serializes_and_removes(cx: &mut TestAppContext) { + init_test(cx); + + let (fixture, mut cx) = setup_selection_mention_fixture(cx).await; + + let buffer_len = fixture.buffer_len; + fixture + .message_editor + .update_in(&mut cx, |message_editor, window, cx| { + message_editor.editor.update(cx, |editor, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([MultiBufferOffset(0)..buffer_len]); + }); + }); + message_editor.cut(&Cut, window, cx); + }); + + let expected_text = format!( + "{} needs work\n{} looks fine", + fixture.first_uri.as_link(), + fixture.second_uri.as_link() + ); + + let clipboard_text = cx + .read_from_clipboard() + .and_then(|item| match item.entries().first().cloned() { + Some(ClipboardEntry::String(entry)) => Some(entry.text().to_string()), + _ => None, + }) + .expect("cut should write serialized text to clipboard"); + assert_eq!(clipboard_text, expected_text); + + let remaining_text = fixture.message_editor.read_with(&cx, |message_editor, cx| { + message_editor.editor.read(cx).text(cx) + }); + assert_eq!(remaining_text, ""); + } + #[gpui::test] async fn test_paste_mention_link_with_completion_trigger_does_not_panic( cx: &mut TestAppContext, From fbf8ac5f0561de7b46fafdc1561cb368602d9ac5 Mon Sep 17 00:00:00 2001 From: Neel Date: Thu, 23 Apr 2026 15:26:51 +0100 Subject: [PATCH 4/6] scope changes to copy --- crates/agent_ui/src/message_editor.rs | 65 ++------------------------- 1 file changed, 3 insertions(+), 62 deletions(-) diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 72e69bfe98e8f5..213ce4e88c9172 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -15,7 +15,7 @@ use anyhow::{Result, anyhow}; use editor::{ Addon, AnchorRangeExt, ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, MultiBufferSnapshot, ToOffset, - actions::{Copy, Cut, Paste}, + actions::{Copy, Paste}, code_context_menus::CodeContextMenu, scroll::Autoscroll, }; @@ -1198,18 +1198,6 @@ impl MessageEditor { cx.write_to_clipboard(ClipboardItem::new_string(text)); } - fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { - let Some(text) = self.serialized_copy_text(cx) else { - cx.propagate(); - return; - }; - - cx.stop_propagation(); - cx.write_to_clipboard(ClipboardItem::new_string(text)); - self.editor - .update(cx, |editor, cx| editor.insert("", window, cx)); - } - fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context) { let editor = self.editor.clone(); window.defer(cx, move |window, cx| { @@ -1876,7 +1864,6 @@ impl Render for MessageEditor { .on_action(cx.listener(Self::chat_with_follow)) .on_action(cx.listener(Self::cancel)) .capture_action(cx.listener(Self::copy)) - .capture_action(cx.listener(Self::cut)) .on_action(cx.listener(Self::paste_raw)) .capture_action(cx.listener(Self::paste)) .flex_1() @@ -2007,7 +1994,7 @@ mod tests { use base64::Engine as _; use editor::{ AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset, SelectionEffects, - actions::{Cut, Paste}, + actions::Paste, }; use fs::FakeFs; @@ -4058,7 +4045,6 @@ mod tests { project.downgrade(), Some(thread_store), None, - None, Default::default(), "Test Agent".into(), "Test", @@ -4105,9 +4091,7 @@ mod tests { message_editor: Entity, first_uri: MentionUri, first_range: Range, - second_uri: MentionUri, second_range: Range, - buffer_len: MultiBufferOffset, } async fn setup_selection_mention_fixture( @@ -4132,7 +4116,7 @@ mod tests { line_range: 2..=3, }; - let buffer_len = message_editor.update_in(&mut cx, |message_editor, window, cx| { + message_editor.update_in(&mut cx, |message_editor, window, cx| { message_editor.set_text(source_text, window, cx); let snapshot = message_editor @@ -4187,8 +4171,6 @@ mod tests { ); }); } - - snapshot.len() }); ( @@ -4196,9 +4178,7 @@ mod tests { message_editor, first_uri, first_range, - second_uri, second_range, - buffer_len, }, cx, ) @@ -4264,45 +4244,6 @@ mod tests { assert_eq!(copied, None); } - #[gpui::test] - async fn test_cut_with_selection_mentions_serializes_and_removes(cx: &mut TestAppContext) { - init_test(cx); - - let (fixture, mut cx) = setup_selection_mention_fixture(cx).await; - - let buffer_len = fixture.buffer_len; - fixture - .message_editor - .update_in(&mut cx, |message_editor, window, cx| { - message_editor.editor.update(cx, |editor, cx| { - editor.change_selections(Default::default(), window, cx, |selections| { - selections.select_ranges([MultiBufferOffset(0)..buffer_len]); - }); - }); - message_editor.cut(&Cut, window, cx); - }); - - let expected_text = format!( - "{} needs work\n{} looks fine", - fixture.first_uri.as_link(), - fixture.second_uri.as_link() - ); - - let clipboard_text = cx - .read_from_clipboard() - .and_then(|item| match item.entries().first().cloned() { - Some(ClipboardEntry::String(entry)) => Some(entry.text().to_string()), - _ => None, - }) - .expect("cut should write serialized text to clipboard"); - assert_eq!(clipboard_text, expected_text); - - let remaining_text = fixture.message_editor.read_with(&cx, |message_editor, cx| { - message_editor.editor.read(cx).text(cx) - }); - assert_eq!(remaining_text, ""); - } - #[gpui::test] async fn test_paste_mention_link_with_completion_trigger_does_not_panic( cx: &mut TestAppContext, From 47dd7c33852a9803e35148670d6a85fa2d6e3a32 Mon Sep 17 00:00:00 2001 From: Neel Date: Thu, 23 Apr 2026 16:59:25 +0100 Subject: [PATCH 5/6] acp_thread: Fix Windows path normalisation The WHATWG URL parser normalizes `\` to `/` inside `file://` URLs, so `MentionUri::parse` must convert them back on Windows so the resulting `PathBuf` matches how worktrees store absolute paths (native separators). --- crates/acp_thread/src/mention.rs | 48 ++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index ac7b2d23cb7966..b688f14b5a6794 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -102,6 +102,11 @@ impl MentionUri { path }; let decoded = decode(normalized).unwrap_or(Cow::Borrowed(normalized)); + let decoded: Cow = if path_style.is_windows() { + Cow::Owned(decoded.replace('/', "\\")) + } else { + decoded + }; let path = decoded.as_ref(); if let Some(fragment) = url.fragment() { @@ -493,6 +498,49 @@ mod tests { assert_eq!(parsed.to_uri().to_string(), file_uri); } + #[test] + fn test_parse_file_uris_use_native_separators_on_windows() { + let parsed = MentionUri::parse("file:///C:/path/to/file.rs", PathStyle::Windows).unwrap(); + match parsed { + MentionUri::File { abs_path } => { + assert_eq!(abs_path, PathBuf::from("C:\\path\\to\\file.rs")); + } + other => panic!("Expected File variant, got {other:?}"), + } + + let parsed = MentionUri::parse("file:///C:/path/to/dir/", PathStyle::Windows).unwrap(); + match parsed { + MentionUri::Directory { abs_path } => { + assert_eq!(abs_path, PathBuf::from("C:\\path\\to\\dir\\")); + } + other => panic!("Expected Directory variant, got {other:?}"), + } + + let parsed = MentionUri::parse( + "file:///C:/path/to/file.rs?symbol=MySymbol#L10:20", + PathStyle::Windows, + ) + .unwrap(); + match parsed { + MentionUri::Symbol { abs_path, .. } => { + assert_eq!(abs_path, PathBuf::from("C:\\path\\to\\file.rs")); + } + other => panic!("Expected Symbol variant, got {other:?}"), + } + + let parsed = + MentionUri::parse("file:///C:/path/to/file.rs#L5:15", PathStyle::Windows).unwrap(); + match parsed { + MentionUri::Selection { + abs_path: Some(abs_path), + .. + } => { + assert_eq!(abs_path, PathBuf::from("C:\\path\\to\\file.rs")); + } + other => panic!("Expected Selection variant, got {other:?}"), + } + } + #[test] fn test_to_directory_uri_without_slash() { let uri = MentionUri::Directory { From 162f18f6a1b6ea452bd382825164c6254203e369 Mon Sep 17 00:00:00 2001 From: Neel Date: Thu, 23 Apr 2026 17:00:34 +0100 Subject: [PATCH 6/6] rename to be more accurate --- crates/acp_thread/src/mention.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index b688f14b5a6794..403b71736c9470 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -96,18 +96,18 @@ impl MentionUri { let path = url.path(); match url.scheme() { "file" => { - let normalized = if path_style.is_windows() { + let trimmed = if path_style.is_windows() { path.trim_start_matches("/") } else { path }; - let decoded = decode(normalized).unwrap_or(Cow::Borrowed(normalized)); - let decoded: Cow = if path_style.is_windows() { + let decoded = decode(trimmed).unwrap_or(Cow::Borrowed(trimmed)); + let normalized: Cow = if path_style.is_windows() { Cow::Owned(decoded.replace('/', "\\")) } else { decoded }; - let path = decoded.as_ref(); + let path = normalized.as_ref(); if let Some(fragment) = url.fragment() { let line_range = parse_line_range(fragment).log_err().unwrap_or(1..=1);