agent: Improve attached context display and show hovers for symbol / selection / rules / thread (#29551)

Michael Sloan and Bennet Bo Fenner created

* Brings back hover popover of selection context.

* Adds hover popover for symbol, rules, and thread context.

* Makes context attached to messages display the names / content at
attachment time.

* Adds the file name as the displayed parent of symbol context.

* Brings back `impl Component for AddedContext`

Release Notes:

- N/A

---------

Co-authored-by: Bennet Bo Fenner <bennet@zed.dev>

Change summary

crates/agent/src/active_thread.rs   |  36 
crates/agent/src/context.rs         | 685 +++++++++++++++++++-----------
crates/agent/src/context_store.rs   |  83 ++-
crates/agent/src/context_strip.rs   |  12 
crates/agent/src/ui/context_pill.rs | 676 +++++++++++++++++++----------
5 files changed, 941 insertions(+), 551 deletions(-)

Detailed changes

crates/agent/src/active_thread.rs đź”—

@@ -1,4 +1,4 @@
-use crate::context::{AgentContext, RULES_ICON};
+use crate::context::{AgentContextHandle, RULES_ICON};
 use crate::context_picker::MentionLink;
 use crate::thread::{
     LastRestoreCheckpoint, MessageId, MessageSegment, Thread, ThreadError, ThreadEvent,
@@ -1491,19 +1491,13 @@ impl ActiveThread {
 
         let workspace = self.workspace.clone();
         let thread = self.thread.read(cx);
-        let prompt_store = self.thread_store.read(cx).prompt_store().as_ref();
 
         // Get all the data we need from thread before we start using it in closures
         let checkpoint = thread.checkpoint_for_message(message_id);
-        let added_context = if let Some(workspace) = workspace.upgrade() {
-            let project = workspace.read(cx).project().read(cx);
-            thread
-                .context_for_message(message_id)
-                .flat_map(|context| AddedContext::new(context.clone(), prompt_store, project, cx))
-                .collect::<Vec<_>>()
-        } else {
-            return Empty.into_any();
-        };
+        let added_context = thread
+            .context_for_message(message_id)
+            .map(|context| AddedContext::new_attached(context, cx))
+            .collect::<Vec<_>>();
 
         let tool_uses = thread.tool_uses_for_message(message_id, cx);
         let has_tool_uses = !tool_uses.is_empty();
@@ -1713,7 +1707,7 @@ impl ActiveThread {
                 .when(!added_context.is_empty(), |parent| {
                     parent.child(h_flex().flex_wrap().gap_1().children(
                         added_context.into_iter().map(|added_context| {
-                            let context = added_context.context.clone();
+                            let context = added_context.handle.clone();
                             ContextPill::added(added_context, false, false, None).on_click(Rc::new(
                                 cx.listener({
                                     let workspace = workspace.clone();
@@ -3188,13 +3182,13 @@ impl Render for ActiveThread {
 }
 
 pub(crate) fn open_context(
-    context: &AgentContext,
+    context: &AgentContextHandle,
     workspace: Entity<Workspace>,
     window: &mut Window,
     cx: &mut App,
 ) {
     match context {
-        AgentContext::File(file_context) => {
+        AgentContextHandle::File(file_context) => {
             if let Some(project_path) = file_context.project_path(cx) {
                 workspace.update(cx, |workspace, cx| {
                     workspace
@@ -3204,7 +3198,7 @@ pub(crate) fn open_context(
             }
         }
 
-        AgentContext::Directory(directory_context) => {
+        AgentContextHandle::Directory(directory_context) => {
             let entry_id = directory_context.entry_id;
             workspace.update(cx, |workspace, cx| {
                 workspace.project().update(cx, |_project, cx| {
@@ -3213,7 +3207,7 @@ pub(crate) fn open_context(
             })
         }
 
-        AgentContext::Symbol(symbol_context) => {
+        AgentContextHandle::Symbol(symbol_context) => {
             let buffer = symbol_context.buffer.read(cx);
             if let Some(project_path) = buffer.project_path(cx) {
                 let snapshot = buffer.snapshot();
@@ -3223,7 +3217,7 @@ pub(crate) fn open_context(
             }
         }
 
-        AgentContext::Selection(selection_context) => {
+        AgentContextHandle::Selection(selection_context) => {
             let buffer = selection_context.buffer.read(cx);
             if let Some(project_path) = buffer.project_path(cx) {
                 let snapshot = buffer.snapshot();
@@ -3234,11 +3228,11 @@ pub(crate) fn open_context(
             }
         }
 
-        AgentContext::FetchedUrl(fetched_url_context) => {
+        AgentContextHandle::FetchedUrl(fetched_url_context) => {
             cx.open_url(&fetched_url_context.url);
         }
 
-        AgentContext::Thread(thread_context) => workspace.update(cx, |workspace, cx| {
+        AgentContextHandle::Thread(thread_context) => workspace.update(cx, |workspace, cx| {
             if let Some(panel) = workspace.panel::<AssistantPanel>(cx) {
                 panel.update(cx, |panel, cx| {
                     let thread_id = thread_context.thread.read(cx).id().clone();
@@ -3249,14 +3243,14 @@ pub(crate) fn open_context(
             }
         }),
 
-        AgentContext::Rules(rules_context) => window.dispatch_action(
+        AgentContextHandle::Rules(rules_context) => window.dispatch_action(
             Box::new(OpenRulesLibrary {
                 prompt_to_select: Some(rules_context.prompt_id.0),
             }),
             cx,
         ),
 
-        AgentContext::Image(_) => {}
+        AgentContextHandle::Image(_) => {}
     }
 }
 

crates/agent/src/context.rs đź”—

@@ -1,4 +1,6 @@
+use std::fmt::{self, Display, Formatter, Write as _};
 use std::hash::{Hash, Hasher};
+use std::path::PathBuf;
 use std::{ops::Range, path::Path, sync::Arc};
 
 use collections::HashSet;
@@ -10,9 +12,10 @@ use language_model::{LanguageModelImage, LanguageModelRequestMessage, MessageCon
 use project::{Project, ProjectEntryId, ProjectPath, Worktree};
 use prompt_store::{PromptStore, UserPromptId};
 use ref_cast::RefCast;
-use rope::{Point, Rope};
+use rope::Point;
 use text::{Anchor, OffsetRangeExt as _};
 use ui::{ElementId, IconName};
+use util::markdown::MarkdownCodeBlock;
 use util::{ResultExt as _, post_inc};
 
 use crate::thread::Thread;
@@ -45,24 +48,24 @@ impl ContextKind {
     }
 }
 
-/// Handle for context that can be added to a user message.
+/// Handle for context that can be attached to a user message.
 ///
 /// This uses IDs that are stable enough for tracking renames and identifying when context has
 /// already been added to the thread. To use this in a set, wrap it in `AgentContextKey` to opt in
 /// to `PartialEq` and `Hash` impls that use the subset of the fields used for this stable identity.
 #[derive(Debug, Clone)]
-pub enum AgentContext {
-    File(FileContext),
-    Directory(DirectoryContext),
-    Symbol(SymbolContext),
-    Selection(SelectionContext),
+pub enum AgentContextHandle {
+    File(FileContextHandle),
+    Directory(DirectoryContextHandle),
+    Symbol(SymbolContextHandle),
+    Selection(SelectionContextHandle),
     FetchedUrl(FetchedUrlContext),
-    Thread(ThreadContext),
-    Rules(RulesContext),
+    Thread(ThreadContextHandle),
+    Rules(RulesContextHandle),
     Image(ImageContext),
 }
 
-impl AgentContext {
+impl AgentContextHandle {
     fn id(&self) -> ContextId {
         match self {
             Self::File(context) => context.context_id,
@@ -81,6 +84,39 @@ impl AgentContext {
     }
 }
 
+/// Loaded context that can be attached to a user message. This can be thought of as a
+/// snapshot of the context along with an `AgentContextHandle`.
+#[derive(Debug, Clone)]
+pub enum AgentContext {
+    File(FileContext),
+    Directory(DirectoryContext),
+    Symbol(SymbolContext),
+    Selection(SelectionContext),
+    FetchedUrl(FetchedUrlContext),
+    Thread(ThreadContext),
+    Rules(RulesContext),
+    Image(ImageContext),
+}
+
+impl AgentContext {
+    pub fn handle(&self) -> AgentContextHandle {
+        match self {
+            AgentContext::File(context) => AgentContextHandle::File(context.handle.clone()),
+            AgentContext::Directory(context) => {
+                AgentContextHandle::Directory(context.handle.clone())
+            }
+            AgentContext::Symbol(context) => AgentContextHandle::Symbol(context.handle.clone()),
+            AgentContext::Selection(context) => {
+                AgentContextHandle::Selection(context.handle.clone())
+            }
+            AgentContext::FetchedUrl(context) => AgentContextHandle::FetchedUrl(context.clone()),
+            AgentContext::Thread(context) => AgentContextHandle::Thread(context.handle.clone()),
+            AgentContext::Rules(context) => AgentContextHandle::Rules(context.handle.clone()),
+            AgentContext::Image(context) => AgentContextHandle::Image(context.clone()),
+        }
+    }
+}
+
 /// ID created at time of context add, for use in ElementId. This is not the stable identity of a
 /// context, instead that's handled by the `PartialEq` and `Hash` impls of `AgentContextKey`.
 #[derive(Debug, Copy, Clone)]
@@ -106,12 +142,19 @@ impl ContextId {
 /// be opened even if the file has been deleted. An alternative might be to use `ProjectEntryId`,
 /// but then when deleted there is no path info or ability to open.
 #[derive(Debug, Clone)]
-pub struct FileContext {
+pub struct FileContextHandle {
     pub buffer: Entity<Buffer>,
     pub context_id: ContextId,
 }
 
-impl FileContext {
+#[derive(Debug, Clone)]
+pub struct FileContext {
+    pub handle: FileContextHandle,
+    pub full_path: Arc<Path>,
+    pub text: SharedString,
+}
+
+impl FileContextHandle {
     pub fn eq_for_key(&self, other: &Self) -> bool {
         self.buffer == other.buffer
     }
@@ -128,19 +171,35 @@ impl FileContext {
         })
     }
 
-    fn load(&self, cx: &App) -> Option<Task<(String, Entity<Buffer>)>> {
+    fn load(self, cx: &App) -> Task<Option<(AgentContext, Vec<Entity<Buffer>>)>> {
         let buffer_ref = self.buffer.read(cx);
         let Some(file) = buffer_ref.file() else {
             log::error!("file context missing path");
-            return None;
+            return Task::ready(None);
         };
         let full_path = file.full_path(cx);
         let rope = buffer_ref.as_rope().clone();
         let buffer = self.buffer.clone();
-        Some(
-            cx.background_spawn(
-                async move { (to_fenced_codeblock(&full_path, rope, None), buffer) },
-            ),
+        cx.background_spawn(async move {
+            let context = AgentContext::File(FileContext {
+                handle: self,
+                full_path: full_path.into(),
+                text: rope.to_string().into(),
+            });
+            Some((context, vec![buffer]))
+        })
+    }
+}
+
+impl Display for FileContext {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "{}",
+            MarkdownCodeBlock {
+                tag: &codeblock_tag(&self.full_path, None),
+                text: &self.text,
+            }
         )
     }
 }
@@ -149,12 +208,26 @@ impl FileContext {
 ///
 /// This has a `ProjectEntryId` so that it follows renames.
 #[derive(Debug, Clone)]
-pub struct DirectoryContext {
+pub struct DirectoryContextHandle {
     pub entry_id: ProjectEntryId,
     pub context_id: ContextId,
 }
 
-impl DirectoryContext {
+#[derive(Debug, Clone)]
+pub struct DirectoryContext {
+    pub handle: DirectoryContextHandle,
+    pub full_path: Arc<Path>,
+    pub descendants: Vec<DirectoryContextDescendant>,
+}
+
+#[derive(Debug, Clone)]
+pub struct DirectoryContextDescendant {
+    /// Path within the directory.
+    pub rel_path: Arc<Path>,
+    pub fenced_codeblock: SharedString,
+}
+
+impl DirectoryContextHandle {
     pub fn eq_for_key(&self, other: &Self) -> bool {
         self.entry_id == other.entry_id
     }
@@ -164,41 +237,116 @@ impl DirectoryContext {
     }
 
     fn load(
-        &self,
+        self,
         project: Entity<Project>,
         cx: &mut App,
-    ) -> Option<Task<Vec<(String, Entity<Buffer>)>>> {
-        let worktree = project.read(cx).worktree_for_entry(self.entry_id, cx)?;
+    ) -> Task<Option<(AgentContext, Vec<Entity<Buffer>>)>> {
+        let Some(worktree) = project.read(cx).worktree_for_entry(self.entry_id, cx) else {
+            return Task::ready(None);
+        };
         let worktree_ref = worktree.read(cx);
-        let entry = worktree_ref.entry_for_id(self.entry_id)?;
+        let Some(entry) = worktree_ref.entry_for_id(self.entry_id) else {
+            return Task::ready(None);
+        };
         if entry.is_file() {
             log::error!("DirectoryContext unexpectedly refers to a file.");
-            return None;
+            return Task::ready(None);
         }
 
-        let file_paths = collect_files_in_path(worktree_ref, entry.path.as_ref());
-        let texts_future = future::join_all(file_paths.into_iter().map(|path| {
-            load_file_path_text_as_fenced_codeblock(project.clone(), worktree.clone(), path, cx)
+        let directory_path = entry.path.clone();
+        let directory_full_path = worktree_ref.full_path(&directory_path).into();
+
+        let file_paths = collect_files_in_path(worktree_ref, &directory_path);
+        let descendants_future = future::join_all(file_paths.into_iter().map(|path| {
+            let worktree_ref = worktree.read(cx);
+            let worktree_id = worktree_ref.id();
+            let full_path = worktree_ref.full_path(&path);
+
+            let rel_path = path
+                .strip_prefix(&directory_path)
+                .log_err()
+                .map_or_else(|| path.clone(), |rel_path| rel_path.into());
+
+            let open_task = project.update(cx, |project, cx| {
+                project.buffer_store().update(cx, |buffer_store, cx| {
+                    let project_path = ProjectPath { worktree_id, path };
+                    buffer_store.open_buffer(project_path, cx)
+                })
+            });
+
+            // TODO: report load errors instead of just logging
+            let rope_task = cx.spawn(async move |cx| {
+                let buffer = open_task.await.log_err()?;
+                let rope = buffer
+                    .read_with(cx, |buffer, _cx| buffer.as_rope().clone())
+                    .log_err()?;
+                Some((rope, buffer))
+            });
+
+            cx.background_spawn(async move {
+                let (rope, buffer) = rope_task.await?;
+                let fenced_codeblock = MarkdownCodeBlock {
+                    tag: &codeblock_tag(&full_path, None),
+                    text: &rope.to_string(),
+                }
+                .to_string()
+                .into();
+                let descendant = DirectoryContextDescendant {
+                    rel_path,
+                    fenced_codeblock,
+                };
+                Some((descendant, buffer))
+            })
         }));
 
-        Some(cx.background_spawn(async move {
-            texts_future.await.into_iter().flatten().collect::<Vec<_>>()
-        }))
+        cx.background_spawn(async move {
+            let (descendants, buffers) = descendants_future.await.into_iter().flatten().unzip();
+            let context = AgentContext::Directory(DirectoryContext {
+                handle: self,
+                full_path: directory_full_path,
+                descendants,
+            });
+            Some((context, buffers))
+        })
+    }
+}
+
+impl Display for DirectoryContext {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let mut is_first = true;
+        for descendant in &self.descendants {
+            if !is_first {
+                write!(f, "\n")?;
+            } else {
+                is_first = false;
+            }
+            write!(f, "{}", descendant.fenced_codeblock)?;
+        }
+        Ok(())
     }
 }
 
 #[derive(Debug, Clone)]
-pub struct SymbolContext {
+pub struct SymbolContextHandle {
     pub buffer: Entity<Buffer>,
     pub symbol: SharedString,
     pub range: Range<Anchor>,
-    /// The range that fully contain the symbol. e.g. for function symbol, this will include not
-    /// only the signature, but also the body. Not used by `PartialEq` or `Hash` for `AgentContextKey`.
+    /// The range that fully contains the symbol. e.g. for function symbol, this will include not
+    /// only the signature, but also the body. Not used by `PartialEq` or `Hash` for
+    /// `AgentContextKey`.
     pub enclosing_range: Range<Anchor>,
     pub context_id: ContextId,
 }
 
-impl SymbolContext {
+#[derive(Debug, Clone)]
+pub struct SymbolContext {
+    pub handle: SymbolContextHandle,
+    pub full_path: Arc<Path>,
+    pub line_range: Range<Point>,
+    pub text: SharedString,
+}
+
+impl SymbolContextHandle {
     pub fn eq_for_key(&self, other: &Self) -> bool {
         self.buffer == other.buffer && self.symbol == other.symbol && self.range == other.range
     }
@@ -209,35 +357,69 @@ impl SymbolContext {
         self.range.hash(state);
     }
 
-    fn load(&self, cx: &App) -> Option<Task<(String, Entity<Buffer>)>> {
+    pub fn full_path(&self, cx: &App) -> Option<PathBuf> {
+        Some(self.buffer.read(cx).file()?.full_path(cx))
+    }
+
+    pub fn enclosing_line_range(&self, cx: &App) -> Range<Point> {
+        self.enclosing_range
+            .to_point(&self.buffer.read(cx).snapshot())
+    }
+
+    pub fn text(&self, cx: &App) -> SharedString {
+        self.buffer
+            .read(cx)
+            .text_for_range(self.enclosing_range.clone())
+            .collect::<String>()
+            .into()
+    }
+
+    fn load(self, cx: &App) -> Task<Option<(AgentContext, Vec<Entity<Buffer>>)>> {
         let buffer_ref = self.buffer.read(cx);
         let Some(file) = buffer_ref.file() else {
             log::error!("symbol context's file has no path");
-            return None;
+            return Task::ready(None);
         };
-        let full_path = file.full_path(cx);
-        let rope = buffer_ref
-            .text_for_range(self.enclosing_range.clone())
-            .collect::<Rope>();
+        let full_path = file.full_path(cx).into();
         let line_range = self.enclosing_range.to_point(&buffer_ref.snapshot());
+        let text = self.text(cx);
         let buffer = self.buffer.clone();
-        Some(cx.background_spawn(async move {
-            (
-                to_fenced_codeblock(&full_path, rope, Some(line_range)),
-                buffer,
-            )
-        }))
+        let context = AgentContext::Symbol(SymbolContext {
+            handle: self,
+            full_path,
+            line_range,
+            text,
+        });
+        Task::ready(Some((context, vec![buffer])))
+    }
+}
+
+impl Display for SymbolContext {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let code_block = MarkdownCodeBlock {
+            tag: &codeblock_tag(&self.full_path, Some(self.line_range.clone())),
+            text: &self.text,
+        };
+        write!(f, "{code_block}",)
     }
 }
 
 #[derive(Debug, Clone)]
-pub struct SelectionContext {
+pub struct SelectionContextHandle {
     pub buffer: Entity<Buffer>,
     pub range: Range<Anchor>,
     pub context_id: ContextId,
 }
 
-impl SelectionContext {
+#[derive(Debug, Clone)]
+pub struct SelectionContext {
+    pub handle: SelectionContextHandle,
+    pub full_path: Arc<Path>,
+    pub line_range: Range<Point>,
+    pub text: SharedString,
+}
+
+impl SelectionContextHandle {
     pub fn eq_for_key(&self, other: &Self) -> bool {
         self.buffer == other.buffer && self.range == other.range
     }
@@ -247,24 +429,47 @@ impl SelectionContext {
         self.range.hash(state);
     }
 
-    fn load(&self, cx: &App) -> Option<Task<(String, Entity<Buffer>)>> {
-        let buffer_ref = self.buffer.read(cx);
-        let Some(file) = buffer_ref.file() else {
+    pub fn full_path(&self, cx: &App) -> Option<PathBuf> {
+        Some(self.buffer.read(cx).file()?.full_path(cx))
+    }
+
+    pub fn line_range(&self, cx: &App) -> Range<Point> {
+        self.range.to_point(&self.buffer.read(cx).snapshot())
+    }
+
+    pub fn text(&self, cx: &App) -> SharedString {
+        self.buffer
+            .read(cx)
+            .text_for_range(self.range.clone())
+            .collect::<String>()
+            .into()
+    }
+
+    fn load(self, cx: &App) -> Task<Option<(AgentContext, Vec<Entity<Buffer>>)>> {
+        let Some(full_path) = self.full_path(cx) else {
             log::error!("selection context's file has no path");
-            return None;
+            return Task::ready(None);
         };
-        let full_path = file.full_path(cx);
-        let rope = buffer_ref
-            .text_for_range(self.range.clone())
-            .collect::<Rope>();
-        let line_range = self.range.to_point(&buffer_ref.snapshot());
+        let text = self.text(cx);
         let buffer = self.buffer.clone();
-        Some(cx.background_spawn(async move {
-            (
-                to_fenced_codeblock(&full_path, rope, Some(line_range)),
-                buffer,
-            )
-        }))
+        let context = AgentContext::Selection(SelectionContext {
+            full_path: full_path.into(),
+            line_range: self.line_range(cx),
+            text,
+            handle: self,
+        });
+
+        Task::ready(Some((context, vec![buffer])))
+    }
+}
+
+impl Display for SelectionContext {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let code_block = MarkdownCodeBlock {
+            tag: &codeblock_tag(&self.full_path, Some(self.line_range.clone())),
+            text: &self.text,
+        };
+        write!(f, "{code_block}",)
     }
 }
 
@@ -288,21 +493,39 @@ impl FetchedUrlContext {
     }
 
     pub fn lookup_key(url: SharedString) -> AgentContextKey {
-        AgentContextKey(AgentContext::FetchedUrl(FetchedUrlContext {
+        AgentContextKey(AgentContextHandle::FetchedUrl(FetchedUrlContext {
             url,
             text: "".into(),
             context_id: ContextId::for_lookup(),
         }))
     }
+
+    pub fn load(self) -> Task<Option<(AgentContext, Vec<Entity<Buffer>>)>> {
+        Task::ready(Some((AgentContext::FetchedUrl(self), vec![])))
+    }
+}
+
+impl Display for FetchedUrlContext {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        // TODO: Better format - url and contents are not delimited.
+        write!(f, "{}\n{}\n", self.url, self.text)
+    }
 }
 
 #[derive(Debug, Clone)]
-pub struct ThreadContext {
+pub struct ThreadContextHandle {
     pub thread: Entity<Thread>,
     pub context_id: ContextId,
 }
 
-impl ThreadContext {
+#[derive(Debug, Clone)]
+pub struct ThreadContext {
+    pub handle: ThreadContextHandle,
+    pub title: SharedString,
+    pub text: SharedString,
+}
+
+impl ThreadContextHandle {
     pub fn eq_for_key(&self, other: &Self) -> bool {
         self.thread == other.thread
     }
@@ -311,32 +534,44 @@ impl ThreadContext {
         self.thread.hash(state)
     }
 
-    pub fn name(&self, cx: &App) -> SharedString {
+    pub fn title(&self, cx: &App) -> SharedString {
         self.thread
             .read(cx)
             .summary()
             .unwrap_or_else(|| "New thread".into())
     }
 
-    pub fn load(&self, cx: &App) -> String {
-        let name = self.name(cx);
-        let contents = self.thread.read(cx).latest_detailed_summary_or_text();
-        let mut text = String::new();
-        text.push_str(&name);
-        text.push('\n');
-        text.push_str(&contents.trim());
-        text.push('\n');
-        text
+    fn load(self, cx: &App) -> Task<Option<(AgentContext, Vec<Entity<Buffer>>)>> {
+        let context = AgentContext::Thread(ThreadContext {
+            title: self.title(cx),
+            text: self.thread.read(cx).latest_detailed_summary_or_text(),
+            handle: self,
+        });
+        Task::ready(Some((context, vec![])))
+    }
+}
+
+impl Display for ThreadContext {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        // TODO: Better format for this - doesn't distinguish title and contents.
+        write!(f, "{}\n{}\n", &self.title, &self.text.trim())
     }
 }
 
 #[derive(Debug, Clone)]
-pub struct RulesContext {
+pub struct RulesContextHandle {
     pub prompt_id: UserPromptId,
     pub context_id: ContextId,
 }
 
-impl RulesContext {
+#[derive(Debug, Clone)]
+pub struct RulesContext {
+    pub handle: RulesContextHandle,
+    pub title: Option<SharedString>,
+    pub text: SharedString,
+}
+
+impl RulesContextHandle {
     pub fn eq_for_key(&self, other: &Self) -> bool {
         self.prompt_id == other.prompt_id
     }
@@ -346,17 +581,17 @@ impl RulesContext {
     }
 
     pub fn lookup_key(prompt_id: UserPromptId) -> AgentContextKey {
-        AgentContextKey(AgentContext::Rules(RulesContext {
+        AgentContextKey(AgentContextHandle::Rules(RulesContextHandle {
             prompt_id,
             context_id: ContextId::for_lookup(),
         }))
     }
 
     pub fn load(
-        &self,
+        self,
         prompt_store: &Option<Entity<PromptStore>>,
         cx: &App,
-    ) -> Task<Option<String>> {
+    ) -> Task<Option<(AgentContext, Vec<Entity<Buffer>>)>> {
         let Some(prompt_store) = prompt_store.as_ref() else {
             return Task::ready(None);
         };
@@ -365,23 +600,34 @@ impl RulesContext {
         let Some(metadata) = prompt_store.metadata(prompt_id) else {
             return Task::ready(None);
         };
-        let contents_task = prompt_store.load(prompt_id, cx);
+        let title = metadata.title;
+        let text_task = prompt_store.load(prompt_id, cx);
         cx.background_spawn(async move {
-            let contents = contents_task.await.ok()?;
-            let mut text = String::new();
-            if let Some(title) = metadata.title {
-                text.push_str("Rules title: ");
-                text.push_str(&title);
-                text.push('\n');
-            }
-            text.push_str("``````\n");
-            text.push_str(contents.trim());
-            text.push_str("\n``````\n");
-            Some(text)
+            // TODO: report load errors instead of just logging
+            let text = text_task.await.log_err()?.into();
+            let context = AgentContext::Rules(RulesContext {
+                handle: self,
+                title,
+                text,
+            });
+            Some((context, vec![]))
         })
     }
 }
 
+impl Display for RulesContext {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        if let Some(title) = &self.title {
+            write!(f, "Rules title: {}\n", title)?;
+        }
+        let code_block = MarkdownCodeBlock {
+            tag: "",
+            text: self.text.trim(),
+        };
+        write!(f, "{code_block}")
+    }
+}
+
 #[derive(Debug, Clone)]
 pub struct ImageContext {
     pub original_image: Arc<gpui::Image>,
@@ -417,6 +663,13 @@ impl ImageContext {
             Some(Some(_)) => ImageStatus::Ready,
         }
     }
+
+    pub fn load(self, cx: &App) -> Task<Option<(AgentContext, Vec<Entity<Buffer>>)>> {
+        cx.background_spawn(async move {
+            self.image_task.clone().await;
+            Some((AgentContext::Image(self), vec![]))
+        })
+    }
 }
 
 #[derive(Debug, Clone, Default)]
@@ -463,64 +716,68 @@ impl LoadedContext {
 
 /// Loads and formats a collection of contexts.
 pub fn load_context(
-    contexts: Vec<AgentContext>,
+    contexts: Vec<AgentContextHandle>,
     project: &Entity<Project>,
     prompt_store: &Option<Entity<PromptStore>>,
     cx: &mut App,
 ) -> Task<ContextLoadResult> {
-    let mut file_tasks = Vec::new();
-    let mut directory_tasks = Vec::new();
-    let mut symbol_tasks = Vec::new();
-    let mut selection_tasks = Vec::new();
-    let mut fetch_context = Vec::new();
-    let mut thread_context = Vec::new();
-    let mut rules_tasks = Vec::new();
-    let mut image_tasks = Vec::new();
+    let mut load_tasks = Vec::new();
 
     for context in contexts.iter().cloned() {
         match context {
-            AgentContext::File(context) => file_tasks.extend(context.load(cx)),
-            AgentContext::Directory(context) => {
-                directory_tasks.extend(context.load(project.clone(), cx))
+            AgentContextHandle::File(context) => load_tasks.push(context.load(cx)),
+            AgentContextHandle::Directory(context) => {
+                load_tasks.push(context.load(project.clone(), cx))
             }
-            AgentContext::Symbol(context) => symbol_tasks.extend(context.load(cx)),
-            AgentContext::Selection(context) => selection_tasks.extend(context.load(cx)),
-            AgentContext::FetchedUrl(context) => fetch_context.push(context),
-            AgentContext::Thread(context) => thread_context.push(context.load(cx)),
-            AgentContext::Rules(context) => rules_tasks.push(context.load(prompt_store, cx)),
-            AgentContext::Image(context) => image_tasks.push(context.image_task.clone()),
+            AgentContextHandle::Symbol(context) => load_tasks.push(context.load(cx)),
+            AgentContextHandle::Selection(context) => load_tasks.push(context.load(cx)),
+            AgentContextHandle::FetchedUrl(context) => load_tasks.push(context.load()),
+            AgentContextHandle::Thread(context) => load_tasks.push(context.load(cx)),
+            AgentContextHandle::Rules(context) => load_tasks.push(context.load(prompt_store, cx)),
+            AgentContextHandle::Image(context) => load_tasks.push(context.load(cx)),
         }
     }
 
     cx.background_spawn(async move {
-        let (
-            file_context,
-            directory_context,
-            symbol_context,
-            selection_context,
-            rules_context,
-            images,
-        ) = futures::join!(
-            future::join_all(file_tasks),
-            future::join_all(directory_tasks),
-            future::join_all(symbol_tasks),
-            future::join_all(selection_tasks),
-            future::join_all(rules_tasks),
-            future::join_all(image_tasks)
-        );
-
-        let directory_context = directory_context.into_iter().flatten().collect::<Vec<_>>();
-        let rules_context = rules_context.into_iter().flatten().collect::<Vec<_>>();
-        let images = images.into_iter().flatten().collect::<Vec<_>>();
+        let load_results = future::join_all(load_tasks).await;
 
-        let mut referenced_buffers = HashSet::default();
+        let mut contexts = Vec::new();
         let mut text = String::new();
+        let mut referenced_buffers = HashSet::default();
+        for context in load_results {
+            let Some((context, buffers)) = context else {
+                continue;
+            };
+            contexts.push(context);
+            referenced_buffers.extend(buffers);
+        }
+
+        let mut file_context = Vec::new();
+        let mut directory_context = Vec::new();
+        let mut symbol_context = Vec::new();
+        let mut selection_context = Vec::new();
+        let mut fetched_url_context = Vec::new();
+        let mut thread_context = Vec::new();
+        let mut rules_context = Vec::new();
+        let mut images = Vec::new();
+        for context in &contexts {
+            match context {
+                AgentContext::File(context) => file_context.push(context),
+                AgentContext::Directory(context) => directory_context.push(context),
+                AgentContext::Symbol(context) => symbol_context.push(context),
+                AgentContext::Selection(context) => selection_context.push(context),
+                AgentContext::FetchedUrl(context) => fetched_url_context.push(context),
+                AgentContext::Thread(context) => thread_context.push(context),
+                AgentContext::Rules(context) => rules_context.push(context),
+                AgentContext::Image(context) => images.extend(context.image()),
+            }
+        }
 
         if file_context.is_empty()
             && directory_context.is_empty()
             && symbol_context.is_empty()
             && selection_context.is_empty()
-            && fetch_context.is_empty()
+            && fetched_url_context.is_empty()
             && thread_context.is_empty()
             && rules_context.is_empty()
         {
@@ -542,60 +799,54 @@ pub fn load_context(
 
         if !file_context.is_empty() {
             text.push_str("<files>");
-            for (file_text, buffer) in file_context {
+            for context in file_context {
                 text.push('\n');
-                text.push_str(&file_text);
-                referenced_buffers.insert(buffer);
+                let _ = write!(text, "{context}");
             }
             text.push_str("</files>\n");
         }
 
         if !directory_context.is_empty() {
             text.push_str("<directories>");
-            for (file_text, buffer) in directory_context {
+            for context in directory_context {
                 text.push('\n');
-                text.push_str(&file_text);
-                referenced_buffers.insert(buffer);
+                let _ = write!(text, "{context}");
             }
             text.push_str("</directories>\n");
         }
 
         if !symbol_context.is_empty() {
             text.push_str("<symbols>");
-            for (symbol_text, buffer) in symbol_context {
+            for context in symbol_context {
                 text.push('\n');
-                text.push_str(&symbol_text);
-                referenced_buffers.insert(buffer);
+                let _ = write!(text, "{context}");
             }
             text.push_str("</symbols>\n");
         }
 
         if !selection_context.is_empty() {
             text.push_str("<selections>");
-            for (selection_text, buffer) in selection_context {
+            for context in selection_context {
                 text.push('\n');
-                text.push_str(&selection_text);
-                referenced_buffers.insert(buffer);
+                let _ = write!(text, "{context}");
             }
             text.push_str("</selections>\n");
         }
 
-        if !fetch_context.is_empty() {
+        if !fetched_url_context.is_empty() {
             text.push_str("<fetched_urls>");
-            for context in fetch_context {
-                text.push('\n');
-                text.push_str(&context.url);
+            for context in fetched_url_context {
                 text.push('\n');
-                text.push_str(&context.text);
+                let _ = write!(text, "{context}");
             }
             text.push_str("</fetched_urls>\n");
         }
 
         if !thread_context.is_empty() {
             text.push_str("<conversation_threads>");
-            for thread_text in thread_context {
+            for context in thread_context {
                 text.push('\n');
-                text.push_str(&thread_text);
+                let _ = write!(text, "{context}");
             }
             text.push_str("</conversation_threads>\n");
         }
@@ -605,9 +856,9 @@ pub fn load_context(
                 "<user_rules>\n\
                 The user has specified the following rules that should be applied:\n",
             );
-            for rules_text in rules_context {
+            for context in rules_context {
                 text.push('\n');
-                text.push_str(&rules_text);
+                let _ = write!(text, "{context}");
             }
             text.push_str("</user_rules>\n");
         }
@@ -639,102 +890,34 @@ fn collect_files_in_path(worktree: &Worktree, path: &Path) -> Vec<Arc<Path>> {
     files
 }
 
-fn load_file_path_text_as_fenced_codeblock(
-    project: Entity<Project>,
-    worktree: Entity<Worktree>,
-    path: Arc<Path>,
-    cx: &mut App,
-) -> Task<Option<(String, Entity<Buffer>)>> {
-    let worktree_ref = worktree.read(cx);
-    let worktree_id = worktree_ref.id();
-    let full_path = worktree_ref.full_path(&path);
-
-    let open_task = project.update(cx, |project, cx| {
-        project.buffer_store().update(cx, |buffer_store, cx| {
-            let project_path = ProjectPath { worktree_id, path };
-            buffer_store.open_buffer(project_path, cx)
-        })
-    });
+fn codeblock_tag(full_path: &Path, line_range: Option<Range<Point>>) -> String {
+    let mut result = String::new();
 
-    let rope_task = cx.spawn(async move |cx| {
-        let buffer = open_task.await.log_err()?;
-        let rope = buffer
-            .read_with(cx, |buffer, _cx| buffer.as_rope().clone())
-            .log_err()?;
-        Some((rope, buffer))
-    });
+    if let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) {
+        let _ = write!(result, "{} ", extension);
+    }
 
-    cx.background_spawn(async move {
-        let (rope, buffer) = rope_task.await?;
-        Some((to_fenced_codeblock(&full_path, rope, None), buffer))
-    })
-}
+    let _ = write!(result, "{}", full_path.display());
 
-fn to_fenced_codeblock(
-    full_path: &Path,
-    content: Rope,
-    line_range: Option<Range<Point>>,
-) -> String {
-    let line_range_text = line_range.map(|range| {
+    if let Some(range) = line_range {
         if range.start.row == range.end.row {
-            format!(":{}", range.start.row + 1)
+            let _ = write!(result, ":{}", range.start.row + 1);
         } else {
-            format!(":{}-{}", range.start.row + 1, range.end.row + 1)
+            let _ = write!(result, ":{}-{}", range.start.row + 1, range.end.row + 1);
         }
-    });
-
-    let path_extension = full_path.extension().and_then(|ext| ext.to_str());
-    let path_string = full_path.to_string_lossy();
-    let capacity = 3
-        + path_extension.map_or(0, |extension| extension.len() + 1)
-        + path_string.len()
-        + line_range_text.as_ref().map_or(0, |text| text.len())
-        + 1
-        + content.len()
-        + 5;
-    let mut buffer = String::with_capacity(capacity);
-
-    buffer.push_str("```");
-
-    if let Some(extension) = path_extension {
-        buffer.push_str(extension);
-        buffer.push(' ');
     }
-    buffer.push_str(&path_string);
 
-    if let Some(line_range_text) = line_range_text {
-        buffer.push_str(&line_range_text);
-    }
-
-    buffer.push('\n');
-    for chunk in content.chunks() {
-        buffer.push_str(chunk);
-    }
-
-    if !buffer.ends_with('\n') {
-        buffer.push('\n');
-    }
-
-    buffer.push_str("```\n");
-
-    debug_assert!(
-        buffer.len() == capacity - 1 || buffer.len() == capacity,
-        "to_fenced_codeblock calculated capacity of {}, but length was {}",
-        capacity,
-        buffer.len(),
-    );
-
-    buffer
+    result
 }
 
 /// Wraps `AgentContext` to opt-in to `PartialEq` and `Hash` impls which use a subset of fields
 /// needed for stable context identity.
 #[derive(Debug, Clone, RefCast)]
 #[repr(transparent)]
-pub struct AgentContextKey(pub AgentContext);
+pub struct AgentContextKey(pub AgentContextHandle);
 
-impl AsRef<AgentContext> for AgentContextKey {
-    fn as_ref(&self) -> &AgentContext {
+impl AsRef<AgentContextHandle> for AgentContextKey {
+    fn as_ref(&self) -> &AgentContextHandle {
         &self.0
     }
 }
@@ -744,43 +927,43 @@ impl Eq for AgentContextKey {}
 impl PartialEq for AgentContextKey {
     fn eq(&self, other: &Self) -> bool {
         match &self.0 {
-            AgentContext::File(context) => {
-                if let AgentContext::File(other_context) = &other.0 {
+            AgentContextHandle::File(context) => {
+                if let AgentContextHandle::File(other_context) = &other.0 {
                     return context.eq_for_key(other_context);
                 }
             }
-            AgentContext::Directory(context) => {
-                if let AgentContext::Directory(other_context) = &other.0 {
+            AgentContextHandle::Directory(context) => {
+                if let AgentContextHandle::Directory(other_context) = &other.0 {
                     return context.eq_for_key(other_context);
                 }
             }
-            AgentContext::Symbol(context) => {
-                if let AgentContext::Symbol(other_context) = &other.0 {
+            AgentContextHandle::Symbol(context) => {
+                if let AgentContextHandle::Symbol(other_context) = &other.0 {
                     return context.eq_for_key(other_context);
                 }
             }
-            AgentContext::Selection(context) => {
-                if let AgentContext::Selection(other_context) = &other.0 {
+            AgentContextHandle::Selection(context) => {
+                if let AgentContextHandle::Selection(other_context) = &other.0 {
                     return context.eq_for_key(other_context);
                 }
             }
-            AgentContext::FetchedUrl(context) => {
-                if let AgentContext::FetchedUrl(other_context) = &other.0 {
+            AgentContextHandle::FetchedUrl(context) => {
+                if let AgentContextHandle::FetchedUrl(other_context) = &other.0 {
                     return context.eq_for_key(other_context);
                 }
             }
-            AgentContext::Thread(context) => {
-                if let AgentContext::Thread(other_context) = &other.0 {
+            AgentContextHandle::Thread(context) => {
+                if let AgentContextHandle::Thread(other_context) = &other.0 {
                     return context.eq_for_key(other_context);
                 }
             }
-            AgentContext::Rules(context) => {
-                if let AgentContext::Rules(other_context) = &other.0 {
+            AgentContextHandle::Rules(context) => {
+                if let AgentContextHandle::Rules(other_context) = &other.0 {
                     return context.eq_for_key(other_context);
                 }
             }
-            AgentContext::Image(context) => {
-                if let AgentContext::Image(other_context) = &other.0 {
+            AgentContextHandle::Image(context) => {
+                if let AgentContextHandle::Image(other_context) = &other.0 {
                     return context.eq_for_key(other_context);
                 }
             }

crates/agent/src/context_store.rs đź”—

@@ -17,8 +17,9 @@ use util::ResultExt as _;
 
 use crate::ThreadStore;
 use crate::context::{
-    AgentContext, AgentContextKey, ContextId, DirectoryContext, FetchedUrlContext, FileContext,
-    ImageContext, RulesContext, SelectionContext, SymbolContext, ThreadContext,
+    AgentContextHandle, AgentContextKey, ContextId, DirectoryContextHandle, FetchedUrlContext,
+    FileContextHandle, ImageContext, RulesContextHandle, SelectionContextHandle,
+    SymbolContextHandle, ThreadContextHandle,
 };
 use crate::context_strip::SuggestedContext;
 use crate::thread::{Thread, ThreadId};
@@ -47,7 +48,7 @@ impl ContextStore {
         }
     }
 
-    pub fn context(&self) -> impl Iterator<Item = &AgentContext> {
+    pub fn context(&self) -> impl Iterator<Item = &AgentContextHandle> {
         self.context_set.iter().map(|entry| entry.as_ref())
     }
 
@@ -56,11 +57,16 @@ impl ContextStore {
         self.context_thread_ids.clear();
     }
 
-    pub fn new_context_for_thread(&self, thread: &Thread) -> Vec<AgentContext> {
+    pub fn new_context_for_thread(&self, thread: &Thread) -> Vec<AgentContextHandle> {
         let existing_context = thread
             .messages()
-            .flat_map(|message| &message.loaded_context.contexts)
-            .map(AgentContextKey::ref_cast)
+            .flat_map(|message| {
+                message
+                    .loaded_context
+                    .contexts
+                    .iter()
+                    .map(|context| AgentContextKey(context.handle()))
+            })
             .collect::<HashSet<_>>();
         self.context_set
             .iter()
@@ -98,7 +104,7 @@ impl ContextStore {
         cx: &mut Context<Self>,
     ) {
         let context_id = self.next_context_id.post_inc();
-        let context = AgentContext::File(FileContext { buffer, context_id });
+        let context = AgentContextHandle::File(FileContextHandle { buffer, context_id });
 
         let already_included = if self.has_context(&context) {
             if remove_if_exists {
@@ -133,7 +139,7 @@ impl ContextStore {
         };
 
         let context_id = self.next_context_id.post_inc();
-        let context = AgentContext::Directory(DirectoryContext {
+        let context = AgentContextHandle::Directory(DirectoryContextHandle {
             entry_id,
             context_id,
         });
@@ -159,7 +165,7 @@ impl ContextStore {
         cx: &mut Context<Self>,
     ) -> bool {
         let context_id = self.next_context_id.post_inc();
-        let context = AgentContext::Symbol(SymbolContext {
+        let context = AgentContextHandle::Symbol(SymbolContextHandle {
             buffer,
             symbol,
             range,
@@ -184,7 +190,7 @@ impl ContextStore {
         cx: &mut Context<Self>,
     ) {
         let context_id = self.next_context_id.post_inc();
-        let context = AgentContext::Thread(ThreadContext { thread, context_id });
+        let context = AgentContextHandle::Thread(ThreadContextHandle { thread, context_id });
 
         if self.has_context(&context) {
             if remove_if_exists {
@@ -237,7 +243,7 @@ impl ContextStore {
         cx: &mut Context<ContextStore>,
     ) {
         let context_id = self.next_context_id.post_inc();
-        let context = AgentContext::Rules(RulesContext {
+        let context = AgentContextHandle::Rules(RulesContextHandle {
             prompt_id,
             context_id,
         });
@@ -257,7 +263,7 @@ impl ContextStore {
         text: impl Into<SharedString>,
         cx: &mut Context<ContextStore>,
     ) {
-        let context = AgentContext::FetchedUrl(FetchedUrlContext {
+        let context = AgentContextHandle::FetchedUrl(FetchedUrlContext {
             url: url.into(),
             text: text.into(),
             context_id: self.next_context_id.post_inc(),
@@ -268,7 +274,7 @@ impl ContextStore {
 
     pub fn add_image(&mut self, image: Arc<Image>, cx: &mut Context<ContextStore>) {
         let image_task = LanguageModelImage::from_image(image.clone(), cx).shared();
-        let context = AgentContext::Image(ImageContext {
+        let context = AgentContextHandle::Image(ImageContext {
             original_image: image,
             image_task,
             context_id: self.next_context_id.post_inc(),
@@ -283,7 +289,7 @@ impl ContextStore {
         cx: &mut Context<ContextStore>,
     ) {
         let context_id = self.next_context_id.post_inc();
-        let context = AgentContext::Selection(SelectionContext {
+        let context = AgentContextHandle::Selection(SelectionContextHandle {
             buffer,
             range,
             context_id,
@@ -304,14 +310,17 @@ impl ContextStore {
             } => {
                 if let Some(buffer) = buffer.upgrade() {
                     let context_id = self.next_context_id.post_inc();
-                    self.insert_context(AgentContext::File(FileContext { buffer, context_id }), cx);
+                    self.insert_context(
+                        AgentContextHandle::File(FileContextHandle { buffer, context_id }),
+                        cx,
+                    );
                 };
             }
             SuggestedContext::Thread { thread, name: _ } => {
                 if let Some(thread) = thread.upgrade() {
                     let context_id = self.next_context_id.post_inc();
                     self.insert_context(
-                        AgentContext::Thread(ThreadContext { thread, context_id }),
+                        AgentContextHandle::Thread(ThreadContextHandle { thread, context_id }),
                         cx,
                     );
                 }
@@ -319,9 +328,9 @@ impl ContextStore {
         }
     }
 
-    fn insert_context(&mut self, context: AgentContext, cx: &mut Context<Self>) -> bool {
+    fn insert_context(&mut self, context: AgentContextHandle, cx: &mut Context<Self>) -> bool {
         match &context {
-            AgentContext::Thread(thread_context) => {
+            AgentContextHandle::Thread(thread_context) => {
                 self.context_thread_ids
                     .insert(thread_context.thread.read(cx).id().clone());
                 self.start_summarizing_thread_if_needed(&thread_context.thread, cx);
@@ -335,13 +344,13 @@ impl ContextStore {
         inserted
     }
 
-    pub fn remove_context(&mut self, context: &AgentContext, cx: &mut Context<Self>) {
+    pub fn remove_context(&mut self, context: &AgentContextHandle, cx: &mut Context<Self>) {
         if self
             .context_set
             .shift_remove(AgentContextKey::ref_cast(context))
         {
             match context {
-                AgentContext::Thread(thread_context) => {
+                AgentContextHandle::Thread(thread_context) => {
                     self.context_thread_ids
                         .remove(thread_context.thread.read(cx).id());
                 }
@@ -351,7 +360,7 @@ impl ContextStore {
         }
     }
 
-    pub fn has_context(&mut self, context: &AgentContext) -> bool {
+    pub fn has_context(&mut self, context: &AgentContextHandle) -> bool {
         self.context_set
             .contains(AgentContextKey::ref_cast(context))
     }
@@ -361,8 +370,10 @@ impl ContextStore {
     pub fn file_path_included(&self, path: &ProjectPath, cx: &App) -> Option<FileInclusion> {
         let project = self.project.upgrade()?.read(cx);
         self.context().find_map(|context| match context {
-            AgentContext::File(file_context) => FileInclusion::check_file(file_context, path, cx),
-            AgentContext::Directory(directory_context) => {
+            AgentContextHandle::File(file_context) => {
+                FileInclusion::check_file(file_context, path, cx)
+            }
+            AgentContextHandle::Directory(directory_context) => {
                 FileInclusion::check_directory(directory_context, path, project, cx)
             }
             _ => None,
@@ -376,7 +387,7 @@ impl ContextStore {
     ) -> Option<FileInclusion> {
         let project = self.project.upgrade()?.read(cx);
         self.context().find_map(|context| match context {
-            AgentContext::Directory(directory_context) => {
+            AgentContextHandle::Directory(directory_context) => {
                 FileInclusion::check_directory(directory_context, path, project, cx)
             }
             _ => None,
@@ -385,7 +396,7 @@ impl ContextStore {
 
     pub fn includes_symbol(&self, symbol: &Symbol, cx: &App) -> bool {
         self.context().any(|context| match context {
-            AgentContext::Symbol(context) => {
+            AgentContextHandle::Symbol(context) => {
                 if context.symbol != symbol.name {
                     return false;
                 }
@@ -410,7 +421,7 @@ impl ContextStore {
 
     pub fn includes_user_rules(&self, prompt_id: UserPromptId) -> bool {
         self.context_set
-            .contains(&RulesContext::lookup_key(prompt_id))
+            .contains(&RulesContextHandle::lookup_key(prompt_id))
     }
 
     pub fn includes_url(&self, url: impl Into<SharedString>) -> bool {
@@ -421,17 +432,17 @@ impl ContextStore {
     pub fn file_paths(&self, cx: &App) -> HashSet<ProjectPath> {
         self.context()
             .filter_map(|context| match context {
-                AgentContext::File(file) => {
+                AgentContextHandle::File(file) => {
                     let buffer = file.buffer.read(cx);
                     buffer.project_path(cx)
                 }
-                AgentContext::Directory(_)
-                | AgentContext::Symbol(_)
-                | AgentContext::Selection(_)
-                | AgentContext::FetchedUrl(_)
-                | AgentContext::Thread(_)
-                | AgentContext::Rules(_)
-                | AgentContext::Image(_) => None,
+                AgentContextHandle::Directory(_)
+                | AgentContextHandle::Symbol(_)
+                | AgentContextHandle::Selection(_)
+                | AgentContextHandle::FetchedUrl(_)
+                | AgentContextHandle::Thread(_)
+                | AgentContextHandle::Rules(_)
+                | AgentContextHandle::Image(_) => None,
             })
             .collect()
     }
@@ -447,7 +458,7 @@ pub enum FileInclusion {
 }
 
 impl FileInclusion {
-    fn check_file(file_context: &FileContext, path: &ProjectPath, cx: &App) -> Option<Self> {
+    fn check_file(file_context: &FileContextHandle, path: &ProjectPath, cx: &App) -> Option<Self> {
         let file_path = file_context.buffer.read(cx).project_path(cx)?;
         if path == &file_path {
             Some(FileInclusion::Direct)
@@ -457,7 +468,7 @@ impl FileInclusion {
     }
 
     fn check_directory(
-        directory_context: &DirectoryContext,
+        directory_context: &DirectoryContextHandle,
         path: &ProjectPath,
         project: &Project,
         cx: &App,

crates/agent/src/context_strip.rs đź”—

@@ -14,7 +14,7 @@ use project::ProjectItem;
 use ui::{KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
 use workspace::Workspace;
 
-use crate::context::{AgentContext, ContextKind};
+use crate::context::{AgentContextHandle, ContextKind};
 use crate::context_picker::ContextPicker;
 use crate::context_store::ContextStore;
 use crate::thread::Thread;
@@ -92,7 +92,9 @@ impl ContextStrip {
             self.context_store
                 .read(cx)
                 .context()
-                .flat_map(|context| AddedContext::new(context.clone(), prompt_store, project, cx))
+                .flat_map(|context| {
+                    AddedContext::new_pending(context.clone(), prompt_store, project, cx)
+                })
                 .collect::<Vec<_>>()
         } else {
             Vec::new()
@@ -288,7 +290,7 @@ impl ContextStrip {
         best.map(|(index, _, _)| index)
     }
 
-    fn open_context(&mut self, context: &AgentContext, window: &mut Window, cx: &mut App) {
+    fn open_context(&mut self, context: &AgentContextHandle, window: &mut Window, cx: &mut App) {
         let Some(workspace) = self.workspace.upgrade() else {
             return;
         };
@@ -309,7 +311,7 @@ impl ContextStrip {
             };
 
             self.context_store.update(cx, |this, cx| {
-                this.remove_context(&context.context, cx);
+                this.remove_context(&context.handle, cx);
             });
 
             let is_now_empty = added_contexts.len() == 1;
@@ -462,7 +464,7 @@ impl Render for ContextStrip {
                     .enumerate()
                     .map(|(i, added_context)| {
                         let name = added_context.name.clone();
-                        let context = added_context.context.clone();
+                        let context = added_context.handle.clone();
                         ContextPill::added(
                             added_context,
                             dupe_names.contains(&name),

crates/agent/src/ui/context_pill.rs đź”—

@@ -1,13 +1,23 @@
-use std::{rc::Rc, time::Duration};
+use std::{ops::Range, path::Path, rc::Rc, sync::Arc, time::Duration};
 
 use file_icons::FileIcons;
-use gpui::{Animation, AnimationExt as _, ClickEvent, Entity, MouseButton, pulsating_between};
+use futures::FutureExt as _;
+use gpui::{
+    Animation, AnimationExt as _, AnyView, ClickEvent, Entity, Image, MouseButton, Task,
+    pulsating_between,
+};
+use language_model::LanguageModelImage;
 use project::Project;
 use prompt_store::PromptStore;
-use text::OffsetRangeExt;
+use rope::Point;
 use ui::{IconButtonShape, Tooltip, prelude::*, tooltip_container};
 
-use crate::context::{AgentContext, ContextKind, ImageStatus};
+use crate::context::{
+    AgentContext, AgentContextHandle, ContextId, ContextKind, DirectoryContext,
+    DirectoryContextHandle, FetchedUrlContext, FileContext, FileContextHandle, ImageContext,
+    ImageStatus, RulesContext, RulesContextHandle, SelectionContext, SelectionContextHandle,
+    SymbolContext, SymbolContextHandle, ThreadContext, ThreadContextHandle,
+};
 
 #[derive(IntoElement)]
 pub enum ContextPill {
@@ -72,7 +82,7 @@ impl ContextPill {
 
     pub fn id(&self) -> ElementId {
         match self {
-            Self::Added { context, .. } => context.context.element_id("context-pill".into()),
+            Self::Added { context, .. } => context.handle.element_id("context-pill".into()),
             Self::Suggested { .. } => "suggested-context-pill".into(),
         }
     }
@@ -165,16 +175,11 @@ impl RenderOnce for ContextPill {
                             .map(|element| match &context.status {
                                 ContextStatus::Ready => element
                                     .when_some(
-                                        context.render_preview.as_ref(),
-                                        |element, render_preview| {
-                                            element.hoverable_tooltip({
-                                                let render_preview = render_preview.clone();
-                                                move |_, cx| {
-                                                    cx.new(|_| ContextPillPreview {
-                                                        render_preview: render_preview.clone(),
-                                                    })
-                                                    .into()
-                                                }
+                                        context.render_hover.as_ref(),
+                                        |element, render_hover| {
+                                            let render_hover = render_hover.clone();
+                                            element.hoverable_tooltip(move |window, cx| {
+                                                render_hover(window, cx)
                                             })
                                         },
                                     )
@@ -197,7 +202,7 @@ impl RenderOnce for ContextPill {
                     .when_some(on_remove.as_ref(), |element, on_remove| {
                         element.child(
                             IconButton::new(
-                                context.context.element_id("remove".into()),
+                                context.handle.element_id("remove".into()),
                                 IconName::Close,
                             )
                             .shape(IconButtonShape::Square)
@@ -262,18 +267,16 @@ pub enum ContextStatus {
     Error { message: SharedString },
 }
 
-// TODO: Component commented out due to new dependency on `Project`.
-//
-// #[derive(RegisterComponent)]
+#[derive(RegisterComponent)]
 pub struct AddedContext {
-    pub context: AgentContext,
+    pub handle: AgentContextHandle,
     pub kind: ContextKind,
     pub name: SharedString,
     pub parent: Option<SharedString>,
     pub tooltip: Option<SharedString>,
     pub icon_path: Option<SharedString>,
     pub status: ContextStatus,
-    pub render_preview: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
+    pub render_hover: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
 }
 
 impl AddedContext {
@@ -281,221 +284,430 @@ impl AddedContext {
     /// `None` if `DirectoryContext` or `RulesContext` no longer exist.
     ///
     /// TODO: `None` cases are unremovable from `ContextStore` and so are a very minor memory leak.
-    pub fn new(
-        context: AgentContext,
+    pub fn new_pending(
+        handle: AgentContextHandle,
         prompt_store: Option<&Entity<PromptStore>>,
         project: &Project,
         cx: &App,
     ) -> Option<AddedContext> {
+        match handle {
+            AgentContextHandle::File(handle) => Self::pending_file(handle, cx),
+            AgentContextHandle::Directory(handle) => Self::pending_directory(handle, project, cx),
+            AgentContextHandle::Symbol(handle) => Self::pending_symbol(handle, cx),
+            AgentContextHandle::Selection(handle) => Self::pending_selection(handle, cx),
+            AgentContextHandle::FetchedUrl(handle) => Some(Self::fetched_url(handle)),
+            AgentContextHandle::Thread(handle) => Some(Self::pending_thread(handle, cx)),
+            AgentContextHandle::Rules(handle) => Self::pending_rules(handle, prompt_store, cx),
+            AgentContextHandle::Image(handle) => Some(Self::image(handle)),
+        }
+    }
+
+    pub fn new_attached(context: &AgentContext, cx: &App) -> AddedContext {
         match context {
-            AgentContext::File(ref file_context) => {
-                let full_path = file_context.buffer.read(cx).file()?.full_path(cx);
-                let full_path_string: SharedString =
-                    full_path.to_string_lossy().into_owned().into();
-                let name = full_path
-                    .file_name()
-                    .map(|n| n.to_string_lossy().into_owned().into())
-                    .unwrap_or_else(|| full_path_string.clone());
-                let parent = full_path
-                    .parent()
-                    .and_then(|p| p.file_name())
-                    .map(|n| n.to_string_lossy().into_owned().into());
-                Some(AddedContext {
-                    kind: ContextKind::File,
-                    name,
-                    parent,
-                    tooltip: Some(full_path_string),
-                    icon_path: FileIcons::get_icon(&full_path, cx),
-                    status: ContextStatus::Ready,
-                    render_preview: None,
-                    context,
-                })
-            }
+            AgentContext::File(context) => Self::attached_file(context, cx),
+            AgentContext::Directory(context) => Self::attached_directory(context),
+            AgentContext::Symbol(context) => Self::attached_symbol(context, cx),
+            AgentContext::Selection(context) => Self::attached_selection(context, cx),
+            AgentContext::FetchedUrl(context) => Self::fetched_url(context.clone()),
+            AgentContext::Thread(context) => Self::attached_thread(context),
+            AgentContext::Rules(context) => Self::attached_rules(context),
+            AgentContext::Image(context) => Self::image(context.clone()),
+        }
+    }
 
-            AgentContext::Directory(ref directory_context) => {
-                let worktree = project
-                    .worktree_for_entry(directory_context.entry_id, cx)?
-                    .read(cx);
-                let entry = worktree.entry_for_id(directory_context.entry_id)?;
-                let full_path = worktree.full_path(&entry.path);
-                let full_path_string: SharedString =
-                    full_path.to_string_lossy().into_owned().into();
-                let name = full_path
-                    .file_name()
-                    .map(|n| n.to_string_lossy().into_owned().into())
-                    .unwrap_or_else(|| full_path_string.clone());
-                let parent = full_path
-                    .parent()
-                    .and_then(|p| p.file_name())
-                    .map(|n| n.to_string_lossy().into_owned().into());
-                Some(AddedContext {
-                    kind: ContextKind::Directory,
-                    name,
-                    parent,
-                    tooltip: Some(full_path_string),
-                    icon_path: None,
-                    status: ContextStatus::Ready,
-                    render_preview: None,
-                    context,
-                })
-            }
+    fn pending_file(handle: FileContextHandle, cx: &App) -> Option<AddedContext> {
+        let full_path = handle.buffer.read(cx).file()?.full_path(cx);
+        Some(Self::file(handle, &full_path, cx))
+    }
 
-            AgentContext::Symbol(ref symbol_context) => Some(AddedContext {
-                kind: ContextKind::Symbol,
-                name: symbol_context.symbol.clone(),
-                parent: None,
-                tooltip: None,
-                icon_path: None,
-                status: ContextStatus::Ready,
-                render_preview: None,
-                context,
-            }),
+    fn attached_file(context: &FileContext, cx: &App) -> AddedContext {
+        Self::file(context.handle.clone(), &context.full_path, cx)
+    }
 
-            AgentContext::Selection(ref selection_context) => {
-                let buffer = selection_context.buffer.read(cx);
-                let full_path = buffer.file()?.full_path(cx);
-                let mut full_path_string = full_path.to_string_lossy().into_owned();
-                let mut name = full_path
-                    .file_name()
-                    .map(|n| n.to_string_lossy().into_owned())
-                    .unwrap_or_else(|| full_path_string.clone());
-
-                let line_range = selection_context.range.to_point(&buffer.snapshot());
-
-                let line_range_text =
-                    format!(" ({}-{})", line_range.start.row + 1, line_range.end.row + 1);
-
-                full_path_string.push_str(&line_range_text);
-                name.push_str(&line_range_text);
-
-                let parent = full_path
-                    .parent()
-                    .and_then(|p| p.file_name())
-                    .map(|n| n.to_string_lossy().into_owned().into());
-
-                Some(AddedContext {
-                    kind: ContextKind::Selection,
-                    name: name.into(),
-                    parent,
-                    tooltip: None,
-                    icon_path: FileIcons::get_icon(&full_path, cx),
-                    status: ContextStatus::Ready,
-                    render_preview: None,
-                    /*
-                    render_preview: Some(Rc::new({
-                        let content = selection_context.text.clone();
-                        move |_, cx| {
-                            div()
-                                .id("context-pill-selection-preview")
-                                .overflow_scroll()
-                                .max_w_128()
-                                .max_h_96()
-                                .child(Label::new(content.clone()).buffer_font(cx))
-                                .into_any_element()
-                        }
-                    })),
-                    */
-                    context,
-                })
-            }
+    fn file(handle: FileContextHandle, full_path: &Path, cx: &App) -> AddedContext {
+        let full_path_string: SharedString = full_path.to_string_lossy().into_owned().into();
+        let name = full_path
+            .file_name()
+            .map(|n| n.to_string_lossy().into_owned().into())
+            .unwrap_or_else(|| full_path_string.clone());
+        let parent = full_path
+            .parent()
+            .and_then(|p| p.file_name())
+            .map(|n| n.to_string_lossy().into_owned().into());
+        AddedContext {
+            kind: ContextKind::File,
+            name,
+            parent,
+            tooltip: Some(full_path_string),
+            icon_path: FileIcons::get_icon(&full_path, cx),
+            status: ContextStatus::Ready,
+            render_hover: None,
+            handle: AgentContextHandle::File(handle),
+        }
+    }
 
-            AgentContext::FetchedUrl(ref fetched_url_context) => Some(AddedContext {
-                kind: ContextKind::FetchedUrl,
-                name: fetched_url_context.url.clone(),
-                parent: None,
-                tooltip: None,
-                icon_path: None,
-                status: ContextStatus::Ready,
-                render_preview: None,
-                context,
-            }),
+    fn pending_directory(
+        handle: DirectoryContextHandle,
+        project: &Project,
+        cx: &App,
+    ) -> Option<AddedContext> {
+        let worktree = project.worktree_for_entry(handle.entry_id, cx)?.read(cx);
+        let entry = worktree.entry_for_id(handle.entry_id)?;
+        let full_path = worktree.full_path(&entry.path);
+        Some(Self::directory(handle, &full_path))
+    }
 
-            AgentContext::Thread(ref thread_context) => Some(AddedContext {
-                kind: ContextKind::Thread,
-                name: thread_context.name(cx),
-                parent: None,
-                tooltip: None,
-                icon_path: None,
-                status: if thread_context
-                    .thread
-                    .read(cx)
-                    .is_generating_detailed_summary()
-                {
-                    ContextStatus::Loading {
-                        message: "Summarizing…".into(),
-                    }
-                } else {
-                    ContextStatus::Ready
-                },
-                render_preview: None,
-                context,
-            }),
+    fn attached_directory(context: &DirectoryContext) -> AddedContext {
+        Self::directory(context.handle.clone(), &context.full_path)
+    }
 
-            AgentContext::Rules(ref user_rules_context) => {
-                let name = prompt_store
-                    .as_ref()?
-                    .read(cx)
-                    .metadata(user_rules_context.prompt_id.into())?
-                    .title?;
-                Some(AddedContext {
-                    kind: ContextKind::Rules,
-                    name: name.clone(),
-                    parent: None,
-                    tooltip: None,
-                    icon_path: None,
-                    status: ContextStatus::Ready,
-                    render_preview: None,
-                    context,
-                })
-            }
+    fn directory(handle: DirectoryContextHandle, full_path: &Path) -> AddedContext {
+        let full_path_string: SharedString = full_path.to_string_lossy().into_owned().into();
+        let name = full_path
+            .file_name()
+            .map(|n| n.to_string_lossy().into_owned().into())
+            .unwrap_or_else(|| full_path_string.clone());
+        let parent = full_path
+            .parent()
+            .and_then(|p| p.file_name())
+            .map(|n| n.to_string_lossy().into_owned().into());
+        AddedContext {
+            kind: ContextKind::Directory,
+            name,
+            parent,
+            tooltip: Some(full_path_string),
+            icon_path: None,
+            status: ContextStatus::Ready,
+            render_hover: None,
+            handle: AgentContextHandle::Directory(handle),
+        }
+    }
 
-            AgentContext::Image(ref image_context) => Some(AddedContext {
-                kind: ContextKind::Image,
-                name: "Image".into(),
-                parent: None,
-                tooltip: None,
-                icon_path: None,
-                status: match image_context.status() {
-                    ImageStatus::Loading => ContextStatus::Loading {
-                        message: "Loading…".into(),
-                    },
-                    ImageStatus::Error => ContextStatus::Error {
-                        message: "Failed to load image".into(),
-                    },
-                    ImageStatus::Ready => ContextStatus::Ready,
+    fn pending_symbol(handle: SymbolContextHandle, cx: &App) -> Option<AddedContext> {
+        let excerpt =
+            ContextFileExcerpt::new(&handle.full_path(cx)?, handle.enclosing_line_range(cx), cx);
+        Some(AddedContext {
+            kind: ContextKind::Symbol,
+            name: handle.symbol.clone(),
+            parent: Some(excerpt.file_name_and_range.clone()),
+            tooltip: None,
+            icon_path: None,
+            status: ContextStatus::Ready,
+            render_hover: {
+                let handle = handle.clone();
+                Some(Rc::new(move |_, cx| {
+                    excerpt.hover_view(handle.text(cx), cx).into()
+                }))
+            },
+            handle: AgentContextHandle::Symbol(handle),
+        })
+    }
+
+    fn attached_symbol(context: &SymbolContext, cx: &App) -> AddedContext {
+        let excerpt = ContextFileExcerpt::new(&context.full_path, context.line_range.clone(), cx);
+        AddedContext {
+            kind: ContextKind::Symbol,
+            name: context.handle.symbol.clone(),
+            parent: Some(excerpt.file_name_and_range.clone()),
+            tooltip: None,
+            icon_path: None,
+            status: ContextStatus::Ready,
+            render_hover: {
+                let text = context.text.clone();
+                Some(Rc::new(move |_, cx| {
+                    excerpt.hover_view(text.clone(), cx).into()
+                }))
+            },
+            handle: AgentContextHandle::Symbol(context.handle.clone()),
+        }
+    }
+
+    fn pending_selection(handle: SelectionContextHandle, cx: &App) -> Option<AddedContext> {
+        let excerpt = ContextFileExcerpt::new(&handle.full_path(cx)?, handle.line_range(cx), cx);
+        Some(AddedContext {
+            kind: ContextKind::Selection,
+            name: excerpt.file_name_and_range.clone(),
+            parent: excerpt.parent_name.clone(),
+            tooltip: None,
+            icon_path: excerpt.icon_path.clone(),
+            status: ContextStatus::Ready,
+            render_hover: {
+                let handle = handle.clone();
+                Some(Rc::new(move |_, cx| {
+                    excerpt.hover_view(handle.text(cx), cx).into()
+                }))
+            },
+            handle: AgentContextHandle::Selection(handle),
+        })
+    }
+
+    fn attached_selection(context: &SelectionContext, cx: &App) -> AddedContext {
+        let excerpt = ContextFileExcerpt::new(&context.full_path, context.line_range.clone(), cx);
+        AddedContext {
+            kind: ContextKind::Selection,
+            name: excerpt.file_name_and_range.clone(),
+            parent: excerpt.parent_name.clone(),
+            tooltip: None,
+            icon_path: excerpt.icon_path.clone(),
+            status: ContextStatus::Ready,
+            render_hover: {
+                let text = context.text.clone();
+                Some(Rc::new(move |_, cx| {
+                    excerpt.hover_view(text.clone(), cx).into()
+                }))
+            },
+            handle: AgentContextHandle::Selection(context.handle.clone()),
+        }
+    }
+
+    fn fetched_url(context: FetchedUrlContext) -> AddedContext {
+        AddedContext {
+            kind: ContextKind::FetchedUrl,
+            name: context.url.clone(),
+            parent: None,
+            tooltip: None,
+            icon_path: None,
+            status: ContextStatus::Ready,
+            render_hover: None,
+            handle: AgentContextHandle::FetchedUrl(context),
+        }
+    }
+
+    fn pending_thread(handle: ThreadContextHandle, cx: &App) -> AddedContext {
+        AddedContext {
+            kind: ContextKind::Thread,
+            name: handle.title(cx),
+            parent: None,
+            tooltip: None,
+            icon_path: None,
+            status: if handle.thread.read(cx).is_generating_detailed_summary() {
+                ContextStatus::Loading {
+                    message: "Summarizing…".into(),
+                }
+            } else {
+                ContextStatus::Ready
+            },
+            render_hover: {
+                let thread = handle.thread.clone();
+                Some(Rc::new(move |_, cx| {
+                    let text = thread.read(cx).latest_detailed_summary_or_text();
+                    text_hover_view(text.clone(), cx).into()
+                }))
+            },
+            handle: AgentContextHandle::Thread(handle),
+        }
+    }
+
+    fn attached_thread(context: &ThreadContext) -> AddedContext {
+        AddedContext {
+            kind: ContextKind::Thread,
+            name: context.title.clone(),
+            parent: None,
+            tooltip: None,
+            icon_path: None,
+            status: ContextStatus::Ready,
+            render_hover: {
+                let text = context.text.clone();
+                Some(Rc::new(move |_, cx| {
+                    text_hover_view(text.clone(), cx).into()
+                }))
+            },
+            handle: AgentContextHandle::Thread(context.handle.clone()),
+        }
+    }
+
+    fn pending_rules(
+        handle: RulesContextHandle,
+        prompt_store: Option<&Entity<PromptStore>>,
+        cx: &App,
+    ) -> Option<AddedContext> {
+        let title = prompt_store
+            .as_ref()?
+            .read(cx)
+            .metadata(handle.prompt_id.into())?
+            .title
+            .unwrap_or_else(|| "Unnamed Rule".into());
+        Some(AddedContext {
+            kind: ContextKind::Rules,
+            name: title.clone(),
+            parent: None,
+            tooltip: None,
+            icon_path: None,
+            status: ContextStatus::Ready,
+            render_hover: None,
+            handle: AgentContextHandle::Rules(handle),
+        })
+    }
+
+    fn attached_rules(context: &RulesContext) -> AddedContext {
+        let title = context
+            .title
+            .clone()
+            .unwrap_or_else(|| "Unnamed Rule".into());
+        AddedContext {
+            kind: ContextKind::Rules,
+            name: title,
+            parent: None,
+            tooltip: None,
+            icon_path: None,
+            status: ContextStatus::Ready,
+            render_hover: {
+                let text = context.text.clone();
+                Some(Rc::new(move |_, cx| {
+                    text_hover_view(text.clone(), cx).into()
+                }))
+            },
+            handle: AgentContextHandle::Rules(context.handle.clone()),
+        }
+    }
+
+    fn image(context: ImageContext) -> AddedContext {
+        AddedContext {
+            kind: ContextKind::Image,
+            name: "Image".into(),
+            parent: None,
+            tooltip: None,
+            icon_path: None,
+            status: match context.status() {
+                ImageStatus::Loading => ContextStatus::Loading {
+                    message: "Loading…".into(),
+                },
+                ImageStatus::Error => ContextStatus::Error {
+                    message: "Failed to load image".into(),
                 },
-                render_preview: Some(Rc::new({
-                    let image = image_context.original_image.clone();
-                    move |_, _| {
+                ImageStatus::Ready => ContextStatus::Ready,
+            },
+            render_hover: Some(Rc::new({
+                let image = context.original_image.clone();
+                move |_, cx| {
+                    let image = image.clone();
+                    ContextPillHover::new(cx, move |_, _| {
                         gpui::img(image.clone())
                             .max_w_96()
                             .max_h_96()
                             .into_any_element()
-                    }
-                })),
-                context,
-            }),
+                    })
+                    .into()
+                }
+            })),
+            handle: AgentContextHandle::Image(context),
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
+struct ContextFileExcerpt {
+    pub file_name_and_range: SharedString,
+    pub full_path_and_range: SharedString,
+    pub parent_name: Option<SharedString>,
+    pub icon_path: Option<SharedString>,
+}
+
+impl ContextFileExcerpt {
+    pub fn new(full_path: &Path, line_range: Range<Point>, cx: &App) -> Self {
+        let full_path_string = full_path.to_string_lossy().into_owned();
+        let file_name = full_path
+            .file_name()
+            .map(|n| n.to_string_lossy().into_owned())
+            .unwrap_or_else(|| full_path_string.clone());
+
+        let line_range_text = format!(" ({}-{})", line_range.start.row + 1, line_range.end.row + 1);
+        let mut full_path_and_range = full_path_string;
+        full_path_and_range.push_str(&line_range_text);
+        let mut file_name_and_range = file_name;
+        file_name_and_range.push_str(&line_range_text);
+
+        let parent_name = full_path
+            .parent()
+            .and_then(|p| p.file_name())
+            .map(|n| n.to_string_lossy().into_owned().into());
+
+        let icon_path = FileIcons::get_icon(&full_path, cx);
+
+        ContextFileExcerpt {
+            file_name_and_range: file_name_and_range.into(),
+            full_path_and_range: full_path_and_range.into(),
+            parent_name,
+            icon_path,
         }
     }
+
+    fn hover_view(&self, text: SharedString, cx: &mut App) -> Entity<ContextPillHover> {
+        let icon_path = self.icon_path.clone();
+        let full_path_and_range = self.full_path_and_range.clone();
+        ContextPillHover::new(cx, move |_, cx| {
+            v_flex()
+                .child(
+                    h_flex()
+                        .gap_0p5()
+                        .w_full()
+                        .max_w_full()
+                        .border_b_1()
+                        .border_color(cx.theme().colors().border.opacity(0.6))
+                        .children(
+                            icon_path
+                                .clone()
+                                .map(Icon::from_path)
+                                .map(|icon| icon.color(Color::Muted).size(IconSize::XSmall)),
+                        )
+                        .child(
+                            // TODO: make this truncate on the left.
+                            Label::new(full_path_and_range.clone())
+                                .size(LabelSize::Small)
+                                .ml_1(),
+                        ),
+                )
+                .child(
+                    div()
+                        .id("context-pill-hover-contents")
+                        .overflow_scroll()
+                        .max_w_128()
+                        .max_h_96()
+                        .child(Label::new(text.clone()).buffer_font(cx)),
+                )
+                .into_any_element()
+        })
+    }
+}
+
+fn text_hover_view(content: SharedString, cx: &mut App) -> Entity<ContextPillHover> {
+    ContextPillHover::new(cx, move |_, _| {
+        div()
+            .id("context-pill-hover-contents")
+            .overflow_scroll()
+            .max_w_128()
+            .max_h_96()
+            .child(content.clone())
+            .into_any_element()
+    })
 }
 
-struct ContextPillPreview {
-    render_preview: Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>,
+struct ContextPillHover {
+    render_hover: Box<dyn Fn(&mut Window, &mut App) -> AnyElement>,
+}
+
+impl ContextPillHover {
+    fn new(
+        cx: &mut App,
+        render_hover: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
+    ) -> Entity<Self> {
+        cx.new(|_| Self {
+            render_hover: Box::new(render_hover),
+        })
+    }
 }
 
-impl Render for ContextPillPreview {
+impl Render for ContextPillHover {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         tooltip_container(window, cx, move |this, window, cx| {
             this.occlude()
                 .on_mouse_move(|_, _, cx| cx.stop_propagation())
                 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
-                .child((self.render_preview)(window, cx))
+                .child((self.render_hover)(window, cx))
         })
     }
 }
 
-// TODO: Component commented out due to new dependency on `Project`.
-/*
 impl Component for AddedContext {
     fn scope() -> ComponentScope {
         ComponentScope::Agent
@@ -505,47 +717,38 @@ impl Component for AddedContext {
         "AddedContext"
     }
 
-    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
-        let next_context_id = ContextId::zero();
+    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
+        let mut next_context_id = ContextId::zero();
         let image_ready = (
             "Ready",
-            AddedContext::new(
-                AgentContext::Image(ImageContext {
-                    context_id: next_context_id.post_inc(),
-                    original_image: Arc::new(Image::empty()),
-                    image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
-                }),
-                cx,
-            ),
+            AddedContext::image(ImageContext {
+                context_id: next_context_id.post_inc(),
+                original_image: Arc::new(Image::empty()),
+                image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
+            }),
         );
 
         let image_loading = (
             "Loading",
-            AddedContext::new(
-                AgentContext::Image(ImageContext {
-                    context_id: next_context_id.post_inc(),
-                    original_image: Arc::new(Image::empty()),
-                    image_task: cx
-                        .background_spawn(async move {
-                            smol::Timer::after(Duration::from_secs(60 * 5)).await;
-                            Some(LanguageModelImage::empty())
-                        })
-                        .shared(),
-                }),
-                cx,
-            ),
+            AddedContext::image(ImageContext {
+                context_id: next_context_id.post_inc(),
+                original_image: Arc::new(Image::empty()),
+                image_task: cx
+                    .background_spawn(async move {
+                        smol::Timer::after(Duration::from_secs(60 * 5)).await;
+                        Some(LanguageModelImage::empty())
+                    })
+                    .shared(),
+            }),
         );
 
         let image_error = (
             "Error",
-            AddedContext::new(
-                AgentContext::Image(ImageContext {
-                    context_id: next_context_id.post_inc(),
-                    original_image: Arc::new(Image::empty()),
-                    image_task: Task::ready(None).shared(),
-                }),
-                cx,
-            ),
+            AddedContext::image(ImageContext {
+                context_id: next_context_id.post_inc(),
+                original_image: Arc::new(Image::empty()),
+                image_task: Task::ready(None).shared(),
+            }),
         );
 
         Some(
@@ -563,8 +766,5 @@ impl Component for AddedContext {
                 )
                 .into_any(),
         )
-
-        None
     }
 }
-*/