Fully support all mention kinds (#36134)

Agus Zubiaga and Cole Miller created

Feature parity with the agent1 @mention kinds:
- File
- Symbols
- Selections
- Threads
- Rules
- Fetch


Release Notes:

- N/A

---------

Co-authored-by: Cole Miller <cole@zed.dev>

Change summary

Cargo.lock                                                |   3 
crates/acp_thread/Cargo.toml                              |   2 
crates/acp_thread/src/acp_thread.rs                       |  26 
crates/acp_thread/src/mention.rs                          | 340 ++
crates/agent/src/thread_store.rs                          |  16 
crates/agent2/src/thread.rs                               |  79 
crates/agent_ui/Cargo.toml                                |   3 
crates/agent_ui/src/acp/completion_provider.rs            | 908 ++++++++
crates/agent_ui/src/acp/message_history.rs                |   6 
crates/agent_ui/src/acp/thread_view.rs                    | 222 +-
crates/agent_ui/src/agent_panel.rs                        |   5 
crates/agent_ui/src/context_picker.rs                     |  68 
crates/agent_ui/src/context_picker/completion_provider.rs |   4 
crates/assistant_context/Cargo.toml                       |   3 
crates/assistant_context/src/context_store.rs             |  21 
crates/editor/src/editor.rs                               |   7 
crates/prompt_store/src/prompt_store.rs                   |   9 
17 files changed, 1,437 insertions(+), 285 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -7,6 +7,7 @@ name = "acp_thread"
 version = "0.1.0"
 dependencies = [
  "action_log",
+ "agent",
  "agent-client-protocol",
  "anyhow",
  "buffer_diff",
@@ -21,6 +22,7 @@ dependencies = [
  "markdown",
  "parking_lot",
  "project",
+ "prompt_store",
  "rand 0.8.5",
  "serde",
  "serde_json",
@@ -392,6 +394,7 @@ dependencies = [
  "ui",
  "ui_input",
  "unindent",
+ "url",
  "urlencoding",
  "util",
  "uuid",

crates/acp_thread/Cargo.toml 🔗

@@ -18,6 +18,7 @@ test-support = ["gpui/test-support", "project/test-support"]
 [dependencies]
 action_log.workspace = true
 agent-client-protocol.workspace = true
+agent.workspace = true
 anyhow.workspace = true
 buffer_diff.workspace = true
 collections.workspace = true
@@ -28,6 +29,7 @@ itertools.workspace = true
 language.workspace = true
 markdown.workspace = true
 project.workspace = true
+prompt_store.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true

crates/acp_thread/src/acp_thread.rs 🔗

@@ -399,7 +399,7 @@ impl ContentBlock {
             }
         }
 
-        let new_content = self.extract_content_from_block(block);
+        let new_content = self.block_string_contents(block);
 
         match self {
             ContentBlock::Empty => {
@@ -409,7 +409,7 @@ impl ContentBlock {
                 markdown.update(cx, |markdown, cx| markdown.append(&new_content, cx));
             }
             ContentBlock::ResourceLink { resource_link } => {
-                let existing_content = Self::resource_link_to_content(&resource_link.uri);
+                let existing_content = Self::resource_link_md(&resource_link.uri);
                 let combined = format!("{}\n{}", existing_content, new_content);
 
                 *self = Self::create_markdown_block(combined, language_registry, cx);
@@ -417,14 +417,6 @@ impl ContentBlock {
         }
     }
 
-    fn resource_link_to_content(uri: &str) -> String {
-        if let Some(uri) = MentionUri::parse(&uri).log_err() {
-            uri.to_link()
-        } else {
-            uri.to_string().clone()
-        }
-    }
-
     fn create_markdown_block(
         content: String,
         language_registry: &Arc<LanguageRegistry>,
@@ -436,11 +428,11 @@ impl ContentBlock {
         }
     }
 
-    fn extract_content_from_block(&self, block: acp::ContentBlock) -> String {
+    fn block_string_contents(&self, block: acp::ContentBlock) -> String {
         match block {
             acp::ContentBlock::Text(text_content) => text_content.text.clone(),
             acp::ContentBlock::ResourceLink(resource_link) => {
-                Self::resource_link_to_content(&resource_link.uri)
+                Self::resource_link_md(&resource_link.uri)
             }
             acp::ContentBlock::Resource(acp::EmbeddedResource {
                 resource:
@@ -449,13 +441,21 @@ impl ContentBlock {
                         ..
                     }),
                 ..
-            }) => Self::resource_link_to_content(&uri),
+            }) => Self::resource_link_md(&uri),
             acp::ContentBlock::Image(_)
             | acp::ContentBlock::Audio(_)
             | acp::ContentBlock::Resource(_) => String::new(),
         }
     }
 
+    fn resource_link_md(uri: &str) -> String {
+        if let Some(uri) = MentionUri::parse(&uri).log_err() {
+            uri.as_link().to_string()
+        } else {
+            uri.to_string()
+        }
+    }
+
     fn to_markdown<'a>(&'a self, cx: &'a App) -> &'a str {
         match self {
             ContentBlock::Empty => "",

crates/acp_thread/src/mention.rs 🔗

@@ -1,13 +1,40 @@
-use agent_client_protocol as acp;
-use anyhow::{Result, bail};
-use std::path::PathBuf;
+use agent::ThreadId;
+use anyhow::{Context as _, Result, bail};
+use prompt_store::{PromptId, UserPromptId};
+use std::{
+    fmt,
+    ops::Range,
+    path::{Path, PathBuf},
+};
+use url::Url;
 
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub enum MentionUri {
     File(PathBuf),
-    Symbol(PathBuf, String),
-    Thread(acp::SessionId),
-    Rule(String),
+    Symbol {
+        path: PathBuf,
+        name: String,
+        line_range: Range<u32>,
+    },
+    Thread {
+        id: ThreadId,
+        name: String,
+    },
+    TextThread {
+        path: PathBuf,
+        name: String,
+    },
+    Rule {
+        id: PromptId,
+        name: String,
+    },
+    Selection {
+        path: PathBuf,
+        line_range: Range<u32>,
+    },
+    Fetch {
+        url: Url,
+    },
 }
 
 impl MentionUri {
@@ -17,7 +44,34 @@ impl MentionUri {
         match url.scheme() {
             "file" => {
                 if let Some(fragment) = url.fragment() {
-                    Ok(Self::Symbol(path.into(), fragment.into()))
+                    let range = fragment
+                        .strip_prefix("L")
+                        .context("Line range must start with \"L\"")?;
+                    let (start, end) = range
+                        .split_once(":")
+                        .context("Line range must use colon as separator")?;
+                    let line_range = start
+                        .parse::<u32>()
+                        .context("Parsing line range start")?
+                        .checked_sub(1)
+                        .context("Line numbers should be 1-based")?
+                        ..end
+                            .parse::<u32>()
+                            .context("Parsing line range end")?
+                            .checked_sub(1)
+                            .context("Line numbers should be 1-based")?;
+                    if let Some(name) = single_query_param(&url, "symbol")? {
+                        Ok(Self::Symbol {
+                            name,
+                            path: path.into(),
+                            line_range,
+                        })
+                    } else {
+                        Ok(Self::Selection {
+                            path: path.into(),
+                            line_range,
+                        })
+                    }
                 } else {
                     let file_path =
                         PathBuf::from(format!("{}{}", url.host_str().unwrap_or(""), path));
@@ -26,100 +80,292 @@ impl MentionUri {
                 }
             }
             "zed" => {
-                if let Some(thread) = path.strip_prefix("/agent/thread/") {
-                    Ok(Self::Thread(acp::SessionId(thread.into())))
-                } else if let Some(rule) = path.strip_prefix("/agent/rule/") {
-                    Ok(Self::Rule(rule.into()))
+                if let Some(thread_id) = path.strip_prefix("/agent/thread/") {
+                    let name = single_query_param(&url, "name")?.context("Missing thread name")?;
+                    Ok(Self::Thread {
+                        id: thread_id.into(),
+                        name,
+                    })
+                } else if let Some(path) = path.strip_prefix("/agent/text-thread/") {
+                    let name = single_query_param(&url, "name")?.context("Missing thread name")?;
+                    Ok(Self::TextThread {
+                        path: path.into(),
+                        name,
+                    })
+                } else if let Some(rule_id) = path.strip_prefix("/agent/rule/") {
+                    let name = single_query_param(&url, "name")?.context("Missing rule name")?;
+                    let rule_id = UserPromptId(rule_id.parse()?);
+                    Ok(Self::Rule {
+                        id: rule_id.into(),
+                        name,
+                    })
                 } else {
                     bail!("invalid zed url: {:?}", input);
                 }
             }
+            "http" | "https" => Ok(MentionUri::Fetch { url }),
             other => bail!("unrecognized scheme {:?}", other),
         }
     }
 
-    pub fn name(&self) -> String {
+    fn name(&self) -> String {
         match self {
-            MentionUri::File(path) => path.file_name().unwrap().to_string_lossy().into_owned(),
-            MentionUri::Symbol(_path, name) => name.clone(),
-            MentionUri::Thread(thread) => thread.to_string(),
-            MentionUri::Rule(rule) => rule.clone(),
+            MentionUri::File(path) => path
+                .file_name()
+                .unwrap_or_default()
+                .to_string_lossy()
+                .into_owned(),
+            MentionUri::Symbol { name, .. } => name.clone(),
+            MentionUri::Thread { name, .. } => name.clone(),
+            MentionUri::TextThread { name, .. } => name.clone(),
+            MentionUri::Rule { name, .. } => name.clone(),
+            MentionUri::Selection {
+                path, line_range, ..
+            } => selection_name(path, line_range),
+            MentionUri::Fetch { url } => url.to_string(),
         }
     }
 
-    pub fn to_link(&self) -> String {
-        let name = self.name();
-        let uri = self.to_uri();
-        format!("[{name}]({uri})")
+    pub fn as_link<'a>(&'a self) -> MentionLink<'a> {
+        MentionLink(self)
     }
 
-    pub fn to_uri(&self) -> String {
+    pub fn to_uri(&self) -> Url {
         match self {
             MentionUri::File(path) => {
-                format!("file://{}", path.display())
+                let mut url = Url::parse("file:///").unwrap();
+                url.set_path(&path.to_string_lossy());
+                url
             }
-            MentionUri::Symbol(path, name) => {
-                format!("file://{}#{}", path.display(), name)
+            MentionUri::Symbol {
+                path,
+                name,
+                line_range,
+            } => {
+                let mut url = Url::parse("file:///").unwrap();
+                url.set_path(&path.to_string_lossy());
+                url.query_pairs_mut().append_pair("symbol", name);
+                url.set_fragment(Some(&format!(
+                    "L{}:{}",
+                    line_range.start + 1,
+                    line_range.end + 1
+                )));
+                url
             }
-            MentionUri::Thread(thread) => {
-                format!("zed:///agent/thread/{}", thread.0)
+            MentionUri::Selection { path, line_range } => {
+                let mut url = Url::parse("file:///").unwrap();
+                url.set_path(&path.to_string_lossy());
+                url.set_fragment(Some(&format!(
+                    "L{}:{}",
+                    line_range.start + 1,
+                    line_range.end + 1
+                )));
+                url
             }
-            MentionUri::Rule(rule) => {
-                format!("zed:///agent/rule/{}", rule)
+            MentionUri::Thread { name, id } => {
+                let mut url = Url::parse("zed:///").unwrap();
+                url.set_path(&format!("/agent/thread/{id}"));
+                url.query_pairs_mut().append_pair("name", name);
+                url
             }
+            MentionUri::TextThread { path, name } => {
+                let mut url = Url::parse("zed:///").unwrap();
+                url.set_path(&format!("/agent/text-thread/{}", path.to_string_lossy()));
+                url.query_pairs_mut().append_pair("name", name);
+                url
+            }
+            MentionUri::Rule { name, id } => {
+                let mut url = Url::parse("zed:///").unwrap();
+                url.set_path(&format!("/agent/rule/{id}"));
+                url.query_pairs_mut().append_pair("name", name);
+                url
+            }
+            MentionUri::Fetch { url } => url.clone(),
         }
     }
 }
 
+pub struct MentionLink<'a>(&'a MentionUri);
+
+impl fmt::Display for MentionLink<'_> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "[@{}]({})", self.0.name(), self.0.to_uri())
+    }
+}
+
+fn single_query_param(url: &Url, name: &'static str) -> Result<Option<String>> {
+    let pairs = url.query_pairs().collect::<Vec<_>>();
+    match pairs.as_slice() {
+        [] => Ok(None),
+        [(k, v)] => {
+            if k != name {
+                bail!("invalid query parameter")
+            }
+
+            Ok(Some(v.to_string()))
+        }
+        _ => bail!("too many query pairs"),
+    }
+}
+
+pub fn selection_name(path: &Path, line_range: &Range<u32>) -> String {
+    format!(
+        "{} ({}:{})",
+        path.file_name().unwrap_or_default().display(),
+        line_range.start + 1,
+        line_range.end + 1
+    )
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
 
     #[test]
-    fn test_mention_uri_parse_and_display() {
-        // Test file URI
+    fn test_parse_file_uri() {
         let file_uri = "file:///path/to/file.rs";
         let parsed = MentionUri::parse(file_uri).unwrap();
         match &parsed {
             MentionUri::File(path) => assert_eq!(path.to_str().unwrap(), "/path/to/file.rs"),
             _ => panic!("Expected File variant"),
         }
-        assert_eq!(parsed.to_uri(), file_uri);
+        assert_eq!(parsed.to_uri().to_string(), file_uri);
+    }
 
-        // Test symbol URI
-        let symbol_uri = "file:///path/to/file.rs#MySymbol";
+    #[test]
+    fn test_parse_symbol_uri() {
+        let symbol_uri = "file:///path/to/file.rs?symbol=MySymbol#L10:20";
         let parsed = MentionUri::parse(symbol_uri).unwrap();
         match &parsed {
-            MentionUri::Symbol(path, symbol) => {
+            MentionUri::Symbol {
+                path,
+                name,
+                line_range,
+            } => {
                 assert_eq!(path.to_str().unwrap(), "/path/to/file.rs");
-                assert_eq!(symbol, "MySymbol");
+                assert_eq!(name, "MySymbol");
+                assert_eq!(line_range.start, 9);
+                assert_eq!(line_range.end, 19);
             }
             _ => panic!("Expected Symbol variant"),
         }
-        assert_eq!(parsed.to_uri(), symbol_uri);
+        assert_eq!(parsed.to_uri().to_string(), symbol_uri);
+    }
+
+    #[test]
+    fn test_parse_selection_uri() {
+        let selection_uri = "file:///path/to/file.rs#L5:15";
+        let parsed = MentionUri::parse(selection_uri).unwrap();
+        match &parsed {
+            MentionUri::Selection { path, line_range } => {
+                assert_eq!(path.to_str().unwrap(), "/path/to/file.rs");
+                assert_eq!(line_range.start, 4);
+                assert_eq!(line_range.end, 14);
+            }
+            _ => panic!("Expected Selection variant"),
+        }
+        assert_eq!(parsed.to_uri().to_string(), selection_uri);
+    }
 
-        // Test thread URI
-        let thread_uri = "zed:///agent/thread/session123";
+    #[test]
+    fn test_parse_thread_uri() {
+        let thread_uri = "zed:///agent/thread/session123?name=Thread+name";
         let parsed = MentionUri::parse(thread_uri).unwrap();
         match &parsed {
-            MentionUri::Thread(session_id) => assert_eq!(session_id.0.as_ref(), "session123"),
+            MentionUri::Thread {
+                id: thread_id,
+                name,
+            } => {
+                assert_eq!(thread_id.to_string(), "session123");
+                assert_eq!(name, "Thread name");
+            }
             _ => panic!("Expected Thread variant"),
         }
-        assert_eq!(parsed.to_uri(), thread_uri);
+        assert_eq!(parsed.to_uri().to_string(), thread_uri);
+    }
 
-        // Test rule URI
-        let rule_uri = "zed:///agent/rule/my_rule";
+    #[test]
+    fn test_parse_rule_uri() {
+        let rule_uri = "zed:///agent/rule/d8694ff2-90d5-4b6f-be33-33c1763acd52?name=Some+rule";
         let parsed = MentionUri::parse(rule_uri).unwrap();
         match &parsed {
-            MentionUri::Rule(rule) => assert_eq!(rule, "my_rule"),
+            MentionUri::Rule { id, name } => {
+                assert_eq!(id.to_string(), "d8694ff2-90d5-4b6f-be33-33c1763acd52");
+                assert_eq!(name, "Some rule");
+            }
             _ => panic!("Expected Rule variant"),
         }
-        assert_eq!(parsed.to_uri(), rule_uri);
+        assert_eq!(parsed.to_uri().to_string(), rule_uri);
+    }
+
+    #[test]
+    fn test_parse_fetch_http_uri() {
+        let http_uri = "http://example.com/path?query=value#fragment";
+        let parsed = MentionUri::parse(http_uri).unwrap();
+        match &parsed {
+            MentionUri::Fetch { url } => {
+                assert_eq!(url.to_string(), http_uri);
+            }
+            _ => panic!("Expected Fetch variant"),
+        }
+        assert_eq!(parsed.to_uri().to_string(), http_uri);
+    }
 
-        // Test invalid scheme
-        assert!(MentionUri::parse("http://example.com").is_err());
+    #[test]
+    fn test_parse_fetch_https_uri() {
+        let https_uri = "https://example.com/api/endpoint";
+        let parsed = MentionUri::parse(https_uri).unwrap();
+        match &parsed {
+            MentionUri::Fetch { url } => {
+                assert_eq!(url.to_string(), https_uri);
+            }
+            _ => panic!("Expected Fetch variant"),
+        }
+        assert_eq!(parsed.to_uri().to_string(), https_uri);
+    }
+
+    #[test]
+    fn test_invalid_scheme() {
+        assert!(MentionUri::parse("ftp://example.com").is_err());
+        assert!(MentionUri::parse("ssh://example.com").is_err());
+        assert!(MentionUri::parse("unknown://example.com").is_err());
+    }
 
-        // Test invalid zed path
+    #[test]
+    fn test_invalid_zed_path() {
         assert!(MentionUri::parse("zed:///invalid/path").is_err());
+        assert!(MentionUri::parse("zed:///agent/unknown/test").is_err());
+    }
+
+    #[test]
+    fn test_invalid_line_range_format() {
+        // Missing L prefix
+        assert!(MentionUri::parse("file:///path/to/file.rs#10:20").is_err());
+
+        // Missing colon separator
+        assert!(MentionUri::parse("file:///path/to/file.rs#L1020").is_err());
+
+        // Invalid numbers
+        assert!(MentionUri::parse("file:///path/to/file.rs#L10:abc").is_err());
+        assert!(MentionUri::parse("file:///path/to/file.rs#Labc:20").is_err());
+    }
+
+    #[test]
+    fn test_invalid_query_parameters() {
+        // Invalid query parameter name
+        assert!(MentionUri::parse("file:///path/to/file.rs#L10:20?invalid=test").is_err());
+
+        // Too many query parameters
+        assert!(
+            MentionUri::parse("file:///path/to/file.rs#L10:20?symbol=test&another=param").is_err()
+        );
+    }
+
+    #[test]
+    fn test_zero_based_line_numbers() {
+        // Test that 0-based line numbers are rejected (should be 1-based)
+        assert!(MentionUri::parse("file:///path/to/file.rs#L0:10").is_err());
+        assert!(MentionUri::parse("file:///path/to/file.rs#L1:0").is_err());
+        assert!(MentionUri::parse("file:///path/to/file.rs#L0:0").is_err());
     }
 }

crates/agent/src/thread_store.rs 🔗

@@ -205,6 +205,22 @@ impl ThreadStore {
         (this, ready_rx)
     }
 
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn fake(project: Entity<Project>, cx: &mut App) -> Self {
+        Self {
+            project,
+            tools: cx.new(|_| ToolWorkingSet::default()),
+            prompt_builder: Arc::new(PromptBuilder::new(None).unwrap()),
+            prompt_store: None,
+            context_server_tool_ids: HashMap::default(),
+            threads: Vec::new(),
+            project_context: SharedProjectContext::default(),
+            reload_system_prompt_tx: mpsc::channel(0).0,
+            _reload_system_prompt_task: Task::ready(()),
+            _subscriptions: vec![],
+        }
+    }
+
     fn handle_project_event(
         &mut self,
         _project: Entity<Project>,

crates/agent2/src/thread.rs 🔗

@@ -25,8 +25,8 @@ use schemars::{JsonSchema, Schema};
 use serde::{Deserialize, Serialize};
 use settings::{Settings, update_settings_file};
 use smol::stream::StreamExt;
-use std::fmt::Write;
 use std::{cell::RefCell, collections::BTreeMap, path::Path, rc::Rc, sync::Arc};
+use std::{fmt::Write, ops::Range};
 use util::{ResultExt, markdown::MarkdownCodeBlock};
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -79,9 +79,9 @@ impl UserMessage {
                 }
                 UserMessageContent::Mention { uri, content } => {
                     if !content.is_empty() {
-                        markdown.push_str(&format!("{}\n\n{}\n", uri.to_link(), content));
+                        let _ = write!(&mut markdown, "{}\n\n{}\n", uri.as_link(), content);
                     } else {
-                        markdown.push_str(&format!("{}\n", uri.to_link()));
+                        let _ = write!(&mut markdown, "{}\n", uri.as_link());
                     }
                 }
             }
@@ -104,12 +104,14 @@ impl UserMessage {
         const OPEN_FILES_TAG: &str = "<files>";
         const OPEN_SYMBOLS_TAG: &str = "<symbols>";
         const OPEN_THREADS_TAG: &str = "<threads>";
+        const OPEN_FETCH_TAG: &str = "<fetched_urls>";
         const OPEN_RULES_TAG: &str =
             "<rules>\nThe user has specified the following rules that should be applied:\n";
 
         let mut file_context = OPEN_FILES_TAG.to_string();
         let mut symbol_context = OPEN_SYMBOLS_TAG.to_string();
         let mut thread_context = OPEN_THREADS_TAG.to_string();
+        let mut fetch_context = OPEN_FETCH_TAG.to_string();
         let mut rules_context = OPEN_RULES_TAG.to_string();
 
         for chunk in &self.content {
@@ -122,21 +124,40 @@ impl UserMessage {
                 }
                 UserMessageContent::Mention { uri, content } => {
                     match uri {
-                        MentionUri::File(path) | MentionUri::Symbol(path, _) => {
+                        MentionUri::File(path) => {
                             write!(
                                 &mut symbol_context,
                                 "\n{}",
                                 MarkdownCodeBlock {
-                                    tag: &codeblock_tag(&path),
+                                    tag: &codeblock_tag(&path, None),
                                     text: &content.to_string(),
                                 }
                             )
                             .ok();
                         }
-                        MentionUri::Thread(_session_id) => {
+                        MentionUri::Symbol {
+                            path, line_range, ..
+                        }
+                        | MentionUri::Selection {
+                            path, line_range, ..
+                        } => {
+                            write!(
+                                &mut rules_context,
+                                "\n{}",
+                                MarkdownCodeBlock {
+                                    tag: &codeblock_tag(&path, Some(line_range)),
+                                    text: &content
+                                }
+                            )
+                            .ok();
+                        }
+                        MentionUri::Thread { .. } => {
+                            write!(&mut thread_context, "\n{}\n", content).ok();
+                        }
+                        MentionUri::TextThread { .. } => {
                             write!(&mut thread_context, "\n{}\n", content).ok();
                         }
-                        MentionUri::Rule(_user_prompt_id) => {
+                        MentionUri::Rule { .. } => {
                             write!(
                                 &mut rules_context,
                                 "\n{}",
@@ -147,9 +168,12 @@ impl UserMessage {
                             )
                             .ok();
                         }
+                        MentionUri::Fetch { url } => {
+                            write!(&mut fetch_context, "\nFetch: {}\n\n{}", url, content).ok();
+                        }
                     }
 
-                    language_model::MessageContent::Text(uri.to_link())
+                    language_model::MessageContent::Text(uri.as_link().to_string())
                 }
             };
 
@@ -179,6 +203,13 @@ impl UserMessage {
                 .push(language_model::MessageContent::Text(thread_context));
         }
 
+        if fetch_context.len() > OPEN_FETCH_TAG.len() {
+            fetch_context.push_str("</fetched_urls>\n");
+            message
+                .content
+                .push(language_model::MessageContent::Text(fetch_context));
+        }
+
         if rules_context.len() > OPEN_RULES_TAG.len() {
             rules_context.push_str("</user_rules>\n");
             message
@@ -200,6 +231,26 @@ impl UserMessage {
     }
 }
 
+fn codeblock_tag(full_path: &Path, line_range: Option<&Range<u32>>) -> String {
+    let mut result = String::new();
+
+    if let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) {
+        let _ = write!(result, "{} ", extension);
+    }
+
+    let _ = write!(result, "{}", full_path.display());
+
+    if let Some(range) = line_range {
+        if range.start == range.end {
+            let _ = write!(result, ":{}", range.start + 1);
+        } else {
+            let _ = write!(result, ":{}-{}", range.start + 1, range.end + 1);
+        }
+    }
+
+    result
+}
+
 impl AgentMessage {
     pub fn to_markdown(&self) -> String {
         let mut markdown = String::from("## Assistant\n\n");
@@ -1367,18 +1418,6 @@ impl std::ops::DerefMut for ToolCallEventStreamReceiver {
     }
 }
 
-fn codeblock_tag(full_path: &Path) -> String {
-    let mut result = String::new();
-
-    if let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) {
-        let _ = write!(result, "{} ", extension);
-    }
-
-    let _ = write!(result, "{}", full_path.display());
-
-    result
-}
-
 impl From<&str> for UserMessageContent {
     fn from(text: &str) -> Self {
         Self::Text(text.into())

crates/agent_ui/Cargo.toml 🔗

@@ -93,6 +93,7 @@ time.workspace = true
 time_format.workspace = true
 ui.workspace = true
 ui_input.workspace = true
+url.workspace = true
 urlencoding.workspace = true
 util.workspace = true
 uuid.workspace = true
@@ -102,6 +103,8 @@ workspace.workspace = true
 zed_actions.workspace = true
 
 [dev-dependencies]
+agent = { workspace = true, features = ["test-support"] }
+assistant_context = { workspace = true, features = ["test-support"] }
 assistant_tools.workspace = true
 buffer_diff = { workspace = true, features = ["test-support"] }
 editor = { workspace = true, features = ["test-support"] }

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

@@ -3,71 +3,184 @@ use std::path::{Path, PathBuf};
 use std::sync::Arc;
 use std::sync::atomic::AtomicBool;
 
-use acp_thread::MentionUri;
-use anyhow::{Context as _, Result};
-use collections::HashMap;
+use acp_thread::{MentionUri, selection_name};
+use anyhow::{Context as _, Result, anyhow};
+use collections::{HashMap, HashSet};
 use editor::display_map::CreaseId;
-use editor::{CompletionProvider, Editor, ExcerptId};
+use editor::{CompletionProvider, Editor, ExcerptId, ToOffset as _};
 use file_icons::FileIcons;
 use futures::future::try_join_all;
+use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{App, Entity, Task, WeakEntity};
+use http_client::HttpClientWithUrl;
+use itertools::Itertools as _;
 use language::{Buffer, CodeLabel, HighlightId};
 use lsp::CompletionContext;
 use parking_lot::Mutex;
-use project::{Completion, CompletionIntent, CompletionResponse, Project, ProjectPath, WorktreeId};
+use project::{
+    Completion, CompletionIntent, CompletionResponse, Project, ProjectPath, Symbol, WorktreeId,
+};
+use prompt_store::PromptStore;
 use rope::Point;
-use text::{Anchor, ToPoint};
+use text::{Anchor, OffsetRangeExt as _, ToPoint as _};
 use ui::prelude::*;
+use url::Url;
 use workspace::Workspace;
-
-use crate::context_picker::MentionLink;
-use crate::context_picker::file_context_picker::{extract_file_name_and_directory, search_files};
+use workspace::notifications::NotifyResultExt;
+
+use agent::{
+    context::RULES_ICON,
+    thread_store::{TextThreadStore, ThreadStore},
+};
+
+use crate::context_picker::fetch_context_picker::fetch_url_content;
+use crate::context_picker::file_context_picker::{FileMatch, search_files};
+use crate::context_picker::rules_context_picker::{RulesContextEntry, search_rules};
+use crate::context_picker::symbol_context_picker::SymbolMatch;
+use crate::context_picker::symbol_context_picker::search_symbols;
+use crate::context_picker::thread_context_picker::{
+    ThreadContextEntry, ThreadMatch, search_threads,
+};
+use crate::context_picker::{
+    ContextPickerAction, ContextPickerEntry, ContextPickerMode, RecentEntry,
+    available_context_picker_entries, recent_context_picker_entries, selection_ranges,
+};
 
 #[derive(Default)]
 pub struct MentionSet {
-    paths_by_crease_id: HashMap<CreaseId, MentionUri>,
+    uri_by_crease_id: HashMap<CreaseId, MentionUri>,
+    fetch_results: HashMap<Url, String>,
 }
 
 impl MentionSet {
-    pub fn insert(&mut self, crease_id: CreaseId, path: PathBuf) {
-        self.paths_by_crease_id
-            .insert(crease_id, MentionUri::File(path));
+    pub fn insert(&mut self, crease_id: CreaseId, uri: MentionUri) {
+        self.uri_by_crease_id.insert(crease_id, uri);
+    }
+
+    pub fn add_fetch_result(&mut self, url: Url, content: String) {
+        self.fetch_results.insert(url, content);
     }
 
     pub fn drain(&mut self) -> impl Iterator<Item = CreaseId> {
-        self.paths_by_crease_id.drain().map(|(id, _)| id)
+        self.fetch_results.clear();
+        self.uri_by_crease_id.drain().map(|(id, _)| id)
     }
 
     pub fn contents(
         &self,
         project: Entity<Project>,
+        thread_store: Entity<ThreadStore>,
+        text_thread_store: Entity<TextThreadStore>,
+        window: &mut Window,
         cx: &mut App,
     ) -> Task<Result<HashMap<CreaseId, Mention>>> {
         let contents = self
-            .paths_by_crease_id
+            .uri_by_crease_id
             .iter()
-            .map(|(crease_id, uri)| match uri {
-                MentionUri::File(path) => {
-                    let crease_id = *crease_id;
-                    let uri = uri.clone();
-                    let path = path.to_path_buf();
-                    let buffer_task = project.update(cx, |project, cx| {
-                        let path = project
-                            .find_project_path(path, cx)
-                            .context("Failed to find project path")?;
-                        anyhow::Ok(project.open_buffer(path, cx))
-                    });
-
-                    cx.spawn(async move |cx| {
-                        let buffer = buffer_task?.await?;
-                        let content = buffer.read_with(cx, |buffer, _cx| buffer.text())?;
-
-                        anyhow::Ok((crease_id, Mention { uri, content }))
-                    })
-                }
-                _ => {
-                    // TODO
-                    unimplemented!()
+            .map(|(&crease_id, uri)| {
+                match uri {
+                    MentionUri::File(path) => {
+                        let uri = uri.clone();
+                        let path = path.to_path_buf();
+                        let buffer_task = project.update(cx, |project, cx| {
+                            let path = project
+                                .find_project_path(path, cx)
+                                .context("Failed to find project path")?;
+                            anyhow::Ok(project.open_buffer(path, cx))
+                        });
+
+                        cx.spawn(async move |cx| {
+                            let buffer = buffer_task?.await?;
+                            let content = buffer.read_with(cx, |buffer, _cx| buffer.text())?;
+
+                            anyhow::Ok((crease_id, Mention { uri, content }))
+                        })
+                    }
+                    MentionUri::Symbol {
+                        path, line_range, ..
+                    }
+                    | MentionUri::Selection {
+                        path, line_range, ..
+                    } => {
+                        let uri = uri.clone();
+                        let path_buf = path.clone();
+                        let line_range = line_range.clone();
+
+                        let buffer_task = project.update(cx, |project, cx| {
+                            let path = project
+                                .find_project_path(&path_buf, cx)
+                                .context("Failed to find project path")?;
+                            anyhow::Ok(project.open_buffer(path, cx))
+                        });
+
+                        cx.spawn(async move |cx| {
+                            let buffer = buffer_task?.await?;
+                            let content = buffer.read_with(cx, |buffer, _cx| {
+                                buffer
+                                    .text_for_range(
+                                        Point::new(line_range.start, 0)
+                                            ..Point::new(
+                                                line_range.end,
+                                                buffer.line_len(line_range.end),
+                                            ),
+                                    )
+                                    .collect()
+                            })?;
+
+                            anyhow::Ok((crease_id, Mention { uri, content }))
+                        })
+                    }
+                    MentionUri::Thread { id: thread_id, .. } => {
+                        let open_task = thread_store.update(cx, |thread_store, cx| {
+                            thread_store.open_thread(&thread_id, window, cx)
+                        });
+
+                        let uri = uri.clone();
+                        cx.spawn(async move |cx| {
+                            let thread = open_task.await?;
+                            let content = thread.read_with(cx, |thread, _cx| {
+                                thread.latest_detailed_summary_or_text().to_string()
+                            })?;
+
+                            anyhow::Ok((crease_id, Mention { uri, content }))
+                        })
+                    }
+                    MentionUri::TextThread { path, .. } => {
+                        let context = text_thread_store.update(cx, |text_thread_store, cx| {
+                            text_thread_store.open_local_context(path.as_path().into(), cx)
+                        });
+                        let uri = uri.clone();
+                        cx.spawn(async move |cx| {
+                            let context = context.await?;
+                            let xml = context.update(cx, |context, cx| context.to_xml(cx))?;
+                            anyhow::Ok((crease_id, Mention { uri, content: xml }))
+                        })
+                    }
+                    MentionUri::Rule { id: prompt_id, .. } => {
+                        let Some(prompt_store) = thread_store.read(cx).prompt_store().clone()
+                        else {
+                            return Task::ready(Err(anyhow!("missing prompt store")));
+                        };
+                        let text_task = prompt_store.read(cx).load(*prompt_id, cx);
+                        let uri = uri.clone();
+                        cx.spawn(async move |_| {
+                            // TODO: report load errors instead of just logging
+                            let text = text_task.await?;
+                            anyhow::Ok((crease_id, Mention { uri, content: text }))
+                        })
+                    }
+                    MentionUri::Fetch { url } => {
+                        let Some(content) = self.fetch_results.get(&url) else {
+                            return Task::ready(Err(anyhow!("missing fetch result")));
+                        };
+                        Task::ready(Ok((
+                            crease_id,
+                            Mention {
+                                uri: uri.clone(),
+                                content: content.clone(),
+                            },
+                        )))
+                    }
                 }
             })
             .collect::<Vec<_>>();
@@ -79,30 +192,458 @@ impl MentionSet {
     }
 }
 
+#[derive(Debug)]
 pub struct Mention {
     pub uri: MentionUri,
     pub content: String,
 }
 
+pub(crate) enum Match {
+    File(FileMatch),
+    Symbol(SymbolMatch),
+    Thread(ThreadMatch),
+    Fetch(SharedString),
+    Rules(RulesContextEntry),
+    Entry(EntryMatch),
+}
+
+pub struct EntryMatch {
+    mat: Option<StringMatch>,
+    entry: ContextPickerEntry,
+}
+
+impl Match {
+    pub fn score(&self) -> f64 {
+        match self {
+            Match::File(file) => file.mat.score,
+            Match::Entry(mode) => mode.mat.as_ref().map(|mat| mat.score).unwrap_or(1.),
+            Match::Thread(_) => 1.,
+            Match::Symbol(_) => 1.,
+            Match::Rules(_) => 1.,
+            Match::Fetch(_) => 1.,
+        }
+    }
+}
+
+fn search(
+    mode: Option<ContextPickerMode>,
+    query: String,
+    cancellation_flag: Arc<AtomicBool>,
+    recent_entries: Vec<RecentEntry>,
+    prompt_store: Option<Entity<PromptStore>>,
+    thread_store: WeakEntity<ThreadStore>,
+    text_thread_context_store: WeakEntity<assistant_context::ContextStore>,
+    workspace: Entity<Workspace>,
+    cx: &mut App,
+) -> Task<Vec<Match>> {
+    match mode {
+        Some(ContextPickerMode::File) => {
+            let search_files_task =
+                search_files(query.clone(), cancellation_flag.clone(), &workspace, cx);
+            cx.background_spawn(async move {
+                search_files_task
+                    .await
+                    .into_iter()
+                    .map(Match::File)
+                    .collect()
+            })
+        }
+
+        Some(ContextPickerMode::Symbol) => {
+            let search_symbols_task =
+                search_symbols(query.clone(), cancellation_flag.clone(), &workspace, cx);
+            cx.background_spawn(async move {
+                search_symbols_task
+                    .await
+                    .into_iter()
+                    .map(Match::Symbol)
+                    .collect()
+            })
+        }
+
+        Some(ContextPickerMode::Thread) => {
+            if let Some((thread_store, context_store)) = thread_store
+                .upgrade()
+                .zip(text_thread_context_store.upgrade())
+            {
+                let search_threads_task = search_threads(
+                    query.clone(),
+                    cancellation_flag.clone(),
+                    thread_store,
+                    context_store,
+                    cx,
+                );
+                cx.background_spawn(async move {
+                    search_threads_task
+                        .await
+                        .into_iter()
+                        .map(Match::Thread)
+                        .collect()
+                })
+            } else {
+                Task::ready(Vec::new())
+            }
+        }
+
+        Some(ContextPickerMode::Fetch) => {
+            if !query.is_empty() {
+                Task::ready(vec![Match::Fetch(query.into())])
+            } else {
+                Task::ready(Vec::new())
+            }
+        }
+
+        Some(ContextPickerMode::Rules) => {
+            if let Some(prompt_store) = prompt_store.as_ref() {
+                let search_rules_task =
+                    search_rules(query.clone(), cancellation_flag.clone(), prompt_store, cx);
+                cx.background_spawn(async move {
+                    search_rules_task
+                        .await
+                        .into_iter()
+                        .map(Match::Rules)
+                        .collect::<Vec<_>>()
+                })
+            } else {
+                Task::ready(Vec::new())
+            }
+        }
+
+        None => {
+            if query.is_empty() {
+                let mut matches = recent_entries
+                    .into_iter()
+                    .map(|entry| match entry {
+                        RecentEntry::File {
+                            project_path,
+                            path_prefix,
+                        } => Match::File(FileMatch {
+                            mat: fuzzy::PathMatch {
+                                score: 1.,
+                                positions: Vec::new(),
+                                worktree_id: project_path.worktree_id.to_usize(),
+                                path: project_path.path,
+                                path_prefix,
+                                is_dir: false,
+                                distance_to_relative_ancestor: 0,
+                            },
+                            is_recent: true,
+                        }),
+                        RecentEntry::Thread(thread_context_entry) => Match::Thread(ThreadMatch {
+                            thread: thread_context_entry,
+                            is_recent: true,
+                        }),
+                    })
+                    .collect::<Vec<_>>();
+
+                matches.extend(
+                    available_context_picker_entries(
+                        &prompt_store,
+                        &Some(thread_store.clone()),
+                        &workspace,
+                        cx,
+                    )
+                    .into_iter()
+                    .map(|mode| {
+                        Match::Entry(EntryMatch {
+                            entry: mode,
+                            mat: None,
+                        })
+                    }),
+                );
+
+                Task::ready(matches)
+            } else {
+                let executor = cx.background_executor().clone();
+
+                let search_files_task =
+                    search_files(query.clone(), cancellation_flag.clone(), &workspace, cx);
+
+                let entries = available_context_picker_entries(
+                    &prompt_store,
+                    &Some(thread_store.clone()),
+                    &workspace,
+                    cx,
+                );
+                let entry_candidates = entries
+                    .iter()
+                    .enumerate()
+                    .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword()))
+                    .collect::<Vec<_>>();
+
+                cx.background_spawn(async move {
+                    let mut matches = search_files_task
+                        .await
+                        .into_iter()
+                        .map(Match::File)
+                        .collect::<Vec<_>>();
+
+                    let entry_matches = fuzzy::match_strings(
+                        &entry_candidates,
+                        &query,
+                        false,
+                        true,
+                        100,
+                        &Arc::new(AtomicBool::default()),
+                        executor,
+                    )
+                    .await;
+
+                    matches.extend(entry_matches.into_iter().map(|mat| {
+                        Match::Entry(EntryMatch {
+                            entry: entries[mat.candidate_id],
+                            mat: Some(mat),
+                        })
+                    }));
+
+                    matches.sort_by(|a, b| {
+                        b.score()
+                            .partial_cmp(&a.score())
+                            .unwrap_or(std::cmp::Ordering::Equal)
+                    });
+
+                    matches
+                })
+            }
+        }
+    }
+}
+
 pub struct ContextPickerCompletionProvider {
+    mention_set: Arc<Mutex<MentionSet>>,
     workspace: WeakEntity<Workspace>,
+    thread_store: WeakEntity<ThreadStore>,
+    text_thread_store: WeakEntity<TextThreadStore>,
     editor: WeakEntity<Editor>,
-    mention_set: Arc<Mutex<MentionSet>>,
 }
 
 impl ContextPickerCompletionProvider {
     pub fn new(
         mention_set: Arc<Mutex<MentionSet>>,
         workspace: WeakEntity<Workspace>,
+        thread_store: WeakEntity<ThreadStore>,
+        text_thread_store: WeakEntity<TextThreadStore>,
         editor: WeakEntity<Editor>,
     ) -> Self {
         Self {
             mention_set,
             workspace,
+            thread_store,
+            text_thread_store,
             editor,
         }
     }
 
+    fn completion_for_entry(
+        entry: ContextPickerEntry,
+        excerpt_id: ExcerptId,
+        source_range: Range<Anchor>,
+        editor: Entity<Editor>,
+        mention_set: Arc<Mutex<MentionSet>>,
+        workspace: &Entity<Workspace>,
+        cx: &mut App,
+    ) -> Option<Completion> {
+        match entry {
+            ContextPickerEntry::Mode(mode) => Some(Completion {
+                replace_range: source_range.clone(),
+                new_text: format!("@{} ", mode.keyword()),
+                label: CodeLabel::plain(mode.label().to_string(), None),
+                icon_path: Some(mode.icon().path().into()),
+                documentation: None,
+                source: project::CompletionSource::Custom,
+                insert_text_mode: None,
+                // This ensures that when a user accepts this completion, the
+                // completion menu will still be shown after "@category " is
+                // inserted
+                confirm: Some(Arc::new(|_, _, _| true)),
+            }),
+            ContextPickerEntry::Action(action) => {
+                let (new_text, on_action) = match action {
+                    ContextPickerAction::AddSelections => {
+                        let selections = selection_ranges(workspace, cx);
+
+                        const PLACEHOLDER: &str = "selection ";
+
+                        let new_text = std::iter::repeat(PLACEHOLDER)
+                            .take(selections.len())
+                            .chain(std::iter::once(""))
+                            .join(" ");
+
+                        let callback = Arc::new({
+                            let mention_set = mention_set.clone();
+                            let selections = selections.clone();
+                            move |_, window: &mut Window, cx: &mut App| {
+                                let editor = editor.clone();
+                                let mention_set = mention_set.clone();
+                                let selections = selections.clone();
+                                window.defer(cx, move |window, cx| {
+                                    let mut current_offset = 0;
+
+                                    for (buffer, selection_range) in selections {
+                                        let snapshot =
+                                            editor.read(cx).buffer().read(cx).snapshot(cx);
+                                        let Some(start) = snapshot
+                                            .anchor_in_excerpt(excerpt_id, source_range.start)
+                                        else {
+                                            return;
+                                        };
+
+                                        let offset = start.to_offset(&snapshot) + current_offset;
+                                        let text_len = PLACEHOLDER.len() - 1;
+
+                                        let range = snapshot.anchor_after(offset)
+                                            ..snapshot.anchor_after(offset + text_len);
+
+                                        let path = buffer
+                                            .read(cx)
+                                            .file()
+                                            .map_or(PathBuf::from("untitled"), |file| {
+                                                file.path().to_path_buf()
+                                            });
+
+                                        let point_range = snapshot
+                                            .as_singleton()
+                                            .map(|(_, _, snapshot)| {
+                                                selection_range.to_point(&snapshot)
+                                            })
+                                            .unwrap_or_default();
+                                        let line_range = point_range.start.row..point_range.end.row;
+                                        let crease = crate::context_picker::crease_for_mention(
+                                            selection_name(&path, &line_range).into(),
+                                            IconName::Reader.path().into(),
+                                            range,
+                                            editor.downgrade(),
+                                        );
+
+                                        let [crease_id]: [_; 1] =
+                                            editor.update(cx, |editor, cx| {
+                                                let crease_ids =
+                                                    editor.insert_creases(vec![crease.clone()], cx);
+                                                editor.fold_creases(
+                                                    vec![crease],
+                                                    false,
+                                                    window,
+                                                    cx,
+                                                );
+                                                crease_ids.try_into().unwrap()
+                                            });
+
+                                        mention_set.lock().insert(
+                                            crease_id,
+                                            MentionUri::Selection { path, line_range },
+                                        );
+
+                                        current_offset += text_len + 1;
+                                    }
+                                });
+
+                                false
+                            }
+                        });
+
+                        (new_text, callback)
+                    }
+                };
+
+                Some(Completion {
+                    replace_range: source_range.clone(),
+                    new_text,
+                    label: CodeLabel::plain(action.label().to_string(), None),
+                    icon_path: Some(action.icon().path().into()),
+                    documentation: None,
+                    source: project::CompletionSource::Custom,
+                    insert_text_mode: None,
+                    // This ensures that when a user accepts this completion, the
+                    // completion menu will still be shown after "@category " is
+                    // inserted
+                    confirm: Some(on_action),
+                })
+            }
+        }
+    }
+
+    fn completion_for_thread(
+        thread_entry: ThreadContextEntry,
+        excerpt_id: ExcerptId,
+        source_range: Range<Anchor>,
+        recent: bool,
+        editor: Entity<Editor>,
+        mention_set: Arc<Mutex<MentionSet>>,
+    ) -> Completion {
+        let icon_for_completion = if recent {
+            IconName::HistoryRerun
+        } else {
+            IconName::Thread
+        };
+
+        let uri = match &thread_entry {
+            ThreadContextEntry::Thread { id, title } => MentionUri::Thread {
+                id: id.clone(),
+                name: title.to_string(),
+            },
+            ThreadContextEntry::Context { path, title } => MentionUri::TextThread {
+                path: path.to_path_buf(),
+                name: title.to_string(),
+            },
+        };
+        let new_text = format!("{} ", uri.as_link());
+
+        let new_text_len = new_text.len();
+        Completion {
+            replace_range: source_range.clone(),
+            new_text,
+            label: CodeLabel::plain(thread_entry.title().to_string(), None),
+            documentation: None,
+            insert_text_mode: None,
+            source: project::CompletionSource::Custom,
+            icon_path: Some(icon_for_completion.path().into()),
+            confirm: Some(confirm_completion_callback(
+                IconName::Thread.path().into(),
+                thread_entry.title().clone(),
+                excerpt_id,
+                source_range.start,
+                new_text_len - 1,
+                editor.clone(),
+                mention_set,
+                uri,
+            )),
+        }
+    }
+
+    fn completion_for_rules(
+        rule: RulesContextEntry,
+        excerpt_id: ExcerptId,
+        source_range: Range<Anchor>,
+        editor: Entity<Editor>,
+        mention_set: Arc<Mutex<MentionSet>>,
+    ) -> Completion {
+        let uri = MentionUri::Rule {
+            id: rule.prompt_id.into(),
+            name: rule.title.to_string(),
+        };
+        let new_text = format!("{} ", uri.as_link());
+        let new_text_len = new_text.len();
+        Completion {
+            replace_range: source_range.clone(),
+            new_text,
+            label: CodeLabel::plain(rule.title.to_string(), None),
+            documentation: None,
+            insert_text_mode: None,
+            source: project::CompletionSource::Custom,
+            icon_path: Some(RULES_ICON.path().into()),
+            confirm: Some(confirm_completion_callback(
+                RULES_ICON.path().into(),
+                rule.title.clone(),
+                excerpt_id,
+                source_range.start,
+                new_text_len - 1,
+                editor.clone(),
+                mention_set,
+                uri,
+            )),
+        }
+    }
+
     pub(crate) fn completion_for_path(
         project_path: ProjectPath,
         path_prefix: &str,
@@ -114,9 +655,12 @@ impl ContextPickerCompletionProvider {
         mention_set: Arc<Mutex<MentionSet>>,
         project: Entity<Project>,
         cx: &App,
-    ) -> Completion {
+    ) -> Option<Completion> {
         let (file_name, directory) =
-            extract_file_name_and_directory(&project_path.path, path_prefix);
+            crate::context_picker::file_context_picker::extract_file_name_and_directory(
+                &project_path.path,
+                path_prefix,
+            );
 
         let label =
             build_code_label_for_full_path(&file_name, directory.as_ref().map(|s| s.as_ref()), cx);
@@ -138,9 +682,12 @@ impl ContextPickerCompletionProvider {
             crease_icon_path.clone()
         };
 
-        let new_text = format!("{} ", MentionLink::for_file(&file_name, &full_path));
+        let abs_path = project.read(cx).absolute_path(&project_path, cx)?;
+
+        let file_uri = MentionUri::File(abs_path);
+        let new_text = format!("{} ", file_uri.as_link());
         let new_text_len = new_text.len();
-        Completion {
+        Some(Completion {
             replace_range: source_range.clone(),
             new_text,
             label,
@@ -151,15 +698,153 @@ impl ContextPickerCompletionProvider {
             confirm: Some(confirm_completion_callback(
                 crease_icon_path,
                 file_name,
-                project_path,
                 excerpt_id,
                 source_range.start,
                 new_text_len - 1,
                 editor,
-                mention_set,
-                project,
+                mention_set.clone(),
+                file_uri,
             )),
-        }
+        })
+    }
+
+    fn completion_for_symbol(
+        symbol: Symbol,
+        excerpt_id: ExcerptId,
+        source_range: Range<Anchor>,
+        editor: Entity<Editor>,
+        mention_set: Arc<Mutex<MentionSet>>,
+        workspace: Entity<Workspace>,
+        cx: &mut App,
+    ) -> Option<Completion> {
+        let project = workspace.read(cx).project().clone();
+
+        let label = CodeLabel::plain(symbol.name.clone(), None);
+
+        let abs_path = project.read(cx).absolute_path(&symbol.path, cx)?;
+        let uri = MentionUri::Symbol {
+            path: abs_path,
+            name: symbol.name.clone(),
+            line_range: symbol.range.start.0.row..symbol.range.end.0.row,
+        };
+        let new_text = format!("{} ", uri.as_link());
+        let new_text_len = new_text.len();
+        Some(Completion {
+            replace_range: source_range.clone(),
+            new_text,
+            label,
+            documentation: None,
+            source: project::CompletionSource::Custom,
+            icon_path: Some(IconName::Code.path().into()),
+            insert_text_mode: None,
+            confirm: Some(confirm_completion_callback(
+                IconName::Code.path().into(),
+                symbol.name.clone().into(),
+                excerpt_id,
+                source_range.start,
+                new_text_len - 1,
+                editor.clone(),
+                mention_set.clone(),
+                uri,
+            )),
+        })
+    }
+
+    fn completion_for_fetch(
+        source_range: Range<Anchor>,
+        url_to_fetch: SharedString,
+        excerpt_id: ExcerptId,
+        editor: Entity<Editor>,
+        mention_set: Arc<Mutex<MentionSet>>,
+        http_client: Arc<HttpClientWithUrl>,
+    ) -> Option<Completion> {
+        let new_text = format!("@fetch {} ", url_to_fetch.clone());
+        let new_text_len = new_text.len();
+        Some(Completion {
+            replace_range: source_range.clone(),
+            new_text,
+            label: CodeLabel::plain(url_to_fetch.to_string(), None),
+            documentation: None,
+            source: project::CompletionSource::Custom,
+            icon_path: Some(IconName::ToolWeb.path().into()),
+            insert_text_mode: None,
+            confirm: Some({
+                let start = source_range.start;
+                let content_len = new_text_len - 1;
+                let editor = editor.clone();
+                let url_to_fetch = url_to_fetch.clone();
+                let source_range = source_range.clone();
+                Arc::new(move |_, window, cx| {
+                    let Some(url) = url::Url::parse(url_to_fetch.as_ref())
+                        .or_else(|_| url::Url::parse(&format!("https://{url_to_fetch}")))
+                        .notify_app_err(cx)
+                    else {
+                        return false;
+                    };
+                    let mention_uri = MentionUri::Fetch { url: url.clone() };
+
+                    let editor = editor.clone();
+                    let mention_set = mention_set.clone();
+                    let http_client = http_client.clone();
+                    let source_range = source_range.clone();
+                    window.defer(cx, move |window, cx| {
+                        let url = url.clone();
+
+                        let Some(crease_id) = crate::context_picker::insert_crease_for_mention(
+                            excerpt_id,
+                            start,
+                            content_len,
+                            url.to_string().into(),
+                            IconName::ToolWeb.path().into(),
+                            editor.clone(),
+                            window,
+                            cx,
+                        ) else {
+                            return;
+                        };
+
+                        let editor = editor.clone();
+                        let mention_set = mention_set.clone();
+                        let http_client = http_client.clone();
+                        let source_range = source_range.clone();
+                        window
+                            .spawn(cx, async move |cx| {
+                                if let Some(content) =
+                                    fetch_url_content(http_client, url.to_string())
+                                        .await
+                                        .notify_async_err(cx)
+                                {
+                                    mention_set.lock().add_fetch_result(url, content);
+                                    mention_set.lock().insert(crease_id, mention_uri.clone());
+                                } else {
+                                    // Remove crease if we failed to fetch
+                                    editor
+                                        .update(cx, |editor, cx| {
+                                            let snapshot = editor.buffer().read(cx).snapshot(cx);
+                                            let Some(anchor) = snapshot
+                                                .anchor_in_excerpt(excerpt_id, source_range.start)
+                                            else {
+                                                return;
+                                            };
+                                            editor.display_map.update(cx, |display_map, cx| {
+                                                display_map.unfold_intersecting(
+                                                    vec![anchor..anchor],
+                                                    true,
+                                                    cx,
+                                                );
+                                            });
+                                            editor.remove_creases([crease_id], cx);
+                                        })
+                                        .ok();
+                                }
+                                Some(())
+                            })
+                            .detach();
+                    });
+                    false
+                })
+            }),
+        })
     }
 }
 
@@ -206,16 +891,66 @@ impl CompletionProvider for ContextPickerCompletionProvider {
         };
 
         let project = workspace.read(cx).project().clone();
+        let http_client = workspace.read(cx).client().http_client();
         let snapshot = buffer.read(cx).snapshot();
         let source_range = snapshot.anchor_before(state.source_range.start)
             ..snapshot.anchor_after(state.source_range.end);
 
+        let thread_store = self.thread_store.clone();
+        let text_thread_store = self.text_thread_store.clone();
         let editor = self.editor.clone();
-        let mention_set = self.mention_set.clone();
-        let MentionCompletion { argument, .. } = state;
+
+        let MentionCompletion { mode, argument, .. } = state;
         let query = argument.unwrap_or_else(|| "".to_string());
 
-        let search_task = search_files(query.clone(), Arc::<AtomicBool>::default(), &workspace, cx);
+        let (exclude_paths, exclude_threads) = {
+            let mention_set = self.mention_set.lock();
+
+            let mut excluded_paths = HashSet::default();
+            let mut excluded_threads = HashSet::default();
+
+            for uri in mention_set.uri_by_crease_id.values() {
+                match uri {
+                    MentionUri::File(path) => {
+                        excluded_paths.insert(path.clone());
+                    }
+                    MentionUri::Thread { id, .. } => {
+                        excluded_threads.insert(id.clone());
+                    }
+                    _ => {}
+                }
+            }
+
+            (excluded_paths, excluded_threads)
+        };
+
+        let recent_entries = recent_context_picker_entries(
+            Some(thread_store.clone()),
+            Some(text_thread_store.clone()),
+            workspace.clone(),
+            &exclude_paths,
+            &exclude_threads,
+            cx,
+        );
+
+        let prompt_store = thread_store
+            .read_with(cx, |thread_store, _cx| thread_store.prompt_store().clone())
+            .ok()
+            .flatten();
+
+        let search_task = search(
+            mode,
+            query,
+            Arc::<AtomicBool>::default(),
+            recent_entries,
+            prompt_store,
+            thread_store.clone(),
+            text_thread_store.clone(),
+            workspace.clone(),
+            cx,
+        );
+
+        let mention_set = self.mention_set.clone();
 
         cx.spawn(async move |_, cx| {
             let matches = search_task.await;
@@ -226,25 +961,74 @@ impl CompletionProvider for ContextPickerCompletionProvider {
             let completions = cx.update(|cx| {
                 matches
                     .into_iter()
-                    .map(|mat| {
-                        let path_match = &mat.mat;
-                        let project_path = ProjectPath {
-                            worktree_id: WorktreeId::from_usize(path_match.worktree_id),
-                            path: path_match.path.clone(),
-                        };
+                    .filter_map(|mat| match mat {
+                        Match::File(FileMatch { mat, is_recent }) => {
+                            let project_path = ProjectPath {
+                                worktree_id: WorktreeId::from_usize(mat.worktree_id),
+                                path: mat.path.clone(),
+                            };
+
+                            Self::completion_for_path(
+                                project_path,
+                                &mat.path_prefix,
+                                is_recent,
+                                mat.is_dir,
+                                excerpt_id,
+                                source_range.clone(),
+                                editor.clone(),
+                                mention_set.clone(),
+                                project.clone(),
+                                cx,
+                            )
+                        }
+
+                        Match::Symbol(SymbolMatch { symbol, .. }) => Self::completion_for_symbol(
+                            symbol,
+                            excerpt_id,
+                            source_range.clone(),
+                            editor.clone(),
+                            mention_set.clone(),
+                            workspace.clone(),
+                            cx,
+                        ),
 
-                        Self::completion_for_path(
-                            project_path,
-                            &path_match.path_prefix,
-                            mat.is_recent,
-                            path_match.is_dir,
+                        Match::Thread(ThreadMatch {
+                            thread, is_recent, ..
+                        }) => Some(Self::completion_for_thread(
+                            thread,
+                            excerpt_id,
+                            source_range.clone(),
+                            is_recent,
+                            editor.clone(),
+                            mention_set.clone(),
+                        )),
+
+                        Match::Rules(user_rules) => Some(Self::completion_for_rules(
+                            user_rules,
                             excerpt_id,
                             source_range.clone(),
                             editor.clone(),
                             mention_set.clone(),
-                            project.clone(),
+                        )),
+
+                        Match::Fetch(url) => Self::completion_for_fetch(
+                            source_range.clone(),
+                            url,
+                            excerpt_id,
+                            editor.clone(),
+                            mention_set.clone(),
+                            http_client.clone(),
+                        ),
+
+                        Match::Entry(EntryMatch { entry, .. }) => Self::completion_for_entry(
+                            entry,
+                            excerpt_id,
+                            source_range.clone(),
+                            editor.clone(),
+                            mention_set.clone(),
+                            &workspace,
                             cx,
-                        )
+                        ),
                     })
                     .collect()
             })?;

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

@@ -4,15 +4,17 @@ use acp_thread::{
 };
 use acp_thread::{AgentConnection, Plan};
 use action_log::ActionLog;
+use agent::{TextThreadStore, ThreadStore};
 use agent_client_protocol as acp;
 use agent_servers::AgentServer;
 use agent_settings::{AgentSettings, NotifyWhenAgentWaiting};
 use audio::{Audio, Sound};
 use buffer_diff::BufferDiff;
 use collections::{HashMap, HashSet};
+use editor::scroll::Autoscroll;
 use editor::{
     AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorMode,
-    EditorStyle, MinimapVisibility, MultiBuffer, PathKey,
+    EditorStyle, MinimapVisibility, MultiBuffer, PathKey, SelectionEffects,
 };
 use file_icons::FileIcons;
 use gpui::{
@@ -27,8 +29,10 @@ use language::{Buffer, Language};
 use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
 use parking_lot::Mutex;
 use project::{CompletionIntent, Project};
+use prompt_store::PromptId;
 use rope::Point;
 use settings::{Settings as _, SettingsStore};
+use std::fmt::Write as _;
 use std::path::PathBuf;
 use std::{
     cell::RefCell, collections::BTreeMap, path::Path, process::ExitStatus, rc::Rc, sync::Arc,
@@ -44,6 +48,7 @@ use ui::{
 use util::{ResultExt, size::format_file_size, time::duration_alt_display};
 use workspace::{CollaboratorId, Workspace};
 use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage, ToggleModelSelector};
+use zed_actions::assistant::OpenRulesLibrary;
 
 use crate::acp::AcpModelSelectorPopover;
 use crate::acp::completion_provider::{ContextPickerCompletionProvider, MentionSet};
@@ -61,6 +66,8 @@ pub struct AcpThreadView {
     agent: Rc<dyn AgentServer>,
     workspace: WeakEntity<Workspace>,
     project: Entity<Project>,
+    thread_store: Entity<ThreadStore>,
+    text_thread_store: Entity<TextThreadStore>,
     thread_state: ThreadState,
     diff_editors: HashMap<EntityId, Entity<Editor>>,
     terminal_views: HashMap<EntityId, Entity<TerminalView>>,
@@ -108,6 +115,8 @@ impl AcpThreadView {
         agent: Rc<dyn AgentServer>,
         workspace: WeakEntity<Workspace>,
         project: Entity<Project>,
+        thread_store: Entity<ThreadStore>,
+        text_thread_store: Entity<TextThreadStore>,
         message_history: Rc<RefCell<MessageHistory<Vec<acp::ContentBlock>>>>,
         min_lines: usize,
         max_lines: Option<usize>,
@@ -145,6 +154,8 @@ impl AcpThreadView {
             editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new(
                 mention_set.clone(),
                 workspace.clone(),
+                thread_store.downgrade(),
+                text_thread_store.downgrade(),
                 cx.weak_entity(),
             ))));
             editor.set_context_menu_options(ContextMenuOptions {
@@ -188,6 +199,8 @@ impl AcpThreadView {
             agent: agent.clone(),
             workspace: workspace.clone(),
             project: project.clone(),
+            thread_store,
+            text_thread_store,
             thread_state: Self::initial_state(agent, workspace, project, window, cx),
             message_editor,
             model_selector: None,
@@ -401,7 +414,13 @@ impl AcpThreadView {
         let mut chunks: Vec<acp::ContentBlock> = Vec::new();
         let project = self.project.clone();
 
-        let contents = self.mention_set.lock().contents(project, cx);
+        let thread_store = self.thread_store.clone();
+        let text_thread_store = self.text_thread_store.clone();
+
+        let contents =
+            self.mention_set
+                .lock()
+                .contents(project, thread_store, text_thread_store, window, cx);
 
         cx.spawn_in(window, async move |this, cx| {
             let contents = match contents.await {
@@ -439,7 +458,7 @@ impl AcpThreadView {
                                         acp::TextResourceContents {
                                             mime_type: None,
                                             text: mention.content.clone(),
-                                            uri: mention.uri.to_uri(),
+                                            uri: mention.uri.to_uri().to_string(),
                                         },
                                     ),
                                 }));
@@ -614,8 +633,7 @@ impl AcpThreadView {
                     let path = PathBuf::from(&resource.uri);
                     let project_path = project.read(cx).project_path_for_absolute_path(&path, cx);
                     let start = text.len();
-                    let content = MentionUri::File(path).to_uri();
-                    text.push_str(&content);
+                    let _ = write!(&mut text, "{}", MentionUri::File(path).to_uri());
                     let end = text.len();
                     if let Some(project_path) = project_path {
                         let filename: SharedString = project_path
@@ -663,7 +681,9 @@ impl AcpThreadView {
                 );
 
                 if let Some(crease_id) = crease_id {
-                    mention_set.lock().insert(crease_id, project_path);
+                    mention_set
+                        .lock()
+                        .insert(crease_id, MentionUri::File(project_path));
                 }
             }
         }
@@ -2698,9 +2718,72 @@ impl AcpThreadView {
                             .detach_and_log_err(cx);
                     }
                 }
-                _ => {
-                    // TODO
-                    unimplemented!()
+                MentionUri::Symbol {
+                    path, line_range, ..
+                }
+                | MentionUri::Selection { path, line_range } => {
+                    let project = workspace.project();
+                    let Some((path, _)) = project.update(cx, |project, cx| {
+                        let path = project.find_project_path(path, cx)?;
+                        let entry = project.entry_for_path(&path, cx)?;
+                        Some((path, entry))
+                    }) else {
+                        return;
+                    };
+
+                    let item = workspace.open_path(path, None, true, window, cx);
+                    window
+                        .spawn(cx, async move |cx| {
+                            let Some(editor) = item.await?.downcast::<Editor>() else {
+                                return Ok(());
+                            };
+                            let range =
+                                Point::new(line_range.start, 0)..Point::new(line_range.start, 0);
+                            editor
+                                .update_in(cx, |editor, window, cx| {
+                                    editor.change_selections(
+                                        SelectionEffects::scroll(Autoscroll::center()),
+                                        window,
+                                        cx,
+                                        |s| s.select_ranges(vec![range]),
+                                    );
+                                })
+                                .ok();
+                            anyhow::Ok(())
+                        })
+                        .detach_and_log_err(cx);
+                }
+                MentionUri::Thread { id, .. } => {
+                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+                        panel.update(cx, |panel, cx| {
+                            panel
+                                .open_thread_by_id(&id, window, cx)
+                                .detach_and_log_err(cx)
+                        });
+                    }
+                }
+                MentionUri::TextThread { path, .. } => {
+                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+                        panel.update(cx, |panel, cx| {
+                            panel
+                                .open_saved_prompt_editor(path.as_path().into(), window, cx)
+                                .detach_and_log_err(cx);
+                        });
+                    }
+                }
+                MentionUri::Rule { id, .. } => {
+                    let PromptId::User { uuid } = id else {
+                        return;
+                    };
+                    window.dispatch_action(
+                        Box::new(OpenRulesLibrary {
+                            prompt_to_select: Some(uuid.0),
+                        }),
+                        cx,
+                    )
+                }
+                MentionUri::Fetch { url } => {
+                    cx.open_url(url.as_str());
                 }
             })
         } else {
@@ -3090,7 +3173,7 @@ impl AcpThreadView {
                 .unwrap_or(path.path.as_os_str())
                 .display()
                 .to_string();
-            let completion = ContextPickerCompletionProvider::completion_for_path(
+            let Some(completion) = ContextPickerCompletionProvider::completion_for_path(
                 path,
                 &path_prefix,
                 false,
@@ -3101,7 +3184,9 @@ impl AcpThreadView {
                 self.mention_set.clone(),
                 self.project.clone(),
                 cx,
-            );
+            ) else {
+                continue;
+            };
 
             self.message_editor.update(cx, |message_editor, cx| {
                 message_editor.edit(
@@ -3431,17 +3516,14 @@ fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
 
 #[cfg(test)]
 mod tests {
+    use agent::{TextThreadStore, ThreadStore};
     use agent_client_protocol::SessionId;
     use editor::EditorSettings;
     use fs::FakeFs;
     use futures::future::try_join_all;
     use gpui::{SemanticVersion, TestAppContext, VisualTestContext};
-    use lsp::{CompletionContext, CompletionTriggerKind};
-    use project::CompletionIntent;
     use rand::Rng;
-    use serde_json::json;
     use settings::SettingsStore;
-    use util::path;
 
     use super::*;
 
@@ -3554,109 +3636,6 @@ mod tests {
         );
     }
 
-    #[gpui::test]
-    async fn test_crease_removal(cx: &mut TestAppContext) {
-        init_test(cx);
-
-        let fs = FakeFs::new(cx.executor());
-        fs.insert_tree("/project", json!({"file": ""})).await;
-        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
-        let agent = StubAgentServer::default();
-        let (workspace, cx) =
-            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
-        let thread_view = cx.update(|window, cx| {
-            cx.new(|cx| {
-                AcpThreadView::new(
-                    Rc::new(agent),
-                    workspace.downgrade(),
-                    project,
-                    Rc::new(RefCell::new(MessageHistory::default())),
-                    1,
-                    None,
-                    window,
-                    cx,
-                )
-            })
-        });
-
-        cx.run_until_parked();
-
-        let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
-        let excerpt_id = message_editor.update(cx, |editor, cx| {
-            editor
-                .buffer()
-                .read(cx)
-                .excerpt_ids()
-                .into_iter()
-                .next()
-                .unwrap()
-        });
-        let completions = message_editor.update_in(cx, |editor, window, cx| {
-            editor.set_text("Hello @", window, cx);
-            let buffer = editor.buffer().read(cx).as_singleton().unwrap();
-            let completion_provider = editor.completion_provider().unwrap();
-            completion_provider.completions(
-                excerpt_id,
-                &buffer,
-                Anchor::MAX,
-                CompletionContext {
-                    trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
-                    trigger_character: Some("@".into()),
-                },
-                window,
-                cx,
-            )
-        });
-        let [_, completion]: [_; 2] = completions
-            .await
-            .unwrap()
-            .into_iter()
-            .flat_map(|response| response.completions)
-            .collect::<Vec<_>>()
-            .try_into()
-            .unwrap();
-
-        message_editor.update_in(cx, |editor, window, cx| {
-            let snapshot = editor.buffer().read(cx).snapshot(cx);
-            let start = snapshot
-                .anchor_in_excerpt(excerpt_id, completion.replace_range.start)
-                .unwrap();
-            let end = snapshot
-                .anchor_in_excerpt(excerpt_id, completion.replace_range.end)
-                .unwrap();
-            editor.edit([(start..end, completion.new_text)], cx);
-            (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
-        });
-
-        cx.run_until_parked();
-
-        // Backspace over the inserted crease (and the following space).
-        message_editor.update_in(cx, |editor, window, cx| {
-            editor.backspace(&Default::default(), window, cx);
-            editor.backspace(&Default::default(), window, cx);
-        });
-
-        thread_view.update_in(cx, |thread_view, window, cx| {
-            thread_view.chat(&Chat, window, cx);
-        });
-
-        cx.run_until_parked();
-
-        let content = thread_view.update_in(cx, |thread_view, _window, _cx| {
-            thread_view
-                .message_history
-                .borrow()
-                .items()
-                .iter()
-                .flatten()
-                .cloned()
-                .collect::<Vec<_>>()
-        });
-
-        // We don't send a resource link for the deleted crease.
-        pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
-    }
-
     async fn setup_thread_view(
         agent: impl AgentServer + 'static,
         cx: &mut TestAppContext,
@@ -3666,12 +3645,19 @@ mod tests {
         let (workspace, cx) =
             cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
 
+        let thread_store =
+            cx.update(|_window, cx| cx.new(|cx| ThreadStore::fake(project.clone(), cx)));
+        let text_thread_store =
+            cx.update(|_window, cx| cx.new(|cx| TextThreadStore::fake(project.clone(), cx)));
+
         let thread_view = cx.update(|window, cx| {
             cx.new(|cx| {
                 AcpThreadView::new(
                     Rc::new(agent),
                     workspace.downgrade(),
                     project,
+                    thread_store.clone(),
+                    text_thread_store.clone(),
                     Rc::new(RefCell::new(MessageHistory::default())),
                     1,
                     None,

crates/agent_ui/src/agent_panel.rs 🔗

@@ -973,6 +973,9 @@ impl AgentPanel {
             agent: crate::ExternalAgent,
         }
 
+        let thread_store = self.thread_store.clone();
+        let text_thread_store = self.context_store.clone();
+
         cx.spawn_in(window, async move |this, cx| {
             let server: Rc<dyn AgentServer> = match agent_choice {
                 Some(agent) => {
@@ -1011,6 +1014,8 @@ impl AgentPanel {
                         server,
                         workspace.clone(),
                         project,
+                        thread_store.clone(),
+                        text_thread_store.clone(),
                         message_history,
                         MIN_EDITOR_LINES,
                         Some(MAX_EDITOR_LINES),

crates/agent_ui/src/context_picker.rs 🔗

@@ -1,15 +1,16 @@
 mod completion_provider;
-mod fetch_context_picker;
+pub(crate) mod fetch_context_picker;
 pub(crate) mod file_context_picker;
-mod rules_context_picker;
-mod symbol_context_picker;
-mod thread_context_picker;
+pub(crate) mod rules_context_picker;
+pub(crate) mod symbol_context_picker;
+pub(crate) mod thread_context_picker;
 
 use std::ops::Range;
 use std::path::{Path, PathBuf};
 use std::sync::Arc;
 
 use anyhow::{Result, anyhow};
+use collections::HashSet;
 pub use completion_provider::ContextPickerCompletionProvider;
 use editor::display_map::{Crease, CreaseId, CreaseMetadata, FoldId};
 use editor::{Anchor, AnchorRangeExt as _, Editor, ExcerptId, FoldPlaceholder, ToOffset};
@@ -45,7 +46,7 @@ use agent::{
 };
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum ContextPickerEntry {
+pub(crate) enum ContextPickerEntry {
     Mode(ContextPickerMode),
     Action(ContextPickerAction),
 }
@@ -74,7 +75,7 @@ impl ContextPickerEntry {
 }
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum ContextPickerMode {
+pub(crate) enum ContextPickerMode {
     File,
     Symbol,
     Fetch,
@@ -83,7 +84,7 @@ enum ContextPickerMode {
 }
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum ContextPickerAction {
+pub(crate) enum ContextPickerAction {
     AddSelections,
 }
 
@@ -531,7 +532,7 @@ impl ContextPicker {
             return vec![];
         };
 
-        recent_context_picker_entries(
+        recent_context_picker_entries_with_store(
             context_store,
             self.thread_store.clone(),
             self.text_thread_store.clone(),
@@ -585,7 +586,8 @@ impl Render for ContextPicker {
             })
     }
 }
-enum RecentEntry {
+
+pub(crate) enum RecentEntry {
     File {
         project_path: ProjectPath,
         path_prefix: Arc<str>,
@@ -593,7 +595,7 @@ enum RecentEntry {
     Thread(ThreadContextEntry),
 }
 
-fn available_context_picker_entries(
+pub(crate) fn available_context_picker_entries(
     prompt_store: &Option<Entity<PromptStore>>,
     thread_store: &Option<WeakEntity<ThreadStore>>,
     workspace: &Entity<Workspace>,
@@ -630,24 +632,56 @@ fn available_context_picker_entries(
     entries
 }
 
-fn recent_context_picker_entries(
+fn recent_context_picker_entries_with_store(
     context_store: Entity<ContextStore>,
     thread_store: Option<WeakEntity<ThreadStore>>,
     text_thread_store: Option<WeakEntity<TextThreadStore>>,
     workspace: Entity<Workspace>,
     exclude_path: Option<ProjectPath>,
     cx: &App,
+) -> Vec<RecentEntry> {
+    let project = workspace.read(cx).project();
+
+    let mut exclude_paths = context_store.read(cx).file_paths(cx);
+    exclude_paths.extend(exclude_path);
+
+    let exclude_paths = exclude_paths
+        .into_iter()
+        .filter_map(|project_path| project.read(cx).absolute_path(&project_path, cx))
+        .collect();
+
+    let exclude_threads = context_store.read(cx).thread_ids();
+
+    recent_context_picker_entries(
+        thread_store,
+        text_thread_store,
+        workspace,
+        &exclude_paths,
+        exclude_threads,
+        cx,
+    )
+}
+
+pub(crate) fn recent_context_picker_entries(
+    thread_store: Option<WeakEntity<ThreadStore>>,
+    text_thread_store: Option<WeakEntity<TextThreadStore>>,
+    workspace: Entity<Workspace>,
+    exclude_paths: &HashSet<PathBuf>,
+    exclude_threads: &HashSet<ThreadId>,
+    cx: &App,
 ) -> Vec<RecentEntry> {
     let mut recent = Vec::with_capacity(6);
-    let mut current_files = context_store.read(cx).file_paths(cx);
-    current_files.extend(exclude_path);
     let workspace = workspace.read(cx);
     let project = workspace.project().read(cx);
 
     recent.extend(
         workspace
             .recent_navigation_history_iter(cx)
-            .filter(|(path, _)| !current_files.contains(path))
+            .filter(|(_, abs_path)| {
+                abs_path
+                    .as_ref()
+                    .map_or(true, |path| !exclude_paths.contains(path.as_path()))
+            })
             .take(4)
             .filter_map(|(project_path, _)| {
                 project
@@ -659,8 +693,6 @@ fn recent_context_picker_entries(
             }),
     );
 
-    let current_threads = context_store.read(cx).thread_ids();
-
     let active_thread_id = workspace
         .panel::<AgentPanel>(cx)
         .and_then(|panel| Some(panel.read(cx).active_thread(cx)?.read(cx).id()));
@@ -672,7 +704,7 @@ fn recent_context_picker_entries(
         let mut threads = unordered_thread_entries(thread_store, text_thread_store, cx)
             .filter(|(_, thread)| match thread {
                 ThreadContextEntry::Thread { id, .. } => {
-                    Some(id) != active_thread_id && !current_threads.contains(id)
+                    Some(id) != active_thread_id && !exclude_threads.contains(id)
                 }
                 ThreadContextEntry::Context { .. } => true,
             })
@@ -710,7 +742,7 @@ fn add_selections_as_context(
     })
 }
 
-fn selection_ranges(
+pub(crate) fn selection_ranges(
     workspace: &Entity<Workspace>,
     cx: &mut App,
 ) -> Vec<(Entity<Buffer>, Range<text::Anchor>)> {

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

@@ -35,7 +35,7 @@ use super::symbol_context_picker::search_symbols;
 use super::thread_context_picker::{ThreadContextEntry, ThreadMatch, search_threads};
 use super::{
     ContextPickerAction, ContextPickerEntry, ContextPickerMode, MentionLink, RecentEntry,
-    available_context_picker_entries, recent_context_picker_entries, selection_ranges,
+    available_context_picker_entries, recent_context_picker_entries_with_store, selection_ranges,
 };
 use crate::message_editor::ContextCreasesAddon;
 
@@ -787,7 +787,7 @@ impl CompletionProvider for ContextPickerCompletionProvider {
             .and_then(|b| b.read(cx).file())
             .map(|file| ProjectPath::from_file(file.as_ref(), cx));
 
-        let recent_entries = recent_context_picker_entries(
+        let recent_entries = recent_context_picker_entries_with_store(
             context_store.clone(),
             thread_store.clone(),
             text_thread_store.clone(),

crates/assistant_context/Cargo.toml 🔗

@@ -11,6 +11,9 @@ workspace = true
 [lib]
 path = "src/assistant_context.rs"
 
+[features]
+test-support = []
+
 [dependencies]
 agent_settings.workspace = true
 anyhow.workspace = true

crates/assistant_context/src/context_store.rs 🔗

@@ -138,6 +138,27 @@ impl ContextStore {
         })
     }
 
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn fake(project: Entity<Project>, cx: &mut Context<Self>) -> Self {
+        Self {
+            contexts: Default::default(),
+            contexts_metadata: Default::default(),
+            context_server_slash_command_ids: Default::default(),
+            host_contexts: Default::default(),
+            fs: project.read(cx).fs().clone(),
+            languages: project.read(cx).languages().clone(),
+            slash_commands: Arc::default(),
+            telemetry: project.read(cx).client().telemetry().clone(),
+            _watch_updates: Task::ready(None),
+            client: project.read(cx).client(),
+            project,
+            project_is_shared: false,
+            client_subscription: None,
+            _project_subscriptions: Default::default(),
+            prompt_builder: Arc::new(PromptBuilder::new(None).unwrap()),
+        }
+    }
+
     async fn handle_advertise_contexts(
         this: Entity<Self>,
         envelope: TypedEnvelope<proto::AdvertiseContexts>,

crates/editor/src/editor.rs 🔗

@@ -12176,6 +12176,8 @@ impl Editor {
         let clipboard_text = Cow::Borrowed(text);
 
         self.transact(window, cx, |this, window, cx| {
+            let had_active_edit_prediction = this.has_active_edit_prediction();
+
             if let Some(mut clipboard_selections) = clipboard_selections {
                 let old_selections = this.selections.all::<usize>(cx);
                 let all_selections_were_entire_line =
@@ -12248,6 +12250,11 @@ impl Editor {
             } else {
                 this.insert(&clipboard_text, window, cx);
             }
+
+            let trigger_in_words =
+                this.show_edit_predictions_in_menu() || !had_active_edit_prediction;
+
+            this.trigger_completion_on_input(&text, trigger_in_words, window, cx);
         });
     }
 

crates/prompt_store/src/prompt_store.rs 🔗

@@ -90,6 +90,15 @@ impl From<Uuid> for UserPromptId {
     }
 }
 
+impl std::fmt::Display for PromptId {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            PromptId::User { uuid } => write!(f, "{}", uuid.0),
+            PromptId::EditWorkflow => write!(f, "Edit workflow"),
+        }
+    }
+}
+
 pub struct PromptStore {
     env: heed::Env,
     metadata_cache: RwLock<MetadataCache>,