Parse and render markdown mention links from pasted text (#45426)

Rocky Shi created

Closes [#ISSUE](https://github.com/zed-industries/zed/issues/45408)

Release Notes:

- Fixed mention links from pasted text.

Recording:


https://github.com/user-attachments/assets/e0d55562-c9a4-4798-be41-af8b8cd1f5d7

Change summary

crates/agent_ui/src/acp/message_editor.rs | 241 ++++++++++++++++++++++++
crates/agent_ui/src/mention_set.rs        |  35 +++
2 files changed, 274 insertions(+), 2 deletions(-)

Detailed changes

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

@@ -31,9 +31,10 @@ use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Wor
 use prompt_store::PromptStore;
 use rope::Point;
 use settings::Settings;
-use std::{cell::RefCell, fmt::Write, rc::Rc, sync::Arc};
+use std::{cell::RefCell, fmt::Write, ops::Range, rc::Rc, sync::Arc};
 use theme::ThemeSettings;
 use ui::{ButtonLike, ButtonStyle, ContextMenu, Disclosure, ElevationIndex, prelude::*};
+use util::paths::PathStyle;
 use util::{ResultExt, debug_panic};
 use workspace::{CollaboratorId, Workspace};
 use zed_actions::agent::{Chat, PasteRaw};
@@ -735,13 +736,94 @@ impl MessageEditor {
             }
             return;
         }
+        // Handle text paste with potential markdown mention links.
+        // This must be checked BEFORE paste_images_as_context because that function
+        // returns a task even when there are no images in the clipboard.
+        if let Some(clipboard_text) = cx
+            .read_from_clipboard()
+            .and_then(|item| item.entries().first().cloned())
+            .and_then(|entry| match entry {
+                ClipboardEntry::String(text) => Some(text.text().to_string()),
+                _ => None,
+            })
+        {
+            let path_style = workspace.read(cx).project().read(cx).path_style(cx);
+
+            // Parse markdown mention links in format: [@name](uri)
+            let parsed_mentions = parse_mention_links(&clipboard_text, path_style);
+
+            if !parsed_mentions.is_empty() {
+                cx.stop_propagation();
+
+                let insertion_offset = self.editor.update(cx, |editor, cx| {
+                    let snapshot = editor.buffer().read(cx).snapshot(cx);
+                    editor.selections.newest_anchor().start.to_offset(&snapshot)
+                });
+
+                // Insert the raw text first
+                self.editor.update(cx, |editor, cx| {
+                    editor.insert(&clipboard_text, window, cx);
+                });
+
+                let supports_images = self.prompt_capabilities.borrow().image;
+                let http_client = workspace.read(cx).client().http_client();
+
+                // Now create creases for each mention and load their content
+                let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx);
+                for (range, mention_uri) in parsed_mentions {
+                    let start_offset = insertion_offset.0 + range.start;
+                    let anchor = snapshot.anchor_before(MultiBufferOffset(start_offset));
+                    let content_len = range.end - range.start;
+
+                    let Some((crease_id, tx)) = insert_crease_for_mention(
+                        anchor.excerpt_id,
+                        anchor.text_anchor,
+                        content_len,
+                        mention_uri.name().into(),
+                        mention_uri.icon_path(cx),
+                        None,
+                        self.editor.clone(),
+                        window,
+                        cx,
+                    ) else {
+                        continue;
+                    };
+
+                    // Create the confirmation task based on the mention URI type.
+                    // This properly loads file content, fetches URLs, etc.
+                    let task = self.mention_set.update(cx, |mention_set, cx| {
+                        mention_set.confirm_mention_for_uri(
+                            mention_uri.clone(),
+                            supports_images,
+                            http_client.clone(),
+                            cx,
+                        )
+                    });
+                    let task = cx
+                        .spawn(async move |_, _| task.await.map_err(|e| e.to_string()))
+                        .shared();
+
+                    self.mention_set.update(cx, |mention_set, _cx| {
+                        mention_set.insert_mention(crease_id, mention_uri.clone(), task.clone())
+                    });
+
+                    // Drop the tx after inserting to signal the crease is ready
+                    drop(tx);
+                }
+                return;
+            }
+        }
 
         if self.prompt_capabilities.borrow().image
             && let Some(task) =
                 paste_images_as_context(self.editor.clone(), self.mention_set.clone(), window, cx)
         {
             task.detach();
+            return;
         }
+
+        // Fall through to default editor paste
+        cx.propagate();
     }
 
     fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context<Self>) {
@@ -1283,6 +1365,69 @@ impl Addon for MessageEditorAddon {
     }
 }
 
+/// Parses markdown mention links in the format `[@name](uri)` from text.
+/// Returns a vector of (range, MentionUri) pairs where range is the byte range in the text.
+fn parse_mention_links(text: &str, path_style: PathStyle) -> Vec<(Range<usize>, MentionUri)> {
+    let mut mentions = Vec::new();
+    let mut search_start = 0;
+
+    while let Some(link_start) = text[search_start..].find("[@") {
+        let absolute_start = search_start + link_start;
+
+        // Find the matching closing bracket for the name, handling nested brackets.
+        // Start at the '[' character so find_matching_bracket can track depth correctly.
+        let Some(name_end) = find_matching_bracket(&text[absolute_start..], '[', ']') else {
+            search_start = absolute_start + 2;
+            continue;
+        };
+        let name_end = absolute_start + name_end;
+
+        // Check for opening parenthesis immediately after
+        if text.get(name_end + 1..name_end + 2) != Some("(") {
+            search_start = name_end + 1;
+            continue;
+        }
+
+        // Find the matching closing parenthesis for the URI, handling nested parens
+        let uri_start = name_end + 2;
+        let Some(uri_end_relative) = find_matching_bracket(&text[name_end + 1..], '(', ')') else {
+            search_start = uri_start;
+            continue;
+        };
+        let uri_end = name_end + 1 + uri_end_relative;
+        let link_end = uri_end + 1;
+
+        let uri_str = &text[uri_start..uri_end];
+
+        // Try to parse the URI as a MentionUri
+        if let Ok(mention_uri) = MentionUri::parse(uri_str, path_style) {
+            mentions.push((absolute_start..link_end, mention_uri));
+        }
+
+        search_start = link_end;
+    }
+
+    mentions
+}
+
+/// Finds the position of the matching closing bracket, handling nested brackets.
+/// The input `text` should start with the opening bracket.
+/// Returns the index of the matching closing bracket relative to `text`.
+fn find_matching_bracket(text: &str, open: char, close: char) -> Option<usize> {
+    let mut depth = 0;
+    for (index, character) in text.char_indices() {
+        if character == open {
+            depth += 1;
+        } else if character == close {
+            depth -= 1;
+            if depth == 0 {
+                return Some(index);
+            }
+        }
+    }
+    None
+}
+
 #[cfg(test)]
 mod tests {
     use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc};
@@ -1308,11 +1453,103 @@ mod tests {
     use workspace::{AppState, Item, Workspace};
 
     use crate::acp::{
-        message_editor::{Mention, MessageEditor},
+        message_editor::{Mention, MessageEditor, parse_mention_links},
         thread_view::tests::init_test,
     };
     use crate::completion_provider::{PromptCompletionProviderDelegate, PromptContextType};
 
+    #[test]
+    fn test_parse_mention_links() {
+        // Single file mention
+        let text = "[@bundle-mac](file:///Users/test/zed/script/bundle-mac)";
+        let mentions = parse_mention_links(text, PathStyle::local());
+        assert_eq!(mentions.len(), 1);
+        assert_eq!(mentions[0].0, 0..text.len());
+        assert!(matches!(mentions[0].1, MentionUri::File { .. }));
+
+        // Multiple mentions
+        let text = "Check [@file1](file:///path/to/file1) and [@file2](file:///path/to/file2)!";
+        let mentions = parse_mention_links(text, PathStyle::local());
+        assert_eq!(mentions.len(), 2);
+
+        // Text without mentions
+        let text = "Just some regular text without mentions";
+        let mentions = parse_mention_links(text, PathStyle::local());
+        assert_eq!(mentions.len(), 0);
+
+        // Malformed mentions (should be skipped)
+        let text = "[@incomplete](invalid://uri) and [@missing](";
+        let mentions = parse_mention_links(text, PathStyle::local());
+        assert_eq!(mentions.len(), 0);
+
+        // Mixed content with valid mention
+        let text = "Before [@valid](file:///path/to/file) after";
+        let mentions = parse_mention_links(text, PathStyle::local());
+        assert_eq!(mentions.len(), 1);
+        assert_eq!(mentions[0].0.start, 7);
+
+        // HTTP URL mention (Fetch)
+        let text = "Check out [@docs](https://example.com/docs) for more info";
+        let mentions = parse_mention_links(text, PathStyle::local());
+        assert_eq!(mentions.len(), 1);
+        assert!(matches!(mentions[0].1, MentionUri::Fetch { .. }));
+
+        // Directory mention (trailing slash)
+        let text = "[@src](file:///path/to/src/)";
+        let mentions = parse_mention_links(text, PathStyle::local());
+        assert_eq!(mentions.len(), 1);
+        assert!(matches!(mentions[0].1, MentionUri::Directory { .. }));
+
+        // Multiple different mention types
+        let text = "File [@f](file:///a) and URL [@u](https://b.com) and dir [@d](file:///c/)";
+        let mentions = parse_mention_links(text, PathStyle::local());
+        assert_eq!(mentions.len(), 3);
+        assert!(matches!(mentions[0].1, MentionUri::File { .. }));
+        assert!(matches!(mentions[1].1, MentionUri::Fetch { .. }));
+        assert!(matches!(mentions[2].1, MentionUri::Directory { .. }));
+
+        // Adjacent mentions without separator
+        let text = "[@a](file:///a)[@b](file:///b)";
+        let mentions = parse_mention_links(text, PathStyle::local());
+        assert_eq!(mentions.len(), 2);
+
+        // Regular markdown link (not a mention) should be ignored
+        let text = "[regular link](https://example.com)";
+        let mentions = parse_mention_links(text, PathStyle::local());
+        assert_eq!(mentions.len(), 0);
+
+        // Incomplete mention link patterns
+        let text = "[@name] without url and [@name( malformed";
+        let mentions = parse_mention_links(text, PathStyle::local());
+        assert_eq!(mentions.len(), 0);
+
+        // Nested brackets in name portion
+        let text = "[@name [with brackets]](file:///path/to/file)";
+        let mentions = parse_mention_links(text, PathStyle::local());
+        assert_eq!(mentions.len(), 1);
+        assert_eq!(mentions[0].0, 0..text.len());
+
+        // Deeply nested brackets
+        let text = "[@outer [inner [deep]]](file:///path)";
+        let mentions = parse_mention_links(text, PathStyle::local());
+        assert_eq!(mentions.len(), 1);
+
+        // Unbalanced brackets should fail gracefully
+        let text = "[@unbalanced [bracket](file:///path)";
+        let mentions = parse_mention_links(text, PathStyle::local());
+        assert_eq!(mentions.len(), 0);
+
+        // Nested parentheses in URI (common in URLs with query params)
+        let text = "[@wiki](https://en.wikipedia.org/wiki/Rust_(programming_language))";
+        let mentions = parse_mention_links(text, PathStyle::local());
+        assert_eq!(mentions.len(), 1);
+        if let MentionUri::Fetch { url } = &mentions[0].1 {
+            assert!(url.as_str().contains("Rust_(programming_language)"));
+        } else {
+            panic!("Expected Fetch URI");
+        }
+    }
+
     #[gpui::test]
     async fn test_at_mention_removal(cx: &mut TestAppContext) {
         init_test(cx);

crates/agent_ui/src/mention_set.rs 🔗

@@ -118,6 +118,41 @@ impl MentionSet {
         self.mentions.insert(crease_id, (uri, task));
     }
 
+    /// Creates the appropriate confirmation task for a mention based on its URI type.
+    /// This is used when pasting mention links to properly load their content.
+    pub fn confirm_mention_for_uri(
+        &mut self,
+        mention_uri: MentionUri,
+        supports_images: bool,
+        http_client: Arc<HttpClientWithUrl>,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<Mention>> {
+        match mention_uri {
+            MentionUri::Fetch { url } => self.confirm_mention_for_fetch(url, http_client, cx),
+            MentionUri::Directory { .. } => Task::ready(Ok(Mention::Link)),
+            MentionUri::Thread { id, .. } => self.confirm_mention_for_thread(id, cx),
+            MentionUri::TextThread { .. } => {
+                Task::ready(Err(anyhow!("Text thread mentions are no longer supported")))
+            }
+            MentionUri::File { abs_path } => {
+                self.confirm_mention_for_file(abs_path, supports_images, cx)
+            }
+            MentionUri::Symbol {
+                abs_path,
+                line_range,
+                ..
+            } => self.confirm_mention_for_symbol(abs_path, line_range, cx),
+            MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx),
+            MentionUri::Diagnostics {
+                include_errors,
+                include_warnings,
+            } => self.confirm_mention_for_diagnostics(include_errors, include_warnings, cx),
+            MentionUri::PastedImage | MentionUri::Selection { .. } => {
+                Task::ready(Err(anyhow!("Unsupported mention URI type for paste")))
+            }
+        }
+    }
+
     pub fn remove_mention(&mut self, crease_id: &CreaseId) {
         self.mentions.remove(crease_id);
     }