agent: Differentiate @mentions from markdown links (#28073)

Bennet Bo Fenner created

This ensures that we display @mentions and normal markdown links
differently:

<img width="670" alt="Screenshot 2025-04-04 at 11 07 51"
src="https://github.com/user-attachments/assets/0a4d0881-abb9-42a8-b3fa-912cd6873ae0"
/>


Release Notes:

- N/A

Change summary

crates/agent/src/active_thread.rs                      | 12 ++
crates/agent/src/context_picker.rs                     | 54 +++++++----
crates/agent/src/context_picker/completion_provider.rs | 36 ++++----
crates/markdown/src/markdown.rs                        | 14 ++
4 files changed, 78 insertions(+), 38 deletions(-)

Detailed changes

crates/agent/src/active_thread.rs 🔗

@@ -245,6 +245,17 @@ fn render_markdown(
             }),
             ..Default::default()
         },
+        link_callback: Some(Rc::new(move |url, cx| {
+            if MentionLink::is_valid(url) {
+                let colors = cx.theme().colors();
+                Some(TextStyleRefinement {
+                    background_color: Some(colors.element_background),
+                    ..Default::default()
+                })
+            } else {
+                None
+            }
+        })),
         ..Default::default()
     };
 
@@ -320,6 +331,7 @@ fn open_markdown_link(
                 });
             }
         }),
+        Some(MentionLink::Fetch(url)) => cx.open_url(&url),
         None => cx.open_url(&text),
     }
 }

crates/agent/src/context_picker.rs 🔗

@@ -609,24 +609,45 @@ fn fold_toggle(
 pub enum MentionLink {
     File(ProjectPath, Entry),
     Symbol(ProjectPath, String),
+    Fetch(String),
     Thread(ThreadId),
 }
 
 impl MentionLink {
+    const FILE: &str = "@file";
+    const SYMBOL: &str = "@symbol";
+    const THREAD: &str = "@thread";
+    const FETCH: &str = "@fetch";
+
+    const SEPARATOR: &str = ":";
+
+    pub fn is_valid(url: &str) -> bool {
+        url.starts_with(Self::FILE)
+            || url.starts_with(Self::SYMBOL)
+            || url.starts_with(Self::FETCH)
+            || url.starts_with(Self::THREAD)
+    }
+
     pub fn for_file(file_name: &str, full_path: &str) -> String {
-        format!("[@{}](file:{})", file_name, full_path)
+        format!("[@{}]({}:{})", file_name, Self::FILE, full_path)
     }
 
     pub fn for_symbol(symbol_name: &str, full_path: &str) -> String {
-        format!("[@{}](symbol:{}:{})", symbol_name, full_path, symbol_name)
+        format!(
+            "[@{}]({}:{}:{})",
+            symbol_name,
+            Self::SYMBOL,
+            full_path,
+            symbol_name
+        )
     }
 
     pub fn for_fetch(url: &str) -> String {
-        format!("[@{}]({})", url, url)
+        format!("[@{}]({}:{})", url, Self::FETCH, url)
     }
 
     pub fn for_thread(thread: &ThreadContextEntry) -> String {
-        format!("[@{}](thread:{})", thread.summary, thread.id)
+        format!("[@{}]({}:{})", thread.summary, Self::THREAD, thread.id)
     }
 
     pub fn try_parse(link: &str, workspace: &Entity<Workspace>, cx: &App) -> Option<Self> {
@@ -649,17 +670,10 @@ impl MentionLink {
             })
         }
 
-        let (prefix, link, target) = {
-            let mut parts = link.splitn(3, ':');
-            let prefix = parts.next();
-            let link = parts.next();
-            let target = parts.next();
-            (prefix, link, target)
-        };
-
-        match (prefix, link, target) {
-            (Some("file"), Some(path), _) => {
-                let project_path = extract_project_path_from_link(path, workspace, cx)?;
+        let (prefix, argument) = link.split_once(Self::SEPARATOR)?;
+        match prefix {
+            Self::FILE => {
+                let project_path = extract_project_path_from_link(argument, workspace, cx)?;
                 let entry = workspace
                     .read(cx)
                     .project()
@@ -667,14 +681,16 @@ impl MentionLink {
                     .entry_for_path(&project_path, cx)?;
                 Some(MentionLink::File(project_path, entry))
             }
-            (Some("symbol"), Some(path), Some(symbol_name)) => {
+            Self::SYMBOL => {
+                let (path, symbol) = argument.split_once(Self::SEPARATOR)?;
                 let project_path = extract_project_path_from_link(path, workspace, cx)?;
-                Some(MentionLink::Symbol(project_path, symbol_name.to_string()))
+                Some(MentionLink::Symbol(project_path, symbol.to_string()))
             }
-            (Some("thread"), Some(thread_id), _) => {
-                let thread_id = ThreadId::from(thread_id);
+            Self::THREAD => {
+                let thread_id = ThreadId::from(argument);
                 Some(MentionLink::Thread(thread_id))
             }
+            Self::FETCH => Some(MentionLink::Fetch(argument.to_string())),
             _ => None,
         }
     }

crates/agent/src/context_picker/completion_provider.rs 🔗

@@ -932,22 +932,22 @@ mod tests {
         });
 
         editor.update(&mut cx, |editor, cx| {
-            assert_eq!(editor.text(cx), "Lorem [@one.txt](file:dir/a/one.txt)",);
+            assert_eq!(editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt)",);
             assert!(!editor.has_visible_completions_menu());
             assert_eq!(
                 crease_ranges(editor, cx),
-                vec![Point::new(0, 6)..Point::new(0, 36)]
+                vec![Point::new(0, 6)..Point::new(0, 37)]
             );
         });
 
         cx.simulate_input(" ");
 
         editor.update(&mut cx, |editor, cx| {
-            assert_eq!(editor.text(cx), "Lorem [@one.txt](file:dir/a/one.txt) ",);
+            assert_eq!(editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt) ",);
             assert!(!editor.has_visible_completions_menu());
             assert_eq!(
                 crease_ranges(editor, cx),
-                vec![Point::new(0, 6)..Point::new(0, 36)]
+                vec![Point::new(0, 6)..Point::new(0, 37)]
             );
         });
 
@@ -956,12 +956,12 @@ mod tests {
         editor.update(&mut cx, |editor, cx| {
             assert_eq!(
                 editor.text(cx),
-                "Lorem [@one.txt](file:dir/a/one.txt) Ipsum ",
+                "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum ",
             );
             assert!(!editor.has_visible_completions_menu());
             assert_eq!(
                 crease_ranges(editor, cx),
-                vec![Point::new(0, 6)..Point::new(0, 36)]
+                vec![Point::new(0, 6)..Point::new(0, 37)]
             );
         });
 
@@ -970,12 +970,12 @@ mod tests {
         editor.update(&mut cx, |editor, cx| {
             assert_eq!(
                 editor.text(cx),
-                "Lorem [@one.txt](file:dir/a/one.txt) Ipsum @file ",
+                "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum @file ",
             );
             assert!(editor.has_visible_completions_menu());
             assert_eq!(
                 crease_ranges(editor, cx),
-                vec![Point::new(0, 6)..Point::new(0, 36)]
+                vec![Point::new(0, 6)..Point::new(0, 37)]
             );
         });
 
@@ -988,14 +988,14 @@ mod tests {
         editor.update(&mut cx, |editor, cx| {
             assert_eq!(
                 editor.text(cx),
-                "Lorem [@one.txt](file:dir/a/one.txt) Ipsum [@editor](file:dir/editor)"
+                "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@editor](@file:dir/editor)"
             );
             assert!(!editor.has_visible_completions_menu());
             assert_eq!(
                 crease_ranges(editor, cx),
                 vec![
-                    Point::new(0, 6)..Point::new(0, 36),
-                    Point::new(0, 43)..Point::new(0, 69)
+                    Point::new(0, 6)..Point::new(0, 37),
+                    Point::new(0, 44)..Point::new(0, 71)
                 ]
             );
         });
@@ -1005,14 +1005,14 @@ mod tests {
         editor.update(&mut cx, |editor, cx| {
             assert_eq!(
                 editor.text(cx),
-                "Lorem [@one.txt](file:dir/a/one.txt) Ipsum [@editor](file:dir/editor)\n@"
+                "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@editor](@file:dir/editor)\n@"
             );
             assert!(editor.has_visible_completions_menu());
             assert_eq!(
                 crease_ranges(editor, cx),
                 vec![
-                    Point::new(0, 6)..Point::new(0, 36),
-                    Point::new(0, 43)..Point::new(0, 69)
+                    Point::new(0, 6)..Point::new(0, 37),
+                    Point::new(0, 44)..Point::new(0, 71)
                 ]
             );
         });
@@ -1026,15 +1026,15 @@ mod tests {
         editor.update(&mut cx, |editor, cx| {
             assert_eq!(
                 editor.text(cx),
-                "Lorem [@one.txt](file:dir/a/one.txt) Ipsum [@editor](file:dir/editor)\n[@seven.txt](file:dir/b/seven.txt)"
+                "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@editor](@file:dir/editor)\n[@seven.txt](@file:dir/b/seven.txt)"
             );
             assert!(!editor.has_visible_completions_menu());
             assert_eq!(
                 crease_ranges(editor, cx),
                 vec![
-                    Point::new(0, 6)..Point::new(0, 36),
-                    Point::new(0, 43)..Point::new(0, 69),
-                    Point::new(1, 0)..Point::new(1, 34)
+                    Point::new(0, 6)..Point::new(0, 37),
+                    Point::new(0, 44)..Point::new(0, 71),
+                    Point::new(1, 0)..Point::new(1, 35)
                 ]
             );
         });

crates/markdown/src/markdown.rs 🔗

@@ -24,6 +24,10 @@ use util::{ResultExt, TryFutureExt};
 
 use crate::parser::CodeBlockKind;
 
+/// A callback function that can be used to customize the style of links based on the destination URL.
+/// If the callback returns `None`, the default link style will be used.
+type LinkStyleCallback = Rc<dyn Fn(&str, &App) -> Option<TextStyleRefinement>>;
+
 #[derive(Clone)]
 pub struct MarkdownStyle {
     pub base_text_style: TextStyle,
@@ -32,6 +36,7 @@ pub struct MarkdownStyle {
     pub inline_code: TextStyleRefinement,
     pub block_quote: TextStyleRefinement,
     pub link: TextStyleRefinement,
+    pub link_callback: Option<LinkStyleCallback>,
     pub rule_color: Hsla,
     pub block_quote_border_color: Hsla,
     pub syntax: Arc<SyntaxTheme>,
@@ -49,6 +54,7 @@ impl Default for MarkdownStyle {
             inline_code: Default::default(),
             block_quote: Default::default(),
             link: Default::default(),
+            link_callback: None,
             rule_color: Default::default(),
             block_quote_border_color: Default::default(),
             syntax: Arc::new(SyntaxTheme::default()),
@@ -679,7 +685,13 @@ impl Element for MarkdownElement {
                         MarkdownTag::Link { dest_url, .. } => {
                             if builder.code_block_stack.is_empty() {
                                 builder.push_link(dest_url.clone(), range.clone());
-                                builder.push_text_style(self.style.link.clone())
+                                let style = self
+                                    .style
+                                    .link_callback
+                                    .as_ref()
+                                    .and_then(|callback| callback(dest_url, cx))
+                                    .unwrap_or_else(|| self.style.link.clone());
+                                builder.push_text_style(style)
                             }
                         }
                         MarkdownTag::MetadataBlock(_) => {}