diff --git a/Cargo.lock b/Cargo.lock index e2613dc27767875e5d3d96aa058923077f20de0c..9917862e72ba3f63e20b2c7305902a85dc0f3191 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -368,7 +368,6 @@ dependencies = [ "prompt_store", "proto", "rand 0.9.2", - "ref-cast", "release_channel", "rope", "rules_library", @@ -394,7 +393,6 @@ dependencies = [ "ui_input", "unindent", "url", - "urlencoding", "util", "watch", "workspace", diff --git a/Cargo.toml b/Cargo.toml index 63303678ebfbdbf5d403b40ff83c4612ad2ba2c8..abf74a8108fa06cf3c154438c13ce015719b7481 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -607,7 +607,6 @@ pulldown-cmark = { version = "0.12.0", default-features = false } quote = "1.0.9" rand = "0.9" rayon = "1.8" -ref-cast = "1.0.24" regex = "1.5" # WARNING: If you change this, you must also publish a new version of zed-reqwest to crates.io reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "c15662463bda39148ba154100dd44d3fba5873a4", default-features = false, features = [ diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 4a6421c4e1f335a4d35fe9ac11d157b30a914004..7ddabfc9f55577c8ced3fbad0cc881cd4bb183d0 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -239,13 +239,11 @@ "ctrl-alt-l": "agent::OpenRulesLibrary", "ctrl-i": "agent::ToggleProfileSelector", "ctrl-alt-/": "agent::ToggleModelSelector", - "ctrl-shift-a": "agent::ToggleContextPicker", "ctrl-shift-j": "agent::ToggleNavigationMenu", "ctrl-alt-i": "agent::ToggleOptionsMenu", "ctrl-alt-shift-n": "agent::ToggleNewThreadMenu", "shift-alt-escape": "agent::ExpandMessageEditor", "ctrl->": "agent::AddSelectionToThread", - "ctrl-alt-e": "agent::RemoveAllContext", "ctrl-shift-e": "project_panel::ToggleFocus", "ctrl-shift-enter": "agent::ContinueThread", "super-ctrl-b": "agent::ToggleBurnMode", @@ -322,17 +320,6 @@ "alt-enter": "editor::Newline" } }, - { - "context": "ContextStrip", - "bindings": { - "up": "agent::FocusUp", - "right": "agent::FocusRight", - "left": "agent::FocusLeft", - "down": "agent::FocusDown", - "backspace": "agent::RemoveFocusedContext", - "enter": "agent::AcceptSuggestedContext" - } - }, { "context": "AcpThread > ModeSelector", "bindings": { @@ -824,8 +811,7 @@ "context": "PromptEditor", "bindings": { "ctrl-[": "agent::CyclePreviousInlineAssist", - "ctrl-]": "agent::CycleNextInlineAssist", - "ctrl-alt-e": "agent::RemoveAllContext" + "ctrl-]": "agent::CycleNextInlineAssist" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 8790acf906effd3e0cd7026a909759978ae39dd5..2f7c25a3560e09bccb9f45c64df38048eefdddd6 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -278,13 +278,11 @@ "cmd-alt-p": "agent::ManageProfiles", "cmd-i": "agent::ToggleProfileSelector", "cmd-alt-/": "agent::ToggleModelSelector", - "cmd-shift-a": "agent::ToggleContextPicker", "cmd-shift-j": "agent::ToggleNavigationMenu", "cmd-alt-m": "agent::ToggleOptionsMenu", "cmd-alt-shift-n": "agent::ToggleNewThreadMenu", "shift-alt-escape": "agent::ExpandMessageEditor", "cmd->": "agent::AddSelectionToThread", - "cmd-alt-e": "agent::RemoveAllContext", "cmd-shift-e": "project_panel::ToggleFocus", "cmd-ctrl-b": "agent::ToggleBurnMode", "cmd-shift-enter": "agent::ContinueThread", @@ -365,18 +363,6 @@ "alt-enter": "editor::Newline" } }, - { - "context": "ContextStrip", - "use_key_equivalents": true, - "bindings": { - "up": "agent::FocusUp", - "right": "agent::FocusRight", - "left": "agent::FocusLeft", - "down": "agent::FocusDown", - "backspace": "agent::RemoveFocusedContext", - "enter": "agent::AcceptSuggestedContext" - } - }, { "context": "AgentConfiguration", "bindings": { @@ -889,9 +875,7 @@ "context": "PromptEditor", "use_key_equivalents": true, "bindings": { - "cmd-shift-a": "agent::ToggleContextPicker", "cmd-alt-/": "agent::ToggleModelSelector", - "cmd-alt-e": "agent::RemoveAllContext", "ctrl-[": "agent::CyclePreviousInlineAssist", "ctrl-]": "agent::CycleNextInlineAssist" } diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 1144eac80b2cd881e8d99cc5beb69b42263338c3..40fdc4609967bfbabf5f575a9bae6cc02abab9cd 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -240,13 +240,11 @@ "shift-alt-p": "agent::ManageProfiles", "ctrl-i": "agent::ToggleProfileSelector", "shift-alt-/": "agent::ToggleModelSelector", - "ctrl-shift-a": "agent::ToggleContextPicker", "ctrl-shift-j": "agent::ToggleNavigationMenu", "ctrl-alt-i": "agent::ToggleOptionsMenu", // "ctrl-shift-alt-n": "agent::ToggleNewThreadMenu", "shift-alt-escape": "agent::ExpandMessageEditor", "ctrl-shift-.": "agent::AddSelectionToThread", - "shift-alt-e": "agent::RemoveAllContext", "ctrl-shift-e": "project_panel::ToggleFocus", "ctrl-shift-enter": "agent::ContinueThread", "super-ctrl-b": "agent::ToggleBurnMode", @@ -328,18 +326,6 @@ "alt-enter": "editor::Newline" } }, - { - "context": "ContextStrip", - "use_key_equivalents": true, - "bindings": { - "up": "agent::FocusUp", - "right": "agent::FocusRight", - "left": "agent::FocusLeft", - "down": "agent::FocusDown", - "backspace": "agent::RemoveFocusedContext", - "enter": "agent::AcceptSuggestedContext" - } - }, { "context": "AcpThread > ModeSelector", "bindings": { @@ -837,8 +823,7 @@ "use_key_equivalents": true, "bindings": { "ctrl-[": "agent::CyclePreviousInlineAssist", - "ctrl-]": "agent::CycleNextInlineAssist", - "shift-alt-e": "agent::RemoveAllContext" + "ctrl-]": "agent::CycleNextInlineAssist" } }, { diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 6447b41335ece093718036091baf30c505ad76fd..17e90775b2fd386524b01ea3ab056a00273aa82f 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -69,7 +69,6 @@ postage.workspace = true project.workspace = true prompt_store.workspace = true proto.workspace = true -ref-cast.workspace = true release_channel.workspace = true rope.workspace = true rules_library.workspace = true @@ -93,7 +92,6 @@ time_format.workspace = true ui.workspace = true ui_input.workspace = true url.workspace = true -urlencoding.workspace = true util.workspace = true watch.workspace = true workspace.workspace = true diff --git a/crates/agent_ui/src/acp.rs b/crates/agent_ui/src/acp.rs index 2e15cd424d6313d981ff8c000f5eeb958aec9370..7a740c2dc4b9fbc769aa847347a0aa56d5f51934 100644 --- a/crates/agent_ui/src/acp.rs +++ b/crates/agent_ui/src/acp.rs @@ -1,4 +1,3 @@ -mod completion_provider; mod entry_view_state; mod message_editor; mod mode_selector; diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 883a7424e47eaf412278995cb9f3d497fd4f5c67..9fbdbb04986294aa319c04cb5d76de63f4a758ab 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -1,68 +1,45 @@ use crate::{ ChatWithFollow, - acp::completion_provider::{ContextPickerCompletionProvider, SlashCommandCompletion}, - context_picker::{ContextPickerAction, fetch_context_picker::fetch_url_content}, + completion_provider::{ + PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextAction, + PromptContextType, SlashCommandCompletion, + }, + mention_set::{ + Mention, MentionImage, MentionSet, insert_crease_for_mention, paste_images_as_context, + }, }; -use acp_thread::{MentionUri, selection_name}; -use agent::{HistoryStore, outline}; +use acp_thread::MentionUri; +use agent::HistoryStore; use agent_client_protocol as acp; -use agent_servers::{AgentServer, AgentServerDelegate}; use anyhow::{Result, anyhow}; -use assistant_slash_commands::codeblock_fence_for_path; -use collections::{HashMap, HashSet}; +use collections::HashSet; use editor::{ - Addon, Anchor, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, - EditorEvent, EditorMode, EditorSnapshot, EditorStyle, ExcerptId, FoldPlaceholder, Inlay, - MultiBuffer, MultiBufferOffset, ToOffset, - actions::Paste, - code_context_menus::CodeContextMenu, - display_map::{Crease, CreaseId, FoldId}, - scroll::Autoscroll, -}; -use futures::{ - FutureExt as _, - future::{Shared, join_all}, + Addon, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, + EditorEvent, EditorMode, EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, ToOffset, + actions::Paste, code_context_menus::CodeContextMenu, scroll::Autoscroll, }; +use futures::{FutureExt as _, future::join_all}; use gpui::{ - Animation, AnimationExt as _, AppContext, ClipboardEntry, Context, Entity, EntityId, - EventEmitter, FocusHandle, Focusable, Image, ImageFormat, Img, KeyContext, SharedString, - Subscription, Task, TextStyle, WeakEntity, pulsating_between, + AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageFormat, KeyContext, + SharedString, Subscription, Task, TextStyle, WeakEntity, }; -use itertools::Either; use language::{Buffer, Language, language_settings::InlayHintKind}; -use language_model::LanguageModelImage; -use postage::stream::Stream as _; -use project::{ - CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, ProjectItem, ProjectPath, - Worktree, -}; -use prompt_store::{PromptId, PromptStore}; +use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Worktree}; +use prompt_store::PromptStore; use rope::Point; use settings::Settings; -use std::{ - cell::RefCell, - ffi::OsStr, - fmt::Write, - ops::{Range, RangeInclusive}, - path::{Path, PathBuf}, - rc::Rc, - sync::Arc, - time::Duration, -}; -use text::OffsetRangeExt; +use std::{cell::RefCell, fmt::Write, rc::Rc, sync::Arc}; use theme::ThemeSettings; -use ui::{ButtonLike, TintColor, Toggleable, prelude::*}; -use util::{ResultExt, debug_panic, rel_path::RelPath}; -use workspace::{CollaboratorId, Workspace, notifications::NotifyResultExt as _}; +use ui::prelude::*; +use util::{ResultExt, debug_panic}; +use workspace::{CollaboratorId, Workspace}; use zed_actions::agent::Chat; pub struct MessageEditor { - mention_set: MentionSet, + mention_set: Entity, editor: Entity, project: Entity, workspace: WeakEntity, - history_store: Entity, - prompt_store: Option>, prompt_capabilities: Rc>, available_commands: Rc>>, agent_name: SharedString, @@ -82,6 +59,41 @@ impl EventEmitter for MessageEditor {} const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0); +impl PromptCompletionProviderDelegate for Entity { + fn supports_images(&self, cx: &App) -> bool { + self.read(cx).prompt_capabilities.borrow().image + } + + fn supported_modes(&self, cx: &App) -> Vec { + let mut supported = vec![PromptContextType::File, PromptContextType::Symbol]; + if self.read(cx).prompt_capabilities.borrow().embedded_context { + supported.extend(&[ + PromptContextType::Thread, + PromptContextType::Fetch, + PromptContextType::Rules, + ]); + } + supported + } + + fn available_commands(&self, cx: &App) -> Vec { + self.read(cx) + .available_commands + .borrow() + .iter() + .map(|cmd| crate::completion_provider::AvailableCommand { + name: cmd.name.clone().into(), + description: cmd.description.clone().into(), + requires_argument: cmd.input.is_some(), + }) + .collect() + } + + fn confirm_command(&self, cx: &mut App) { + self.update(cx, |this, cx| this.send(cx)); + } +} + impl MessageEditor { pub fn new( workspace: WeakEntity, @@ -103,15 +115,7 @@ impl MessageEditor { }, None, ); - let completion_provider = Rc::new(ContextPickerCompletionProvider::new( - cx.weak_entity(), - workspace.clone(), - history_store.clone(), - prompt_store.clone(), - prompt_capabilities.clone(), - available_commands.clone(), - )); - let mention_set = MentionSet::default(); + let editor = cx.new(|cx| { let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); @@ -121,7 +125,6 @@ impl MessageEditor { editor.set_show_indent_guides(false, cx); editor.set_soft_wrap(); editor.set_use_modal_editing(true); - editor.set_completion_provider(Some(completion_provider.clone())); editor.set_context_menu_options(ContextMenuOptions { min_entries_visible: 12, max_entries_visible: 12, @@ -130,6 +133,26 @@ impl MessageEditor { editor.register_addon(MessageEditorAddon::new()); editor }); + let mention_set = cx.new(|cx| { + MentionSet::new( + editor.clone(), + project.downgrade(), + history_store.clone(), + prompt_store.clone(), + window, + cx, + ) + }); + let completion_provider = Rc::new(PromptCompletionProvider::new( + cx.entity(), + mention_set.clone(), + history_store.clone(), + prompt_store.clone(), + workspace.clone(), + )); + editor.update(cx, |editor, _cx| { + editor.set_completion_provider(Some(completion_provider.clone())) + }); cx.on_focus_in(&editor.focus_handle(cx), window, |_, _, cx| { cx.emit(MessageEditorEvent::Focus) @@ -143,12 +166,12 @@ impl MessageEditor { let mut has_hint = false; let mut subscriptions = Vec::new(); - subscriptions.push(cx.subscribe_in(&editor, window, { - move |this, editor, event, window, cx| { + subscriptions.push(cx.subscribe(&editor, { + move |this, editor, event, cx| { if let EditorEvent::Edited { .. } = event && !editor.read(cx).read_only(cx) { - let snapshot = editor.update(cx, |editor, cx| { + editor.update(cx, |editor, cx| { let new_hints = this .command_hint(editor.buffer(), cx) .into_iter() @@ -164,11 +187,7 @@ impl MessageEditor { cx, ); has_hint = has_new_hint; - - editor.snapshot(window, cx) }); - this.mention_set.remove_invalid(snapshot); - cx.notify(); } } @@ -179,8 +198,6 @@ impl MessageEditor { project, mention_set, workspace, - history_store, - prompt_store, prompt_capabilities, available_commands, agent_name, @@ -238,6 +255,9 @@ impl MessageEditor { window: &mut Window, cx: &mut Context, ) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; let uri = MentionUri::Thread { id: thread.id.clone(), name: thread.title.to_string(), @@ -256,7 +276,21 @@ impl MessageEditor { .text_anchor }); - self.confirm_mention_completion(thread.title, start, content_len, uri, window, cx) + let supports_images = self.prompt_capabilities.borrow().image; + + self.mention_set + .update(cx, |mention_set, cx| { + mention_set.confirm_mention_completion( + thread.title, + start, + content_len, + uri, + supports_images, + &workspace, + window, + cx, + ) + }) .detach(); } @@ -265,11 +299,6 @@ impl MessageEditor { &self.editor } - #[cfg(test)] - pub(crate) fn mention_set(&mut self) -> &mut MentionSet { - &mut self.mention_set - } - pub fn is_empty(&self, cx: &App) -> bool { self.editor.read(cx).is_empty(cx) } @@ -283,388 +312,9 @@ impl MessageEditor { .is_some_and(|menu| matches!(menu, CodeContextMenu::Completions(_)) && menu.visible()) } - pub fn mentions(&self) -> HashSet { - self.mention_set - .mentions - .values() - .map(|(uri, _)| uri.clone()) - .collect() - } - - pub fn confirm_mention_completion( - &mut self, - crease_text: SharedString, - start: text::Anchor, - content_len: usize, - mention_uri: MentionUri, - window: &mut Window, - cx: &mut Context, - ) -> Task<()> { - let snapshot = self - .editor - .update(cx, |editor, cx| editor.snapshot(window, cx)); - let Some(start_anchor) = snapshot.buffer_snapshot().as_singleton_anchor(start) else { - return Task::ready(()); - }; - let excerpt_id = start_anchor.excerpt_id; - let end_anchor = snapshot.buffer_snapshot().anchor_before( - start_anchor.to_offset(&snapshot.buffer_snapshot()) + content_len + 1usize, - ); - - let crease = if let MentionUri::File { abs_path } = &mention_uri - && let Some(extension) = abs_path.extension() - && let Some(extension) = extension.to_str() - && Img::extensions().contains(&extension) - && !extension.contains("svg") - { - let Some(project_path) = self - .project - .read(cx) - .project_path_for_absolute_path(&abs_path, cx) - else { - log::error!("project path not found"); - return Task::ready(()); - }; - let image = self - .project - .update(cx, |project, cx| project.open_image(project_path, cx)); - let image = cx - .spawn(async move |_, cx| { - let image = image.await.map_err(|e| e.to_string())?; - let image = image - .update(cx, |image, _| image.image.clone()) - .map_err(|e| e.to_string())?; - Ok(image) - }) - .shared(); - insert_crease_for_mention( - excerpt_id, - start, - content_len, - mention_uri.name().into(), - IconName::Image.path().into(), - Some(image), - self.editor.clone(), - window, - cx, - ) - } else { - insert_crease_for_mention( - excerpt_id, - start, - content_len, - crease_text, - mention_uri.icon_path(cx), - None, - self.editor.clone(), - window, - cx, - ) - }; - let Some((crease_id, tx)) = crease else { - return Task::ready(()); - }; - - let task = match mention_uri.clone() { - MentionUri::Fetch { url } => self.confirm_mention_for_fetch(url, cx), - MentionUri::Directory { .. } => Task::ready(Ok(Mention::Link)), - MentionUri::Thread { id, .. } => self.confirm_mention_for_thread(id, cx), - MentionUri::TextThread { path, .. } => self.confirm_mention_for_text_thread(path, cx), - MentionUri::File { abs_path } => self.confirm_mention_for_file(abs_path, cx), - MentionUri::Symbol { - abs_path, - line_range, - .. - } => self.confirm_mention_for_symbol(abs_path, line_range, cx), - MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx), - MentionUri::PastedImage => { - debug_panic!("pasted image URI should not be included in completions"); - Task::ready(Err(anyhow!( - "pasted imaged URI should not be included in completions" - ))) - } - MentionUri::Selection { .. } => { - debug_panic!("unexpected selection URI"); - Task::ready(Err(anyhow!("unexpected selection URI"))) - } - }; - let task = cx - .spawn(async move |_, _| task.await.map_err(|e| e.to_string())) - .shared(); - self.mention_set - .mentions - .insert(crease_id, (mention_uri, task.clone())); - - // Notify the user if we failed to load the mentioned context - cx.spawn_in(window, async move |this, cx| { - let result = task.await.notify_async_err(cx); - drop(tx); - if result.is_none() { - this.update(cx, |this, cx| { - this.editor.update(cx, |editor, cx| { - // Remove mention - editor.edit([(start_anchor..end_anchor, "")], cx); - }); - this.mention_set.mentions.remove(&crease_id); - }) - .ok(); - } - }) - } - - fn confirm_mention_for_file( - &mut self, - abs_path: PathBuf, - cx: &mut Context, - ) -> Task> { - let Some(project_path) = self - .project - .read(cx) - .project_path_for_absolute_path(&abs_path, cx) - else { - return Task::ready(Err(anyhow!("project path not found"))); - }; - let extension = abs_path - .extension() - .and_then(OsStr::to_str) - .unwrap_or_default(); - - if Img::extensions().contains(&extension) && !extension.contains("svg") { - if !self.prompt_capabilities.borrow().image { - return Task::ready(Err(anyhow!("This model does not support images yet"))); - } - let task = self - .project - .update(cx, |project, cx| project.open_image(project_path, cx)); - return cx.spawn(async move |_, cx| { - let image = task.await?; - let image = image.update(cx, |image, _| image.image.clone())?; - let format = image.format; - let image = cx - .update(|cx| LanguageModelImage::from_image(image, cx))? - .await; - if let Some(image) = image { - Ok(Mention::Image(MentionImage { - data: image.source, - format, - })) - } else { - Err(anyhow!("Failed to convert image")) - } - }); - } - - let buffer = self - .project - .update(cx, |project, cx| project.open_buffer(project_path, cx)); - cx.spawn(async move |_, cx| { - let buffer = buffer.await?; - let buffer_content = outline::get_buffer_content_or_outline( - buffer.clone(), - Some(&abs_path.to_string_lossy()), - &cx, - ) - .await?; - - Ok(Mention::Text { - content: buffer_content.text, - tracked_buffers: vec![buffer], - }) - }) - } - - fn confirm_mention_for_fetch( - &mut self, - url: url::Url, - cx: &mut Context, - ) -> Task> { - let http_client = match self - .workspace - .update(cx, |workspace, _| workspace.client().http_client()) - { - Ok(http_client) => http_client, - Err(e) => return Task::ready(Err(e)), - }; - cx.background_executor().spawn(async move { - let content = fetch_url_content(http_client, url.to_string()).await?; - Ok(Mention::Text { - content, - tracked_buffers: Vec::new(), - }) - }) - } - - fn confirm_mention_for_symbol( - &mut self, - abs_path: PathBuf, - line_range: RangeInclusive, - cx: &mut Context, - ) -> Task> { - let Some(project_path) = self - .project - .read(cx) - .project_path_for_absolute_path(&abs_path, cx) - else { - return Task::ready(Err(anyhow!("project path not found"))); - }; - let buffer = self - .project - .update(cx, |project, cx| project.open_buffer(project_path, cx)); - cx.spawn(async move |_, cx| { - let buffer = buffer.await?; - let mention = buffer.update(cx, |buffer, cx| { - let start = Point::new(*line_range.start(), 0).min(buffer.max_point()); - let end = Point::new(*line_range.end() + 1, 0).min(buffer.max_point()); - let content = buffer.text_for_range(start..end).collect(); - Mention::Text { - content, - tracked_buffers: vec![cx.entity()], - } - })?; - anyhow::Ok(mention) - }) - } - - fn confirm_mention_for_rule( - &mut self, - id: PromptId, - cx: &mut Context, - ) -> Task> { - let Some(prompt_store) = self.prompt_store.clone() else { - return Task::ready(Err(anyhow!("missing prompt store"))); - }; - let prompt = prompt_store.read(cx).load(id, cx); - cx.spawn(async move |_, _| { - let prompt = prompt.await?; - Ok(Mention::Text { - content: prompt, - tracked_buffers: Vec::new(), - }) - }) - } - - pub fn confirm_mention_for_selection( - &mut self, - source_range: Range, - selections: Vec<(Entity, Range, Range)>, - window: &mut Window, - cx: &mut Context, - ) { - let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx); - let Some(start) = snapshot.as_singleton_anchor(source_range.start) else { - return; - }; - - let offset = start.to_offset(&snapshot); - - for (buffer, selection_range, range_to_fold) in selections { - let range = snapshot.anchor_after(offset + range_to_fold.start) - ..snapshot.anchor_after(offset + range_to_fold.end); - - let abs_path = buffer - .read(cx) - .project_path(cx) - .and_then(|project_path| self.project.read(cx).absolute_path(&project_path, cx)); - let snapshot = buffer.read(cx).snapshot(); - - let text = snapshot - .text_for_range(selection_range.clone()) - .collect::(); - let point_range = selection_range.to_point(&snapshot); - let line_range = point_range.start.row..=point_range.end.row; - - let uri = MentionUri::Selection { - abs_path: abs_path.clone(), - line_range: line_range.clone(), - }; - let crease = crate::context_picker::crease_for_mention( - selection_name(abs_path.as_deref(), &line_range).into(), - uri.icon_path(cx), - range, - self.editor.downgrade(), - ); - - let crease_id = self.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.first().copied().unwrap() - }); - - self.mention_set.mentions.insert( - crease_id, - ( - uri, - Task::ready(Ok(Mention::Text { - content: text, - tracked_buffers: vec![buffer], - })) - .shared(), - ), - ); - } - - // Take this explanation with a grain of salt but, with creases being - // inserted, GPUI's recomputes the editor layout in the next frames, so - // directly calling `editor.request_autoscroll` wouldn't work as - // expected. We're leveraging `cx.on_next_frame` to wait 2 frames and - // ensure that the layout has been recalculated so that the autoscroll - // request actually shows the cursor's new position. - let editor = self.editor.clone(); - cx.on_next_frame(window, move |_, window, cx| { - cx.on_next_frame(window, move |_, _, cx| { - editor.update(cx, |editor, cx| { - editor.request_autoscroll(Autoscroll::fit(), cx) - }); - }); - }); - } - - fn confirm_mention_for_thread( - &mut self, - id: acp::SessionId, - cx: &mut Context, - ) -> Task> { - let server = Rc::new(agent::NativeAgentServer::new( - self.project.read(cx).fs().clone(), - self.history_store.clone(), - )); - let delegate = AgentServerDelegate::new( - self.project.read(cx).agent_server_store().clone(), - self.project.clone(), - None, - None, - ); - let connection = server.connect(None, delegate, cx); - cx.spawn(async move |_, cx| { - let (agent, _) = connection.await?; - let agent = agent.downcast::().unwrap(); - let summary = agent - .0 - .update(cx, |agent, cx| agent.thread_summary(id, cx))? - .await?; - anyhow::Ok(Mention::Text { - content: summary.to_string(), - tracked_buffers: Vec::new(), - }) - }) - } - - fn confirm_mention_for_text_thread( - &mut self, - path: PathBuf, - cx: &mut Context, - ) -> Task> { - let text_thread_task = self.history_store.update(cx, |store, cx| { - store.load_text_thread(path.as_path().into(), cx) - }); - cx.spawn(async move |_, cx| { - let text_thread = text_thread_task.await?; - let xml = text_thread.update(cx, |text_thread, cx| text_thread.to_xml(cx))?; - Ok(Mention::Text { - content: xml, - tracked_buffers: Vec::new(), - }) - }) + #[cfg(test)] + pub fn mention_set(&self) -> &Entity { + &self.mention_set } fn validate_slash_commands( @@ -716,7 +366,7 @@ impl MessageEditor { let contents = self .mention_set - .contents(full_mention_content, self.project.clone(), cx); + .update(cx, |store, cx| store.contents(full_mention_content, cx)); let editor = self.editor.clone(); let supports_embedded_context = self.prompt_capabilities.borrow().embedded_context; @@ -828,10 +478,12 @@ impl MessageEditor { self.editor.update(cx, |editor, cx| { editor.clear(window, cx); editor.remove_creases( - self.mention_set - .mentions - .drain() - .map(|(crease_id, _)| crease_id), + self.mention_set.update(cx, |mention_set, _cx| { + mention_set + .remove_all() + .map(|(crease_id, _)| crease_id) + .collect::>() + }), cx, ) }); @@ -910,153 +562,12 @@ impl MessageEditor { } fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { - if !self.prompt_capabilities.borrow().image { - return; + if self.prompt_capabilities.borrow().image + && let Some(task) = + paste_images_as_context(self.editor.clone(), self.mention_set.clone(), window, cx) + { + task.detach(); } - let Some(clipboard) = cx.read_from_clipboard() else { - return; - }; - cx.spawn_in(window, async move |this, cx| { - use itertools::Itertools; - let (mut images, paths) = clipboard - .into_entries() - .filter_map(|entry| match entry { - ClipboardEntry::Image(image) => Some(Either::Left(image)), - ClipboardEntry::ExternalPaths(paths) => Some(Either::Right(paths)), - _ => None, - }) - .partition_map::, Vec<_>, _, _, _>(std::convert::identity); - - if !paths.is_empty() { - images.extend( - cx.background_spawn(async move { - let mut images = vec![]; - for path in paths.into_iter().flat_map(|paths| paths.paths().to_owned()) { - let Ok(content) = async_fs::read(path).await else { - continue; - }; - let Ok(format) = image::guess_format(&content) else { - continue; - }; - images.push(gpui::Image::from_bytes( - match format { - image::ImageFormat::Png => gpui::ImageFormat::Png, - image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg, - image::ImageFormat::WebP => gpui::ImageFormat::Webp, - image::ImageFormat::Gif => gpui::ImageFormat::Gif, - image::ImageFormat::Bmp => gpui::ImageFormat::Bmp, - image::ImageFormat::Tiff => gpui::ImageFormat::Tiff, - image::ImageFormat::Ico => gpui::ImageFormat::Ico, - _ => continue, - }, - content, - )); - } - images - }) - .await, - ); - } - - if images.is_empty() { - return; - } - - let replacement_text = MentionUri::PastedImage.as_link().to_string(); - let Ok(editor) = this.update(cx, |this, cx| { - cx.stop_propagation(); - this.editor.clone() - }) else { - return; - }; - for image in images { - let Ok((excerpt_id, text_anchor, multibuffer_anchor)) = - editor.update_in(cx, |message_editor, window, cx| { - let snapshot = message_editor.snapshot(window, cx); - let (excerpt_id, _, buffer_snapshot) = - snapshot.buffer_snapshot().as_singleton().unwrap(); - - let text_anchor = buffer_snapshot.anchor_before(buffer_snapshot.len()); - let multibuffer_anchor = snapshot - .buffer_snapshot() - .anchor_in_excerpt(*excerpt_id, text_anchor); - message_editor.edit( - [( - multi_buffer::Anchor::max()..multi_buffer::Anchor::max(), - format!("{replacement_text} "), - )], - cx, - ); - (*excerpt_id, text_anchor, multibuffer_anchor) - }) - else { - break; - }; - - let content_len = replacement_text.len(); - let Some(start_anchor) = multibuffer_anchor else { - continue; - }; - let Ok(end_anchor) = editor.update(cx, |editor, cx| { - let snapshot = editor.buffer().read(cx).snapshot(cx); - snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len) - }) else { - continue; - }; - let image = Arc::new(image); - let Ok(Some((crease_id, tx))) = cx.update(|window, cx| { - insert_crease_for_mention( - excerpt_id, - text_anchor, - content_len, - MentionUri::PastedImage.name().into(), - IconName::Image.path().into(), - Some(Task::ready(Ok(image.clone())).shared()), - editor.clone(), - window, - cx, - ) - }) else { - continue; - }; - let task = cx - .spawn(async move |cx| { - let format = image.format; - let image = cx - .update(|_, cx| LanguageModelImage::from_image(image, cx)) - .map_err(|e| e.to_string())? - .await; - drop(tx); - if let Some(image) = image { - Ok(Mention::Image(MentionImage { - data: image.source, - format, - })) - } else { - Err("Failed to convert image".into()) - } - }) - .shared(); - - this.update(cx, |this, _| { - this.mention_set - .mentions - .insert(crease_id, (MentionUri::PastedImage, task.clone())) - }) - .ok(); - - if task.await.notify_async_err(cx).is_none() { - this.update(cx, |this, cx| { - this.editor.update(cx, |editor, cx| { - editor.edit([(start_anchor..end_anchor, "")], cx); - }); - this.mention_set.mentions.remove(&crease_id); - }) - .ok(); - } - } - }) - .detach(); } pub fn insert_dragged_files( @@ -1066,6 +577,9 @@ impl MessageEditor { window: &mut Window, cx: &mut Context, ) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; let path_style = self.project.read(cx).path_style(cx); let buffer = self.editor.read(cx).buffer().clone(); let Some(buffer) = buffer.read(cx).as_singleton() else { @@ -1080,12 +594,11 @@ impl MessageEditor { continue; }; let abs_path = worktree.read(cx).absolutize(&path.path); - let (file_name, _) = - crate::context_picker::file_context_picker::extract_file_name_and_directory( - &path.path, - worktree.read(cx).root_name(), - path_style, - ); + let (file_name, _) = crate::completion_provider::extract_file_name_and_directory( + &path.path, + worktree.read(cx).root_name(), + path_style, + ); let uri = if entry.is_dir() { MentionUri::Directory { abs_path } @@ -1107,14 +620,19 @@ impl MessageEditor { cx, ); }); - tasks.push(self.confirm_mention_completion( - file_name, - anchor, - content_len, - uri, - window, - cx, - )); + let supports_images = self.prompt_capabilities.borrow().image; + tasks.push(self.mention_set.update(cx, |mention_set, cx| { + mention_set.confirm_mention_completion( + file_name, + anchor, + content_len, + uri, + supports_images, + &workspace, + window, + cx, + ) + })); } cx.spawn(async move |_, _| { join_all(tasks).await; @@ -1137,13 +655,15 @@ impl MessageEditor { let Some(workspace) = self.workspace.upgrade() else { return; }; - let Some(completion) = ContextPickerCompletionProvider::completion_for_action( - ContextPickerAction::AddSelections, - anchor..anchor, - cx.weak_entity(), - &workspace, - cx, - ) else { + let Some(completion) = + PromptCompletionProvider::>::completion_for_action( + PromptContextAction::AddSelections, + anchor..anchor, + self.mention_set.downgrade(), + &workspace, + cx, + ) + else { return; }; @@ -1274,10 +794,13 @@ impl MessageEditor { }; drop(tx); - self.mention_set.mentions.insert( - crease_id, - (mention_uri.clone(), Task::ready(Ok(mention)).shared()), - ); + self.mention_set.update(cx, |mention_set, _cx| { + mention_set.insert_mention( + crease_id, + mention_uri.clone(), + Task::ready(Ok(mention)).shared(), + ) + }); } cx.notify(); } @@ -1305,111 +828,6 @@ impl MessageEditor { } } -fn full_mention_for_directory( - project: &Entity, - abs_path: &Path, - cx: &mut App, -) -> Task> { - fn collect_files_in_path(worktree: &Worktree, path: &RelPath) -> Vec<(Arc, String)> { - let mut files = Vec::new(); - - for entry in worktree.child_entries(path) { - if entry.is_dir() { - files.extend(collect_files_in_path(worktree, &entry.path)); - } else if entry.is_file() { - files.push(( - entry.path.clone(), - worktree - .full_path(&entry.path) - .to_string_lossy() - .to_string(), - )); - } - } - - files - } - - let Some(project_path) = project - .read(cx) - .project_path_for_absolute_path(&abs_path, cx) - else { - return Task::ready(Err(anyhow!("project path not found"))); - }; - let Some(entry) = project.read(cx).entry_for_path(&project_path, cx) else { - return Task::ready(Err(anyhow!("project entry not found"))); - }; - let directory_path = entry.path.clone(); - let worktree_id = project_path.worktree_id; - let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else { - return Task::ready(Err(anyhow!("worktree not found"))); - }; - let project = project.clone(); - cx.spawn(async move |cx| { - let file_paths = worktree.read_with(cx, |worktree, _cx| { - collect_files_in_path(worktree, &directory_path) - })?; - let descendants_future = cx.update(|cx| { - join_all(file_paths.into_iter().map(|(worktree_path, full_path)| { - let rel_path = worktree_path - .strip_prefix(&directory_path) - .log_err() - .map_or_else(|| worktree_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: worktree_path, - }; - buffer_store.open_buffer(project_path, cx) - }) - }); - - cx.spawn(async move |cx| { - let buffer = open_task.await.log_err()?; - let buffer_content = outline::get_buffer_content_or_outline( - buffer.clone(), - Some(&full_path), - &cx, - ) - .await - .ok()?; - - Some((rel_path, full_path, buffer_content.text, buffer)) - }) - })) - })?; - - let contents = cx - .background_spawn(async move { - let (contents, tracked_buffers) = descendants_future - .await - .into_iter() - .flatten() - .map(|(rel_path, full_path, rope, buffer)| { - ((rel_path, full_path, rope), buffer) - }) - .unzip(); - Mention::Text { - content: render_directory_contents(contents), - tracked_buffers, - } - }) - .await; - anyhow::Ok(contents) - }) -} - -fn render_directory_contents(entries: Vec<(Arc, String, String)>) -> String { - let mut output = String::new(); - for (_relative_path, full_path, content) in entries { - let fence = codeblock_fence_for_path(Some(&full_path), None); - write!(output, "\n{fence}\n{content}\n```").unwrap(); - } - output -} - impl Focusable for MessageEditor { fn focus_handle(&self, cx: &App) -> FocusHandle { self.editor.focus_handle(cx) @@ -1453,233 +871,6 @@ impl Render for MessageEditor { } } -pub(crate) fn insert_crease_for_mention( - excerpt_id: ExcerptId, - anchor: text::Anchor, - content_len: usize, - crease_label: SharedString, - crease_icon: SharedString, - // abs_path: Option>, - image: Option, String>>>>, - editor: Entity, - window: &mut Window, - cx: &mut App, -) -> Option<(CreaseId, postage::barrier::Sender)> { - let (tx, rx) = postage::barrier::channel(); - - let crease_id = editor.update(cx, |editor, cx| { - let snapshot = editor.buffer().read(cx).snapshot(cx); - - let start = snapshot.anchor_in_excerpt(excerpt_id, anchor)?; - - let start = start.bias_right(&snapshot); - let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len); - - let placeholder = FoldPlaceholder { - render: render_mention_fold_button( - crease_label, - crease_icon, - start..end, - rx, - image, - cx.weak_entity(), - cx, - ), - merge_adjacent: false, - ..Default::default() - }; - - let crease = Crease::Inline { - range: start..end, - placeholder, - render_toggle: None, - render_trailer: None, - metadata: None, - }; - - let ids = editor.insert_creases(vec![crease.clone()], cx); - editor.fold_creases(vec![crease], false, window, cx); - - Some(ids[0]) - })?; - - Some((crease_id, tx)) -} - -fn render_mention_fold_button( - label: SharedString, - icon: SharedString, - range: Range, - mut loading_finished: postage::barrier::Receiver, - image_task: Option, String>>>>, - editor: WeakEntity, - cx: &mut App, -) -> Arc, &mut App) -> AnyElement> { - let loading = cx.new(|cx| { - let loading = cx.spawn(async move |this, cx| { - loading_finished.recv().await; - this.update(cx, |this: &mut LoadingContext, cx| { - this.loading = None; - cx.notify(); - }) - .ok(); - }); - LoadingContext { - id: cx.entity_id(), - label, - icon, - range, - editor, - loading: Some(loading), - image: image_task.clone(), - } - }); - Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element()) -} - -struct LoadingContext { - id: EntityId, - label: SharedString, - icon: SharedString, - range: Range, - editor: WeakEntity, - loading: Option>, - image: Option, String>>>>, -} - -impl Render for LoadingContext { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let is_in_text_selection = self - .editor - .update(cx, |editor, cx| editor.is_range_selected(&self.range, cx)) - .unwrap_or_default(); - ButtonLike::new(("loading-context", self.id)) - .style(ButtonStyle::Filled) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .toggle_state(is_in_text_selection) - .when_some(self.image.clone(), |el, image_task| { - el.hoverable_tooltip(move |_, cx| { - let image = image_task.peek().cloned().transpose().ok().flatten(); - let image_task = image_task.clone(); - cx.new::(|cx| ImageHover { - image, - _task: cx.spawn(async move |this, cx| { - if let Ok(image) = image_task.clone().await { - this.update(cx, |this, cx| { - if this.image.replace(image).is_none() { - cx.notify(); - } - }) - .ok(); - } - }), - }) - .into() - }) - }) - .child( - h_flex() - .gap_1() - .child( - Icon::from_path(self.icon.clone()) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child( - Label::new(self.label.clone()) - .size(LabelSize::Small) - .buffer_font(cx) - .single_line(), - ) - .map(|el| { - if self.loading.is_some() { - el.with_animation( - "loading-context-crease", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.4, 0.8)), - |label, delta| label.opacity(delta), - ) - .into_any() - } else { - el.into_any() - } - }), - ) - } -} - -struct ImageHover { - image: Option>, - _task: Task<()>, -} - -impl Render for ImageHover { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - if let Some(image) = self.image.clone() { - gpui::img(image).max_w_96().max_h_96().into_any_element() - } else { - gpui::Empty.into_any_element() - } - } -} - -#[derive(Debug, Clone, Eq, PartialEq)] -pub enum Mention { - Text { - content: String, - tracked_buffers: Vec>, - }, - Image(MentionImage), - Link, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct MentionImage { - pub data: SharedString, - pub format: ImageFormat, -} - -#[derive(Default)] -pub struct MentionSet { - mentions: HashMap>>)>, -} - -impl MentionSet { - fn contents( - &self, - full_mention_content: bool, - project: Entity, - cx: &mut App, - ) -> Task>> { - let mentions = self.mentions.clone(); - cx.spawn(async move |cx| { - let mut contents = HashMap::default(); - for (crease_id, (mention_uri, task)) in mentions { - let content = if full_mention_content - && let MentionUri::Directory { abs_path } = &mention_uri - { - cx.update(|cx| full_mention_for_directory(&project, abs_path, cx))? - .await? - } else { - task.await.map_err(|e| anyhow!("{e}"))? - }; - - contents.insert(crease_id, (mention_uri, content)); - } - Ok(contents) - }) - } - - fn remove_invalid(&mut self, snapshot: EditorSnapshot) { - for (crease_id, crease) in snapshot.crease_snapshot.creases() { - if !crease.range().start.is_valid(&snapshot.buffer_snapshot()) { - self.mentions.remove(&crease_id); - } - } - } -} - pub struct MessageEditorAddon {} impl MessageEditorAddon { @@ -2391,7 +1582,7 @@ mod tests { .update(&mut cx, |message_editor, cx| { message_editor .mention_set() - .contents(false, project.clone(), cx) + .update(cx, |mention_set, cx| mention_set.contents(false, cx)) }) .await .unwrap() @@ -2446,7 +1637,7 @@ mod tests { .update(&mut cx, |message_editor, cx| { message_editor .mention_set() - .contents(false, project.clone(), cx) + .update(cx, |mention_set, cx| mention_set.contents(false, cx)) }) .await .unwrap() @@ -2569,7 +1760,7 @@ mod tests { .update(&mut cx, |message_editor, cx| { message_editor .mention_set() - .contents(false, project.clone(), cx) + .update(cx, |mention_set, cx| mention_set.contents(false, cx)) }) .await .unwrap() @@ -2617,7 +1808,7 @@ mod tests { .update(&mut cx, |message_editor, cx| { message_editor .mention_set() - .contents(false, project.clone(), cx) + .update(cx, |mention_set, cx| mention_set.contents(false, cx)) }) .await .expect_err("Should fail to load x.png"); @@ -2670,7 +1861,7 @@ mod tests { .update(&mut cx, |message_editor, cx| { message_editor .mention_set() - .contents(false, project.clone(), cx) + .update(cx, |mention_set, cx| mention_set.contents(false, cx)) }) .await .unwrap(); @@ -2781,7 +1972,9 @@ mod tests { worktree_root.join("large_file.txt") }); let large_file_task = message_editor.update(cx, |editor, cx| { - editor.confirm_mention_for_file(large_file_abs_path, cx) + editor.mention_set().update(cx, |set, cx| { + set.confirm_mention_for_file(large_file_abs_path, true, cx) + }) }); let large_file_mention = large_file_task.await.unwrap(); @@ -2813,7 +2006,9 @@ mod tests { worktree_root.join("small_file.txt") }); let small_file_task = message_editor.update(cx, |editor, cx| { - editor.confirm_mention_for_file(small_file_abs_path, cx) + editor.mention_set().update(cx, |set, cx| { + set.confirm_mention_for_file(small_file_abs_path, true, cx) + }) }); let small_file_mention = small_file_task.await.unwrap(); @@ -2888,7 +2083,7 @@ mod tests { text ); - let mentions = editor.mentions(); + let mentions = editor.mention_set().read(cx).mentions(); assert_eq!( mentions.len(), 1, diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 3cbedfbe198cf826a2e82e1f42f1a0d794da49e6..6ff909389986ec27b998c4554fe2d86115ef1785 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -17,6 +17,7 @@ use settings::{ use zed_actions::agent::{OpenClaudeCodeOnboardingModal, ReauthenticateAgent}; +use crate::ManageProfiles; use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal}; use crate::{ AddContextServer, AgentDiffPane, DeleteRecentlyOpenThread, Follow, InlineAssistant, @@ -34,7 +35,6 @@ use crate::{ acp::{AcpThreadHistory, ThreadHistoryEvent}, }; use crate::{ExternalAgent, NewExternalAgentThread, NewNativeAgentThreadFromSummary}; -use crate::{ManageProfiles, context_store::ContextStore}; use agent_settings::AgentSettings; use ai_onboarding::AgentPanelOnboarding; use anyhow::{Result, anyhow}; @@ -431,7 +431,6 @@ pub struct AgentPanel { text_thread_store: Entity, prompt_store: Option>, context_server_registry: Entity, - inline_assist_context_store: Entity, configuration: Option>, configuration_subscription: Option, active_view: ActiveView, @@ -543,7 +542,6 @@ impl AgentPanel { let client = workspace.client().clone(); let workspace = workspace.weak_handle(); - let inline_assist_context_store = cx.new(|_cx| ContextStore::new(project.downgrade())); let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); @@ -680,7 +678,6 @@ impl AgentPanel { configuration: None, configuration_subscription: None, context_server_registry, - inline_assist_context_store, previous_view: None, new_thread_menu_handle: PopoverMenuHandle::default(), agent_panel_menu_handle: PopoverMenuHandle::default(), @@ -721,10 +718,6 @@ impl AgentPanel { &self.prompt_store } - pub(crate) fn inline_assist_context_store(&self) -> &Entity { - &self.inline_assist_context_store - } - pub(crate) fn thread_store(&self) -> &Entity { &self.history_store } @@ -2664,23 +2657,19 @@ impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist { cx: &mut Context, ) { InlineAssistant::update_global(cx, |assistant, cx| { - let Some(project) = self - .workspace - .upgrade() - .map(|workspace| workspace.read(cx).project().downgrade()) - else { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + let Some(panel) = workspace.read(cx).panel::(cx) else { return; }; - let prompt_store = None; - let thread_store = None; - let context_store = cx.new(|_| ContextStore::new(project.clone())); + let project = workspace.read(cx).project().downgrade(); assistant.assist( prompt_editor, self.workspace.clone(), - context_store, project, - prompt_store, - thread_store, + panel.read(cx).thread_store().clone(), + None, initial_prompt, window, cx, diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index e06364988c1b49ab8877e40571393e02c252b47b..ae4cb70d4af419184519afb53ab62849b8a0eab8 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -4,14 +4,13 @@ mod agent_diff; mod agent_model_selector; mod agent_panel; mod buffer_codegen; +mod completion_provider; mod context; -mod context_picker; mod context_server_configuration; -mod context_store; -mod context_strip; mod inline_assistant; mod inline_prompt_editor; mod language_model_selector; +mod mention_set; mod profile_selector; mod slash_command; mod slash_command_picker; @@ -35,7 +34,7 @@ use language::{ language_settings::{AllLanguageSettings, EditPredictionProvider}, }; use language_model::{ - ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry, + ConfiguredModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry, }; use project::DisableAiSettings; use prompt_store::PromptBuilder; @@ -56,8 +55,6 @@ actions!( [ /// Creates a new text-based conversation thread. NewTextThread, - /// Toggles the context picker interface for adding files, symbols, or other context. - ToggleContextPicker, /// Toggles the menu to create new agent threads. ToggleNewThreadMenu, /// Toggles the navigation menu for switching between threads and views. @@ -70,8 +67,6 @@ actions!( ToggleProfileSelector, /// Cycles through available session modes. CycleModeSelector, - /// Removes all added context from the current conversation. - RemoveAllContext, /// Expands the message editor to full size. ExpandMessageEditor, /// Opens the conversation history view. @@ -94,10 +89,6 @@ actions!( FocusLeft, /// Moves focus right in the interface. FocusRight, - /// Removes the currently focused context item. - RemoveFocusedContext, - /// Accepts the suggested context item. - AcceptSuggestedContext, /// Opens the active thread as a markdown file. OpenActiveThreadAsMarkdown, /// Opens the agent diff view to review changes. @@ -220,11 +211,6 @@ impl ModelUsageContext { } } } - - pub fn language_model(&self, cx: &App) -> Option> { - self.configured_model(cx) - .map(|configured_model| configured_model.model) - } } /// Initializes the `agent` crate. diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index 3877bede3370589d1b6f74529cfa3c6ca1f34f0a..ba52b0298d37211626b6baf6aae1fb3da0be6372 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -1,6 +1,4 @@ -use crate::{ - context::load_context, context_store::ContextStore, inline_prompt_editor::CodegenStatus, -}; +use crate::{context::LoadedContext, inline_prompt_editor::CodegenStatus}; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; use client::telemetry::Telemetry; @@ -8,9 +6,12 @@ use cloud_llm_client::CompletionIntent; use collections::HashSet; use editor::{Anchor, AnchorRangeExt, MultiBuffer, MultiBufferSnapshot, ToOffset as _, ToPoint}; use futures::{ - SinkExt, Stream, StreamExt, TryStreamExt as _, channel::mpsc, future::LocalBoxFuture, join, + SinkExt, Stream, StreamExt, TryStreamExt as _, + channel::mpsc, + future::{LocalBoxFuture, Shared}, + join, }; -use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Subscription, Task, WeakEntity}; +use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Subscription, Task}; use language::{Buffer, IndentKind, Point, TransactionId, line_diff}; use language_model::{ LanguageModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, @@ -18,8 +19,7 @@ use language_model::{ }; use multi_buffer::MultiBufferRow; use parking_lot::Mutex; -use project::Project; -use prompt_store::{PromptBuilder, PromptStore}; +use prompt_store::PromptBuilder; use rope::Rope; use smol::future::FutureExt; use std::{ @@ -43,9 +43,6 @@ pub struct BufferCodegen { buffer: Entity, range: Range, initial_transaction_id: Option, - context_store: Entity, - project: WeakEntity, - prompt_store: Option>, telemetry: Arc, builder: Arc, pub is_insertion: bool, @@ -56,9 +53,6 @@ impl BufferCodegen { buffer: Entity, range: Range, initial_transaction_id: Option, - context_store: Entity, - project: WeakEntity, - prompt_store: Option>, telemetry: Arc, builder: Arc, cx: &mut Context, @@ -68,9 +62,6 @@ impl BufferCodegen { buffer.clone(), range.clone(), false, - Some(context_store.clone()), - project.clone(), - prompt_store.clone(), Some(telemetry.clone()), builder.clone(), cx, @@ -85,9 +76,6 @@ impl BufferCodegen { buffer, range, initial_transaction_id, - context_store, - project, - prompt_store, telemetry, builder, }; @@ -148,6 +136,7 @@ impl BufferCodegen { &mut self, primary_model: Arc, user_prompt: String, + context_task: Shared>>, cx: &mut Context, ) -> Result<()> { let alternative_models = LanguageModelRegistry::read_global(cx) @@ -165,9 +154,6 @@ impl BufferCodegen { self.buffer.clone(), self.range.clone(), false, - Some(self.context_store.clone()), - self.project.clone(), - self.prompt_store.clone(), Some(self.telemetry.clone()), self.builder.clone(), cx, @@ -180,7 +166,7 @@ impl BufferCodegen { .zip(&self.alternatives) { alternative.update(cx, |alternative, cx| { - alternative.start(user_prompt.clone(), model.clone(), cx) + alternative.start(user_prompt.clone(), context_task.clone(), model.clone(), cx) })?; } @@ -243,9 +229,6 @@ pub struct CodegenAlternative { status: CodegenStatus, generation: Task<()>, diff: Diff, - context_store: Option>, - project: WeakEntity, - prompt_store: Option>, telemetry: Option>, _subscription: gpui::Subscription, builder: Arc, @@ -264,9 +247,6 @@ impl CodegenAlternative { buffer: Entity, range: Range, active: bool, - context_store: Option>, - project: WeakEntity, - prompt_store: Option>, telemetry: Option>, builder: Arc, cx: &mut Context, @@ -307,9 +287,6 @@ impl CodegenAlternative { status: CodegenStatus::Idle, generation: Task::ready(()), diff: Diff::default(), - context_store, - project, - prompt_store, telemetry, _subscription: cx.subscribe(&buffer, Self::handle_buffer_event), builder, @@ -366,6 +343,7 @@ impl CodegenAlternative { pub fn start( &mut self, user_prompt: String, + context_task: Shared>>, model: Arc, cx: &mut Context, ) -> Result<()> { @@ -384,7 +362,7 @@ impl CodegenAlternative { if user_prompt.trim().to_lowercase() == "delete" { async { Ok(LanguageModelTextStream::default()) }.boxed_local() } else { - let request = self.build_request(&model, user_prompt, cx)?; + let request = self.build_request(&model, user_prompt, context_task, cx)?; cx.spawn(async move |_, cx| { Ok(model.stream_completion_text(request.await, cx).await?) }) @@ -398,6 +376,7 @@ impl CodegenAlternative { &self, model: &Arc, user_prompt: String, + context_task: Shared>>, cx: &mut App, ) -> Result> { let buffer = self.buffer.read(cx).snapshot(cx); @@ -437,19 +416,6 @@ impl CodegenAlternative { ) .context("generating content prompt")?; - let context_task = self.context_store.as_ref().and_then(|context_store| { - if let Some(project) = self.project.upgrade() { - let context = context_store - .read(cx) - .context() - .cloned() - .collect::>(); - Some(load_context(context, &project, &self.prompt_store, cx)) - } else { - None - } - }); - let temperature = AgentSettings::temperature_for_model(model, cx); Ok(cx.spawn(async move |_cx| { @@ -459,10 +425,8 @@ impl CodegenAlternative { cache: false, }; - if let Some(context_task) = context_task { - context_task - .await - .add_to_request_message(&mut request_message); + if let Some(context) = context_task.await { + context.add_to_request_message(&mut request_message); } request_message.content.push(prompt.into()); @@ -1088,7 +1052,6 @@ impl Diff { #[cfg(test)] mod tests { use super::*; - use fs::FakeFs; use futures::{ Stream, stream::{self}, @@ -1120,17 +1083,12 @@ mod tests { snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_after(Point::new(4, 5)) }); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs, vec![], cx).await; let codegen = cx.new(|cx| { CodegenAlternative::new( buffer.clone(), range.clone(), true, None, - project.downgrade(), - None, - None, prompt_builder, cx, ) @@ -1187,17 +1145,12 @@ mod tests { snapshot.anchor_before(Point::new(1, 6))..snapshot.anchor_after(Point::new(1, 6)) }); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs, vec![], cx).await; let codegen = cx.new(|cx| { CodegenAlternative::new( buffer.clone(), range.clone(), true, None, - project.downgrade(), - None, - None, prompt_builder, cx, ) @@ -1256,17 +1209,12 @@ mod tests { snapshot.anchor_before(Point::new(1, 2))..snapshot.anchor_after(Point::new(1, 2)) }); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs, vec![], cx).await; let codegen = cx.new(|cx| { CodegenAlternative::new( buffer.clone(), range.clone(), true, None, - project.downgrade(), - None, - None, prompt_builder, cx, ) @@ -1325,17 +1273,12 @@ mod tests { snapshot.anchor_before(Point::new(0, 0))..snapshot.anchor_after(Point::new(4, 2)) }); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs, vec![], cx).await; let codegen = cx.new(|cx| { CodegenAlternative::new( buffer.clone(), range.clone(), true, None, - project.downgrade(), - None, - None, prompt_builder, cx, ) @@ -1382,17 +1325,12 @@ mod tests { snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_after(Point::new(1, 14)) }); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs, vec![], cx).await; let codegen = cx.new(|cx| { CodegenAlternative::new( buffer.clone(), range.clone(), false, None, - project.downgrade(), - None, - None, prompt_builder, cx, ) diff --git a/crates/agent_ui/src/acp/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs similarity index 62% rename from crates/agent_ui/src/acp/completion_provider.rs rename to crates/agent_ui/src/completion_provider.rs index e87526957ce844a10c7c4f07f7ec6790927b142e..4e813570a42b9d7fee3f4ea5ef9ad6dafe1cc80e 100644 --- a/crates/agent_ui/src/acp/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -1,41 +1,130 @@ -use std::cell::RefCell; +use std::cmp::Reverse; use std::ops::Range; use std::path::PathBuf; -use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::AtomicBool; use acp_thread::MentionUri; use agent::{HistoryEntry, HistoryStore}; -use agent_client_protocol as acp; use anyhow::Result; use editor::{CompletionProvider, Editor, ExcerptId}; -use fuzzy::{StringMatch, StringMatchCandidate}; +use fuzzy::{PathMatch, StringMatch, StringMatchCandidate}; use gpui::{App, Entity, Task, WeakEntity}; use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId}; use lsp::CompletionContext; +use ordered_float::OrderedFloat; use project::lsp_store::{CompletionDocumentation, SymbolLocation}; use project::{ - Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, Project, - ProjectPath, Symbol, WorktreeId, + Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, + PathMatchCandidateSet, Project, ProjectPath, Symbol, WorktreeId, }; -use prompt_store::PromptStore; +use prompt_store::{PromptId, PromptStore, UserPromptId}; use rope::Point; use text::{Anchor, ToPoint as _}; use ui::prelude::*; +use util::ResultExt as _; +use util::paths::PathStyle; use util::rel_path::RelPath; use workspace::Workspace; use crate::AgentPanel; -use crate::acp::message_editor::MessageEditor; -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::search_threads; -use crate::context_picker::{ - ContextPickerAction, ContextPickerEntry, ContextPickerMode, selection_ranges, -}; +use crate::mention_set::MentionSet; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PromptContextEntry { + Mode(PromptContextType), + Action(PromptContextAction), +} + +impl PromptContextEntry { + pub fn keyword(&self) -> &'static str { + match self { + Self::Mode(mode) => mode.keyword(), + Self::Action(action) => action.keyword(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PromptContextType { + File, + Symbol, + Fetch, + Thread, + Rules, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PromptContextAction { + AddSelections, +} + +impl PromptContextAction { + pub fn keyword(&self) -> &'static str { + match self { + Self::AddSelections => "selection", + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::AddSelections => "Selection", + } + } + + pub fn icon(&self) -> IconName { + match self { + Self::AddSelections => IconName::Reader, + } + } +} + +impl TryFrom<&str> for PromptContextType { + type Error = String; + + fn try_from(value: &str) -> Result { + match value { + "file" => Ok(Self::File), + "symbol" => Ok(Self::Symbol), + "fetch" => Ok(Self::Fetch), + "thread" => Ok(Self::Thread), + "rule" => Ok(Self::Rules), + _ => Err(format!("Invalid context picker mode: {}", value)), + } + } +} + +impl PromptContextType { + pub fn keyword(&self) -> &'static str { + match self { + Self::File => "file", + Self::Symbol => "symbol", + Self::Fetch => "fetch", + Self::Thread => "thread", + Self::Rules => "rule", + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::File => "Files & Directories", + Self::Symbol => "Symbols", + Self::Fetch => "Fetch", + Self::Thread => "Threads", + Self::Rules => "Rules", + } + } + + pub fn icon(&self) -> IconName { + match self { + Self::File => IconName::File, + Self::Symbol => IconName::Code, + Self::Fetch => IconName::ToolWeb, + Self::Thread => IconName::Thread, + Self::Rules => IconName::Reader, + } + } +} pub(crate) enum Match { File(FileMatch), @@ -47,11 +136,6 @@ pub(crate) enum Match { Entry(EntryMatch), } -pub struct EntryMatch { - mat: Option, - entry: ContextPickerEntry, -} - impl Match { pub fn score(&self) -> f64 { match self { @@ -66,43 +150,69 @@ impl Match { } } -pub struct ContextPickerCompletionProvider { - message_editor: WeakEntity, - workspace: WeakEntity, +pub struct EntryMatch { + mat: Option, + entry: PromptContextEntry, +} + +#[derive(Debug, Clone)] +pub struct RulesContextEntry { + pub prompt_id: UserPromptId, + pub title: SharedString, +} + +#[derive(Debug, Clone)] +pub struct AvailableCommand { + pub name: Arc, + pub description: Arc, + pub requires_argument: bool, +} + +pub trait PromptCompletionProviderDelegate: Send + Sync + 'static { + fn supports_context(&self, mode: PromptContextType, cx: &App) -> bool { + self.supported_modes(cx).contains(&mode) + } + fn supported_modes(&self, cx: &App) -> Vec; + fn supports_images(&self, cx: &App) -> bool; + + fn available_commands(&self, cx: &App) -> Vec; + fn confirm_command(&self, cx: &mut App); +} + +pub struct PromptCompletionProvider { + source: Arc, + mention_set: Entity, history_store: Entity, prompt_store: Option>, - prompt_capabilities: Rc>, - available_commands: Rc>>, + workspace: WeakEntity, } -impl ContextPickerCompletionProvider { +impl PromptCompletionProvider { pub fn new( - message_editor: WeakEntity, - workspace: WeakEntity, + source: T, + mention_set: Entity, history_store: Entity, prompt_store: Option>, - prompt_capabilities: Rc>, - available_commands: Rc>>, + workspace: WeakEntity, ) -> Self { Self { - message_editor, + source: Arc::new(source), + mention_set, workspace, history_store, prompt_store, - prompt_capabilities, - available_commands, } } fn completion_for_entry( - entry: ContextPickerEntry, + entry: PromptContextEntry, source_range: Range, - message_editor: WeakEntity, + mention_set: WeakEntity, workspace: &Entity, cx: &mut App, ) -> Option { match entry { - ContextPickerEntry::Mode(mode) => Some(Completion { + PromptContextEntry::Mode(mode) => Some(Completion { replace_range: source_range, new_text: format!("@{} ", mode.keyword()), label: CodeLabel::plain(mode.label().to_string(), None), @@ -117,8 +227,8 @@ impl ContextPickerCompletionProvider { // inserted confirm: Some(Arc::new(|_, _, _| true)), }), - ContextPickerEntry::Action(action) => { - Self::completion_for_action(action, source_range, message_editor, workspace, cx) + PromptContextEntry::Action(action) => { + Self::completion_for_action(action, source_range, mention_set, workspace, cx) } } } @@ -127,7 +237,9 @@ impl ContextPickerCompletionProvider { thread_entry: HistoryEntry, source_range: Range, recent: bool, - editor: WeakEntity, + source: Arc, + mention_set: WeakEntity, + workspace: Entity, cx: &mut App, ) -> Completion { let uri = thread_entry.mention_uri(); @@ -155,8 +267,10 @@ impl ContextPickerCompletionProvider { thread_entry.title().clone(), source_range.start, new_text_len - 1, - editor, uri, + source, + mention_set, + workspace, )), } } @@ -164,7 +278,9 @@ impl ContextPickerCompletionProvider { fn completion_for_rules( rule: RulesContextEntry, source_range: Range, - editor: WeakEntity, + source: Arc, + mention_set: WeakEntity, + workspace: Entity, cx: &mut App, ) -> Completion { let uri = MentionUri::Rule { @@ -188,8 +304,10 @@ impl ContextPickerCompletionProvider { rule.title, source_range.start, new_text_len - 1, - editor, uri, + source, + mention_set, + workspace, )), } } @@ -200,20 +318,18 @@ impl ContextPickerCompletionProvider { is_recent: bool, is_directory: bool, source_range: Range, - message_editor: WeakEntity, + source: Arc, + mention_set: WeakEntity, + workspace: Entity, project: Entity, cx: &mut App, ) -> Option { let path_style = project.read(cx).path_style(cx); let (file_name, directory) = - crate::context_picker::file_context_picker::extract_file_name_and_directory( - &project_path.path, - path_prefix, - path_style, - ); + extract_file_name_and_directory(&project_path.path, path_prefix, path_style); let label = - build_code_label_for_full_path(&file_name, directory.as_ref().map(|s| s.as_ref()), cx); + build_code_label_for_path(&file_name, directory.as_ref().map(|s| s.as_ref()), None, cx); let abs_path = project.read(cx).absolute_path(&project_path, cx)?; @@ -246,8 +362,10 @@ impl ContextPickerCompletionProvider { file_name, source_range.start, new_text_len - 1, - message_editor, uri, + source, + mention_set, + workspace, )), }) } @@ -255,7 +373,8 @@ impl ContextPickerCompletionProvider { fn completion_for_symbol( symbol: Symbol, source_range: Range, - message_editor: WeakEntity, + source: Arc, + mention_set: WeakEntity, workspace: Entity, cx: &mut App, ) -> Option { @@ -275,7 +394,12 @@ impl ContextPickerCompletionProvider { ), }; - let label = build_symbol_label(&symbol.name, &file_name, symbol.range.start.0.row + 1, cx); + let label = build_code_label_for_path( + &symbol.name, + Some(&file_name), + Some(symbol.range.start.0.row + 1), + cx, + ); let uri = MentionUri::Symbol { abs_path, @@ -299,8 +423,10 @@ impl ContextPickerCompletionProvider { symbol.name.into(), source_range.start, new_text_len - 1, - message_editor, uri, + source, + mention_set, + workspace, )), }) } @@ -308,7 +434,9 @@ impl ContextPickerCompletionProvider { fn completion_for_fetch( source_range: Range, url_to_fetch: SharedString, - message_editor: WeakEntity, + source: Arc, + mention_set: WeakEntity, + workspace: Entity, cx: &mut App, ) -> Option { let new_text = format!("@fetch {} ", url_to_fetch); @@ -333,21 +461,23 @@ impl ContextPickerCompletionProvider { url_to_fetch.to_string().into(), source_range.start, new_text.len() - 1, - message_editor, mention_uri, + source, + mention_set, + workspace, )), }) } pub(crate) fn completion_for_action( - action: ContextPickerAction, + action: PromptContextAction, source_range: Range, - message_editor: WeakEntity, + mention_set: WeakEntity, workspace: &Entity, cx: &mut App, ) -> Option { let (new_text, on_action) = match action { - ContextPickerAction::AddSelections => { + PromptContextAction::AddSelections => { const PLACEHOLDER: &str = "selection "; let selections = selection_ranges(workspace, cx) .into_iter() @@ -367,12 +497,12 @@ impl ContextPickerCompletionProvider { let source_range = source_range.clone(); move |_, window: &mut Window, cx: &mut App| { let selections = selections.clone(); - let message_editor = message_editor.clone(); + let mention_set = mention_set.clone(); let source_range = source_range.clone(); window.defer(cx, move |window, cx| { - message_editor - .update(cx, |message_editor, cx| { - message_editor.confirm_mention_for_selection( + mention_set + .update(cx, |store, cx| { + store.confirm_mention_for_selection( source_range, selections, window, @@ -406,12 +536,8 @@ impl ContextPickerCompletionProvider { }) } - fn search_slash_commands( - &self, - query: String, - cx: &mut App, - ) -> Task> { - let commands = self.available_commands.borrow().clone(); + fn search_slash_commands(&self, query: String, cx: &mut App) -> Task> { + let commands = self.source.available_commands(cx); if commands.is_empty() { return Task::ready(Vec::new()); } @@ -443,7 +569,7 @@ impl ContextPickerCompletionProvider { fn search_mentions( &self, - mode: Option, + mode: Option, query: String, cancellation_flag: Arc, cx: &mut App, @@ -452,7 +578,7 @@ impl ContextPickerCompletionProvider { return Task::ready(Vec::default()); }; match mode { - Some(ContextPickerMode::File) => { + Some(PromptContextType::File) => { let search_files_task = search_files(query, cancellation_flag, &workspace, cx); cx.background_spawn(async move { search_files_task @@ -463,7 +589,7 @@ impl ContextPickerCompletionProvider { }) } - Some(ContextPickerMode::Symbol) => { + Some(PromptContextType::Symbol) => { let search_symbols_task = search_symbols(query, cancellation_flag, &workspace, cx); cx.background_spawn(async move { search_symbols_task @@ -474,7 +600,7 @@ impl ContextPickerCompletionProvider { }) } - Some(ContextPickerMode::Thread) => { + Some(PromptContextType::Thread) => { let search_threads_task = search_threads(query, cancellation_flag, &self.history_store, cx); cx.background_spawn(async move { @@ -486,7 +612,7 @@ impl ContextPickerCompletionProvider { }) } - Some(ContextPickerMode::Fetch) => { + Some(PromptContextType::Fetch) => { if !query.is_empty() { Task::ready(vec![Match::Fetch(query.into())]) } else { @@ -494,7 +620,7 @@ impl ContextPickerCompletionProvider { } } - Some(ContextPickerMode::Rules) => { + Some(PromptContextType::Rules) => { if let Some(prompt_store) = self.prompt_store.as_ref() { let search_rules_task = search_rules(query, cancellation_flag, prompt_store, cx); @@ -584,9 +710,8 @@ impl ContextPickerCompletionProvider { let mut recent = Vec::with_capacity(6); let mut mentions = self - .message_editor - .read_with(cx, |message_editor, _cx| message_editor.mentions()) - .unwrap_or_default(); + .mention_set + .read_with(cx, |store, _cx| store.mentions()); let workspace = workspace.read(cx); let project = workspace.project().read(cx); let include_root_name = workspace.visible_worktrees(cx).count() > 1; @@ -637,7 +762,7 @@ impl ContextPickerCompletionProvider { }), ); - if self.prompt_capabilities.borrow().embedded_context { + if self.source.supports_context(PromptContextType::Thread, cx) { const RECENT_COUNT: usize = 2; let threads = self .history_store @@ -658,15 +783,14 @@ impl ContextPickerCompletionProvider { &self, workspace: &Entity, cx: &mut App, - ) -> Vec { - let embedded_context = self.prompt_capabilities.borrow().embedded_context; + ) -> Vec { let mut entries = vec![ - ContextPickerEntry::Mode(ContextPickerMode::File), - ContextPickerEntry::Mode(ContextPickerMode::Symbol), + PromptContextEntry::Mode(PromptContextType::File), + PromptContextEntry::Mode(PromptContextType::Symbol), ]; - if embedded_context { - entries.push(ContextPickerEntry::Mode(ContextPickerMode::Thread)); + if self.source.supports_context(PromptContextType::Thread, cx) { + entries.push(PromptContextEntry::Mode(PromptContextType::Thread)); } let has_selection = workspace @@ -679,53 +803,25 @@ impl ContextPickerCompletionProvider { }) }); if has_selection { - entries.push(ContextPickerEntry::Action( - ContextPickerAction::AddSelections, + entries.push(PromptContextEntry::Action( + PromptContextAction::AddSelections, )); } - if embedded_context { - if self.prompt_store.is_some() { - entries.push(ContextPickerEntry::Mode(ContextPickerMode::Rules)); - } + if self.prompt_store.is_some() && self.source.supports_context(PromptContextType::Rules, cx) + { + entries.push(PromptContextEntry::Mode(PromptContextType::Rules)); + } - entries.push(ContextPickerEntry::Mode(ContextPickerMode::Fetch)); + if self.source.supports_context(PromptContextType::Fetch, cx) { + entries.push(PromptContextEntry::Mode(PromptContextType::Fetch)); } entries } } -fn build_symbol_label(symbol_name: &str, file_name: &str, line: u32, cx: &App) -> CodeLabel { - let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId); - let mut label = CodeLabelBuilder::default(); - - label.push_str(symbol_name, None); - label.push_str(" ", None); - label.push_str(&format!("{} L{}", file_name, line), comment_id); - - label.build() -} - -fn build_code_label_for_full_path(file_name: &str, directory: Option<&str>, cx: &App) -> CodeLabel { - let path = cx - .theme() - .syntax() - .highlight_id("variable") - .map(HighlightId); - let mut label = CodeLabelBuilder::default(); - - label.push_str(file_name, None); - label.push_str(" ", None); - - if let Some(directory) = directory { - label.push_str(directory, path); - } - - label.build() -} - -impl CompletionProvider for ContextPickerCompletionProvider { +impl CompletionProvider for PromptCompletionProvider { fn completions( &self, _excerpt_id: ExcerptId, @@ -735,17 +831,13 @@ impl CompletionProvider for ContextPickerCompletionProvider { _window: &mut Window, cx: &mut Context, ) -> Task>> { - let state = buffer.update(cx, |buffer, _cx| { + let state = buffer.update(cx, |buffer, cx| { let position = buffer_position.to_point(buffer); let line_start = Point::new(position.row, 0); let offset_to_line = buffer.point_to_offset(line_start); let mut lines = buffer.text_for_range(line_start..position).lines(); let line = lines.next()?; - ContextCompletion::try_parse( - line, - offset_to_line, - self.prompt_capabilities.borrow().embedded_context, - ) + ContextCompletion::try_parse(line, offset_to_line, &self.source.supported_modes(cx)) }); let Some(state) = state else { return Task::ready(Ok(Vec::new())); @@ -760,8 +852,8 @@ impl CompletionProvider for ContextPickerCompletionProvider { let source_range = snapshot.anchor_before(state.source_range().start) ..snapshot.anchor_after(state.source_range().end); - let editor = self.message_editor.clone(); - + let source = self.source.clone(); + let mention_set = self.mention_set.downgrade(); match state { ContextCompletion::SlashCommand(SlashCommandCompletion { command, argument, .. @@ -778,7 +870,8 @@ impl CompletionProvider for ContextPickerCompletionProvider { format!("/{} ", command.name) }; - let is_missing_argument = argument.is_none() && command.input.is_some(); + let is_missing_argument = + command.requires_argument && argument.is_none(); Completion { replace_range: source_range.clone(), new_text, @@ -792,26 +885,18 @@ impl CompletionProvider for ContextPickerCompletionProvider { snippet_deduplication_key: None, insert_text_mode: None, confirm: Some(Arc::new({ - let editor = editor.clone(); + let source = source.clone(); move |intent, _window, cx| { if !is_missing_argument { cx.defer({ - let editor = editor.clone(); - move |cx| { - editor - .update(cx, |editor, cx| { - match intent { - CompletionIntent::Complete - | CompletionIntent::CompleteWithInsert - | CompletionIntent::CompleteWithReplace => { - if !is_missing_argument { - editor.send(cx); - } - } - CompletionIntent::Compose => {} - } - }) - .ok(); + let source = source.clone(); + move |cx| match intent { + CompletionIntent::Complete + | CompletionIntent::CompleteWithInsert + | CompletionIntent::CompleteWithReplace => { + source.confirm_command(cx); + } + CompletionIntent::Compose => {} } }); } @@ -869,7 +954,9 @@ impl CompletionProvider for ContextPickerCompletionProvider { is_recent, mat.is_dir, source_range.clone(), - editor.clone(), + source.clone(), + mention_set.clone(), + workspace.clone(), project.clone(), cx, ) @@ -879,7 +966,8 @@ impl CompletionProvider for ContextPickerCompletionProvider { Self::completion_for_symbol( symbol, source_range.clone(), - editor.clone(), + source.clone(), + mention_set.clone(), workspace.clone(), cx, ) @@ -889,7 +977,9 @@ impl CompletionProvider for ContextPickerCompletionProvider { thread, source_range.clone(), false, - editor.clone(), + source.clone(), + mention_set.clone(), + workspace.clone(), cx, )), @@ -897,21 +987,27 @@ impl CompletionProvider for ContextPickerCompletionProvider { thread, source_range.clone(), true, - editor.clone(), + source.clone(), + mention_set.clone(), + workspace.clone(), cx, )), Match::Rules(user_rules) => Some(Self::completion_for_rules( user_rules, source_range.clone(), - editor.clone(), + source.clone(), + mention_set.clone(), + workspace.clone(), cx, )), Match::Fetch(url) => Self::completion_for_fetch( source_range.clone(), url, - editor.clone(), + source.clone(), + mention_set.clone(), + workspace.clone(), cx, ), @@ -919,7 +1015,7 @@ impl CompletionProvider for ContextPickerCompletionProvider { Self::completion_for_entry( entry, source_range.clone(), - editor.clone(), + mention_set.clone(), &workspace, cx, ) @@ -957,27 +1053,24 @@ impl CompletionProvider for ContextPickerCompletionProvider { let offset_to_line = buffer.point_to_offset(line_start); let mut lines = buffer.text_for_range(line_start..position).lines(); if let Some(line) = lines.next() { - ContextCompletion::try_parse( - line, - offset_to_line, - self.prompt_capabilities.borrow().embedded_context, - ) - .filter(|completion| { - // Right now we don't support completing arguments of slash commands - let is_slash_command_with_argument = matches!( - completion, - ContextCompletion::SlashCommand(SlashCommandCompletion { - argument: Some(_), - .. - }) - ); - !is_slash_command_with_argument - }) - .map(|completion| { - completion.source_range().start <= offset_to_line + position.column as usize - && completion.source_range().end >= offset_to_line + position.column as usize - }) - .unwrap_or(false) + ContextCompletion::try_parse(line, offset_to_line, &self.source.supported_modes(cx)) + .filter(|completion| { + // Right now we don't support completing arguments of slash commands + let is_slash_command_with_argument = matches!( + completion, + ContextCompletion::SlashCommand(SlashCommandCompletion { + argument: Some(_), + .. + }) + ); + !is_slash_command_with_argument + }) + .map(|completion| { + completion.source_range().start <= offset_to_line + position.column as usize + && completion.source_range().end + >= offset_to_line + position.column as usize + }) + .unwrap_or(false) } else { false } @@ -992,27 +1085,33 @@ impl CompletionProvider for ContextPickerCompletionProvider { } } -fn confirm_completion_callback( +fn confirm_completion_callback( crease_text: SharedString, start: Anchor, content_len: usize, - message_editor: WeakEntity, mention_uri: MentionUri, + source: Arc, + mention_set: WeakEntity, + workspace: Entity, ) -> Arc bool + Send + Sync> { Arc::new(move |_, window, cx| { - let message_editor = message_editor.clone(); + let source = source.clone(); + let mention_set = mention_set.clone(); let crease_text = crease_text.clone(); let mention_uri = mention_uri.clone(); + let workspace = workspace.clone(); window.defer(cx, move |window, cx| { - message_editor + mention_set .clone() - .update(cx, |message_editor, cx| { - message_editor + .update(cx, |mention_set, cx| { + mention_set .confirm_mention_completion( crease_text, start, content_len, mention_uri, + source.supports_images(cx), + &workspace, window, cx, ) @@ -1037,11 +1136,15 @@ impl ContextCompletion { } } - fn try_parse(line: &str, offset_to_line: usize, allow_non_file_mentions: bool) -> Option { + fn try_parse( + line: &str, + offset_to_line: usize, + supported_modes: &[PromptContextType], + ) -> Option { if let Some(command) = SlashCommandCompletion::try_parse(line, offset_to_line) { Some(Self::SlashCommand(command)) } else if let Some(mention) = - MentionCompletion::try_parse(allow_non_file_mentions, line, offset_to_line) + MentionCompletion::try_parse(line, offset_to_line, supported_modes) { Some(Self::Mention(mention)) } else { @@ -1098,12 +1201,16 @@ impl SlashCommandCompletion { #[derive(Debug, Default, PartialEq)] struct MentionCompletion { source_range: Range, - mode: Option, + mode: Option, argument: Option, } impl MentionCompletion { - fn try_parse(allow_non_file_mentions: bool, line: &str, offset_to_line: usize) -> Option { + fn try_parse( + line: &str, + offset_to_line: usize, + supported_modes: &[PromptContextType], + ) -> Option { let last_mention_start = line.rfind('@')?; // No whitespace immediately after '@' @@ -1137,8 +1244,8 @@ impl MentionCompletion { // Safe since we check no leading whitespace above end += mode_text.len(); - if let Some(parsed_mode) = ContextPickerMode::try_from(mode_text).ok() - && (allow_non_file_mentions || matches!(parsed_mode, ContextPickerMode::File)) + if let Some(parsed_mode) = PromptContextType::try_from(mode_text).ok() + && supported_modes.contains(&parsed_mode) { mode = Some(parsed_mode); } else { @@ -1172,6 +1279,339 @@ impl MentionCompletion { } } +pub(crate) fn search_files( + query: String, + cancellation_flag: Arc, + workspace: &Entity, + cx: &App, +) -> Task> { + if query.is_empty() { + let workspace = workspace.read(cx); + let project = workspace.project().read(cx); + let visible_worktrees = workspace.visible_worktrees(cx).collect::>(); + let include_root_name = visible_worktrees.len() > 1; + + let recent_matches = workspace + .recent_navigation_history(Some(10), cx) + .into_iter() + .map(|(project_path, _)| { + let path_prefix = if include_root_name { + project + .worktree_for_id(project_path.worktree_id, cx) + .map(|wt| wt.read(cx).root_name().into()) + .unwrap_or_else(|| RelPath::empty().into()) + } else { + RelPath::empty().into() + }; + + FileMatch { + mat: PathMatch { + score: 0., + positions: Vec::new(), + worktree_id: project_path.worktree_id.to_usize(), + path: project_path.path, + path_prefix, + distance_to_relative_ancestor: 0, + is_dir: false, + }, + is_recent: true, + } + }); + + let file_matches = visible_worktrees.into_iter().flat_map(|worktree| { + let worktree = worktree.read(cx); + let path_prefix: Arc = if include_root_name { + worktree.root_name().into() + } else { + RelPath::empty().into() + }; + worktree.entries(false, 0).map(move |entry| FileMatch { + mat: PathMatch { + score: 0., + positions: Vec::new(), + worktree_id: worktree.id().to_usize(), + path: entry.path.clone(), + path_prefix: path_prefix.clone(), + distance_to_relative_ancestor: 0, + is_dir: entry.is_dir(), + }, + is_recent: false, + }) + }); + + Task::ready(recent_matches.chain(file_matches).collect()) + } else { + let worktrees = workspace.read(cx).visible_worktrees(cx).collect::>(); + let include_root_name = worktrees.len() > 1; + let candidate_sets = worktrees + .into_iter() + .map(|worktree| { + let worktree = worktree.read(cx); + + PathMatchCandidateSet { + snapshot: worktree.snapshot(), + include_ignored: worktree.root_entry().is_some_and(|entry| entry.is_ignored), + include_root_name, + candidates: project::Candidates::Entries, + } + }) + .collect::>(); + + let executor = cx.background_executor().clone(); + cx.foreground_executor().spawn(async move { + fuzzy::match_path_sets( + candidate_sets.as_slice(), + query.as_str(), + &None, + false, + 100, + &cancellation_flag, + executor, + ) + .await + .into_iter() + .map(|mat| FileMatch { + mat, + is_recent: false, + }) + .collect::>() + }) + } +} + +pub(crate) fn search_symbols( + query: String, + cancellation_flag: Arc, + workspace: &Entity, + cx: &mut App, +) -> Task> { + let symbols_task = workspace.update(cx, |workspace, cx| { + workspace + .project() + .update(cx, |project, cx| project.symbols(&query, cx)) + }); + let project = workspace.read(cx).project().clone(); + cx.spawn(async move |cx| { + let Some(symbols) = symbols_task.await.log_err() else { + return Vec::new(); + }; + let Some((visible_match_candidates, external_match_candidates)): Option<(Vec<_>, Vec<_>)> = + project + .update(cx, |project, cx| { + symbols + .iter() + .enumerate() + .map(|(id, symbol)| { + StringMatchCandidate::new(id, symbol.label.filter_text()) + }) + .partition(|candidate| match &symbols[candidate.id].path { + SymbolLocation::InProject(project_path) => project + .entry_for_path(project_path, cx) + .is_some_and(|e| !e.is_ignored), + SymbolLocation::OutsideProject { .. } => false, + }) + }) + .log_err() + else { + return Vec::new(); + }; + + const MAX_MATCHES: usize = 100; + let mut visible_matches = cx.background_executor().block(fuzzy::match_strings( + &visible_match_candidates, + &query, + false, + true, + MAX_MATCHES, + &cancellation_flag, + cx.background_executor().clone(), + )); + let mut external_matches = cx.background_executor().block(fuzzy::match_strings( + &external_match_candidates, + &query, + false, + true, + MAX_MATCHES - visible_matches.len().min(MAX_MATCHES), + &cancellation_flag, + cx.background_executor().clone(), + )); + let sort_key_for_match = |mat: &StringMatch| { + let symbol = &symbols[mat.candidate_id]; + (Reverse(OrderedFloat(mat.score)), symbol.label.filter_text()) + }; + + visible_matches.sort_unstable_by_key(sort_key_for_match); + external_matches.sort_unstable_by_key(sort_key_for_match); + let mut matches = visible_matches; + matches.append(&mut external_matches); + + matches + .into_iter() + .map(|mut mat| { + let symbol = symbols[mat.candidate_id].clone(); + let filter_start = symbol.label.filter_range.start; + for position in &mut mat.positions { + *position += filter_start; + } + SymbolMatch { symbol } + }) + .collect() + }) +} + +pub(crate) fn search_threads( + query: String, + cancellation_flag: Arc, + thread_store: &Entity, + cx: &mut App, +) -> Task> { + let threads = thread_store.read(cx).entries().collect(); + if query.is_empty() { + return Task::ready(threads); + } + + let executor = cx.background_executor().clone(); + cx.background_spawn(async move { + let candidates = threads + .iter() + .enumerate() + .map(|(id, thread)| StringMatchCandidate::new(id, thread.title())) + .collect::>(); + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + true, + 100, + &cancellation_flag, + executor, + ) + .await; + + matches + .into_iter() + .map(|mat| threads[mat.candidate_id].clone()) + .collect() + }) +} + +pub(crate) fn search_rules( + query: String, + cancellation_flag: Arc, + prompt_store: &Entity, + cx: &mut App, +) -> Task> { + let search_task = prompt_store.read(cx).search(query, cancellation_flag, cx); + cx.background_spawn(async move { + search_task + .await + .into_iter() + .flat_map(|metadata| { + // Default prompts are filtered out as they are automatically included. + if metadata.default { + None + } else { + match metadata.id { + PromptId::EditWorkflow => None, + PromptId::User { uuid } => Some(RulesContextEntry { + prompt_id: uuid, + title: metadata.title?, + }), + } + } + }) + .collect::>() + }) +} + +pub struct SymbolMatch { + pub symbol: Symbol, +} + +pub struct FileMatch { + pub mat: PathMatch, + pub is_recent: bool, +} + +pub fn extract_file_name_and_directory( + path: &RelPath, + path_prefix: &RelPath, + path_style: PathStyle, +) -> (SharedString, Option) { + // If path is empty, this means we're matching with the root directory itself + // so we use the path_prefix as the name + if path.is_empty() && !path_prefix.is_empty() { + return (path_prefix.display(path_style).to_string().into(), None); + } + + let full_path = path_prefix.join(path); + let file_name = full_path.file_name().unwrap_or_default(); + let display_path = full_path.display(path_style); + let (directory, file_name) = display_path.split_at(display_path.len() - file_name.len()); + ( + file_name.to_string().into(), + Some(SharedString::new(directory)).filter(|dir| !dir.is_empty()), + ) +} + +fn build_code_label_for_path( + file: &str, + directory: Option<&str>, + line_number: Option, + cx: &App, +) -> CodeLabel { + let variable_highlight_id = cx + .theme() + .syntax() + .highlight_id("variable") + .map(HighlightId); + let mut label = CodeLabelBuilder::default(); + + label.push_str(file, None); + label.push_str(" ", None); + + if let Some(directory) = directory { + label.push_str(directory, variable_highlight_id); + } + if let Some(line_number) = line_number { + label.push_str(&format!(" L{}", line_number), variable_highlight_id); + } + label.build() +} + +fn selection_ranges( + workspace: &Entity, + cx: &mut App, +) -> Vec<(Entity, Range)> { + let Some(editor) = workspace + .read(cx) + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + else { + return Vec::new(); + }; + + editor.update(cx, |editor, cx| { + let selections = editor.selections.all_adjusted(&editor.display_snapshot(cx)); + + let buffer = editor.buffer().clone().read(cx); + let snapshot = buffer.snapshot(cx); + + selections + .into_iter() + .map(|s| snapshot.anchor_after(s.start)..snapshot.anchor_before(s.end)) + .flat_map(|range| { + let (start_buffer, start) = buffer.text_anchor_for_position(range.start, cx)?; + let (end_buffer, end) = buffer.text_anchor_for_position(range.end, cx)?; + if start_buffer != end_buffer { + return None; + } + Some((start_buffer, start..end)) + }) + .collect::>() + }) +} + #[cfg(test)] mod tests { use super::*; @@ -1245,10 +1685,15 @@ mod tests { #[test] fn test_mention_completion_parse() { - assert_eq!(MentionCompletion::try_parse(true, "Lorem Ipsum", 0), None); + let supported_modes = vec![PromptContextType::File, PromptContextType::Symbol]; + + assert_eq!( + MentionCompletion::try_parse("Lorem Ipsum", 0, &supported_modes), + None + ); assert_eq!( - MentionCompletion::try_parse(true, "Lorem @", 0), + MentionCompletion::try_parse("Lorem @", 0, &supported_modes), Some(MentionCompletion { source_range: 6..7, mode: None, @@ -1257,52 +1702,52 @@ mod tests { ); assert_eq!( - MentionCompletion::try_parse(true, "Lorem @file", 0), + MentionCompletion::try_parse("Lorem @file", 0, &supported_modes), Some(MentionCompletion { source_range: 6..11, - mode: Some(ContextPickerMode::File), + mode: Some(PromptContextType::File), argument: None, }) ); assert_eq!( - MentionCompletion::try_parse(true, "Lorem @file ", 0), + MentionCompletion::try_parse("Lorem @file ", 0, &supported_modes), Some(MentionCompletion { source_range: 6..12, - mode: Some(ContextPickerMode::File), + mode: Some(PromptContextType::File), argument: None, }) ); assert_eq!( - MentionCompletion::try_parse(true, "Lorem @file main.rs", 0), + MentionCompletion::try_parse("Lorem @file main.rs", 0, &supported_modes), Some(MentionCompletion { source_range: 6..19, - mode: Some(ContextPickerMode::File), + mode: Some(PromptContextType::File), argument: Some("main.rs".to_string()), }) ); assert_eq!( - MentionCompletion::try_parse(true, "Lorem @file main.rs ", 0), + MentionCompletion::try_parse("Lorem @file main.rs ", 0, &supported_modes), Some(MentionCompletion { source_range: 6..19, - mode: Some(ContextPickerMode::File), + mode: Some(PromptContextType::File), argument: Some("main.rs".to_string()), }) ); assert_eq!( - MentionCompletion::try_parse(true, "Lorem @file main.rs Ipsum", 0), + MentionCompletion::try_parse("Lorem @file main.rs Ipsum", 0, &supported_modes), Some(MentionCompletion { source_range: 6..19, - mode: Some(ContextPickerMode::File), + mode: Some(PromptContextType::File), argument: Some("main.rs".to_string()), }) ); assert_eq!( - MentionCompletion::try_parse(true, "Lorem @main", 0), + MentionCompletion::try_parse("Lorem @main", 0, &supported_modes), Some(MentionCompletion { source_range: 6..11, mode: None, @@ -1311,7 +1756,7 @@ mod tests { ); assert_eq!( - MentionCompletion::try_parse(true, "Lorem @main ", 0), + MentionCompletion::try_parse("Lorem @main ", 0, &supported_modes), Some(MentionCompletion { source_range: 6..12, mode: None, @@ -1319,41 +1764,47 @@ mod tests { }) ); - assert_eq!(MentionCompletion::try_parse(true, "Lorem @main m", 0), None); + assert_eq!( + MentionCompletion::try_parse("Lorem @main m", 0, &supported_modes), + None + ); - assert_eq!(MentionCompletion::try_parse(true, "test@", 0), None); + assert_eq!( + MentionCompletion::try_parse("test@", 0, &supported_modes), + None + ); // Allowed non-file mentions assert_eq!( - MentionCompletion::try_parse(true, "Lorem @symbol main", 0), + MentionCompletion::try_parse("Lorem @symbol main", 0, &supported_modes), Some(MentionCompletion { source_range: 6..18, - mode: Some(ContextPickerMode::Symbol), + mode: Some(PromptContextType::Symbol), argument: Some("main".to_string()), }) ); // Disallowed non-file mentions assert_eq!( - MentionCompletion::try_parse(false, "Lorem @symbol main", 0), + MentionCompletion::try_parse("Lorem @symbol main", 0, &[PromptContextType::File]), None ); assert_eq!( - MentionCompletion::try_parse(true, "Lorem@symbol", 0), + MentionCompletion::try_parse("Lorem@symbol", 0, &supported_modes), None, "Should not parse mention inside word" ); assert_eq!( - MentionCompletion::try_parse(true, "Lorem @ file", 0), + MentionCompletion::try_parse("Lorem @ file", 0, &supported_modes), None, "Should not parse with a space after @" ); assert_eq!( - MentionCompletion::try_parse(true, "@ file", 0), + MentionCompletion::try_parse("@ file", 0, &supported_modes), None, "Should not parse with a space after @ at the start of the line" ); diff --git a/crates/agent_ui/src/context.rs b/crates/agent_ui/src/context.rs index 7f497f9cab9eae7ca9fa2a573100ab2993546228..ad8c95ba3e61f8f51d2b70ce59d0c8a9451e6571 100644 --- a/crates/agent_ui/src/context.rs +++ b/crates/agent_ui/src/context.rs @@ -1,764 +1,10 @@ -use agent::outline; -use assistant_text_thread::TextThread; -use futures::future; -use futures::{FutureExt, future::Shared}; -use gpui::{App, AppContext as _, ElementId, Entity, SharedString, Task}; -use language::Buffer; +use crate::mention_set::Mention; +use gpui::{AppContext as _, Entity, Task}; use language_model::{LanguageModelImage, LanguageModelRequestMessage, MessageContent}; -use project::{Project, ProjectEntryId, ProjectPath, Worktree}; -use prompt_store::{PromptStore, UserPromptId}; -use ref_cast::RefCast; -use rope::Point; -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 text::{Anchor, OffsetRangeExt as _}; -use ui::IconName; -use util::markdown::MarkdownCodeBlock; -use util::rel_path::RelPath; -use util::{ResultExt as _, post_inc}; +use ui::App; +use util::ResultExt as _; -pub const RULES_ICON: IconName = IconName::Reader; - -pub enum ContextKind { - File, - Directory, - Symbol, - Selection, - FetchedUrl, - Thread, - TextThread, - Rules, - Image, -} - -impl ContextKind { - pub fn icon(&self) -> IconName { - match self { - ContextKind::File => IconName::File, - ContextKind::Directory => IconName::Folder, - ContextKind::Symbol => IconName::Code, - ContextKind::Selection => IconName::Reader, - ContextKind::FetchedUrl => IconName::ToolWeb, - ContextKind::Thread => IconName::Thread, - ContextKind::TextThread => IconName::TextThread, - ContextKind::Rules => RULES_ICON, - ContextKind::Image => IconName::Image, - } - } -} - -/// 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 AgentContextHandle { - File(FileContextHandle), - Directory(DirectoryContextHandle), - Symbol(SymbolContextHandle), - Selection(SelectionContextHandle), - FetchedUrl(FetchedUrlContext), - Thread(ThreadContextHandle), - TextThread(TextThreadContextHandle), - Rules(RulesContextHandle), - Image(ImageContext), -} - -impl AgentContextHandle { - pub fn id(&self) -> ContextId { - match self { - Self::File(context) => context.context_id, - Self::Directory(context) => context.context_id, - Self::Symbol(context) => context.context_id, - Self::Selection(context) => context.context_id, - Self::FetchedUrl(context) => context.context_id, - Self::Thread(context) => context.context_id, - Self::TextThread(context) => context.context_id, - Self::Rules(context) => context.context_id, - Self::Image(context) => context.context_id, - } - } - - pub fn element_id(&self, name: SharedString) -> ElementId { - ElementId::NamedInteger(name, self.id().0) - } -} - -/// 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), - TextThread(TextThreadContext), - 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::TextThread(context) => { - AgentContextHandle::TextThread(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)] -pub struct ContextId(u64); - -impl ContextId { - pub fn zero() -> Self { - ContextId(0) - } - - fn for_lookup() -> Self { - ContextId(u64::MAX) - } - - pub fn post_inc(&mut self) -> Self { - Self(post_inc(&mut self.0)) - } -} - -/// File context provides the entire contents of a file. -/// -/// This holds an `Entity` so that file path renames affect its display and so that it can -/// 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 FileContextHandle { - pub buffer: Entity, - pub context_id: ContextId, -} - -#[derive(Debug, Clone)] -pub struct FileContext { - pub handle: FileContextHandle, - pub full_path: String, - pub text: SharedString, - pub is_outline: bool, -} - -impl FileContextHandle { - pub fn eq_for_key(&self, other: &Self) -> bool { - self.buffer == other.buffer - } - - pub fn hash_for_key(&self, state: &mut H) { - self.buffer.hash(state) - } - - pub fn project_path(&self, cx: &App) -> Option { - let file = self.buffer.read(cx).file()?; - Some(ProjectPath { - worktree_id: file.worktree_id(cx), - path: file.path().clone(), - }) - } - - fn load(self, cx: &App) -> Task> { - let buffer_ref = self.buffer.read(cx); - let Some(file) = buffer_ref.file() else { - log::error!("file context missing path"); - return Task::ready(None); - }; - let full_path = file.full_path(cx).to_string_lossy().into_owned(); - let rope = buffer_ref.as_rope().clone(); - let buffer = self.buffer.clone(); - - cx.spawn(async move |cx| { - let buffer_content = - outline::get_buffer_content_or_outline(buffer.clone(), Some(&full_path), &cx) - .await - .unwrap_or_else(|_| outline::BufferContent { - text: rope.to_string(), - is_outline: false, - }); - - let context = AgentContext::File(FileContext { - handle: self, - full_path, - text: buffer_content.text.into(), - is_outline: buffer_content.is_outline, - }); - Some(context) - }) - } -} - -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, - } - ) - } -} - -/// Directory contents provides the entire contents of text files in a directory. -/// -/// This has a `ProjectEntryId` so that it follows renames. -#[derive(Debug, Clone)] -pub struct DirectoryContextHandle { - pub entry_id: ProjectEntryId, - pub context_id: ContextId, -} - -#[derive(Debug, Clone)] -pub struct DirectoryContext { - pub handle: DirectoryContextHandle, - pub full_path: String, - pub descendants: Vec, -} - -#[derive(Debug, Clone)] -pub struct DirectoryContextDescendant { - /// Path within the directory. - pub rel_path: Arc, - pub fenced_codeblock: SharedString, -} - -impl DirectoryContextHandle { - pub fn eq_for_key(&self, other: &Self) -> bool { - self.entry_id == other.entry_id - } - - pub fn hash_for_key(&self, state: &mut H) { - self.entry_id.hash(state) - } - - fn load(self, project: Entity, cx: &mut App) -> Task> { - 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 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 Task::ready(None); - } - - let directory_path = entry.path.clone(); - let directory_full_path = worktree_ref - .full_path(&directory_path) - .to_string_lossy() - .to_string(); - - 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).to_string_lossy().into_owned(); - - 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) - }) - })); - - cx.background_spawn(async move { - let descendants = descendants_future - .await - .into_iter() - .flatten() - .collect::>(); - let context = AgentContext::Directory(DirectoryContext { - handle: self, - full_path: directory_full_path, - descendants, - }); - Some(context) - }) - } -} - -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 { - writeln!(f)?; - } else { - is_first = false; - } - write!(f, "{}", descendant.fenced_codeblock)?; - } - Ok(()) - } -} - -#[derive(Debug, Clone)] -pub struct SymbolContextHandle { - pub buffer: Entity, - pub symbol: SharedString, - pub range: Range, - /// 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, - pub context_id: ContextId, -} - -#[derive(Debug, Clone)] -pub struct SymbolContext { - pub handle: SymbolContextHandle, - pub full_path: String, - pub line_range: Range, - 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 - } - - pub fn hash_for_key(&self, state: &mut H) { - self.buffer.hash(state); - self.symbol.hash(state); - self.range.hash(state); - } - - pub fn full_path(&self, cx: &App) -> Option { - Some(self.buffer.read(cx).file()?.full_path(cx)) - } - - pub fn enclosing_line_range(&self, cx: &App) -> Range { - 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::() - .into() - } - - fn load(self, cx: &App) -> Task> { - let buffer_ref = self.buffer.read(cx); - let Some(file) = buffer_ref.file() else { - log::error!("symbol context's file has no path"); - return Task::ready(None); - }; - let full_path = file.full_path(cx).to_string_lossy().into_owned(); - let line_range = self.enclosing_range.to_point(&buffer_ref.snapshot()); - let text = self.text(cx); - let context = AgentContext::Symbol(SymbolContext { - handle: self, - full_path, - line_range, - text, - }); - Task::ready(Some(context)) - } -} - -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 SelectionContextHandle { - pub buffer: Entity, - pub range: Range, - pub context_id: ContextId, -} - -#[derive(Debug, Clone)] -pub struct SelectionContext { - pub handle: SelectionContextHandle, - pub full_path: String, - pub line_range: Range, - pub text: SharedString, -} - -impl SelectionContextHandle { - pub fn eq_for_key(&self, other: &Self) -> bool { - self.buffer == other.buffer && self.range == other.range - } - - pub fn hash_for_key(&self, state: &mut H) { - self.buffer.hash(state); - self.range.hash(state); - } - - pub fn full_path(&self, cx: &App) -> Option { - Some(self.buffer.read(cx).file()?.full_path(cx)) - } - - pub fn line_range(&self, cx: &App) -> Range { - 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::() - .into() - } - - fn load(self, cx: &App) -> Task> { - let Some(full_path) = self.full_path(cx) else { - log::error!("selection context's file has no path"); - return Task::ready(None); - }; - let text = self.text(cx); - let context = AgentContext::Selection(SelectionContext { - full_path: full_path.to_string_lossy().into_owned(), - line_range: self.line_range(cx), - text, - handle: self, - }); - - Task::ready(Some(context)) - } -} - -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}",) - } -} - -#[derive(Debug, Clone)] -pub struct FetchedUrlContext { - pub url: SharedString, - /// Text contents of the fetched url. Unlike other context types, the contents of this gets - /// populated when added rather than when sending the message. Not used by `PartialEq` or `Hash` - /// for `AgentContextKey`. - pub text: SharedString, - pub context_id: ContextId, -} - -impl FetchedUrlContext { - pub fn eq_for_key(&self, other: &Self) -> bool { - self.url == other.url - } - - pub fn hash_for_key(&self, state: &mut H) { - self.url.hash(state); - } - - pub fn lookup_key(url: SharedString) -> AgentContextKey { - AgentContextKey(AgentContextHandle::FetchedUrl(FetchedUrlContext { - url, - text: "".into(), - context_id: ContextId::for_lookup(), - })) - } - - pub fn load(self) -> Task> { - Task::ready(Some(AgentContext::FetchedUrl(self))) - } -} - -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 ThreadContextHandle { - pub thread: Entity, - pub context_id: ContextId, -} - -#[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 - } - - pub fn hash_for_key(&self, state: &mut H) { - self.thread.hash(state) - } - - pub fn title(&self, cx: &App) -> SharedString { - self.thread.read(cx).title() - } - - fn load(self, cx: &mut App) -> Task> { - let task = self.thread.update(cx, |thread, cx| thread.summary(cx)); - let title = self.title(cx); - cx.background_spawn(async move { - let text = task.await?; - let context = AgentContext::Thread(ThreadContext { - title, - text, - handle: self, - }); - Some(context) - }) - } -} - -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 TextThreadContextHandle { - pub text_thread: Entity, - pub context_id: ContextId, -} - -#[derive(Debug, Clone)] -pub struct TextThreadContext { - pub handle: TextThreadContextHandle, - pub title: SharedString, - pub text: SharedString, -} - -impl TextThreadContextHandle { - // pub fn lookup_key() -> - pub fn eq_for_key(&self, other: &Self) -> bool { - self.text_thread == other.text_thread - } - - pub fn hash_for_key(&self, state: &mut H) { - self.text_thread.hash(state) - } - - pub fn title(&self, cx: &App) -> SharedString { - self.text_thread.read(cx).summary().or_default() - } - - fn load(self, cx: &App) -> Task> { - let title = self.title(cx); - let text = self.text_thread.read(cx).to_xml(cx); - let context = AgentContext::TextThread(TextThreadContext { - title, - text: text.into(), - handle: self, - }); - Task::ready(Some(context)) - } -} - -impl Display for TextThreadContext { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, " write!(f, "&")?, - '<' => write!(f, "<")?, - '>' => write!(f, ">")?, - '"' => write!(f, """)?, - '\'' => write!(f, "'")?, - _ => write!(f, "{}", c)?, - } - } - writeln!(f, "\">")?; - write!(f, "{}", self.text.trim())?; - write!(f, "\n") - } -} - -#[derive(Debug, Clone)] -pub struct RulesContextHandle { - pub prompt_id: UserPromptId, - pub context_id: ContextId, -} - -#[derive(Debug, Clone)] -pub struct RulesContext { - pub handle: RulesContextHandle, - pub title: Option, - pub text: SharedString, -} - -impl RulesContextHandle { - pub fn eq_for_key(&self, other: &Self) -> bool { - self.prompt_id == other.prompt_id - } - - pub fn hash_for_key(&self, state: &mut H) { - self.prompt_id.hash(state) - } - - pub fn lookup_key(prompt_id: UserPromptId) -> AgentContextKey { - AgentContextKey(AgentContextHandle::Rules(RulesContextHandle { - prompt_id, - context_id: ContextId::for_lookup(), - })) - } - - pub fn load( - self, - prompt_store: &Option>, - cx: &App, - ) -> Task> { - let Some(prompt_store) = prompt_store.as_ref() else { - return Task::ready(None); - }; - let prompt_store = prompt_store.read(cx); - let prompt_id = self.prompt_id.into(); - let Some(metadata) = prompt_store.metadata(prompt_id) else { - return Task::ready(None); - }; - let title = metadata.title; - let text_task = prompt_store.load(prompt_id, cx); - cx.background_spawn(async move { - // 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) - }) - } -} - -impl Display for RulesContext { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(title) = &self.title { - writeln!(f, "Rules title: {}", title)?; - } - let code_block = MarkdownCodeBlock { - tag: "", - text: self.text.trim(), - }; - write!(f, "{code_block}") - } -} - -#[derive(Debug, Clone)] -pub struct ImageContext { - pub project_path: Option, - pub full_path: Option, - pub original_image: Arc, - // TODO: handle this elsewhere and remove `ignore-interior-mutability` opt-out in clippy.toml - // needed due to a false positive of `clippy::mutable_key_type`. - pub image_task: Shared>>, - pub context_id: ContextId, -} - -pub enum ImageStatus { - Loading, - Error, - Warning, - Ready, -} - -impl ImageContext { - pub fn eq_for_key(&self, other: &Self) -> bool { - self.original_image.id() == other.original_image.id() - } - - pub fn hash_for_key(&self, state: &mut H) { - self.original_image.id().hash(state); - } - - pub fn image(&self) -> Option { - self.image_task.clone().now_or_never().flatten() - } - - pub fn status(&self, model: Option<&Arc>) -> ImageStatus { - match self.image_task.clone().now_or_never() { - None => ImageStatus::Loading, - Some(None) => ImageStatus::Error, - Some(Some(_)) => { - if model.is_some_and(|model| !model.supports_images()) { - ImageStatus::Warning - } else { - ImageStatus::Ready - } - } - } - } - - pub fn load(self, cx: &App) -> Task> { - cx.background_spawn(async move { - self.image_task.clone().await; - Some(AgentContext::Image(self)) - }) - } -} +use crate::mention_set::MentionSet; #[derive(Debug, Clone, Default)] pub struct LoadedContext { @@ -792,382 +38,26 @@ impl LoadedContext { } /// Loads and formats a collection of contexts. -pub fn load_context( - contexts: Vec, - project: &Entity, - prompt_store: &Option>, - cx: &mut App, -) -> Task { - let load_tasks: Vec<_> = contexts - .into_iter() - .map(|context| match context { - AgentContextHandle::File(context) => context.load(cx), - AgentContextHandle::Directory(context) => context.load(project.clone(), cx), - AgentContextHandle::Symbol(context) => context.load(cx), - AgentContextHandle::Selection(context) => context.load(cx), - AgentContextHandle::FetchedUrl(context) => context.load(), - AgentContextHandle::Thread(context) => context.load(cx), - AgentContextHandle::TextThread(context) => context.load(cx), - AgentContextHandle::Rules(context) => context.load(prompt_store, cx), - AgentContextHandle::Image(context) => context.load(cx), - }) - .collect(); - +pub fn load_context(mention_set: &Entity, cx: &mut App) -> Task> { + let task = mention_set.update(cx, |mention_set, cx| mention_set.contents(true, cx)); cx.background_spawn(async move { - let load_results = future::join_all(load_tasks).await; - - let mut text = String::new(); - - 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 text_thread_context = Vec::new(); - let mut rules_context = Vec::new(); - let mut images = Vec::new(); - for context in load_results.into_iter().flatten() { - 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::TextThread(context) => text_thread_context.push(context), - AgentContext::Rules(context) => rules_context.push(context), - AgentContext::Image(context) => images.extend(context.image()), - } - } - - // Use empty text if there are no contexts that contribute to text (everything but image - // context). - if file_context.is_empty() - && directory_context.is_empty() - && symbol_context.is_empty() - && selection_context.is_empty() - && fetched_url_context.is_empty() - && thread_context.is_empty() - && text_thread_context.is_empty() - && rules_context.is_empty() - { - return LoadedContext { text, images }; - } - - text.push_str( - "\n\n\ - The following items were attached by the user. \ - They are up-to-date and don't need to be re-read.\n\n", - ); - - if !file_context.is_empty() { - text.push_str(""); - for context in file_context { - text.push('\n'); - let _ = write!(text, "{context}"); - } - text.push_str("\n"); - } - - if !directory_context.is_empty() { - text.push_str(""); - for context in directory_context { - text.push('\n'); - let _ = write!(text, "{context}"); - } - text.push_str("\n"); - } - - if !symbol_context.is_empty() { - text.push_str(""); - for context in symbol_context { - text.push('\n'); - let _ = write!(text, "{context}"); - } - text.push_str("\n"); - } - - if !selection_context.is_empty() { - text.push_str(""); - for context in selection_context { - text.push('\n'); - let _ = write!(text, "{context}"); - } - text.push_str("\n"); - } - - if !fetched_url_context.is_empty() { - text.push_str(""); - for context in fetched_url_context { - text.push('\n'); - let _ = write!(text, "{context}"); - } - text.push_str("\n"); - } - - if !thread_context.is_empty() { - text.push_str(""); - for context in thread_context { - text.push('\n'); - let _ = write!(text, "{context}"); - } - text.push_str("\n"); - } - - if !text_thread_context.is_empty() { - text.push_str(""); - for context in text_thread_context { - text.push('\n'); - let _ = writeln!(text, "{context}"); - } - text.push_str(""); - } - - if !rules_context.is_empty() { - text.push_str( - "\n\ - The user has specified the following rules that should be applied:\n", - ); - for context in rules_context { - text.push('\n'); - let _ = write!(text, "{context}"); - } - text.push_str("\n"); - } - - text.push_str("\n"); - - LoadedContext { text, images } + let mentions = task.await.log_err()?; + let mut loaded_context = LoadedContext::default(); + loaded_context + .text + .push_str("The following items were attached by the user.\n"); + for (_, (_, mention)) in mentions { + match mention { + Mention::Text { content, .. } => { + loaded_context.text.push_str(&content); + } + Mention::Image(mention_image) => loaded_context.images.push(LanguageModelImage { + source: mention_image.data, + ..LanguageModelImage::empty() + }), + Mention::Link => {} + } + } + Some(loaded_context) }) } - -fn collect_files_in_path(worktree: &Worktree, path: &RelPath) -> Vec> { - let mut files = Vec::new(); - - for entry in worktree.child_entries(path) { - if entry.is_dir() { - files.extend(collect_files_in_path(worktree, &entry.path)); - } else if entry.is_file() { - files.push(entry.path.clone()); - } - } - - files -} - -fn codeblock_tag(full_path: &str, line_range: Option>) -> String { - let mut result = String::new(); - - if let Some(extension) = Path::new(full_path) - .extension() - .and_then(|ext| ext.to_str()) - { - let _ = write!(result, "{} ", extension); - } - - let _ = write!(result, "{}", full_path); - - if let Some(range) = line_range { - if range.start.row == range.end.row { - let _ = write!(result, ":{}", range.start.row + 1); - } else { - let _ = write!(result, ":{}-{}", range.start.row + 1, range.end.row + 1); - } - } - - 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 AgentContextHandle); - -impl AsRef for AgentContextKey { - fn as_ref(&self) -> &AgentContextHandle { - &self.0 - } -} - -impl Eq for AgentContextKey {} - -impl PartialEq for AgentContextKey { - fn eq(&self, other: &Self) -> bool { - match &self.0 { - AgentContextHandle::File(context) => { - if let AgentContextHandle::File(other_context) = &other.0 { - return context.eq_for_key(other_context); - } - } - AgentContextHandle::Directory(context) => { - if let AgentContextHandle::Directory(other_context) = &other.0 { - return context.eq_for_key(other_context); - } - } - AgentContextHandle::Symbol(context) => { - if let AgentContextHandle::Symbol(other_context) = &other.0 { - return context.eq_for_key(other_context); - } - } - AgentContextHandle::Selection(context) => { - if let AgentContextHandle::Selection(other_context) = &other.0 { - return context.eq_for_key(other_context); - } - } - AgentContextHandle::FetchedUrl(context) => { - if let AgentContextHandle::FetchedUrl(other_context) = &other.0 { - return context.eq_for_key(other_context); - } - } - AgentContextHandle::Thread(context) => { - if let AgentContextHandle::Thread(other_context) = &other.0 { - return context.eq_for_key(other_context); - } - } - AgentContextHandle::Rules(context) => { - if let AgentContextHandle::Rules(other_context) = &other.0 { - return context.eq_for_key(other_context); - } - } - AgentContextHandle::Image(context) => { - if let AgentContextHandle::Image(other_context) = &other.0 { - return context.eq_for_key(other_context); - } - } - AgentContextHandle::TextThread(context) => { - if let AgentContextHandle::TextThread(other_context) = &other.0 { - return context.eq_for_key(other_context); - } - } - } - false - } -} - -impl Hash for AgentContextKey { - fn hash(&self, state: &mut H) { - match &self.0 { - AgentContextHandle::File(context) => context.hash_for_key(state), - AgentContextHandle::Directory(context) => context.hash_for_key(state), - AgentContextHandle::Symbol(context) => context.hash_for_key(state), - AgentContextHandle::Selection(context) => context.hash_for_key(state), - AgentContextHandle::FetchedUrl(context) => context.hash_for_key(state), - AgentContextHandle::Thread(context) => context.hash_for_key(state), - AgentContextHandle::TextThread(context) => context.hash_for_key(state), - AgentContextHandle::Rules(context) => context.hash_for_key(state), - AgentContextHandle::Image(context) => context.hash_for_key(state), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use gpui::TestAppContext; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use util::path; - - fn init_test_settings(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - }); - } - - // Helper to create a test project with test files - async fn create_test_project( - cx: &mut TestAppContext, - files: serde_json::Value, - ) -> Entity { - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree(path!("/test"), files).await; - Project::test(fs, [path!("/test").as_ref()], cx).await - } - - #[gpui::test] - async fn test_large_file_uses_fallback(cx: &mut TestAppContext) { - init_test_settings(cx); - - // Create a large file that exceeds AUTO_OUTLINE_SIZE - const LINE: &str = "Line with some text\n"; - let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len())); - let content_len = large_content.len(); - - assert!(content_len > outline::AUTO_OUTLINE_SIZE); - - let file_context = load_context_for("file.txt", large_content, cx).await; - - // Should contain some of the actual file content - assert!( - file_context.text.contains(LINE), - "Should contain some of the file content" - ); - - // Should be much smaller than original - assert!( - file_context.text.len() < content_len / 10, - "Should be significantly smaller than original content" - ); - } - - #[gpui::test] - async fn test_small_file_uses_full_content(cx: &mut TestAppContext) { - init_test_settings(cx); - - let small_content = "This is a small file.\n"; - let content_len = small_content.len(); - - assert!(content_len < outline::AUTO_OUTLINE_SIZE); - - let file_context = load_context_for("file.txt", small_content.to_string(), cx).await; - - assert!( - !file_context - .text - .contains(&format!("# File outline for {}", path!("test/file.txt"))), - "Small files should not get an outline" - ); - - assert!( - file_context.text.contains(small_content), - "Small files should use full content" - ); - } - - async fn load_context_for( - filename: &str, - content: String, - cx: &mut TestAppContext, - ) -> LoadedContext { - // Create a test project with the file - let project = create_test_project( - cx, - json!({ - filename: content, - }), - ) - .await; - - // Open the buffer - let buffer_path = project - .read_with(cx, |project, cx| project.find_project_path(filename, cx)) - .unwrap(); - - let buffer = project - .update(cx, |project, cx| project.open_buffer(buffer_path, cx)) - .await - .unwrap(); - - let context_handle = AgentContextHandle::File(FileContextHandle { - buffer: buffer.clone(), - context_id: ContextId::zero(), - }); - - cx.update(|cx| load_context(vec![context_handle], &project, &None, cx)) - .await - } -} diff --git a/crates/agent_ui/src/context_picker.rs b/crates/agent_ui/src/context_picker.rs deleted file mode 100644 index 0a6e811673aa47339087e538003e87b1940d0039..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/context_picker.rs +++ /dev/null @@ -1,931 +0,0 @@ -mod completion_provider; -pub(crate) mod fetch_context_picker; -pub(crate) mod file_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::PathBuf; -use std::sync::Arc; - -use agent::{HistoryEntry, HistoryEntryId, HistoryStore}; -use agent_client_protocol as acp; -use anyhow::{Result, anyhow}; -use collections::HashSet; -pub use completion_provider::ContextPickerCompletionProvider; -use editor::display_map::{Crease, CreaseId, CreaseMetadata, FoldId}; -use editor::{Anchor, Editor, ExcerptId, FoldPlaceholder, ToOffset}; -use fetch_context_picker::FetchContextPicker; -use file_context_picker::FileContextPicker; -use file_context_picker::render_file_context_entry; -use gpui::{ - App, DismissEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, Subscription, Task, - WeakEntity, -}; -use language::Buffer; -use multi_buffer::MultiBufferRow; -use project::ProjectPath; -use prompt_store::PromptStore; -use rules_context_picker::{RulesContextEntry, RulesContextPicker}; -use symbol_context_picker::SymbolContextPicker; -use thread_context_picker::render_thread_context_entry; -use ui::{ - ButtonLike, ContextMenu, ContextMenuEntry, ContextMenuItem, Disclosure, TintColor, prelude::*, -}; -use util::paths::PathStyle; -use util::rel_path::RelPath; -use workspace::{Workspace, notifications::NotifyResultExt}; - -use crate::context_picker::thread_context_picker::ThreadContextPicker; -use crate::{context::RULES_ICON, context_store::ContextStore}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ContextPickerEntry { - Mode(ContextPickerMode), - Action(ContextPickerAction), -} - -impl ContextPickerEntry { - pub fn keyword(&self) -> &'static str { - match self { - Self::Mode(mode) => mode.keyword(), - Self::Action(action) => action.keyword(), - } - } - - pub fn label(&self) -> &'static str { - match self { - Self::Mode(mode) => mode.label(), - Self::Action(action) => action.label(), - } - } - - pub fn icon(&self) -> IconName { - match self { - Self::Mode(mode) => mode.icon(), - Self::Action(action) => action.icon(), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ContextPickerMode { - File, - Symbol, - Fetch, - Thread, - Rules, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ContextPickerAction { - AddSelections, -} - -impl ContextPickerAction { - pub fn keyword(&self) -> &'static str { - match self { - Self::AddSelections => "selection", - } - } - - pub fn label(&self) -> &'static str { - match self { - Self::AddSelections => "Selection", - } - } - - pub fn icon(&self) -> IconName { - match self { - Self::AddSelections => IconName::Reader, - } - } -} - -impl TryFrom<&str> for ContextPickerMode { - type Error = String; - - fn try_from(value: &str) -> Result { - match value { - "file" => Ok(Self::File), - "symbol" => Ok(Self::Symbol), - "fetch" => Ok(Self::Fetch), - "thread" => Ok(Self::Thread), - "rule" => Ok(Self::Rules), - _ => Err(format!("Invalid context picker mode: {}", value)), - } - } -} - -impl ContextPickerMode { - pub fn keyword(&self) -> &'static str { - match self { - Self::File => "file", - Self::Symbol => "symbol", - Self::Fetch => "fetch", - Self::Thread => "thread", - Self::Rules => "rule", - } - } - - pub fn label(&self) -> &'static str { - match self { - Self::File => "Files & Directories", - Self::Symbol => "Symbols", - Self::Fetch => "Fetch", - Self::Thread => "Threads", - Self::Rules => "Rules", - } - } - - pub fn icon(&self) -> IconName { - match self { - Self::File => IconName::File, - Self::Symbol => IconName::Code, - Self::Fetch => IconName::ToolWeb, - Self::Thread => IconName::Thread, - Self::Rules => RULES_ICON, - } - } -} - -#[derive(Debug, Clone)] -enum ContextPickerState { - Default(Entity), - File(Entity), - Symbol(Entity), - Fetch(Entity), - Thread(Entity), - Rules(Entity), -} - -pub(super) struct ContextPicker { - mode: ContextPickerState, - workspace: WeakEntity, - context_store: WeakEntity, - thread_store: Option>, - prompt_store: Option>, - _subscriptions: Vec, -} - -impl ContextPicker { - pub fn new( - workspace: WeakEntity, - thread_store: Option>, - prompt_store: Option>, - context_store: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let subscriptions = context_store - .upgrade() - .map(|context_store| { - cx.observe(&context_store, |this, _, cx| this.notify_current_picker(cx)) - }) - .into_iter() - .chain( - thread_store - .as_ref() - .and_then(|thread_store| thread_store.upgrade()) - .map(|thread_store| { - cx.observe(&thread_store, |this, _, cx| this.notify_current_picker(cx)) - }), - ) - .collect::>(); - - ContextPicker { - mode: ContextPickerState::Default(ContextMenu::build( - window, - cx, - |menu, _window, _cx| menu, - )), - workspace, - context_store, - thread_store, - prompt_store, - _subscriptions: subscriptions, - } - } - - pub fn init(&mut self, window: &mut Window, cx: &mut Context) { - self.mode = ContextPickerState::Default(self.build_menu(window, cx)); - cx.notify(); - } - - fn build_menu(&mut self, window: &mut Window, cx: &mut Context) -> Entity { - let context_picker = cx.entity(); - - let menu = ContextMenu::build(window, cx, move |menu, _window, cx| { - let Some(workspace) = self.workspace.upgrade() else { - return menu; - }; - let path_style = workspace.read(cx).path_style(cx); - let recent = self.recent_entries(cx); - let has_recent = !recent.is_empty(); - let recent_entries = recent - .into_iter() - .enumerate() - .map(|(ix, entry)| { - self.recent_menu_item(context_picker.clone(), ix, entry, path_style) - }) - .collect::>(); - - let entries = self - .workspace - .upgrade() - .map(|workspace| { - available_context_picker_entries( - &self.prompt_store, - &self.thread_store, - &workspace, - cx, - ) - }) - .unwrap_or_default(); - - menu.when(has_recent, |menu| { - menu.custom_row(|_, _| { - div() - .mb_1() - .child( - Label::new("Recent") - .color(Color::Muted) - .size(LabelSize::Small), - ) - .into_any_element() - }) - }) - .extend(recent_entries) - .when(has_recent, |menu| menu.separator()) - .extend(entries.into_iter().map(|entry| { - let context_picker = context_picker.clone(); - - ContextMenuEntry::new(entry.label()) - .icon(entry.icon()) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .handler(move |window, cx| { - context_picker.update(cx, |this, cx| this.select_entry(entry, window, cx)) - }) - })) - .keep_open_on_confirm(true) - }); - - cx.subscribe(&menu, move |_, _, _: &DismissEvent, cx| { - cx.emit(DismissEvent); - }) - .detach(); - - menu - } - - /// Whether threads are allowed as context. - pub fn allow_threads(&self) -> bool { - self.thread_store.is_some() - } - - fn select_entry( - &mut self, - entry: ContextPickerEntry, - window: &mut Window, - cx: &mut Context, - ) { - let context_picker = cx.entity().downgrade(); - - match entry { - ContextPickerEntry::Mode(mode) => match mode { - ContextPickerMode::File => { - self.mode = ContextPickerState::File(cx.new(|cx| { - FileContextPicker::new( - context_picker.clone(), - self.workspace.clone(), - self.context_store.clone(), - window, - cx, - ) - })); - } - ContextPickerMode::Symbol => { - self.mode = ContextPickerState::Symbol(cx.new(|cx| { - SymbolContextPicker::new( - context_picker.clone(), - self.workspace.clone(), - self.context_store.clone(), - window, - cx, - ) - })); - } - ContextPickerMode::Rules => { - if let Some(prompt_store) = self.prompt_store.as_ref() { - self.mode = ContextPickerState::Rules(cx.new(|cx| { - RulesContextPicker::new( - prompt_store.clone(), - context_picker.clone(), - self.context_store.clone(), - window, - cx, - ) - })); - } - } - ContextPickerMode::Fetch => { - self.mode = ContextPickerState::Fetch(cx.new(|cx| { - FetchContextPicker::new( - context_picker.clone(), - self.workspace.clone(), - self.context_store.clone(), - window, - cx, - ) - })); - } - ContextPickerMode::Thread => { - if let Some(thread_store) = self.thread_store.clone() { - self.mode = ContextPickerState::Thread(cx.new(|cx| { - ThreadContextPicker::new( - thread_store, - context_picker.clone(), - self.context_store.clone(), - self.workspace.clone(), - window, - cx, - ) - })); - } - } - }, - ContextPickerEntry::Action(action) => match action { - ContextPickerAction::AddSelections => { - if let Some((context_store, workspace)) = - self.context_store.upgrade().zip(self.workspace.upgrade()) - { - add_selections_as_context(&context_store, &workspace, cx); - } - - cx.emit(DismissEvent); - } - }, - } - - cx.notify(); - cx.focus_self(window); - } - - pub fn select_first(&mut self, window: &mut Window, cx: &mut Context) { - // Other variants already select their first entry on open automatically - if let ContextPickerState::Default(entity) = &self.mode { - entity.update(cx, |entity, cx| { - entity.select_first(&Default::default(), window, cx) - }) - } - } - - fn recent_menu_item( - &self, - context_picker: Entity, - ix: usize, - entry: RecentEntry, - path_style: PathStyle, - ) -> ContextMenuItem { - match entry { - RecentEntry::File { - project_path, - path_prefix, - } => { - let context_store = self.context_store.clone(); - let worktree_id = project_path.worktree_id; - let path = project_path.path.clone(); - - ContextMenuItem::custom_entry( - move |_window, cx| { - render_file_context_entry( - ElementId::named_usize("ctx-recent", ix), - worktree_id, - &path, - &path_prefix, - false, - path_style, - context_store.clone(), - cx, - ) - .into_any() - }, - move |window, cx| { - context_picker.update(cx, |this, cx| { - this.add_recent_file(project_path.clone(), window, cx); - }) - }, - None, - ) - } - RecentEntry::Thread(thread) => { - let context_store = self.context_store.clone(); - let view_thread = thread.clone(); - - ContextMenuItem::custom_entry( - move |_window, cx| { - render_thread_context_entry(&view_thread, context_store.clone(), cx) - .into_any() - }, - move |window, cx| { - context_picker.update(cx, |this, cx| { - this.add_recent_thread(thread.clone(), window, cx) - .detach_and_log_err(cx); - }) - }, - None, - ) - } - } - } - - fn add_recent_file( - &self, - project_path: ProjectPath, - window: &mut Window, - cx: &mut Context, - ) { - let Some(context_store) = self.context_store.upgrade() else { - return; - }; - - let task = context_store.update(cx, |context_store, cx| { - context_store.add_file_from_path(project_path.clone(), true, cx) - }); - - cx.spawn_in(window, async move |_, cx| task.await.notify_async_err(cx)) - .detach(); - - cx.notify(); - } - - fn add_recent_thread( - &self, - entry: HistoryEntry, - _window: &mut Window, - cx: &mut Context, - ) -> Task> { - let Some(context_store) = self.context_store.upgrade() else { - return Task::ready(Err(anyhow!("context store not available"))); - }; - let Some(project) = self - .workspace - .upgrade() - .map(|workspace| workspace.read(cx).project().clone()) - else { - return Task::ready(Err(anyhow!("project not available"))); - }; - - match entry { - HistoryEntry::AcpThread(thread) => { - let Some(thread_store) = self - .thread_store - .as_ref() - .and_then(|thread_store| thread_store.upgrade()) - else { - return Task::ready(Err(anyhow!("thread store not available"))); - }; - let load_thread_task = - agent::load_agent_thread(thread.id, thread_store, project, cx); - cx.spawn(async move |this, cx| { - let thread = load_thread_task.await?; - context_store.update(cx, |context_store, cx| { - context_store.add_thread(thread, true, cx); - })?; - this.update(cx, |_this, cx| cx.notify()) - }) - } - HistoryEntry::TextThread(thread) => { - let Some(thread_store) = self - .thread_store - .as_ref() - .and_then(|thread_store| thread_store.upgrade()) - else { - return Task::ready(Err(anyhow!("text thread store not available"))); - }; - - let task = thread_store.update(cx, |this, cx| { - this.load_text_thread(thread.path.clone(), cx) - }); - cx.spawn(async move |this, cx| { - let thread = task.await?; - context_store.update(cx, |context_store, cx| { - context_store.add_text_thread(thread, true, cx); - })?; - this.update(cx, |_this, cx| cx.notify()) - }) - } - } - } - - fn recent_entries(&self, cx: &mut App) -> Vec { - let Some(workspace) = self.workspace.upgrade() else { - return vec![]; - }; - - let Some(context_store) = self.context_store.upgrade() else { - return vec![]; - }; - - recent_context_picker_entries_with_store( - context_store, - self.thread_store.clone(), - workspace, - None, - cx, - ) - } - - fn notify_current_picker(&mut self, cx: &mut Context) { - match &self.mode { - ContextPickerState::Default(entity) => entity.update(cx, |_, cx| cx.notify()), - ContextPickerState::File(entity) => entity.update(cx, |_, cx| cx.notify()), - ContextPickerState::Symbol(entity) => entity.update(cx, |_, cx| cx.notify()), - ContextPickerState::Fetch(entity) => entity.update(cx, |_, cx| cx.notify()), - ContextPickerState::Thread(entity) => entity.update(cx, |_, cx| cx.notify()), - ContextPickerState::Rules(entity) => entity.update(cx, |_, cx| cx.notify()), - } - } -} - -impl EventEmitter for ContextPicker {} - -impl Focusable for ContextPicker { - fn focus_handle(&self, cx: &App) -> FocusHandle { - match &self.mode { - ContextPickerState::Default(menu) => menu.focus_handle(cx), - ContextPickerState::File(file_picker) => file_picker.focus_handle(cx), - ContextPickerState::Symbol(symbol_picker) => symbol_picker.focus_handle(cx), - ContextPickerState::Fetch(fetch_picker) => fetch_picker.focus_handle(cx), - ContextPickerState::Thread(thread_picker) => thread_picker.focus_handle(cx), - ContextPickerState::Rules(user_rules_picker) => user_rules_picker.focus_handle(cx), - } - } -} - -impl Render for ContextPicker { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - v_flex() - .w(px(400.)) - .min_w(px(400.)) - .map(|parent| match &self.mode { - ContextPickerState::Default(menu) => parent.child(menu.clone()), - ContextPickerState::File(file_picker) => parent.child(file_picker.clone()), - ContextPickerState::Symbol(symbol_picker) => parent.child(symbol_picker.clone()), - ContextPickerState::Fetch(fetch_picker) => parent.child(fetch_picker.clone()), - ContextPickerState::Thread(thread_picker) => parent.child(thread_picker.clone()), - ContextPickerState::Rules(user_rules_picker) => { - parent.child(user_rules_picker.clone()) - } - }) - } -} - -pub(crate) enum RecentEntry { - File { - project_path: ProjectPath, - path_prefix: Arc, - }, - Thread(HistoryEntry), -} - -pub(crate) fn available_context_picker_entries( - prompt_store: &Option>, - thread_store: &Option>, - workspace: &Entity, - cx: &mut App, -) -> Vec { - let mut entries = vec![ - ContextPickerEntry::Mode(ContextPickerMode::File), - ContextPickerEntry::Mode(ContextPickerMode::Symbol), - ]; - - let has_selection = workspace - .read(cx) - .active_item(cx) - .and_then(|item| item.downcast::()) - .is_some_and(|editor| { - editor.update(cx, |editor, cx| { - editor.has_non_empty_selection(&editor.display_snapshot(cx)) - }) - }); - if has_selection { - entries.push(ContextPickerEntry::Action( - ContextPickerAction::AddSelections, - )); - } - - if thread_store.is_some() { - entries.push(ContextPickerEntry::Mode(ContextPickerMode::Thread)); - } - - if prompt_store.is_some() { - entries.push(ContextPickerEntry::Mode(ContextPickerMode::Rules)); - } - - entries.push(ContextPickerEntry::Mode(ContextPickerMode::Fetch)); - - entries -} - -fn recent_context_picker_entries_with_store( - context_store: Entity, - thread_store: Option>, - workspace: Entity, - exclude_path: Option, - cx: &App, -) -> Vec { - 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, workspace, &exclude_paths, exclude_threads, cx) -} - -pub(crate) fn recent_context_picker_entries( - thread_store: Option>, - workspace: Entity, - exclude_paths: &HashSet, - exclude_threads: &HashSet, - cx: &App, -) -> Vec { - let mut recent = Vec::with_capacity(6); - let workspace = workspace.read(cx); - let project = workspace.project().read(cx); - let include_root_name = workspace.visible_worktrees(cx).count() > 1; - - recent.extend( - workspace - .recent_navigation_history_iter(cx) - .filter(|(_, abs_path)| { - abs_path - .as_ref() - .is_none_or(|path| !exclude_paths.contains(path.as_path())) - }) - .take(4) - .filter_map(|(project_path, _)| { - project - .worktree_for_id(project_path.worktree_id, cx) - .map(|worktree| { - let path_prefix = if include_root_name { - worktree.read(cx).root_name().into() - } else { - RelPath::empty().into() - }; - RecentEntry::File { - project_path, - path_prefix, - } - }) - }), - ); - - if let Some(thread_store) = thread_store.and_then(|store| store.upgrade()) { - const RECENT_THREADS_COUNT: usize = 2; - recent.extend( - thread_store - .read(cx) - .recently_opened_entries(cx) - .iter() - .filter(|e| match e.id() { - HistoryEntryId::AcpThread(session_id) => !exclude_threads.contains(&session_id), - HistoryEntryId::TextThread(path) => { - !exclude_paths.contains(&path.to_path_buf()) - } - }) - .take(RECENT_THREADS_COUNT) - .map(|thread| RecentEntry::Thread(thread.clone())), - ); - } - - recent -} - -fn add_selections_as_context( - context_store: &Entity, - workspace: &Entity, - cx: &mut App, -) { - let selection_ranges = selection_ranges(workspace, cx); - context_store.update(cx, |context_store, cx| { - for (buffer, range) in selection_ranges { - context_store.add_selection(buffer, range, cx); - } - }) -} - -pub(crate) fn selection_ranges( - workspace: &Entity, - cx: &mut App, -) -> Vec<(Entity, Range)> { - let Some(editor) = workspace - .read(cx) - .active_item(cx) - .and_then(|item| item.act_as::(cx)) - else { - return Vec::new(); - }; - - editor.update(cx, |editor, cx| { - let selections = editor.selections.all_adjusted(&editor.display_snapshot(cx)); - - let buffer = editor.buffer().clone().read(cx); - let snapshot = buffer.snapshot(cx); - - selections - .into_iter() - .map(|s| snapshot.anchor_after(s.start)..snapshot.anchor_before(s.end)) - .flat_map(|range| { - let (start_buffer, start) = buffer.text_anchor_for_position(range.start, cx)?; - let (end_buffer, end) = buffer.text_anchor_for_position(range.end, cx)?; - if start_buffer != end_buffer { - return None; - } - Some((start_buffer, start..end)) - }) - .collect::>() - }) -} - -pub(crate) fn insert_crease_for_mention( - excerpt_id: ExcerptId, - crease_start: text::Anchor, - content_len: usize, - crease_label: SharedString, - crease_icon_path: SharedString, - editor_entity: Entity, - window: &mut Window, - cx: &mut App, -) -> Option { - editor_entity.update(cx, |editor, cx| { - let snapshot = editor.buffer().read(cx).snapshot(cx); - - let start = snapshot.anchor_in_excerpt(excerpt_id, crease_start)?; - - let start = start.bias_right(&snapshot); - let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len); - - let crease = crease_for_mention( - crease_label, - crease_icon_path, - start..end, - editor_entity.downgrade(), - ); - - let ids = editor.insert_creases(vec![crease.clone()], cx); - editor.fold_creases(vec![crease], false, window, cx); - - Some(ids[0]) - }) -} - -pub fn crease_for_mention( - label: SharedString, - icon_path: SharedString, - range: Range, - editor_entity: WeakEntity, -) -> Crease { - let placeholder = FoldPlaceholder { - render: render_fold_icon_button(icon_path.clone(), label.clone(), editor_entity), - merge_adjacent: false, - ..Default::default() - }; - - let render_trailer = move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any(); - - Crease::inline(range, placeholder, fold_toggle("mention"), render_trailer) - .with_metadata(CreaseMetadata { icon_path, label }) -} - -fn render_fold_icon_button( - icon_path: SharedString, - label: SharedString, - editor: WeakEntity, -) -> Arc, &mut App) -> AnyElement> { - Arc::new({ - move |fold_id, fold_range, cx| { - let is_in_text_selection = editor - .update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx)) - .unwrap_or_default(); - - ButtonLike::new(fold_id) - .style(ButtonStyle::Filled) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .toggle_state(is_in_text_selection) - .child( - h_flex() - .gap_1() - .child( - Icon::from_path(icon_path.clone()) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child( - Label::new(label.clone()) - .size(LabelSize::Small) - .buffer_font(cx) - .single_line(), - ), - ) - .into_any_element() - } - }) -} - -fn fold_toggle( - name: &'static str, -) -> impl Fn( - MultiBufferRow, - bool, - Arc, - &mut Window, - &mut App, -) -> AnyElement { - move |row, is_folded, fold, _window, _cx| { - Disclosure::new((name, row.0 as u64), !is_folded) - .toggle_state(is_folded) - .on_click(move |_e, window, cx| fold(!is_folded, window, cx)) - .into_any_element() - } -} - -pub struct MentionLink; - -impl MentionLink { - const FILE: &str = "@file"; - const SYMBOL: &str = "@symbol"; - const SELECTION: &str = "@selection"; - const THREAD: &str = "@thread"; - const FETCH: &str = "@fetch"; - const RULE: &str = "@rule"; - - const TEXT_THREAD_URL_PREFIX: &str = "text-thread://"; - - pub fn for_file(file_name: &str, full_path: &str) -> String { - format!("[@{}]({}:{})", file_name, Self::FILE, full_path) - } - - pub fn for_symbol(symbol_name: &str, full_path: &str) -> String { - format!( - "[@{}]({}:{}:{})", - symbol_name, - Self::SYMBOL, - full_path, - symbol_name - ) - } - - pub fn for_selection(file_name: &str, full_path: &str, line_range: Range) -> String { - format!( - "[@{} ({}-{})]({}:{}:{}-{})", - file_name, - line_range.start + 1, - line_range.end + 1, - Self::SELECTION, - full_path, - line_range.start, - line_range.end - ) - } - - pub fn for_thread(thread: &HistoryEntry) -> String { - match thread { - HistoryEntry::AcpThread(thread) => { - format!("[@{}]({}:{})", thread.title, Self::THREAD, thread.id) - } - HistoryEntry::TextThread(thread) => { - let filename = thread - .path - .file_name() - .unwrap_or_default() - .to_string_lossy(); - let escaped_filename = urlencoding::encode(&filename); - format!( - "[@{}]({}:{}{})", - thread.title, - Self::THREAD, - Self::TEXT_THREAD_URL_PREFIX, - escaped_filename - ) - } - } - } - - pub fn for_fetch(url: &str) -> String { - format!("[@{}]({}:{})", url, Self::FETCH, url) - } - - pub fn for_rule(rule: &RulesContextEntry) -> String { - format!("[@{}]({}:{})", rule.title, Self::RULE, rule.prompt_id.0) - } -} diff --git a/crates/agent_ui/src/context_picker/fetch_context_picker.rs b/crates/agent_ui/src/context_picker/fetch_context_picker.rs deleted file mode 100644 index 31fc45aca3ccbf561793769939169d214aaa2d99..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/context_picker/fetch_context_picker.rs +++ /dev/null @@ -1,252 +0,0 @@ -use std::cell::RefCell; -use std::rc::Rc; -use std::sync::Arc; - -use anyhow::{Context as _, Result, bail}; -use futures::AsyncReadExt as _; -use gpui::{App, DismissEvent, Entity, FocusHandle, Focusable, Task, WeakEntity}; -use html_to_markdown::{TagHandler, convert_html_to_markdown, markdown}; -use http_client::{AsyncBody, HttpClientWithUrl}; -use picker::{Picker, PickerDelegate}; -use ui::{Context, ListItem, Window, prelude::*}; -use workspace::Workspace; - -use crate::{context_picker::ContextPicker, context_store::ContextStore}; - -pub struct FetchContextPicker { - picker: Entity>, -} - -impl FetchContextPicker { - pub fn new( - context_picker: WeakEntity, - workspace: WeakEntity, - context_store: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let delegate = FetchContextPickerDelegate::new(context_picker, workspace, context_store); - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); - - Self { picker } - } -} - -impl Focusable for FetchContextPicker { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.picker.focus_handle(cx) - } -} - -impl Render for FetchContextPicker { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - self.picker.clone() - } -} - -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] -enum ContentType { - Html, - Plaintext, - Json, -} - -pub struct FetchContextPickerDelegate { - context_picker: WeakEntity, - workspace: WeakEntity, - context_store: WeakEntity, - url: String, -} - -impl FetchContextPickerDelegate { - pub fn new( - context_picker: WeakEntity, - workspace: WeakEntity, - context_store: WeakEntity, - ) -> Self { - FetchContextPickerDelegate { - context_picker, - workspace, - context_store, - url: String::new(), - } - } -} - -pub(crate) async fn fetch_url_content( - http_client: Arc, - url: String, -) -> Result { - let url = if !url.starts_with("https://") && !url.starts_with("http://") { - format!("https://{url}") - } else { - url - }; - - let mut response = http_client.get(&url, AsyncBody::default(), true).await?; - - let mut body = Vec::new(); - response - .body_mut() - .read_to_end(&mut body) - .await - .context("error reading response body")?; - - if response.status().is_client_error() { - let text = String::from_utf8_lossy(body.as_slice()); - bail!( - "status error {}, response: {text:?}", - response.status().as_u16() - ); - } - - let Some(content_type) = response.headers().get("content-type") else { - bail!("missing Content-Type header"); - }; - let content_type = content_type - .to_str() - .context("invalid Content-Type header")?; - let content_type = match content_type { - "text/html" => ContentType::Html, - "text/plain" => ContentType::Plaintext, - "application/json" => ContentType::Json, - _ => ContentType::Html, - }; - - match content_type { - ContentType::Html => { - let mut handlers: Vec = vec![ - Rc::new(RefCell::new(markdown::WebpageChromeRemover)), - Rc::new(RefCell::new(markdown::ParagraphHandler)), - Rc::new(RefCell::new(markdown::HeadingHandler)), - Rc::new(RefCell::new(markdown::ListHandler)), - Rc::new(RefCell::new(markdown::TableHandler::new())), - Rc::new(RefCell::new(markdown::StyledTextHandler)), - ]; - if url.contains("wikipedia.org") { - use html_to_markdown::structure::wikipedia; - - handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover))); - handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler))); - handlers.push(Rc::new( - RefCell::new(wikipedia::WikipediaCodeHandler::new()), - )); - } else { - handlers.push(Rc::new(RefCell::new(markdown::CodeHandler))); - } - - convert_html_to_markdown(&body[..], &mut handlers) - } - ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()), - ContentType::Json => { - let json: serde_json::Value = serde_json::from_slice(&body)?; - - Ok(format!( - "```json\n{}\n```", - serde_json::to_string_pretty(&json)? - )) - } - } -} - -impl PickerDelegate for FetchContextPickerDelegate { - type ListItem = ListItem; - - fn match_count(&self) -> usize { - if self.url.is_empty() { 0 } else { 1 } - } - - fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { - Some("Enter the URL that you would like to fetch".into()) - } - - fn selected_index(&self) -> usize { - 0 - } - - fn set_selected_index( - &mut self, - _ix: usize, - _window: &mut Window, - _cx: &mut Context>, - ) { - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Enter a URL…".into() - } - - fn update_matches( - &mut self, - query: String, - _window: &mut Window, - _cx: &mut Context>, - ) -> Task<()> { - self.url = query; - - Task::ready(()) - } - - fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { - let Some(workspace) = self.workspace.upgrade() else { - return; - }; - - let http_client = workspace.read(cx).client().http_client(); - let url = self.url.clone(); - cx.spawn_in(window, async move |this, cx| { - let text = cx - .background_spawn(fetch_url_content(http_client, url.clone())) - .await?; - - this.update(cx, |this, cx| { - this.delegate.context_store.update(cx, |context_store, cx| { - context_store.add_fetched_url(url, text, cx) - }) - })??; - - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - - fn dismissed(&mut self, _window: &mut Window, cx: &mut Context>) { - self.context_picker - .update(cx, |_, cx| { - cx.emit(DismissEvent); - }) - .ok(); - } - - fn render_match( - &self, - ix: usize, - selected: bool, - _window: &mut Window, - cx: &mut Context>, - ) -> Option { - let added = self - .context_store - .upgrade() - .is_some_and(|context_store| context_store.read(cx).includes_url(&self.url)); - - Some( - ListItem::new(ix) - .inset(true) - .toggle_state(selected) - .child(Label::new(self.url.clone())) - .when(added, |child| { - child.disabled(true).end_slot( - h_flex() - .gap_1() - .child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success), - ) - .child(Label::new("Added").size(LabelSize::Small)), - ) - }), - ) - } -} diff --git a/crates/agent_ui/src/context_picker/file_context_picker.rs b/crates/agent_ui/src/context_picker/file_context_picker.rs deleted file mode 100644 index ded24caa922d27d8821e46e5c58b5ed22ab754ff..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/context_picker/file_context_picker.rs +++ /dev/null @@ -1,392 +0,0 @@ -use std::sync::Arc; -use std::sync::atomic::AtomicBool; - -use file_icons::FileIcons; -use fuzzy::PathMatch; -use gpui::{ - App, AppContext, DismissEvent, Entity, FocusHandle, Focusable, Stateful, Task, WeakEntity, -}; -use picker::{Picker, PickerDelegate}; -use project::{PathMatchCandidateSet, ProjectPath, WorktreeId}; -use ui::{ListItem, Tooltip, prelude::*}; -use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath}; -use workspace::Workspace; - -use crate::{ - context_picker::ContextPicker, - context_store::{ContextStore, FileInclusion}, -}; - -pub struct FileContextPicker { - picker: Entity>, -} - -impl FileContextPicker { - pub fn new( - context_picker: WeakEntity, - workspace: WeakEntity, - context_store: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let delegate = FileContextPickerDelegate::new(context_picker, workspace, context_store); - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); - - Self { picker } - } -} - -impl Focusable for FileContextPicker { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.picker.focus_handle(cx) - } -} - -impl Render for FileContextPicker { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - self.picker.clone() - } -} - -pub struct FileContextPickerDelegate { - context_picker: WeakEntity, - workspace: WeakEntity, - context_store: WeakEntity, - matches: Vec, - selected_index: usize, -} - -impl FileContextPickerDelegate { - pub fn new( - context_picker: WeakEntity, - workspace: WeakEntity, - context_store: WeakEntity, - ) -> Self { - Self { - context_picker, - workspace, - context_store, - matches: Vec::new(), - selected_index: 0, - } - } -} - -impl PickerDelegate for FileContextPickerDelegate { - type ListItem = ListItem; - - fn match_count(&self) -> usize { - self.matches.len() - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index( - &mut self, - ix: usize, - _window: &mut Window, - _cx: &mut Context>, - ) { - self.selected_index = ix; - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Search files & directories…".into() - } - - fn update_matches( - &mut self, - query: String, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - let Some(workspace) = self.workspace.upgrade() else { - return Task::ready(()); - }; - - let search_task = search_files(query, Arc::::default(), &workspace, cx); - - cx.spawn_in(window, async move |this, cx| { - // TODO: This should be probably be run in the background. - let paths = search_task.await; - - this.update(cx, |this, _cx| { - this.delegate.matches = paths; - }) - .log_err(); - }) - } - - fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context>) { - let Some(FileMatch { mat, .. }) = self.matches.get(self.selected_index) else { - return; - }; - - let project_path = ProjectPath { - worktree_id: WorktreeId::from_usize(mat.worktree_id), - path: mat.path.clone(), - }; - - let is_directory = mat.is_dir; - - self.context_store - .update(cx, |context_store, cx| { - if is_directory { - context_store - .add_directory(&project_path, true, cx) - .log_err(); - } else { - context_store - .add_file_from_path(project_path.clone(), true, cx) - .detach_and_log_err(cx); - } - }) - .ok(); - } - - fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { - self.context_picker - .update(cx, |_, cx| { - cx.emit(DismissEvent); - }) - .ok(); - } - - fn render_match( - &self, - ix: usize, - selected: bool, - _window: &mut Window, - cx: &mut Context>, - ) -> Option { - let FileMatch { mat, .. } = &self.matches.get(ix)?; - let workspace = self.workspace.upgrade()?; - let path_style = workspace.read(cx).path_style(cx); - - Some( - ListItem::new(ix) - .inset(true) - .toggle_state(selected) - .child(render_file_context_entry( - ElementId::named_usize("file-ctx-picker", ix), - WorktreeId::from_usize(mat.worktree_id), - &mat.path, - &mat.path_prefix, - mat.is_dir, - path_style, - self.context_store.clone(), - cx, - )), - ) - } -} - -pub struct FileMatch { - pub mat: PathMatch, - pub is_recent: bool, -} - -pub(crate) fn search_files( - query: String, - cancellation_flag: Arc, - workspace: &Entity, - cx: &App, -) -> Task> { - if query.is_empty() { - let workspace = workspace.read(cx); - let project = workspace.project().read(cx); - let visible_worktrees = workspace.visible_worktrees(cx).collect::>(); - let include_root_name = visible_worktrees.len() > 1; - - let recent_matches = workspace - .recent_navigation_history(Some(10), cx) - .into_iter() - .map(|(project_path, _)| { - let path_prefix = if include_root_name { - project - .worktree_for_id(project_path.worktree_id, cx) - .map(|wt| wt.read(cx).root_name().into()) - .unwrap_or_else(|| RelPath::empty().into()) - } else { - RelPath::empty().into() - }; - - FileMatch { - mat: PathMatch { - score: 0., - positions: Vec::new(), - worktree_id: project_path.worktree_id.to_usize(), - path: project_path.path, - path_prefix, - distance_to_relative_ancestor: 0, - is_dir: false, - }, - is_recent: true, - } - }); - - let file_matches = visible_worktrees.into_iter().flat_map(|worktree| { - let worktree = worktree.read(cx); - let path_prefix: Arc = if include_root_name { - worktree.root_name().into() - } else { - RelPath::empty().into() - }; - worktree.entries(false, 0).map(move |entry| FileMatch { - mat: PathMatch { - score: 0., - positions: Vec::new(), - worktree_id: worktree.id().to_usize(), - path: entry.path.clone(), - path_prefix: path_prefix.clone(), - distance_to_relative_ancestor: 0, - is_dir: entry.is_dir(), - }, - is_recent: false, - }) - }); - - Task::ready(recent_matches.chain(file_matches).collect()) - } else { - let worktrees = workspace.read(cx).visible_worktrees(cx).collect::>(); - let include_root_name = worktrees.len() > 1; - let candidate_sets = worktrees - .into_iter() - .map(|worktree| { - let worktree = worktree.read(cx); - - PathMatchCandidateSet { - snapshot: worktree.snapshot(), - include_ignored: worktree.root_entry().is_some_and(|entry| entry.is_ignored), - include_root_name, - candidates: project::Candidates::Entries, - } - }) - .collect::>(); - - let executor = cx.background_executor().clone(); - cx.foreground_executor().spawn(async move { - fuzzy::match_path_sets( - candidate_sets.as_slice(), - query.as_str(), - &None, - false, - 100, - &cancellation_flag, - executor, - ) - .await - .into_iter() - .map(|mat| FileMatch { - mat, - is_recent: false, - }) - .collect::>() - }) - } -} - -pub fn extract_file_name_and_directory( - path: &RelPath, - path_prefix: &RelPath, - path_style: PathStyle, -) -> (SharedString, Option) { - // If path is empty, this means we're matching with the root directory itself - // so we use the path_prefix as the name - if path.is_empty() && !path_prefix.is_empty() { - return (path_prefix.display(path_style).to_string().into(), None); - } - - let full_path = path_prefix.join(path); - let file_name = full_path.file_name().unwrap_or_default(); - let display_path = full_path.display(path_style); - let (directory, file_name) = display_path.split_at(display_path.len() - file_name.len()); - ( - file_name.to_string().into(), - Some(SharedString::new(directory)).filter(|dir| !dir.is_empty()), - ) -} - -pub fn render_file_context_entry( - id: ElementId, - worktree_id: WorktreeId, - path: &Arc, - path_prefix: &Arc, - is_directory: bool, - path_style: PathStyle, - context_store: WeakEntity, - cx: &App, -) -> Stateful
{ - let (file_name, directory) = extract_file_name_and_directory(path, path_prefix, path_style); - - let added = context_store.upgrade().and_then(|context_store| { - let project_path = ProjectPath { - worktree_id, - path: path.clone(), - }; - if is_directory { - context_store - .read(cx) - .path_included_in_directory(&project_path, cx) - } else { - context_store.read(cx).file_path_included(&project_path, cx) - } - }); - - let file_icon = if is_directory { - FileIcons::get_folder_icon(false, path.as_std_path(), cx) - } else { - FileIcons::get_icon(path.as_std_path(), cx) - } - .map(Icon::from_path) - .unwrap_or_else(|| Icon::new(IconName::File)); - - h_flex() - .id(id) - .gap_1p5() - .w_full() - .child(file_icon.size(IconSize::Small).color(Color::Muted)) - .child( - h_flex() - .gap_1() - .child(Label::new(file_name)) - .children(directory.map(|directory| { - Label::new(directory) - .size(LabelSize::Small) - .color(Color::Muted) - })), - ) - .when_some(added, |el, added| match added { - FileInclusion::Direct => el.child( - h_flex() - .w_full() - .justify_end() - .gap_0p5() - .child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success), - ) - .child(Label::new("Added").size(LabelSize::Small)), - ), - FileInclusion::InDirectory { full_path } => { - let directory_full_path = full_path.to_string_lossy().into_owned(); - - el.child( - h_flex() - .w_full() - .justify_end() - .gap_0p5() - .child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success), - ) - .child(Label::new("Included").size(LabelSize::Small)), - ) - .tooltip(Tooltip::text(format!("in {directory_full_path}"))) - } - }) -} diff --git a/crates/agent_ui/src/context_picker/rules_context_picker.rs b/crates/agent_ui/src/context_picker/rules_context_picker.rs deleted file mode 100644 index 68f4917a4fd5689aab1a418dd78d2c8a322cd717..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/context_picker/rules_context_picker.rs +++ /dev/null @@ -1,224 +0,0 @@ -use std::sync::Arc; -use std::sync::atomic::AtomicBool; - -use gpui::{App, DismissEvent, Entity, FocusHandle, Focusable, Task, WeakEntity}; -use picker::{Picker, PickerDelegate}; -use prompt_store::{PromptId, PromptStore, UserPromptId}; -use ui::{ListItem, prelude::*}; -use util::ResultExt as _; - -use crate::{ - context::RULES_ICON, - context_picker::ContextPicker, - context_store::{self, ContextStore}, -}; - -pub struct RulesContextPicker { - picker: Entity>, -} - -impl RulesContextPicker { - pub fn new( - prompt_store: WeakEntity, - context_picker: WeakEntity, - context_store: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let delegate = RulesContextPickerDelegate::new(prompt_store, context_picker, context_store); - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); - - RulesContextPicker { picker } - } -} - -impl Focusable for RulesContextPicker { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.picker.focus_handle(cx) - } -} - -impl Render for RulesContextPicker { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - self.picker.clone() - } -} - -#[derive(Debug, Clone)] -pub struct RulesContextEntry { - pub prompt_id: UserPromptId, - pub title: SharedString, -} - -pub struct RulesContextPickerDelegate { - prompt_store: WeakEntity, - context_picker: WeakEntity, - context_store: WeakEntity, - matches: Vec, - selected_index: usize, -} - -impl RulesContextPickerDelegate { - pub fn new( - prompt_store: WeakEntity, - context_picker: WeakEntity, - context_store: WeakEntity, - ) -> Self { - RulesContextPickerDelegate { - prompt_store, - context_picker, - context_store, - matches: Vec::new(), - selected_index: 0, - } - } -} - -impl PickerDelegate for RulesContextPickerDelegate { - type ListItem = ListItem; - - fn match_count(&self) -> usize { - self.matches.len() - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index( - &mut self, - ix: usize, - _window: &mut Window, - _cx: &mut Context>, - ) { - self.selected_index = ix; - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Search available rules…".into() - } - - fn update_matches( - &mut self, - query: String, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - let Some(prompt_store) = self.prompt_store.upgrade() else { - return Task::ready(()); - }; - let search_task = search_rules(query, Arc::new(AtomicBool::default()), &prompt_store, cx); - cx.spawn_in(window, async move |this, cx| { - let matches = search_task.await; - this.update(cx, |this, cx| { - this.delegate.matches = matches; - this.delegate.selected_index = 0; - cx.notify(); - }) - .ok(); - }) - } - - fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context>) { - let Some(entry) = self.matches.get(self.selected_index) else { - return; - }; - - self.context_store - .update(cx, |context_store, cx| { - context_store.add_rules(entry.prompt_id, true, cx) - }) - .log_err(); - } - - fn dismissed(&mut self, _window: &mut Window, cx: &mut Context>) { - self.context_picker - .update(cx, |_, cx| { - cx.emit(DismissEvent); - }) - .ok(); - } - - fn render_match( - &self, - ix: usize, - selected: bool, - _window: &mut Window, - cx: &mut Context>, - ) -> Option { - let thread = &self.matches.get(ix)?; - - Some(ListItem::new(ix).inset(true).toggle_state(selected).child( - render_thread_context_entry(thread, self.context_store.clone(), cx), - )) - } -} - -pub fn render_thread_context_entry( - user_rules: &RulesContextEntry, - context_store: WeakEntity, - cx: &mut App, -) -> Div { - let added = context_store.upgrade().is_some_and(|context_store| { - context_store - .read(cx) - .includes_user_rules(user_rules.prompt_id) - }); - - h_flex() - .gap_1p5() - .w_full() - .justify_between() - .child( - h_flex() - .gap_1p5() - .max_w_72() - .child( - Icon::new(RULES_ICON) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child(Label::new(user_rules.title.clone()).truncate()), - ) - .when(added, |el| { - el.child( - h_flex() - .gap_1() - .child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success), - ) - .child(Label::new("Added").size(LabelSize::Small)), - ) - }) -} - -pub(crate) fn search_rules( - query: String, - cancellation_flag: Arc, - prompt_store: &Entity, - cx: &mut App, -) -> Task> { - let search_task = prompt_store.read(cx).search(query, cancellation_flag, cx); - cx.background_spawn(async move { - search_task - .await - .into_iter() - .flat_map(|metadata| { - // Default prompts are filtered out as they are automatically included. - if metadata.default { - None - } else { - match metadata.id { - PromptId::EditWorkflow => None, - PromptId::User { uuid } => Some(RulesContextEntry { - prompt_id: uuid, - title: metadata.title?, - }), - } - } - }) - .collect::>() - }) -} diff --git a/crates/agent_ui/src/context_picker/symbol_context_picker.rs b/crates/agent_ui/src/context_picker/symbol_context_picker.rs deleted file mode 100644 index fbce71d94efd84b1acc6e0b5d4ea11cb2b9243d5..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/context_picker/symbol_context_picker.rs +++ /dev/null @@ -1,415 +0,0 @@ -use std::cmp::Reverse; -use std::sync::Arc; -use std::sync::atomic::AtomicBool; - -use anyhow::{Result, anyhow}; -use fuzzy::{StringMatch, StringMatchCandidate}; -use gpui::{ - App, AppContext, DismissEvent, Entity, FocusHandle, Focusable, Stateful, Task, WeakEntity, -}; -use ordered_float::OrderedFloat; -use picker::{Picker, PickerDelegate}; -use project::lsp_store::SymbolLocation; -use project::{DocumentSymbol, Symbol}; -use ui::{ListItem, prelude::*}; -use util::ResultExt as _; -use workspace::Workspace; - -use crate::{ - context::AgentContextHandle, context_picker::ContextPicker, context_store::ContextStore, -}; - -pub struct SymbolContextPicker { - picker: Entity>, -} - -impl SymbolContextPicker { - pub fn new( - context_picker: WeakEntity, - workspace: WeakEntity, - context_store: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let delegate = SymbolContextPickerDelegate::new(context_picker, workspace, context_store); - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); - - Self { picker } - } -} - -impl Focusable for SymbolContextPicker { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.picker.focus_handle(cx) - } -} - -impl Render for SymbolContextPicker { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - self.picker.clone() - } -} - -pub struct SymbolContextPickerDelegate { - context_picker: WeakEntity, - workspace: WeakEntity, - context_store: WeakEntity, - matches: Vec, - selected_index: usize, -} - -impl SymbolContextPickerDelegate { - pub fn new( - context_picker: WeakEntity, - workspace: WeakEntity, - context_store: WeakEntity, - ) -> Self { - Self { - context_picker, - workspace, - context_store, - matches: Vec::new(), - selected_index: 0, - } - } -} - -impl PickerDelegate for SymbolContextPickerDelegate { - type ListItem = ListItem; - - fn match_count(&self) -> usize { - self.matches.len() - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index( - &mut self, - ix: usize, - _window: &mut Window, - _cx: &mut Context>, - ) { - self.selected_index = ix; - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Search symbols…".into() - } - - fn update_matches( - &mut self, - query: String, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - let Some(workspace) = self.workspace.upgrade() else { - return Task::ready(()); - }; - - let search_task = search_symbols(query, Arc::::default(), &workspace, cx); - let context_store = self.context_store.clone(); - cx.spawn_in(window, async move |this, cx| { - let symbols = search_task.await; - - let symbol_entries = context_store - .read_with(cx, |context_store, cx| { - compute_symbol_entries(symbols, context_store, cx) - }) - .log_err() - .unwrap_or_default(); - - this.update(cx, |this, _cx| { - this.delegate.matches = symbol_entries; - }) - .log_err(); - }) - } - - fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context>) { - let Some(mat) = self.matches.get(self.selected_index) else { - return; - }; - let Some(workspace) = self.workspace.upgrade() else { - return; - }; - - let add_symbol_task = add_symbol( - mat.symbol.clone(), - true, - workspace, - self.context_store.clone(), - cx, - ); - - let selected_index = self.selected_index; - cx.spawn(async move |this, cx| { - let (_, included) = add_symbol_task.await?; - this.update(cx, |this, _| { - if let Some(mat) = this.delegate.matches.get_mut(selected_index) { - mat.is_included = included; - } - }) - }) - .detach_and_log_err(cx); - } - - fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { - self.context_picker - .update(cx, |_, cx| { - cx.emit(DismissEvent); - }) - .ok(); - } - - fn render_match( - &self, - ix: usize, - selected: bool, - _window: &mut Window, - _: &mut Context>, - ) -> Option { - let mat = &self.matches.get(ix)?; - - Some(ListItem::new(ix).inset(true).toggle_state(selected).child( - render_symbol_context_entry(ElementId::named_usize("symbol-ctx-picker", ix), mat), - )) - } -} - -pub(crate) struct SymbolEntry { - pub symbol: Symbol, - pub is_included: bool, -} - -pub(crate) fn add_symbol( - symbol: Symbol, - remove_if_exists: bool, - workspace: Entity, - context_store: WeakEntity, - cx: &mut App, -) -> Task, bool)>> { - let project = workspace.read(cx).project().clone(); - let open_buffer_task = project.update(cx, |project, cx| { - let SymbolLocation::InProject(symbol_path) = &symbol.path else { - return Task::ready(Err(anyhow!("can't add symbol from outside of project"))); - }; - project.open_buffer(symbol_path.clone(), cx) - }); - cx.spawn(async move |cx| { - let buffer = open_buffer_task.await?; - let document_symbols = project - .update(cx, |project, cx| project.document_symbols(&buffer, cx))? - .await?; - - // Try to find a matching document symbol. Document symbols include - // not only the symbol itself (e.g. function name), but they also - // include the context that they contain (e.g. function body). - let (name, range, enclosing_range) = if let Some(DocumentSymbol { - name, - range, - selection_range, - .. - }) = - find_matching_symbol(&symbol, document_symbols.as_slice()) - { - (name, selection_range, range) - } else { - // If we do not find a matching document symbol, fall back to - // just the symbol itself - (symbol.name, symbol.range.clone(), symbol.range) - }; - - let (range, enclosing_range) = buffer.read_with(cx, |buffer, _| { - ( - buffer.anchor_after(range.start)..buffer.anchor_before(range.end), - buffer.anchor_after(enclosing_range.start) - ..buffer.anchor_before(enclosing_range.end), - ) - })?; - - context_store.update(cx, move |context_store, cx| { - context_store.add_symbol( - buffer, - name.into(), - range, - enclosing_range, - remove_if_exists, - cx, - ) - }) - }) -} - -fn find_matching_symbol(symbol: &Symbol, candidates: &[DocumentSymbol]) -> Option { - let mut candidates = candidates.iter(); - let mut candidate = candidates.next()?; - - loop { - if candidate.range.start > symbol.range.end { - return None; - } - if candidate.range.end < symbol.range.start { - candidate = candidates.next()?; - continue; - } - if candidate.selection_range == symbol.range { - return Some(candidate.clone()); - } - if candidate.range.start <= symbol.range.start && symbol.range.end <= candidate.range.end { - candidates = candidate.children.iter(); - candidate = candidates.next()?; - continue; - } - return None; - } -} - -pub struct SymbolMatch { - pub symbol: Symbol, -} - -pub(crate) fn search_symbols( - query: String, - cancellation_flag: Arc, - workspace: &Entity, - cx: &mut App, -) -> Task> { - let symbols_task = workspace.update(cx, |workspace, cx| { - workspace - .project() - .update(cx, |project, cx| project.symbols(&query, cx)) - }); - let project = workspace.read(cx).project().clone(); - cx.spawn(async move |cx| { - let Some(symbols) = symbols_task.await.log_err() else { - return Vec::new(); - }; - let Some((visible_match_candidates, external_match_candidates)): Option<(Vec<_>, Vec<_>)> = - project - .update(cx, |project, cx| { - symbols - .iter() - .enumerate() - .map(|(id, symbol)| { - StringMatchCandidate::new(id, symbol.label.filter_text()) - }) - .partition(|candidate| match &symbols[candidate.id].path { - SymbolLocation::InProject(project_path) => project - .entry_for_path(project_path, cx) - .is_some_and(|e| !e.is_ignored), - SymbolLocation::OutsideProject { .. } => false, - }) - }) - .log_err() - else { - return Vec::new(); - }; - - const MAX_MATCHES: usize = 100; - let mut visible_matches = cx.background_executor().block(fuzzy::match_strings( - &visible_match_candidates, - &query, - false, - true, - MAX_MATCHES, - &cancellation_flag, - cx.background_executor().clone(), - )); - let mut external_matches = cx.background_executor().block(fuzzy::match_strings( - &external_match_candidates, - &query, - false, - true, - MAX_MATCHES - visible_matches.len().min(MAX_MATCHES), - &cancellation_flag, - cx.background_executor().clone(), - )); - let sort_key_for_match = |mat: &StringMatch| { - let symbol = &symbols[mat.candidate_id]; - (Reverse(OrderedFloat(mat.score)), symbol.label.filter_text()) - }; - - visible_matches.sort_unstable_by_key(sort_key_for_match); - external_matches.sort_unstable_by_key(sort_key_for_match); - let mut matches = visible_matches; - matches.append(&mut external_matches); - - matches - .into_iter() - .map(|mut mat| { - let symbol = symbols[mat.candidate_id].clone(); - let filter_start = symbol.label.filter_range.start; - for position in &mut mat.positions { - *position += filter_start; - } - SymbolMatch { symbol } - }) - .collect() - }) -} - -fn compute_symbol_entries( - symbols: Vec, - context_store: &ContextStore, - cx: &App, -) -> Vec { - symbols - .into_iter() - .map(|SymbolMatch { symbol, .. }| SymbolEntry { - is_included: context_store.includes_symbol(&symbol, cx), - symbol, - }) - .collect::>() -} - -pub fn render_symbol_context_entry(id: ElementId, entry: &SymbolEntry) -> Stateful
{ - let path = match &entry.symbol.path { - SymbolLocation::InProject(project_path) => { - project_path.path.file_name().unwrap_or_default().into() - } - SymbolLocation::OutsideProject { - abs_path, - signature: _, - } => abs_path - .file_name() - .map(|f| f.to_string_lossy()) - .unwrap_or_default(), - }; - let symbol_location = format!("{} L{}", path, entry.symbol.range.start.0.row + 1); - - h_flex() - .id(id) - .gap_1p5() - .w_full() - .child( - Icon::new(IconName::Code) - .size(IconSize::Small) - .color(Color::Muted), - ) - .child( - h_flex() - .gap_1() - .child(Label::new(&entry.symbol.name)) - .child( - Label::new(symbol_location) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .when(entry.is_included, |el| { - el.child( - h_flex() - .w_full() - .justify_end() - .gap_0p5() - .child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success), - ) - .child(Label::new("Added").size(LabelSize::Small)), - ) - }) -} diff --git a/crates/agent_ui/src/context_picker/thread_context_picker.rs b/crates/agent_ui/src/context_picker/thread_context_picker.rs deleted file mode 100644 index d6a3a270742fe28c483d2d7d39894eb9e3c021ea..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/context_picker/thread_context_picker.rs +++ /dev/null @@ -1,280 +0,0 @@ -use std::sync::Arc; -use std::sync::atomic::AtomicBool; - -use crate::{ - context_picker::ContextPicker, - context_store::{self, ContextStore}, -}; -use agent::{HistoryEntry, HistoryStore}; -use fuzzy::StringMatchCandidate; -use gpui::{App, DismissEvent, Entity, FocusHandle, Focusable, Task, WeakEntity}; -use picker::{Picker, PickerDelegate}; -use ui::{ListItem, prelude::*}; -use workspace::Workspace; - -pub struct ThreadContextPicker { - picker: Entity>, -} - -impl ThreadContextPicker { - pub fn new( - thread_store: WeakEntity, - context_picker: WeakEntity, - context_store: WeakEntity, - workspace: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let delegate = ThreadContextPickerDelegate::new( - thread_store, - context_picker, - context_store, - workspace, - ); - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); - - ThreadContextPicker { picker } - } -} - -impl Focusable for ThreadContextPicker { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.picker.focus_handle(cx) - } -} - -impl Render for ThreadContextPicker { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - self.picker.clone() - } -} - -pub struct ThreadContextPickerDelegate { - thread_store: WeakEntity, - context_picker: WeakEntity, - context_store: WeakEntity, - workspace: WeakEntity, - matches: Vec, - selected_index: usize, -} - -impl ThreadContextPickerDelegate { - pub fn new( - thread_store: WeakEntity, - context_picker: WeakEntity, - context_store: WeakEntity, - workspace: WeakEntity, - ) -> Self { - ThreadContextPickerDelegate { - thread_store, - context_picker, - context_store, - workspace, - matches: Vec::new(), - selected_index: 0, - } - } -} - -impl PickerDelegate for ThreadContextPickerDelegate { - type ListItem = ListItem; - - fn match_count(&self) -> usize { - self.matches.len() - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index( - &mut self, - ix: usize, - _window: &mut Window, - _cx: &mut Context>, - ) { - self.selected_index = ix; - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Search threads…".into() - } - - fn update_matches( - &mut self, - query: String, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - let Some(thread_store) = self.thread_store.upgrade() else { - return Task::ready(()); - }; - - let search_task = search_threads(query, Arc::new(AtomicBool::default()), &thread_store, cx); - cx.spawn_in(window, async move |this, cx| { - let matches = search_task.await; - this.update(cx, |this, cx| { - this.delegate.matches = matches; - this.delegate.selected_index = 0; - cx.notify(); - }) - .ok(); - }) - } - - fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context>) { - let Some(project) = self - .workspace - .upgrade() - .map(|w| w.read(cx).project().clone()) - else { - return; - }; - let Some((entry, thread_store)) = self - .matches - .get(self.selected_index) - .zip(self.thread_store.upgrade()) - else { - return; - }; - - match entry { - HistoryEntry::AcpThread(thread) => { - let load_thread_task = - agent::load_agent_thread(thread.id.clone(), thread_store, project, cx); - - cx.spawn(async move |this, cx| { - let thread = load_thread_task.await?; - this.update(cx, |this, cx| { - this.delegate - .context_store - .update(cx, |context_store, cx| { - context_store.add_thread(thread, true, cx) - }) - .ok(); - }) - }) - .detach_and_log_err(cx); - } - HistoryEntry::TextThread(thread) => { - let task = thread_store.update(cx, |this, cx| { - this.load_text_thread(thread.path.clone(), cx) - }); - - cx.spawn(async move |this, cx| { - let thread = task.await?; - this.update(cx, |this, cx| { - this.delegate - .context_store - .update(cx, |context_store, cx| { - context_store.add_text_thread(thread, true, cx) - }) - .ok(); - }) - }) - .detach_and_log_err(cx); - } - } - } - - fn dismissed(&mut self, _window: &mut Window, cx: &mut Context>) { - self.context_picker - .update(cx, |_, cx| { - cx.emit(DismissEvent); - }) - .ok(); - } - - fn render_match( - &self, - ix: usize, - selected: bool, - _window: &mut Window, - cx: &mut Context>, - ) -> Option { - let thread = &self.matches.get(ix)?; - - Some(ListItem::new(ix).inset(true).toggle_state(selected).child( - render_thread_context_entry(thread, self.context_store.clone(), cx), - )) - } -} - -pub fn render_thread_context_entry( - entry: &HistoryEntry, - context_store: WeakEntity, - cx: &mut App, -) -> Div { - let is_added = match entry { - HistoryEntry::AcpThread(thread) => context_store - .upgrade() - .is_some_and(|ctx_store| ctx_store.read(cx).includes_thread(&thread.id)), - HistoryEntry::TextThread(thread) => context_store - .upgrade() - .is_some_and(|ctx_store| ctx_store.read(cx).includes_text_thread(&thread.path)), - }; - - h_flex() - .gap_1p5() - .w_full() - .justify_between() - .child( - h_flex() - .gap_1p5() - .max_w_72() - .child( - Icon::new(IconName::Thread) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child(Label::new(entry.title().clone()).truncate()), - ) - .when(is_added, |el| { - el.child( - h_flex() - .gap_1() - .child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success), - ) - .child(Label::new("Added").size(LabelSize::Small)), - ) - }) -} - -pub(crate) fn search_threads( - query: String, - cancellation_flag: Arc, - thread_store: &Entity, - cx: &mut App, -) -> Task> { - let threads = thread_store.read(cx).entries().collect(); - if query.is_empty() { - return Task::ready(threads); - } - - let executor = cx.background_executor().clone(); - cx.background_spawn(async move { - let candidates = threads - .iter() - .enumerate() - .map(|(id, thread)| StringMatchCandidate::new(id, thread.title())) - .collect::>(); - let matches = fuzzy::match_strings( - &candidates, - &query, - false, - true, - 100, - &cancellation_flag, - executor, - ) - .await; - - matches - .into_iter() - .map(|mat| threads[mat.candidate_id].clone()) - .collect() - }) -} diff --git a/crates/agent_ui/src/context_store.rs b/crates/agent_ui/src/context_store.rs deleted file mode 100644 index 18aa59c8f716d59e4a0d717904b09472494c4dbc..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/context_store.rs +++ /dev/null @@ -1,614 +0,0 @@ -use crate::context::{ - AgentContextHandle, AgentContextKey, ContextId, ContextKind, DirectoryContextHandle, - FetchedUrlContext, FileContextHandle, ImageContext, RulesContextHandle, SelectionContextHandle, - SymbolContextHandle, TextThreadContextHandle, ThreadContextHandle, -}; -use agent_client_protocol as acp; -use anyhow::{Context as _, Result, anyhow}; -use assistant_text_thread::TextThread; -use collections::{HashSet, IndexSet}; -use futures::{self, FutureExt}; -use gpui::{App, Context, Entity, EventEmitter, Image, SharedString, Task, WeakEntity}; -use language::{Buffer, File as _}; -use language_model::LanguageModelImage; -use project::{ - Project, ProjectItem, ProjectPath, Symbol, image_store::is_image_file, - lsp_store::SymbolLocation, -}; -use prompt_store::UserPromptId; -use ref_cast::RefCast as _; -use std::{ - ops::Range, - path::{Path, PathBuf}, - sync::Arc, -}; -use text::{Anchor, OffsetRangeExt}; - -pub struct ContextStore { - project: WeakEntity, - next_context_id: ContextId, - context_set: IndexSet, - context_thread_ids: HashSet, - context_text_thread_paths: HashSet>, -} - -pub enum ContextStoreEvent { - ContextRemoved(AgentContextKey), -} - -impl EventEmitter for ContextStore {} - -impl ContextStore { - pub fn new(project: WeakEntity) -> Self { - Self { - project, - next_context_id: ContextId::zero(), - context_set: IndexSet::default(), - context_thread_ids: HashSet::default(), - context_text_thread_paths: HashSet::default(), - } - } - - pub fn context(&self) -> impl Iterator { - self.context_set.iter().map(|entry| entry.as_ref()) - } - - pub fn clear(&mut self, cx: &mut Context) { - self.context_set.clear(); - self.context_thread_ids.clear(); - cx.notify(); - } - - pub fn add_file_from_path( - &mut self, - project_path: ProjectPath, - remove_if_exists: bool, - cx: &mut Context, - ) -> Task>> { - let Some(project) = self.project.upgrade() else { - return Task::ready(Err(anyhow!("failed to read project"))); - }; - - if is_image_file(&project, &project_path, cx) { - self.add_image_from_path(project_path, remove_if_exists, cx) - } else { - cx.spawn(async move |this, cx| { - let open_buffer_task = project.update(cx, |project, cx| { - project.open_buffer(project_path.clone(), cx) - })?; - let buffer = open_buffer_task.await?; - this.update(cx, |this, cx| { - this.add_file_from_buffer(&project_path, buffer, remove_if_exists, cx) - }) - }) - } - } - - pub fn add_file_from_buffer( - &mut self, - project_path: &ProjectPath, - buffer: Entity, - remove_if_exists: bool, - cx: &mut Context, - ) -> Option { - let context_id = self.next_context_id.post_inc(); - let context = AgentContextHandle::File(FileContextHandle { buffer, context_id }); - - if let Some(key) = self.context_set.get(AgentContextKey::ref_cast(&context)) { - if remove_if_exists { - self.remove_context(&context, cx); - None - } else { - Some(key.as_ref().clone()) - } - } else if self.path_included_in_directory(project_path, cx).is_some() { - None - } else { - self.insert_context(context.clone(), cx); - Some(context) - } - } - - pub fn add_directory( - &mut self, - project_path: &ProjectPath, - remove_if_exists: bool, - cx: &mut Context, - ) -> Result> { - let project = self.project.upgrade().context("failed to read project")?; - let entry_id = project - .read(cx) - .entry_for_path(project_path, cx) - .map(|entry| entry.id) - .context("no entry found for directory context")?; - - let context_id = self.next_context_id.post_inc(); - let context = AgentContextHandle::Directory(DirectoryContextHandle { - entry_id, - context_id, - }); - - let context = - if let Some(existing) = self.context_set.get(AgentContextKey::ref_cast(&context)) { - if remove_if_exists { - self.remove_context(&context, cx); - None - } else { - Some(existing.as_ref().clone()) - } - } else { - self.insert_context(context.clone(), cx); - Some(context) - }; - - anyhow::Ok(context) - } - - pub fn add_symbol( - &mut self, - buffer: Entity, - symbol: SharedString, - range: Range, - enclosing_range: Range, - remove_if_exists: bool, - cx: &mut Context, - ) -> (Option, bool) { - let context_id = self.next_context_id.post_inc(); - let context = AgentContextHandle::Symbol(SymbolContextHandle { - buffer, - symbol, - range, - enclosing_range, - context_id, - }); - - if let Some(key) = self.context_set.get(AgentContextKey::ref_cast(&context)) { - let handle = if remove_if_exists { - self.remove_context(&context, cx); - None - } else { - Some(key.as_ref().clone()) - }; - return (handle, false); - } - - let included = self.insert_context(context.clone(), cx); - (Some(context), included) - } - - pub fn add_thread( - &mut self, - thread: Entity, - remove_if_exists: bool, - cx: &mut Context, - ) -> Option { - let context_id = self.next_context_id.post_inc(); - let context = AgentContextHandle::Thread(ThreadContextHandle { thread, context_id }); - - if let Some(existing) = self.context_set.get(AgentContextKey::ref_cast(&context)) { - if remove_if_exists { - self.remove_context(&context, cx); - None - } else { - Some(existing.as_ref().clone()) - } - } else { - self.insert_context(context.clone(), cx); - Some(context) - } - } - - pub fn add_text_thread( - &mut self, - text_thread: Entity, - remove_if_exists: bool, - cx: &mut Context, - ) -> Option { - let context_id = self.next_context_id.post_inc(); - let context = AgentContextHandle::TextThread(TextThreadContextHandle { - text_thread, - context_id, - }); - - if let Some(existing) = self.context_set.get(AgentContextKey::ref_cast(&context)) { - if remove_if_exists { - self.remove_context(&context, cx); - None - } else { - Some(existing.as_ref().clone()) - } - } else { - self.insert_context(context.clone(), cx); - Some(context) - } - } - - pub fn add_rules( - &mut self, - prompt_id: UserPromptId, - remove_if_exists: bool, - cx: &mut Context, - ) -> Option { - let context_id = self.next_context_id.post_inc(); - let context = AgentContextHandle::Rules(RulesContextHandle { - prompt_id, - context_id, - }); - - if let Some(existing) = self.context_set.get(AgentContextKey::ref_cast(&context)) { - if remove_if_exists { - self.remove_context(&context, cx); - None - } else { - Some(existing.as_ref().clone()) - } - } else { - self.insert_context(context.clone(), cx); - Some(context) - } - } - - pub fn add_fetched_url( - &mut self, - url: String, - text: impl Into, - cx: &mut Context, - ) -> AgentContextHandle { - let context = AgentContextHandle::FetchedUrl(FetchedUrlContext { - url: url.into(), - text: text.into(), - context_id: self.next_context_id.post_inc(), - }); - - self.insert_context(context.clone(), cx); - context - } - - pub fn add_image_from_path( - &mut self, - project_path: ProjectPath, - remove_if_exists: bool, - cx: &mut Context, - ) -> Task>> { - let project = self.project.clone(); - cx.spawn(async move |this, cx| { - let open_image_task = project.update(cx, |project, cx| { - project.open_image(project_path.clone(), cx) - })?; - let image_item = open_image_task.await?; - - this.update(cx, |this, cx| { - let item = image_item.read(cx); - this.insert_image( - Some(item.project_path(cx)), - Some(item.file.full_path(cx).to_string_lossy().into_owned()), - item.image.clone(), - remove_if_exists, - cx, - ) - }) - }) - } - - pub fn add_image_instance(&mut self, image: Arc, cx: &mut Context) { - self.insert_image(None, None, image, false, cx); - } - - fn insert_image( - &mut self, - project_path: Option, - full_path: Option, - image: Arc, - remove_if_exists: bool, - cx: &mut Context, - ) -> Option { - let image_task = LanguageModelImage::from_image(image.clone(), cx).shared(); - let context = AgentContextHandle::Image(ImageContext { - project_path, - full_path, - original_image: image, - image_task, - context_id: self.next_context_id.post_inc(), - }); - if self.has_context(&context) && remove_if_exists { - self.remove_context(&context, cx); - return None; - } - - self.insert_context(context.clone(), cx); - Some(context) - } - - pub fn add_selection( - &mut self, - buffer: Entity, - range: Range, - cx: &mut Context, - ) { - let context_id = self.next_context_id.post_inc(); - let context = AgentContextHandle::Selection(SelectionContextHandle { - buffer, - range, - context_id, - }); - self.insert_context(context, cx); - } - - pub fn add_suggested_context( - &mut self, - suggested: &SuggestedContext, - cx: &mut Context, - ) { - match suggested { - SuggestedContext::File { - buffer, - icon_path: _, - name: _, - } => { - if let Some(buffer) = buffer.upgrade() { - let context_id = self.next_context_id.post_inc(); - self.insert_context( - AgentContextHandle::File(FileContextHandle { buffer, context_id }), - cx, - ); - }; - } - SuggestedContext::TextThread { - text_thread, - name: _, - } => { - if let Some(text_thread) = text_thread.upgrade() { - let context_id = self.next_context_id.post_inc(); - self.insert_context( - AgentContextHandle::TextThread(TextThreadContextHandle { - text_thread, - context_id, - }), - cx, - ); - } - } - } - } - - fn insert_context(&mut self, context: AgentContextHandle, cx: &mut Context) -> bool { - match &context { - // AgentContextHandle::Thread(thread_context) => { - // if let Some(thread_store) = self.thread_store.clone() { - // thread_context.thread.update(cx, |thread, cx| { - // thread.start_generating_detailed_summary_if_needed(thread_store, cx); - // }); - // self.context_thread_ids - // .insert(thread_context.thread.read(cx).id().clone()); - // } else { - // return false; - // } - // } - AgentContextHandle::TextThread(text_thread_context) => { - self.context_text_thread_paths - .extend(text_thread_context.text_thread.read(cx).path().cloned()); - } - _ => {} - } - let inserted = self.context_set.insert(AgentContextKey(context)); - if inserted { - cx.notify(); - } - inserted - } - - pub fn remove_context(&mut self, context: &AgentContextHandle, cx: &mut Context) { - if let Some((_, key)) = self - .context_set - .shift_remove_full(AgentContextKey::ref_cast(context)) - { - match context { - AgentContextHandle::Thread(thread_context) => { - self.context_thread_ids - .remove(thread_context.thread.read(cx).id()); - } - AgentContextHandle::TextThread(text_thread_context) => { - if let Some(path) = text_thread_context.text_thread.read(cx).path() { - self.context_text_thread_paths.remove(path); - } - } - _ => {} - } - cx.emit(ContextStoreEvent::ContextRemoved(key)); - cx.notify(); - } - } - - pub fn has_context(&mut self, context: &AgentContextHandle) -> bool { - self.context_set - .contains(AgentContextKey::ref_cast(context)) - } - - /// Returns whether this file path is already included directly in the context, or if it will be - /// included in the context via a directory. - pub fn file_path_included(&self, path: &ProjectPath, cx: &App) -> Option { - let project = self.project.upgrade()?.read(cx); - self.context().find_map(|context| match context { - AgentContextHandle::File(file_context) => { - FileInclusion::check_file(file_context, path, cx) - } - AgentContextHandle::Image(image_context) => { - FileInclusion::check_image(image_context, path) - } - AgentContextHandle::Directory(directory_context) => { - FileInclusion::check_directory(directory_context, path, project, cx) - } - _ => None, - }) - } - - pub fn path_included_in_directory( - &self, - path: &ProjectPath, - cx: &App, - ) -> Option { - let project = self.project.upgrade()?.read(cx); - self.context().find_map(|context| match context { - AgentContextHandle::Directory(directory_context) => { - FileInclusion::check_directory(directory_context, path, project, cx) - } - _ => None, - }) - } - - pub fn includes_symbol(&self, symbol: &Symbol, cx: &App) -> bool { - self.context().any(|context| match context { - AgentContextHandle::Symbol(context) => { - if context.symbol != symbol.name { - return false; - } - let buffer = context.buffer.read(cx); - let Some(context_path) = buffer.project_path(cx) else { - return false; - }; - if symbol.path != SymbolLocation::InProject(context_path) { - return false; - } - let context_range = context.range.to_point_utf16(&buffer.snapshot()); - context_range.start == symbol.range.start.0 - && context_range.end == symbol.range.end.0 - } - _ => false, - }) - } - - pub fn includes_thread(&self, thread_id: &acp::SessionId) -> bool { - self.context_thread_ids.contains(thread_id) - } - - pub fn includes_text_thread(&self, path: &Arc) -> bool { - self.context_text_thread_paths.contains(path) - } - - pub fn includes_user_rules(&self, prompt_id: UserPromptId) -> bool { - self.context_set - .contains(&RulesContextHandle::lookup_key(prompt_id)) - } - - pub fn includes_url(&self, url: impl Into) -> bool { - self.context_set - .contains(&FetchedUrlContext::lookup_key(url.into())) - } - - pub fn get_url_context(&self, url: SharedString) -> Option { - self.context_set - .get(&FetchedUrlContext::lookup_key(url)) - .map(|key| key.as_ref().clone()) - } - - pub fn file_paths(&self, cx: &App) -> HashSet { - self.context() - .filter_map(|context| match context { - AgentContextHandle::File(file) => { - let buffer = file.buffer.read(cx); - buffer.project_path(cx) - } - AgentContextHandle::Directory(_) - | AgentContextHandle::Symbol(_) - | AgentContextHandle::Thread(_) - | AgentContextHandle::Selection(_) - | AgentContextHandle::FetchedUrl(_) - | AgentContextHandle::TextThread(_) - | AgentContextHandle::Rules(_) - | AgentContextHandle::Image(_) => None, - }) - .collect() - } - - pub fn thread_ids(&self) -> &HashSet { - &self.context_thread_ids - } -} - -#[derive(Clone)] -pub enum SuggestedContext { - File { - name: SharedString, - icon_path: Option, - buffer: WeakEntity, - }, - TextThread { - name: SharedString, - text_thread: WeakEntity, - }, -} - -impl SuggestedContext { - pub fn name(&self) -> &SharedString { - match self { - Self::File { name, .. } => name, - Self::TextThread { name, .. } => name, - } - } - - pub fn icon_path(&self) -> Option { - match self { - Self::File { icon_path, .. } => icon_path.clone(), - Self::TextThread { .. } => None, - } - } - - pub fn kind(&self) -> ContextKind { - match self { - Self::File { .. } => ContextKind::File, - Self::TextThread { .. } => ContextKind::TextThread, - } - } -} - -pub enum FileInclusion { - Direct, - InDirectory { full_path: PathBuf }, -} - -impl FileInclusion { - fn check_file(file_context: &FileContextHandle, path: &ProjectPath, cx: &App) -> Option { - let file_path = file_context.buffer.read(cx).project_path(cx)?; - if path == &file_path { - Some(FileInclusion::Direct) - } else { - None - } - } - - fn check_image(image_context: &ImageContext, path: &ProjectPath) -> Option { - let image_path = image_context.project_path.as_ref()?; - if path == image_path { - Some(FileInclusion::Direct) - } else { - None - } - } - - fn check_directory( - directory_context: &DirectoryContextHandle, - path: &ProjectPath, - project: &Project, - cx: &App, - ) -> Option { - let worktree = project - .worktree_for_entry(directory_context.entry_id, cx)? - .read(cx); - let entry = worktree.entry_for_id(directory_context.entry_id)?; - let directory_path = ProjectPath { - worktree_id: worktree.id(), - path: entry.path.clone(), - }; - if path.starts_with(&directory_path) { - if path == &directory_path { - Some(FileInclusion::Direct) - } else { - Some(FileInclusion::InDirectory { - full_path: worktree.full_path(&entry.path), - }) - } - } else { - None - } - } -} diff --git a/crates/agent_ui/src/context_strip.rs b/crates/agent_ui/src/context_strip.rs deleted file mode 100644 index d2393ac4f612cebc6cf97d10a38894e7022e53b9..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/context_strip.rs +++ /dev/null @@ -1,619 +0,0 @@ -use crate::{ - AcceptSuggestedContext, AgentPanel, FocusDown, FocusLeft, FocusRight, FocusUp, - ModelUsageContext, RemoveAllContext, RemoveFocusedContext, ToggleContextPicker, - context_picker::ContextPicker, - ui::{AddedContext, ContextPill}, -}; -use crate::{ - context::AgentContextHandle, - context_store::{ContextStore, SuggestedContext}, -}; -use agent::HistoryStore; -use collections::HashSet; -use editor::Editor; -use gpui::{ - App, Bounds, ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - Subscription, Task, WeakEntity, -}; -use itertools::Itertools; -use project::ProjectItem; -use prompt_store::PromptStore; -use rope::Point; -use std::rc::Rc; -use text::ToPoint as _; -use ui::{PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*}; -use util::ResultExt as _; -use workspace::Workspace; -use zed_actions::assistant::OpenRulesLibrary; - -pub struct ContextStrip { - context_store: Entity, - context_picker: Entity, - context_picker_menu_handle: PopoverMenuHandle, - focus_handle: FocusHandle, - suggest_context_kind: SuggestContextKind, - workspace: WeakEntity, - prompt_store: Option>, - _subscriptions: Vec, - focused_index: Option, - children_bounds: Option>>, - model_usage_context: ModelUsageContext, -} - -impl ContextStrip { - pub fn new( - context_store: Entity, - workspace: WeakEntity, - thread_store: Option>, - prompt_store: Option>, - context_picker_menu_handle: PopoverMenuHandle, - suggest_context_kind: SuggestContextKind, - model_usage_context: ModelUsageContext, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let context_picker = cx.new(|cx| { - ContextPicker::new( - workspace.clone(), - thread_store.clone(), - prompt_store.clone(), - context_store.downgrade(), - window, - cx, - ) - }); - - let focus_handle = cx.focus_handle(); - - let subscriptions = vec![ - cx.observe(&context_store, |_, _, cx| cx.notify()), - cx.subscribe_in(&context_picker, window, Self::handle_context_picker_event), - cx.on_focus(&focus_handle, window, Self::handle_focus), - cx.on_blur(&focus_handle, window, Self::handle_blur), - ]; - - Self { - context_store: context_store.clone(), - context_picker, - context_picker_menu_handle, - focus_handle, - suggest_context_kind, - workspace, - prompt_store, - _subscriptions: subscriptions, - focused_index: None, - children_bounds: None, - model_usage_context, - } - } - - /// Whether or not the context strip has items to display - pub fn has_context_items(&self, cx: &App) -> bool { - self.context_store.read(cx).context().next().is_some() - || self.suggested_context(cx).is_some() - } - - fn added_contexts(&self, cx: &App) -> Vec { - if let Some(workspace) = self.workspace.upgrade() { - let project = workspace.read(cx).project().read(cx); - let prompt_store = self.prompt_store.as_ref().and_then(|p| p.upgrade()); - - let current_model = self.model_usage_context.language_model(cx); - - self.context_store - .read(cx) - .context() - .flat_map(|context| { - AddedContext::new_pending( - context.clone(), - prompt_store.as_ref(), - project, - current_model.as_ref(), - cx, - ) - }) - .collect::>() - } else { - Vec::new() - } - } - - fn suggested_context(&self, cx: &App) -> Option { - match self.suggest_context_kind { - SuggestContextKind::Thread => self.suggested_thread(cx), - } - } - - fn suggested_thread(&self, cx: &App) -> Option { - if !self.context_picker.read(cx).allow_threads() { - return None; - } - - let workspace = self.workspace.upgrade()?; - let panel = workspace.read(cx).panel::(cx)?.read(cx); - - if let Some(active_text_thread_editor) = panel.active_text_thread_editor() { - let text_thread = active_text_thread_editor.read(cx).text_thread(); - let weak_text_thread = text_thread.downgrade(); - let text_thread = text_thread.read(cx); - let path = text_thread.path()?; - - if self.context_store.read(cx).includes_text_thread(path) { - return None; - } - - Some(SuggestedContext::TextThread { - name: text_thread.summary().or_default(), - text_thread: weak_text_thread, - }) - } else { - None - } - } - - fn handle_context_picker_event( - &mut self, - _picker: &Entity, - _event: &DismissEvent, - _window: &mut Window, - cx: &mut Context, - ) { - cx.emit(ContextStripEvent::PickerDismissed); - } - - fn handle_focus(&mut self, _window: &mut Window, cx: &mut Context) { - self.focused_index = self.last_pill_index(); - cx.notify(); - } - - fn handle_blur(&mut self, _window: &mut Window, cx: &mut Context) { - self.focused_index = None; - cx.notify(); - } - - fn focus_left(&mut self, _: &FocusLeft, _window: &mut Window, cx: &mut Context) { - self.focused_index = match self.focused_index { - Some(index) if index > 0 => Some(index - 1), - _ => self.last_pill_index(), - }; - - cx.notify(); - } - - fn focus_right(&mut self, _: &FocusRight, _window: &mut Window, cx: &mut Context) { - let Some(last_index) = self.last_pill_index() else { - return; - }; - - self.focused_index = match self.focused_index { - Some(index) if index < last_index => Some(index + 1), - _ => Some(0), - }; - - cx.notify(); - } - - fn focus_up(&mut self, _: &FocusUp, _window: &mut Window, cx: &mut Context) { - let Some(focused_index) = self.focused_index else { - return; - }; - - if focused_index == 0 { - return cx.emit(ContextStripEvent::BlurredUp); - } - - let Some((focused, pills)) = self.focused_bounds(focused_index) else { - return; - }; - - let iter = pills[..focused_index].iter().enumerate().rev(); - self.focused_index = Self::find_best_horizontal_match(focused, iter).or(Some(0)); - cx.notify(); - } - - fn focus_down(&mut self, _: &FocusDown, _window: &mut Window, cx: &mut Context) { - let Some(focused_index) = self.focused_index else { - return; - }; - - let last_index = self.last_pill_index(); - - if self.focused_index == last_index { - return cx.emit(ContextStripEvent::BlurredDown); - } - - let Some((focused, pills)) = self.focused_bounds(focused_index) else { - return; - }; - - let iter = pills.iter().enumerate().skip(focused_index + 1); - self.focused_index = Self::find_best_horizontal_match(focused, iter).or(last_index); - cx.notify(); - } - - fn focused_bounds(&self, focused: usize) -> Option<(&Bounds, &[Bounds])> { - let pill_bounds = self.pill_bounds()?; - let focused = pill_bounds.get(focused)?; - - Some((focused, pill_bounds)) - } - - fn pill_bounds(&self) -> Option<&[Bounds]> { - let bounds = self.children_bounds.as_ref()?; - let eraser = if bounds.len() < 3 { 0 } else { 1 }; - let pills = &bounds[1..bounds.len() - eraser]; - - if pills.is_empty() { None } else { Some(pills) } - } - - fn last_pill_index(&self) -> Option { - Some(self.pill_bounds()?.len() - 1) - } - - fn find_best_horizontal_match<'a>( - focused: &'a Bounds, - iter: impl Iterator)>, - ) -> Option { - let mut best = None; - - let focused_left = focused.left(); - let focused_right = focused.right(); - - for (index, probe) in iter { - if probe.origin.y == focused.origin.y { - continue; - } - - let overlap = probe.right().min(focused_right) - probe.left().max(focused_left); - - best = match best { - Some((_, prev_overlap, y)) if probe.origin.y != y || prev_overlap > overlap => { - break; - } - Some(_) | None => Some((index, overlap, probe.origin.y)), - }; - } - - best.map(|(index, _, _)| index) - } - - fn open_context(&mut self, context: &AgentContextHandle, window: &mut Window, cx: &mut App) { - let Some(workspace) = self.workspace.upgrade() else { - return; - }; - - match context { - AgentContextHandle::File(file_context) => { - if let Some(project_path) = file_context.project_path(cx) { - workspace.update(cx, |workspace, cx| { - workspace - .open_path(project_path, None, true, window, cx) - .detach_and_log_err(cx); - }); - } - } - - AgentContextHandle::Directory(directory_context) => { - let entry_id = directory_context.entry_id; - workspace.update(cx, |workspace, cx| { - workspace.project().update(cx, |_project, cx| { - cx.emit(project::Event::RevealInProjectPanel(entry_id)); - }) - }) - } - - AgentContextHandle::Symbol(symbol_context) => { - let buffer = symbol_context.buffer.read(cx); - if let Some(project_path) = buffer.project_path(cx) { - let snapshot = buffer.snapshot(); - let target_position = symbol_context.range.start.to_point(&snapshot); - open_editor_at_position(project_path, target_position, &workspace, window, cx) - .detach(); - } - } - - AgentContextHandle::Selection(selection_context) => { - let buffer = selection_context.buffer.read(cx); - if let Some(project_path) = buffer.project_path(cx) { - let snapshot = buffer.snapshot(); - let target_position = selection_context.range.start.to_point(&snapshot); - - open_editor_at_position(project_path, target_position, &workspace, window, cx) - .detach(); - } - } - - AgentContextHandle::FetchedUrl(fetched_url_context) => { - cx.open_url(&fetched_url_context.url); - } - - AgentContextHandle::Thread(_thread_context) => {} - - AgentContextHandle::TextThread(text_thread_context) => { - workspace.update(cx, |workspace, cx| { - if let Some(panel) = workspace.panel::(cx) { - let context = text_thread_context.text_thread.clone(); - window.defer(cx, move |window, cx| { - panel.update(cx, |panel, cx| { - panel.open_text_thread(context, window, cx) - }); - }); - } - }) - } - - AgentContextHandle::Rules(rules_context) => window.dispatch_action( - Box::new(OpenRulesLibrary { - prompt_to_select: Some(rules_context.prompt_id.0), - }), - cx, - ), - - AgentContextHandle::Image(_) => {} - } - } - - fn remove_focused_context( - &mut self, - _: &RemoveFocusedContext, - _window: &mut Window, - cx: &mut Context, - ) { - if let Some(index) = self.focused_index { - let added_contexts = self.added_contexts(cx); - let Some(context) = added_contexts.get(index) else { - return; - }; - - self.context_store.update(cx, |this, cx| { - this.remove_context(&context.handle, cx); - }); - - let is_now_empty = added_contexts.len() == 1; - if is_now_empty { - cx.emit(ContextStripEvent::BlurredEmpty); - } else { - self.focused_index = Some(index.saturating_sub(1)); - cx.notify(); - } - } - } - - fn is_suggested_focused(&self, added_contexts: &Vec) -> bool { - // We only suggest one item after the actual context - self.focused_index == Some(added_contexts.len()) - } - - fn accept_suggested_context( - &mut self, - _: &AcceptSuggestedContext, - _window: &mut Window, - cx: &mut Context, - ) { - if let Some(suggested) = self.suggested_context(cx) - && self.is_suggested_focused(&self.added_contexts(cx)) - { - self.add_suggested_context(&suggested, cx); - } - } - - fn add_suggested_context(&mut self, suggested: &SuggestedContext, cx: &mut Context) { - self.context_store.update(cx, |context_store, cx| { - context_store.add_suggested_context(suggested, cx) - }); - cx.notify(); - } -} - -impl Focusable for ContextStrip { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for ContextStrip { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let context_picker = self.context_picker.clone(); - let focus_handle = self.focus_handle.clone(); - - let added_contexts = self.added_contexts(cx); - let dupe_names = added_contexts - .iter() - .map(|c| c.name.clone()) - .sorted() - .tuple_windows() - .filter(|(a, b)| a == b) - .map(|(a, _)| a) - .collect::>(); - let no_added_context = added_contexts.is_empty(); - - let suggested_context = self.suggested_context(cx).map(|suggested_context| { - ( - suggested_context, - self.is_suggested_focused(&added_contexts), - ) - }); - - h_flex() - .flex_wrap() - .gap_1() - .track_focus(&focus_handle) - .key_context("ContextStrip") - .on_action(cx.listener(Self::focus_up)) - .on_action(cx.listener(Self::focus_right)) - .on_action(cx.listener(Self::focus_down)) - .on_action(cx.listener(Self::focus_left)) - .on_action(cx.listener(Self::remove_focused_context)) - .on_action(cx.listener(Self::accept_suggested_context)) - .on_children_prepainted({ - let entity = cx.entity().downgrade(); - move |children_bounds, _window, cx| { - entity - .update(cx, |this, _| { - this.children_bounds = Some(children_bounds); - }) - .ok(); - } - }) - .child( - PopoverMenu::new("context-picker") - .menu({ - let context_picker = context_picker.clone(); - move |window, cx| { - context_picker.update(cx, |this, cx| { - this.init(window, cx); - }); - - Some(context_picker.clone()) - } - }) - .on_open({ - let context_picker = context_picker.downgrade(); - Rc::new(move |window, cx| { - context_picker - .update(cx, |context_picker, cx| { - context_picker.select_first(window, cx); - }) - .ok(); - }) - }) - .trigger_with_tooltip( - IconButton::new("add-context", IconName::Plus) - .icon_size(IconSize::Small) - .style(ui::ButtonStyle::Filled), - { - let focus_handle = focus_handle.clone(); - move |_window, cx| { - Tooltip::for_action_in( - "Add Context", - &ToggleContextPicker, - &focus_handle, - cx, - ) - } - }, - ) - .attach(gpui::Corner::TopLeft) - .anchor(gpui::Corner::BottomLeft) - .offset(gpui::Point { - x: px(0.0), - y: px(-2.0), - }) - .with_handle(self.context_picker_menu_handle.clone()), - ) - .children( - added_contexts - .into_iter() - .enumerate() - .map(|(i, added_context)| { - let name = added_context.name.clone(); - let context = added_context.handle.clone(); - ContextPill::added( - added_context, - dupe_names.contains(&name), - self.focused_index == Some(i), - Some({ - let context = context.clone(); - let context_store = self.context_store.clone(); - Rc::new(cx.listener(move |_this, _event, _window, cx| { - context_store.update(cx, |this, cx| { - this.remove_context(&context, cx); - }); - cx.notify(); - })) - }), - ) - .on_click({ - Rc::new(cx.listener(move |this, event: &ClickEvent, window, cx| { - if event.click_count() > 1 { - this.open_context(&context, window, cx); - } else { - this.focused_index = Some(i); - } - cx.notify(); - })) - }) - }), - ) - .when_some(suggested_context, |el, (suggested, focused)| { - el.child( - ContextPill::suggested( - suggested.name().clone(), - suggested.icon_path(), - suggested.kind(), - focused, - ) - .on_click(Rc::new(cx.listener( - move |this, _event, _window, cx| { - this.add_suggested_context(&suggested, cx); - }, - ))), - ) - }) - .when(!no_added_context, { - move |parent| { - parent.child( - IconButton::new("remove-all-context", IconName::Eraser) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |_window, cx| { - Tooltip::for_action_in( - "Remove All Context", - &RemoveAllContext, - &focus_handle, - cx, - ) - } - }) - .on_click(cx.listener({ - let focus_handle = focus_handle.clone(); - move |_this, _event, window, cx| { - focus_handle.dispatch_action(&RemoveAllContext, window, cx); - } - })), - ) - } - }) - .into_any() - } -} - -pub enum ContextStripEvent { - PickerDismissed, - BlurredEmpty, - BlurredDown, - BlurredUp, -} - -impl EventEmitter for ContextStrip {} - -pub enum SuggestContextKind { - Thread, -} - -fn open_editor_at_position( - project_path: project::ProjectPath, - target_position: Point, - workspace: &Entity, - window: &mut Window, - cx: &mut App, -) -> Task<()> { - let open_task = workspace.update(cx, |workspace, cx| { - workspace.open_path(project_path, None, true, window, cx) - }); - window.spawn(cx, async move |cx| { - if let Some(active_editor) = open_task - .await - .log_err() - .and_then(|item| item.downcast::()) - { - active_editor - .downgrade() - .update_in(cx, |editor, window, cx| { - editor.go_to_singleton_buffer_point(target_position, window, cx); - }) - .log_err(); - } - }) -} diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index 17a6a8e022f322575cabab728bd512d68754f4df..f822c79f2589c757173bcd2699ef6abf2ac51027 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -4,10 +4,11 @@ use std::ops::Range; use std::rc::Rc; use std::sync::Arc; +use crate::context::load_context; +use crate::mention_set::MentionSet; use crate::{ AgentPanel, buffer_codegen::{BufferCodegen, CodegenAlternative, CodegenEvent}, - context_store::ContextStore, inline_prompt_editor::{CodegenStatus, InlineAssistId, PromptEditor, PromptEditorEvent}, terminal_inline_assistant::TerminalInlineAssistant, }; @@ -31,6 +32,7 @@ use editor::{ }, }; use fs::Fs; +use futures::FutureExt; use gpui::{ App, Context, Entity, Focusable, Global, HighlightStyle, Subscription, Task, UpdateGlobal, WeakEntity, Window, point, @@ -214,16 +216,10 @@ impl InlineAssistant { if let Some(editor) = item.act_as::(cx) { editor.update(cx, |editor, cx| { if is_ai_enabled { - let panel = workspace.read(cx).panel::(cx); - let thread_store = panel - .as_ref() - .map(|agent_panel| agent_panel.read(cx).thread_store().downgrade()); - editor.add_code_action_provider( Rc::new(AssistantCodeActionProvider { editor: cx.entity().downgrade(), workspace: workspace.downgrade(), - thread_store, }), window, cx, @@ -235,9 +231,6 @@ impl InlineAssistant { editor.cancel(&Default::default(), window, cx); } } - - // Remove the Assistant1 code action provider, as it still might be registered. - editor.remove_code_action_provider("assistant".into(), window, cx); } else { editor.remove_code_action_provider( ASSISTANT_CODE_ACTION_PROVIDER_ID.into(), @@ -279,8 +272,7 @@ impl InlineAssistant { let agent_panel = agent_panel.read(cx); let prompt_store = agent_panel.prompt_store().as_ref().cloned(); - let thread_store = Some(agent_panel.thread_store().downgrade()); - let context_store = agent_panel.inline_assist_context_store().clone(); + let thread_store = agent_panel.thread_store().clone(); let handle_assist = |window: &mut Window, cx: &mut Context| match inline_assist_target { @@ -289,10 +281,9 @@ impl InlineAssistant { assistant.assist( &active_editor, cx.entity().downgrade(), - context_store, workspace.project().downgrade(), - prompt_store, thread_store, + prompt_store, action.prompt.clone(), window, cx, @@ -305,8 +296,8 @@ impl InlineAssistant { &active_terminal, cx.entity().downgrade(), workspace.project().downgrade(), - prompt_store, thread_store, + prompt_store, action.prompt.clone(), window, cx, @@ -477,10 +468,9 @@ impl InlineAssistant { &mut self, editor: &Entity, workspace: WeakEntity, - context_store: Entity, project: WeakEntity, + thread_store: Entity, prompt_store: Option>, - thread_store: Option>, initial_prompt: Option, window: &mut Window, codegen_ranges: &[Range], @@ -508,9 +498,6 @@ impl InlineAssistant { editor.read(cx).buffer().clone(), range.clone(), initial_transaction_id, - context_store.clone(), - project.clone(), - prompt_store.clone(), self.telemetry.clone(), self.prompt_builder.clone(), cx, @@ -526,10 +513,10 @@ impl InlineAssistant { prompt_buffer.clone(), codegen.clone(), self.fs.clone(), - context_store.clone(), - workspace.clone(), thread_store.clone(), - prompt_store.as_ref().map(|s| s.downgrade()), + prompt_store.clone(), + project.clone(), + workspace.clone(), window, cx, ) @@ -606,10 +593,9 @@ impl InlineAssistant { &mut self, editor: &Entity, workspace: WeakEntity, - context_store: Entity, project: WeakEntity, + thread_store: Entity, prompt_store: Option>, - thread_store: Option>, initial_prompt: Option, window: &mut Window, cx: &mut App, @@ -625,10 +611,9 @@ impl InlineAssistant { let assist_to_focus = self.batch_assist( editor, workspace, - context_store, project, - prompt_store, thread_store, + prompt_store, initial_prompt, window, &codegen_ranges, @@ -650,8 +635,8 @@ impl InlineAssistant { initial_transaction_id: Option, focus: bool, workspace: Entity, + thread_store: Entity, prompt_store: Option>, - thread_store: Option>, window: &mut Window, cx: &mut App, ) -> InlineAssistId { @@ -663,16 +648,14 @@ impl InlineAssistant { } let project = workspace.read(cx).project().downgrade(); - let context_store = cx.new(|_cx| ContextStore::new(project.clone())); let assist_id = self .batch_assist( editor, workspace.downgrade(), - context_store, project, - prompt_store, thread_store, + prompt_store, Some(initial_prompt), window, &[range], @@ -1294,7 +1277,8 @@ impl InlineAssistant { return; } - let Some(user_prompt) = assist.user_prompt(cx) else { + let Some((user_prompt, mention_set)) = assist.user_prompt(cx).zip(assist.mention_set(cx)) + else { return; }; @@ -1310,9 +1294,12 @@ impl InlineAssistant { return; }; + let context_task = load_context(&mention_set, cx).shared(); assist .codegen - .update(cx, |codegen, cx| codegen.start(model, user_prompt, cx)) + .update(cx, |codegen, cx| { + codegen.start(model, user_prompt, context_task, cx) + }) .log_err(); } @@ -1778,6 +1765,11 @@ impl InlineAssist { let decorations = self.decorations.as_ref()?; Some(decorations.prompt_editor.read(cx).prompt(cx)) } + + fn mention_set(&self, cx: &App) -> Option> { + let decorations = self.decorations.as_ref()?; + Some(decorations.prompt_editor.read(cx).mention_set().clone()) + } } struct InlineAssistDecorations { @@ -1790,10 +1782,9 @@ struct InlineAssistDecorations { struct AssistantCodeActionProvider { editor: WeakEntity, workspace: WeakEntity, - thread_store: Option>, } -const ASSISTANT_CODE_ACTION_PROVIDER_ID: &str = "assistant2"; +const ASSISTANT_CODE_ACTION_PROVIDER_ID: &str = "assistant"; impl CodeActionProvider for AssistantCodeActionProvider { fn id(&self) -> Arc { @@ -1861,10 +1852,20 @@ impl CodeActionProvider for AssistantCodeActionProvider { ) -> Task> { let editor = self.editor.clone(); let workspace = self.workspace.clone(); - let thread_store = self.thread_store.clone(); let prompt_store = PromptStore::global(cx); window.spawn(cx, async move |cx| { let workspace = workspace.upgrade().context("workspace was released")?; + let thread_store = cx.update(|_window, cx| { + anyhow::Ok( + workspace + .read(cx) + .panel::(cx) + .context("missing agent panel")? + .read(cx) + .thread_store() + .clone(), + ) + })??; let editor = editor.upgrade().context("editor was released")?; let range = editor .update(cx, |editor, cx| { @@ -1907,8 +1908,8 @@ impl CodeActionProvider for AssistantCodeActionProvider { None, true, workspace, - prompt_store, thread_store, + prompt_store, window, cx, ); diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index 3a0866f47063a6dc5f68df1c36c3fdb0e07d2b74..7cd7a9d58a71effa18612234f9f718f794c99c06 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -1,19 +1,21 @@ use agent::HistoryStore; -use collections::{HashMap, VecDeque}; +use collections::VecDeque; use editor::actions::Paste; -use editor::display_map::{CreaseId, EditorMargins}; -use editor::{Addon, AnchorRangeExt as _, MultiBufferOffset}; +use editor::code_context_menus::CodeContextMenu; +use editor::display_map::EditorMargins; +use editor::{AnchorRangeExt as _, MultiBufferOffset, ToOffset as _}; use editor::{ ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, MultiBuffer, actions::{MoveDown, MoveUp}, }; use fs::Fs; use gpui::{ - AnyElement, App, ClipboardEntry, Context, CursorStyle, Entity, EventEmitter, FocusHandle, - Focusable, Subscription, TextStyle, WeakEntity, Window, + AnyElement, App, Context, CursorStyle, Entity, EventEmitter, FocusHandle, Focusable, + Subscription, TextStyle, WeakEntity, Window, }; use language_model::{LanguageModel, LanguageModelRegistry}; use parking_lot::Mutex; +use project::Project; use prompt_store::PromptStore; use settings::Settings; use std::cmp; @@ -28,22 +30,21 @@ use zed_actions::agent::ToggleModelSelector; use crate::agent_model_selector::AgentModelSelector; use crate::buffer_codegen::BufferCodegen; -use crate::context::{AgentContextHandle, AgentContextKey}; -use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider, crease_for_mention}; -use crate::context_store::{ContextStore, ContextStoreEvent}; -use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind}; -use crate::terminal_codegen::TerminalCodegen; -use crate::{ - CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext, RemoveAllContext, - ToggleContextPicker, +use crate::completion_provider::{ + PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextType, }; +use crate::mention_set::paste_images_as_context; +use crate::mention_set::{MentionSet, crease_for_mention}; +use crate::terminal_codegen::TerminalCodegen; +use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext}; pub struct PromptEditor { pub editor: Entity, mode: PromptEditorMode, - context_store: Entity, - context_strip: Entity, - context_picker_menu_handle: PopoverMenuHandle, + mention_set: Entity, + history_store: Entity, + prompt_store: Option>, + workspace: WeakEntity, model_selector: Entity, edited_since_done: bool, prompt_history: VecDeque, @@ -51,7 +52,6 @@ pub struct PromptEditor { pending_prompt: String, _codegen_subscription: Subscription, editor_subscriptions: Vec, - _context_strip_subscription: Subscription, show_rate_limit_notice: bool, _phantom: std::marker::PhantomData, } @@ -98,6 +98,19 @@ impl Render for PromptEditor { buttons.extend(self.render_buttons(window, cx)); + let menu_visible = self.is_completions_menu_visible(cx); + let add_context_button = IconButton::new("add-context", IconName::AtSign) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .when(!menu_visible, |this| { + this.tooltip(move |_window, cx| { + Tooltip::with_meta("Add Context", None, "Or type @ to include context", cx) + }) + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.trigger_completion_menu(window, cx); + })); + v_flex() .key_context("PromptEditor") .capture_action(cx.listener(Self::paste)) @@ -114,7 +127,6 @@ impl Render for PromptEditor { h_flex() .items_start() .cursor(CursorStyle::Arrow) - .on_action(cx.listener(Self::toggle_context_picker)) .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| { this.model_selector .update(cx, |model_selector, cx| model_selector.toggle(window, cx)); @@ -123,7 +135,6 @@ impl Render for PromptEditor { .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::move_up)) .on_action(cx.listener(Self::move_down)) - .on_action(cx.listener(Self::remove_all_context)) .capture_action(cx.listener(Self::cycle_prev)) .capture_action(cx.listener(Self::cycle_next)) .child( @@ -182,7 +193,7 @@ impl Render for PromptEditor { .pl_1() .items_start() .justify_between() - .child(self.context_strip.clone()) + .child(add_context_button) .child(self.model_selector.clone()), ), ) @@ -214,6 +225,18 @@ impl PromptEditor { )); } + fn assign_completion_provider(&mut self, cx: &mut Context) { + self.editor.update(cx, |editor, _cx| { + editor.set_completion_provider(Some(Rc::new(PromptCompletionProvider::new( + PromptEditorCompletionProviderDelegate, + self.mention_set.clone(), + self.history_store.clone(), + self.prompt_store.clone(), + self.workspace.clone(), + )))); + }); + } + pub fn set_show_cursor_when_unfocused( &mut self, show_cursor_when_unfocused: bool, @@ -226,27 +249,23 @@ impl PromptEditor { pub fn unlink(&mut self, window: &mut Window, cx: &mut Context) { let prompt = self.prompt(cx); - let existing_creases = self.editor.update(cx, extract_message_creases); - + let existing_creases = self.editor.update(cx, |editor, cx| { + extract_message_creases(editor, &self.mention_set, window, cx) + }); let focus = self.editor.focus_handle(cx).contains_focused(window, cx); self.editor = cx.new(|cx| { let mut editor = Editor::auto_height(1, Self::MAX_LINES as usize, window, cx); editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx); editor.set_placeholder_text("Add a prompt…", window, cx); editor.set_text(prompt, window, cx); - insert_message_creases( - &mut editor, - &existing_creases, - &self.context_store, - window, - cx, - ); + insert_message_creases(&mut editor, &existing_creases, window, cx); if focus { window.focus(&editor.focus_handle(cx)); } editor }); + self.assign_completion_provider(cx); self.subscribe_to_editor(window, cx); } @@ -274,32 +293,13 @@ impl PromptEditor { self.editor.read(cx).text(cx) } - fn paste(&mut self, _: &Paste, _window: &mut Window, cx: &mut Context) { - let images = cx - .read_from_clipboard() - .map(|item| { - item.into_entries() - .filter_map(|entry| { - if let ClipboardEntry::Image(image) = entry { - Some(image) - } else { - None - } - }) - .collect::>() - }) - .unwrap_or_default(); - - if images.is_empty() { - return; + fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { + if inline_assistant_model_supports_images(cx) + && let Some(task) = + paste_images_as_context(self.editor.clone(), self.mention_set.clone(), window, cx) + { + task.detach(); } - cx.stop_propagation(); - - self.context_store.update(cx, |store, cx| { - for image in images { - store.add_image_instance(Arc::new(image), cx); - } - }); } fn handle_prompt_editor_events( @@ -343,23 +343,44 @@ impl PromptEditor { } } - fn toggle_context_picker( - &mut self, - _: &ToggleContextPicker, - window: &mut Window, - cx: &mut Context, - ) { - self.context_picker_menu_handle.toggle(window, cx); + pub fn is_completions_menu_visible(&self, cx: &App) -> bool { + self.editor + .read(cx) + .context_menu() + .borrow() + .as_ref() + .is_some_and(|menu| matches!(menu, CodeContextMenu::Completions(_)) && menu.visible()) } - pub fn remove_all_context( - &mut self, - _: &RemoveAllContext, - _window: &mut Window, - cx: &mut Context, - ) { - self.context_store.update(cx, |store, cx| store.clear(cx)); - cx.notify(); + pub fn trigger_completion_menu(&mut self, window: &mut Window, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + let menu_is_open = editor.context_menu().borrow().as_ref().is_some_and(|menu| { + matches!(menu, CodeContextMenu::Completions(_)) && menu.visible() + }); + + let has_at_sign = { + let snapshot = editor.display_snapshot(cx); + let cursor = editor.selections.newest::(&snapshot).head(); + let offset = cursor.to_offset(&snapshot); + if offset.0 > 0 { + snapshot + .buffer_snapshot() + .reversed_chars_at(offset) + .next() + .map(|sign| sign == '@') + .unwrap_or(false) + } else { + false + } + }; + + if menu_is_open && has_at_sign { + return; + } + + editor.insert("@", window, cx); + editor.show_completions(&editor::actions::ShowCompletions, window, cx); + }); } fn cancel( @@ -434,8 +455,6 @@ impl PromptEditor { editor.move_to_end(&Default::default(), window, cx) }); } - } else if self.context_strip.read(cx).has_context_items(cx) { - self.context_strip.focus_handle(cx).focus(window); } } @@ -709,6 +728,7 @@ impl PromptEditor { EditorStyle { background: colors.editor_background, local_player: cx.theme().players().local(), + syntax: cx.theme().syntax().clone(), text: text_style, ..Default::default() }, @@ -716,21 +736,6 @@ impl PromptEditor { }) .into_any_element() } - - fn handle_context_strip_event( - &mut self, - _context_strip: &Entity, - event: &ContextStripEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - ContextStripEvent::PickerDismissed - | ContextStripEvent::BlurredEmpty - | ContextStripEvent::BlurredUp => self.editor.focus_handle(cx).focus(window), - ContextStripEvent::BlurredDown => {} - } - } } pub enum PromptEditorMode { @@ -765,6 +770,36 @@ impl InlineAssistId { } } +struct PromptEditorCompletionProviderDelegate; + +fn inline_assistant_model_supports_images(cx: &App) -> bool { + LanguageModelRegistry::read_global(cx) + .inline_assistant_model() + .map_or(false, |m| m.model.supports_images()) +} + +impl PromptCompletionProviderDelegate for PromptEditorCompletionProviderDelegate { + fn supported_modes(&self, _cx: &App) -> Vec { + vec![ + PromptContextType::File, + PromptContextType::Symbol, + PromptContextType::Thread, + PromptContextType::Fetch, + PromptContextType::Rules, + ] + } + + fn supports_images(&self, cx: &App) -> bool { + inline_assistant_model_supports_images(cx) + } + + fn available_commands(&self, _cx: &App) -> Vec { + Vec::new() + } + + fn confirm_command(&self, _cx: &mut App) {} +} + impl PromptEditor { pub fn new_buffer( id: InlineAssistId, @@ -773,15 +808,14 @@ impl PromptEditor { prompt_buffer: Entity, codegen: Entity, fs: Arc, - context_store: Entity, + history_store: Entity, + prompt_store: Option>, + project: WeakEntity, workspace: WeakEntity, - thread_store: Option>, - prompt_store: Option>, window: &mut Window, cx: &mut Context>, ) -> PromptEditor { let codegen_subscription = cx.observe(&codegen, Self::handle_codegen_changed); - let codegen_buffer = codegen.read(cx).buffer(cx).read(cx).as_singleton(); let mode = PromptEditorMode::Buffer { id, codegen, @@ -805,7 +839,6 @@ impl PromptEditor { // typing in one will make what you typed appear in all of them. editor.set_show_cursor_when_unfocused(true, cx); editor.set_placeholder_text(&Self::placeholder_text(&mode, window, cx), window, cx); - editor.register_addon(ContextCreasesAddon::new()); editor.set_context_menu_options(ContextMenuOptions { min_entries_visible: 12, max_entries_visible: 12, @@ -815,43 +848,25 @@ impl PromptEditor { editor }); - let prompt_editor_entity = prompt_editor.downgrade(); - prompt_editor.update(cx, |editor, _| { - editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new( - workspace.clone(), - context_store.downgrade(), - thread_store.clone(), + let mention_set = cx.new(|cx| { + MentionSet::new( + prompt_editor.clone(), + project, + history_store.clone(), prompt_store.clone(), - prompt_editor_entity, - codegen_buffer.as_ref().map(Entity::downgrade), - )))); - }); - - let context_picker_menu_handle = PopoverMenuHandle::default(); - let model_selector_menu_handle = PopoverMenuHandle::default(); - - let context_strip = cx.new(|cx| { - ContextStrip::new( - context_store.clone(), - workspace.clone(), - thread_store.clone(), - prompt_store, - context_picker_menu_handle.clone(), - SuggestContextKind::Thread, - ModelUsageContext::InlineAssistant, window, cx, ) }); - let context_strip_subscription = - cx.subscribe_in(&context_strip, window, Self::handle_context_strip_event); + let model_selector_menu_handle = PopoverMenuHandle::default(); let mut this: PromptEditor = PromptEditor { editor: prompt_editor.clone(), - context_store, - context_strip, - context_picker_menu_handle, + mention_set, + history_store, + prompt_store, + workspace, model_selector: cx.new(|cx| { AgentModelSelector::new( fs, @@ -868,12 +883,12 @@ impl PromptEditor { pending_prompt: String::new(), _codegen_subscription: codegen_subscription, editor_subscriptions: Vec::new(), - _context_strip_subscription: context_strip_subscription, show_rate_limit_notice: false, mode, _phantom: Default::default(), }; + this.assign_completion_provider(cx); this.subscribe_to_editor(window, cx); this } @@ -919,6 +934,10 @@ impl PromptEditor { } } + pub fn mention_set(&self) -> &Entity { + &self.mention_set + } + pub fn editor_margins(&self) -> &Arc> { match &self.mode { PromptEditorMode::Buffer { editor_margins, .. } => editor_margins, @@ -945,10 +964,10 @@ impl PromptEditor { prompt_buffer: Entity, codegen: Entity, fs: Arc, - context_store: Entity, + history_store: Entity, + prompt_store: Option>, + project: WeakEntity, workspace: WeakEntity, - thread_store: Option>, - prompt_store: Option>, window: &mut Window, cx: &mut Context, ) -> Self { @@ -980,43 +999,25 @@ impl PromptEditor { editor }); - let prompt_editor_entity = prompt_editor.downgrade(); - prompt_editor.update(cx, |editor, _| { - editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new( - workspace.clone(), - context_store.downgrade(), - thread_store.clone(), + let mention_set = cx.new(|cx| { + MentionSet::new( + prompt_editor.clone(), + project, + history_store.clone(), prompt_store.clone(), - prompt_editor_entity, - None, - )))); - }); - - let context_picker_menu_handle = PopoverMenuHandle::default(); - let model_selector_menu_handle = PopoverMenuHandle::default(); - - let context_strip = cx.new(|cx| { - ContextStrip::new( - context_store.clone(), - workspace.clone(), - thread_store.clone(), - prompt_store.clone(), - context_picker_menu_handle.clone(), - SuggestContextKind::Thread, - ModelUsageContext::InlineAssistant, window, cx, ) }); - let context_strip_subscription = - cx.subscribe_in(&context_strip, window, Self::handle_context_strip_event); + let model_selector_menu_handle = PopoverMenuHandle::default(); let mut this = Self { editor: prompt_editor.clone(), - context_store, - context_strip, - context_picker_menu_handle, + mention_set, + history_store, + prompt_store, + workspace, model_selector: cx.new(|cx| { AgentModelSelector::new( fs, @@ -1033,12 +1034,12 @@ impl PromptEditor { pending_prompt: String::new(), _codegen_subscription: codegen_subscription, editor_subscriptions: Vec::new(), - _context_strip_subscription: context_strip_subscription, mode, show_rate_limit_notice: false, _phantom: Default::default(), }; this.count_lines(cx); + this.assign_completion_provider(cx); this.subscribe_to_editor(window, cx); this } @@ -1085,6 +1086,10 @@ impl PromptEditor { } } + pub fn mention_set(&self) -> &Entity { + &self.mention_set + } + pub fn codegen(&self) -> &Entity { match &self.mode { PromptEditorMode::Buffer { .. } => unreachable!(), @@ -1164,128 +1169,38 @@ impl GenerationMode { /// Stored information that can be used to resurrect a context crease when creating an editor for a past message. #[derive(Clone, Debug)] -pub struct MessageCrease { - pub range: Range, - pub icon_path: SharedString, - pub label: SharedString, - /// None for a deserialized message, Some otherwise. - pub context: Option, -} - -#[derive(Default)] -pub struct ContextCreasesAddon { - creases: HashMap>, - _subscription: Option, +struct MessageCrease { + range: Range, + icon_path: SharedString, + label: SharedString, } -impl Addon for ContextCreasesAddon { - fn to_any(&self) -> &dyn std::any::Any { - self - } - - fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> { - Some(self) - } -} - -impl ContextCreasesAddon { - pub fn new() -> Self { - Self { - creases: HashMap::default(), - _subscription: None, - } - } - - pub fn add_creases( - &mut self, - context_store: &Entity, - key: AgentContextKey, - creases: impl IntoIterator, - cx: &mut Context, - ) { - self.creases.entry(key).or_default().extend(creases); - self._subscription = Some( - cx.subscribe(context_store, |editor, _, event, cx| match event { - ContextStoreEvent::ContextRemoved(key) => { - let Some(this) = editor.addon_mut::() else { - return; - }; - let (crease_ids, replacement_texts): (Vec<_>, Vec<_>) = this - .creases - .remove(key) - .unwrap_or_default() - .into_iter() - .unzip(); - let ranges = editor - .remove_creases(crease_ids, cx) - .into_iter() - .map(|(_, range)| range) - .collect::>(); - editor.unfold_ranges(&ranges, false, false, cx); - editor.edit(ranges.into_iter().zip(replacement_texts), cx); - cx.notify(); - } - }), - ) - } - - pub fn into_inner(self) -> HashMap> { - self.creases - } -} - -pub fn extract_message_creases( +fn extract_message_creases( editor: &mut Editor, + mention_set: &Entity, + window: &mut Window, cx: &mut Context<'_, Editor>, ) -> Vec { - let buffer_snapshot = editor.buffer().read(cx).snapshot(cx); - let mut contexts_by_crease_id = editor - .addon_mut::() - .map(std::mem::take) - .unwrap_or_default() - .into_inner() - .into_iter() - .flat_map(|(key, creases)| { - let context = key.0; - creases - .into_iter() - .map(move |(id, _)| (id, context.clone())) - }) - .collect::>(); - // Filter the addon's list of creases based on what the editor reports, - // since the addon might have removed creases in it. - - editor.display_map.update(cx, |display_map, cx| { - display_map - .snapshot(cx) - .crease_snapshot - .creases() - .filter_map(|(id, crease)| { - Some(( - id, - ( - crease.range().to_offset(&buffer_snapshot), - crease.metadata()?.clone(), - ), - )) + let creases = mention_set.read(cx).creases(); + let snapshot = editor.snapshot(window, cx); + snapshot + .crease_snapshot + .creases() + .filter(|(id, _)| creases.contains(id)) + .filter_map(|(_, crease)| { + let metadata = crease.metadata()?.clone(); + Some(MessageCrease { + range: crease.range().to_offset(snapshot.buffer()), + label: metadata.label, + icon_path: metadata.icon_path, }) - .map(|(id, (range, metadata))| { - let context = contexts_by_crease_id.remove(&id); - MessageCrease { - range, - context, - label: metadata.label, - icon_path: metadata.icon_path, - } - }) - .collect() - }) + }) + .collect() } -pub fn insert_message_creases( +fn insert_message_creases( editor: &mut Editor, message_creases: &[MessageCrease], - context_store: &Entity, window: &mut Window, cx: &mut Context<'_, Editor>, ) { @@ -1303,14 +1218,6 @@ pub fn insert_message_creases( ) }) .collect::>(); - let ids = editor.insert_creases(creases.clone(), cx); + editor.insert_creases(creases.clone(), cx); editor.fold_creases(creases, false, window, cx); - if let Some(addon) = editor.addon_mut::() { - for (crease, id) in message_creases.iter().zip(ids) { - if let Some(context) = crease.context.as_ref() { - let key = AgentContextKey(context.clone()); - addon.add_creases(context_store, key, vec![(id, crease.label.clone())], cx); - } - } - } } diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs new file mode 100644 index 0000000000000000000000000000000000000000..156e62949ae425532dcb897754928011ed2bd8a6 --- /dev/null +++ b/crates/agent_ui/src/mention_set.rs @@ -0,0 +1,1112 @@ +use acp_thread::{MentionUri, selection_name}; +use agent::{HistoryStore, outline}; +use agent_client_protocol as acp; +use agent_servers::{AgentServer, AgentServerDelegate}; +use anyhow::{Context as _, Result, anyhow}; +use assistant_slash_commands::codeblock_fence_for_path; +use collections::{HashMap, HashSet}; +use editor::{ + Anchor, Editor, EditorEvent, EditorSnapshot, ExcerptId, FoldPlaceholder, ToOffset, + display_map::{Crease, CreaseId, CreaseMetadata, FoldId}, + scroll::Autoscroll, +}; +use futures::{AsyncReadExt as _, FutureExt as _, future::Shared}; +use gpui::{ + Animation, AnimationExt as _, AppContext, ClipboardEntry, Context, Empty, Entity, EntityId, + Image, ImageFormat, Img, SharedString, Subscription, Task, WeakEntity, pulsating_between, +}; +use http_client::{AsyncBody, HttpClientWithUrl}; +use itertools::Either; +use language::Buffer; +use language_model::LanguageModelImage; +use multi_buffer::MultiBufferRow; +use postage::stream::Stream as _; +use project::{Project, ProjectItem, ProjectPath, Worktree}; +use prompt_store::{PromptId, PromptStore}; +use rope::Point; +use std::{ + cell::RefCell, + ffi::OsStr, + fmt::Write, + ops::{Range, RangeInclusive}, + path::{Path, PathBuf}, + rc::Rc, + sync::Arc, + time::Duration, +}; +use text::OffsetRangeExt; +use ui::{ButtonLike, Disclosure, TintColor, Toggleable, prelude::*}; +use util::{ResultExt, debug_panic, rel_path::RelPath}; +use workspace::{Workspace, notifications::NotifyResultExt as _}; + +pub type MentionTask = Shared>>; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum Mention { + Text { + content: String, + tracked_buffers: Vec>, + }, + Image(MentionImage), + Link, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MentionImage { + pub data: SharedString, + pub format: ImageFormat, +} + +pub struct MentionSet { + editor: Entity, + project: WeakEntity, + history_store: Entity, + prompt_store: Option>, + mentions: HashMap, + _editor_subscription: Subscription, +} + +impl MentionSet { + pub fn new( + editor: Entity, + project: WeakEntity, + history_store: Entity, + prompt_store: Option>, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let editor_subscription = + cx.subscribe_in(&editor, window, move |this, editor, event, window, cx| { + if let EditorEvent::Edited { .. } = event + && !editor.read(cx).read_only(cx) + { + let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx)); + this.remove_invalid(snapshot); + } + }); + + Self { + editor, + project, + history_store, + prompt_store, + mentions: HashMap::default(), + _editor_subscription: editor_subscription, + } + } + + pub fn contents( + &self, + full_mention_content: bool, + cx: &mut App, + ) -> Task>> { + let Some(project) = self.project.upgrade() else { + return Task::ready(Err(anyhow!("Project not found"))); + }; + let mentions = self.mentions.clone(); + cx.spawn(async move |cx| { + let mut contents = HashMap::default(); + for (crease_id, (mention_uri, task)) in mentions { + let content = if full_mention_content + && let MentionUri::Directory { abs_path } = &mention_uri + { + cx.update(|cx| full_mention_for_directory(&project, abs_path, cx))? + .await? + } else { + task.await.map_err(|e| anyhow!("{e}"))? + }; + + contents.insert(crease_id, (mention_uri, content)); + } + Ok(contents) + }) + } + + fn remove_invalid(&mut self, snapshot: EditorSnapshot) { + for (crease_id, crease) in snapshot.crease_snapshot.creases() { + if !crease.range().start.is_valid(&snapshot.buffer_snapshot()) { + self.mentions.remove(&crease_id); + } + } + } + + pub fn insert_mention(&mut self, crease_id: CreaseId, uri: MentionUri, task: MentionTask) { + self.mentions.insert(crease_id, (uri, task)); + } + + pub fn remove_mention(&mut self, crease_id: &CreaseId) { + self.mentions.remove(crease_id); + } + + pub fn creases(&self) -> HashSet { + self.mentions.keys().cloned().collect() + } + + pub fn mentions(&self) -> HashSet { + self.mentions.values().map(|(uri, _)| uri.clone()).collect() + } + + pub fn remove_all(&mut self) -> impl Iterator { + self.mentions.drain() + } + + pub fn confirm_mention_completion( + &mut self, + crease_text: SharedString, + start: text::Anchor, + content_len: usize, + mention_uri: MentionUri, + supports_images: bool, + workspace: &Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task<()> { + let Some(project) = self.project.upgrade() else { + return Task::ready(()); + }; + + let snapshot = self + .editor + .update(cx, |editor, cx| editor.snapshot(window, cx)); + let Some(start_anchor) = snapshot.buffer_snapshot().as_singleton_anchor(start) else { + return Task::ready(()); + }; + let excerpt_id = start_anchor.excerpt_id; + let end_anchor = snapshot.buffer_snapshot().anchor_before( + start_anchor.to_offset(&snapshot.buffer_snapshot()) + content_len + 1usize, + ); + + let crease = if let MentionUri::File { abs_path } = &mention_uri + && let Some(extension) = abs_path.extension() + && let Some(extension) = extension.to_str() + && Img::extensions().contains(&extension) + && !extension.contains("svg") + { + let Some(project_path) = project + .read(cx) + .project_path_for_absolute_path(&abs_path, cx) + else { + log::error!("project path not found"); + return Task::ready(()); + }; + let image_task = project.update(cx, |project, cx| project.open_image(project_path, cx)); + let image = cx + .spawn(async move |_, cx| { + let image = image_task.await.map_err(|e| e.to_string())?; + let image = image + .update(cx, |image, _| image.image.clone()) + .map_err(|e| e.to_string())?; + Ok(image) + }) + .shared(); + insert_crease_for_mention( + excerpt_id, + start, + content_len, + mention_uri.name().into(), + IconName::Image.path().into(), + Some(image), + self.editor.clone(), + window, + cx, + ) + } else { + insert_crease_for_mention( + excerpt_id, + start, + content_len, + crease_text, + mention_uri.icon_path(cx), + None, + self.editor.clone(), + window, + cx, + ) + }; + let Some((crease_id, tx)) = crease else { + return Task::ready(()); + }; + + let task = match mention_uri.clone() { + MentionUri::Fetch { url } => { + self.confirm_mention_for_fetch(url, workspace.read(cx).client().http_client(), cx) + } + MentionUri::Directory { .. } => Task::ready(Ok(Mention::Link)), + MentionUri::Thread { id, .. } => self.confirm_mention_for_thread(id, cx), + MentionUri::TextThread { path, .. } => self.confirm_mention_for_text_thread(path, cx), + MentionUri::File { abs_path } => { + self.confirm_mention_for_file(abs_path, supports_images, cx) + } + MentionUri::Symbol { + abs_path, + line_range, + .. + } => self.confirm_mention_for_symbol(abs_path, line_range, cx), + MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx), + MentionUri::PastedImage => { + debug_panic!("pasted image URI should not be included in completions"); + Task::ready(Err(anyhow!( + "pasted imaged URI should not be included in completions" + ))) + } + MentionUri::Selection { .. } => { + debug_panic!("unexpected selection URI"); + Task::ready(Err(anyhow!("unexpected selection URI"))) + } + }; + let task = cx + .spawn(async move |_, _| task.await.map_err(|e| e.to_string())) + .shared(); + self.mentions.insert(crease_id, (mention_uri, task.clone())); + + // Notify the user if we failed to load the mentioned context + cx.spawn_in(window, async move |this, cx| { + let result = task.await.notify_async_err(cx); + drop(tx); + if result.is_none() { + this.update(cx, |this, cx| { + this.editor.update(cx, |editor, cx| { + // Remove mention + editor.edit([(start_anchor..end_anchor, "")], cx); + }); + this.mentions.remove(&crease_id); + }) + .ok(); + } + }) + } + + pub fn confirm_mention_for_file( + &self, + abs_path: PathBuf, + supports_images: bool, + cx: &mut Context, + ) -> Task> { + let Some(project) = self.project.upgrade() else { + return Task::ready(Err(anyhow!("project not found"))); + }; + + let Some(project_path) = project + .read(cx) + .project_path_for_absolute_path(&abs_path, cx) + else { + return Task::ready(Err(anyhow!("project path not found"))); + }; + let extension = abs_path + .extension() + .and_then(OsStr::to_str) + .unwrap_or_default(); + + if Img::extensions().contains(&extension) && !extension.contains("svg") { + if !supports_images { + return Task::ready(Err(anyhow!("This model does not support images yet"))); + } + let task = project.update(cx, |project, cx| project.open_image(project_path, cx)); + return cx.spawn(async move |_, cx| { + let image = task.await?; + let image = image.update(cx, |image, _| image.image.clone())?; + let format = image.format; + let image = cx + .update(|cx| LanguageModelImage::from_image(image, cx))? + .await; + if let Some(image) = image { + Ok(Mention::Image(MentionImage { + data: image.source, + format, + })) + } else { + Err(anyhow!("Failed to convert image")) + } + }); + } + + let buffer = project.update(cx, |project, cx| project.open_buffer(project_path, cx)); + cx.spawn(async move |_, cx| { + let buffer = buffer.await?; + let buffer_content = outline::get_buffer_content_or_outline( + buffer.clone(), + Some(&abs_path.to_string_lossy()), + &cx, + ) + .await?; + + Ok(Mention::Text { + content: buffer_content.text, + tracked_buffers: vec![buffer], + }) + }) + } + + fn confirm_mention_for_fetch( + &self, + url: url::Url, + http_client: Arc, + cx: &mut Context, + ) -> Task> { + cx.background_executor().spawn(async move { + let content = fetch_url_content(http_client, url.to_string()).await?; + Ok(Mention::Text { + content, + tracked_buffers: Vec::new(), + }) + }) + } + + fn confirm_mention_for_symbol( + &self, + abs_path: PathBuf, + line_range: RangeInclusive, + cx: &mut Context, + ) -> Task> { + let Some(project) = self.project.upgrade() else { + return Task::ready(Err(anyhow!("project not found"))); + }; + let Some(project_path) = project + .read(cx) + .project_path_for_absolute_path(&abs_path, cx) + else { + return Task::ready(Err(anyhow!("project path not found"))); + }; + let buffer = project.update(cx, |project, cx| project.open_buffer(project_path, cx)); + cx.spawn(async move |_, cx| { + let buffer = buffer.await?; + let mention = buffer.update(cx, |buffer, cx| { + let start = Point::new(*line_range.start(), 0).min(buffer.max_point()); + let end = Point::new(*line_range.end() + 1, 0).min(buffer.max_point()); + let content = buffer.text_for_range(start..end).collect(); + Mention::Text { + content, + tracked_buffers: vec![cx.entity()], + } + })?; + anyhow::Ok(mention) + }) + } + + fn confirm_mention_for_rule( + &mut self, + id: PromptId, + cx: &mut Context, + ) -> Task> { + let Some(prompt_store) = self.prompt_store.as_ref() else { + return Task::ready(Err(anyhow!("Missing prompt store"))); + }; + let prompt = prompt_store.read(cx).load(id, cx); + cx.spawn(async move |_, _| { + let prompt = prompt.await?; + Ok(Mention::Text { + content: prompt, + tracked_buffers: Vec::new(), + }) + }) + } + + pub fn confirm_mention_for_selection( + &mut self, + source_range: Range, + selections: Vec<(Entity, Range, Range)>, + window: &mut Window, + cx: &mut Context, + ) { + let Some(project) = self.project.upgrade() else { + return; + }; + + let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx); + let Some(start) = snapshot.as_singleton_anchor(source_range.start) else { + return; + }; + + let offset = start.to_offset(&snapshot); + + for (buffer, selection_range, range_to_fold) in selections { + let range = snapshot.anchor_after(offset + range_to_fold.start) + ..snapshot.anchor_after(offset + range_to_fold.end); + + let abs_path = buffer + .read(cx) + .project_path(cx) + .and_then(|project_path| project.read(cx).absolute_path(&project_path, cx)); + let snapshot = buffer.read(cx).snapshot(); + + let text = snapshot + .text_for_range(selection_range.clone()) + .collect::(); + let point_range = selection_range.to_point(&snapshot); + let line_range = point_range.start.row..=point_range.end.row; + + let uri = MentionUri::Selection { + abs_path: abs_path.clone(), + line_range: line_range.clone(), + }; + let crease = crease_for_mention( + selection_name(abs_path.as_deref(), &line_range).into(), + uri.icon_path(cx), + range, + self.editor.downgrade(), + ); + + let crease_id = self.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.first().copied().unwrap() + }); + + self.mentions.insert( + crease_id, + ( + uri, + Task::ready(Ok(Mention::Text { + content: text, + tracked_buffers: vec![buffer], + })) + .shared(), + ), + ); + } + + // Take this explanation with a grain of salt but, with creases being + // inserted, GPUI's recomputes the editor layout in the next frames, so + // directly calling `editor.request_autoscroll` wouldn't work as + // expected. We're leveraging `cx.on_next_frame` to wait 2 frames and + // ensure that the layout has been recalculated so that the autoscroll + // request actually shows the cursor's new position. + let editor = self.editor.clone(); + cx.on_next_frame(window, move |_, window, cx| { + cx.on_next_frame(window, move |_, _, cx| { + editor.update(cx, |editor, cx| { + editor.request_autoscroll(Autoscroll::fit(), cx) + }); + }); + }); + } + + fn confirm_mention_for_thread( + &mut self, + id: acp::SessionId, + cx: &mut Context, + ) -> Task> { + let Some(project) = self.project.upgrade() else { + return Task::ready(Err(anyhow!("project not found"))); + }; + + let server = Rc::new(agent::NativeAgentServer::new( + project.read(cx).fs().clone(), + self.history_store.clone(), + )); + let delegate = AgentServerDelegate::new( + project.read(cx).agent_server_store().clone(), + project.clone(), + None, + None, + ); + let connection = server.connect(None, delegate, cx); + cx.spawn(async move |_, cx| { + let (agent, _) = connection.await?; + let agent = agent.downcast::().unwrap(); + let summary = agent + .0 + .update(cx, |agent, cx| agent.thread_summary(id, cx))? + .await?; + anyhow::Ok(Mention::Text { + content: summary.to_string(), + tracked_buffers: Vec::new(), + }) + }) + } + + fn confirm_mention_for_text_thread( + &mut self, + path: PathBuf, + cx: &mut Context, + ) -> Task> { + let text_thread_task = self.history_store.update(cx, |store, cx| { + store.load_text_thread(path.as_path().into(), cx) + }); + cx.spawn(async move |_, cx| { + let text_thread = text_thread_task.await?; + let xml = text_thread.update(cx, |text_thread, cx| text_thread.to_xml(cx))?; + Ok(Mention::Text { + content: xml, + tracked_buffers: Vec::new(), + }) + }) + } +} + +pub(crate) fn paste_images_as_context( + editor: Entity, + mention_set: Entity, + window: &mut Window, + cx: &mut App, +) -> Option> { + let clipboard = cx.read_from_clipboard()?; + Some(window.spawn(cx, async move |cx| { + use itertools::Itertools; + let (mut images, paths) = clipboard + .into_entries() + .filter_map(|entry| match entry { + ClipboardEntry::Image(image) => Some(Either::Left(image)), + ClipboardEntry::ExternalPaths(paths) => Some(Either::Right(paths)), + _ => None, + }) + .partition_map::, Vec<_>, _, _, _>(std::convert::identity); + + if !paths.is_empty() { + images.extend( + cx.background_spawn(async move { + let mut images = vec![]; + for path in paths.into_iter().flat_map(|paths| paths.paths().to_owned()) { + let Ok(content) = async_fs::read(path).await else { + continue; + }; + let Ok(format) = image::guess_format(&content) else { + continue; + }; + images.push(gpui::Image::from_bytes( + match format { + image::ImageFormat::Png => gpui::ImageFormat::Png, + image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg, + image::ImageFormat::WebP => gpui::ImageFormat::Webp, + image::ImageFormat::Gif => gpui::ImageFormat::Gif, + image::ImageFormat::Bmp => gpui::ImageFormat::Bmp, + image::ImageFormat::Tiff => gpui::ImageFormat::Tiff, + image::ImageFormat::Ico => gpui::ImageFormat::Ico, + _ => continue, + }, + content, + )); + } + images + }) + .await, + ); + } + + if images.is_empty() { + return; + } + + let replacement_text = MentionUri::PastedImage.as_link().to_string(); + cx.update(|_window, cx| { + cx.stop_propagation(); + }) + .ok(); + for image in images { + let Ok((excerpt_id, text_anchor, multibuffer_anchor)) = + editor.update_in(cx, |message_editor, window, cx| { + let snapshot = message_editor.snapshot(window, cx); + let (excerpt_id, _, buffer_snapshot) = + snapshot.buffer_snapshot().as_singleton().unwrap(); + + let text_anchor = buffer_snapshot.anchor_before(buffer_snapshot.len()); + let multibuffer_anchor = snapshot + .buffer_snapshot() + .anchor_in_excerpt(*excerpt_id, text_anchor); + message_editor.edit( + [( + multi_buffer::Anchor::max()..multi_buffer::Anchor::max(), + format!("{replacement_text} "), + )], + cx, + ); + (*excerpt_id, text_anchor, multibuffer_anchor) + }) + else { + break; + }; + + let content_len = replacement_text.len(); + let Some(start_anchor) = multibuffer_anchor else { + continue; + }; + let Ok(end_anchor) = editor.update(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len) + }) else { + continue; + }; + let image = Arc::new(image); + let Ok(Some((crease_id, tx))) = cx.update(|window, cx| { + insert_crease_for_mention( + excerpt_id, + text_anchor, + content_len, + MentionUri::PastedImage.name().into(), + IconName::Image.path().into(), + Some(Task::ready(Ok(image.clone())).shared()), + editor.clone(), + window, + cx, + ) + }) else { + continue; + }; + let task = cx + .spawn(async move |cx| { + let format = image.format; + let image = cx + .update(|_, cx| LanguageModelImage::from_image(image, cx)) + .map_err(|e| e.to_string())? + .await; + drop(tx); + if let Some(image) = image { + Ok(Mention::Image(MentionImage { + data: image.source, + format, + })) + } else { + Err("Failed to convert image".into()) + } + }) + .shared(); + + mention_set + .update(cx, |mention_set, _cx| { + mention_set.insert_mention(crease_id, MentionUri::PastedImage, task.clone()) + }) + .ok(); + + if task.await.notify_async_err(cx).is_none() { + editor + .update(cx, |editor, cx| { + editor.edit([(start_anchor..end_anchor, "")], cx); + }) + .ok(); + mention_set + .update(cx, |mention_set, _cx| { + mention_set.remove_mention(&crease_id) + }) + .ok(); + } + } + })) +} + +pub(crate) fn insert_crease_for_mention( + excerpt_id: ExcerptId, + anchor: text::Anchor, + content_len: usize, + crease_label: SharedString, + crease_icon: SharedString, + // abs_path: Option>, + image: Option, String>>>>, + editor: Entity, + window: &mut Window, + cx: &mut App, +) -> Option<(CreaseId, postage::barrier::Sender)> { + let (tx, rx) = postage::barrier::channel(); + + let crease_id = editor.update(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + + let start = snapshot.anchor_in_excerpt(excerpt_id, anchor)?; + + let start = start.bias_right(&snapshot); + let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len); + + let placeholder = FoldPlaceholder { + render: render_mention_fold_button( + crease_label.clone(), + crease_icon.clone(), + start..end, + rx, + image, + cx.weak_entity(), + cx, + ), + merge_adjacent: false, + ..Default::default() + }; + + let crease = Crease::Inline { + range: start..end, + placeholder, + render_toggle: None, + render_trailer: None, + metadata: Some(CreaseMetadata { + label: crease_label, + icon_path: crease_icon, + }), + }; + + let ids = editor.insert_creases(vec![crease.clone()], cx); + editor.fold_creases(vec![crease], false, window, cx); + + Some(ids[0]) + })?; + + Some((crease_id, tx)) +} + +pub(crate) fn crease_for_mention( + label: SharedString, + icon_path: SharedString, + range: Range, + editor_entity: WeakEntity, +) -> Crease { + let placeholder = FoldPlaceholder { + render: render_fold_icon_button(icon_path.clone(), label.clone(), editor_entity), + merge_adjacent: false, + ..Default::default() + }; + + let render_trailer = move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any(); + + Crease::inline(range, placeholder, fold_toggle("mention"), render_trailer) + .with_metadata(CreaseMetadata { icon_path, label }) +} + +fn render_fold_icon_button( + icon_path: SharedString, + label: SharedString, + editor: WeakEntity, +) -> Arc, &mut App) -> AnyElement> { + Arc::new({ + move |fold_id, fold_range, cx| { + let is_in_text_selection = editor + .update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx)) + .unwrap_or_default(); + + ButtonLike::new(fold_id) + .style(ButtonStyle::Filled) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .toggle_state(is_in_text_selection) + .child( + h_flex() + .gap_1() + .child( + Icon::from_path(icon_path.clone()) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child( + Label::new(label.clone()) + .size(LabelSize::Small) + .buffer_font(cx) + .single_line(), + ), + ) + .into_any_element() + } + }) +} + +fn fold_toggle( + name: &'static str, +) -> impl Fn( + MultiBufferRow, + bool, + Arc, + &mut Window, + &mut App, +) -> AnyElement { + move |row, is_folded, fold, _window, _cx| { + Disclosure::new((name, row.0 as u64), !is_folded) + .toggle_state(is_folded) + .on_click(move |_e, window, cx| fold(!is_folded, window, cx)) + .into_any_element() + } +} + +fn full_mention_for_directory( + project: &Entity, + abs_path: &Path, + cx: &mut App, +) -> Task> { + fn collect_files_in_path(worktree: &Worktree, path: &RelPath) -> Vec<(Arc, String)> { + let mut files = Vec::new(); + + for entry in worktree.child_entries(path) { + if entry.is_dir() { + files.extend(collect_files_in_path(worktree, &entry.path)); + } else if entry.is_file() { + files.push(( + entry.path.clone(), + worktree + .full_path(&entry.path) + .to_string_lossy() + .to_string(), + )); + } + } + + files + } + + let Some(project_path) = project + .read(cx) + .project_path_for_absolute_path(&abs_path, cx) + else { + return Task::ready(Err(anyhow!("project path not found"))); + }; + let Some(entry) = project.read(cx).entry_for_path(&project_path, cx) else { + return Task::ready(Err(anyhow!("project entry not found"))); + }; + let directory_path = entry.path.clone(); + let worktree_id = project_path.worktree_id; + let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else { + return Task::ready(Err(anyhow!("worktree not found"))); + }; + let project = project.clone(); + cx.spawn(async move |cx| { + let file_paths = worktree.read_with(cx, |worktree, _cx| { + collect_files_in_path(worktree, &directory_path) + })?; + let descendants_future = cx.update(|cx| { + futures::future::join_all(file_paths.into_iter().map(|(worktree_path, full_path)| { + let rel_path = worktree_path + .strip_prefix(&directory_path) + .log_err() + .map_or_else(|| worktree_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: worktree_path, + }; + buffer_store.open_buffer(project_path, cx) + }) + }); + + cx.spawn(async move |cx| { + let buffer = open_task.await.log_err()?; + let buffer_content = outline::get_buffer_content_or_outline( + buffer.clone(), + Some(&full_path), + &cx, + ) + .await + .ok()?; + + Some((rel_path, full_path, buffer_content.text, buffer)) + }) + })) + })?; + + let contents = cx + .background_spawn(async move { + let (contents, tracked_buffers) = descendants_future + .await + .into_iter() + .flatten() + .map(|(rel_path, full_path, rope, buffer)| { + ((rel_path, full_path, rope), buffer) + }) + .unzip(); + Mention::Text { + content: render_directory_contents(contents), + tracked_buffers, + } + }) + .await; + anyhow::Ok(contents) + }) +} + +fn render_directory_contents(entries: Vec<(Arc, String, String)>) -> String { + let mut output = String::new(); + for (_relative_path, full_path, content) in entries { + let fence = codeblock_fence_for_path(Some(&full_path), None); + write!(output, "\n{fence}\n{content}\n```").unwrap(); + } + output +} + +fn render_mention_fold_button( + label: SharedString, + icon: SharedString, + range: Range, + mut loading_finished: postage::barrier::Receiver, + image_task: Option, String>>>>, + editor: WeakEntity, + cx: &mut App, +) -> Arc, &mut App) -> AnyElement> { + let loading = cx.new(|cx| { + let loading = cx.spawn(async move |this, cx| { + loading_finished.recv().await; + this.update(cx, |this: &mut LoadingContext, cx| { + this.loading = None; + cx.notify(); + }) + .ok(); + }); + LoadingContext { + id: cx.entity_id(), + label, + icon, + range, + editor, + loading: Some(loading), + image: image_task.clone(), + } + }); + Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element()) +} + +struct LoadingContext { + id: EntityId, + label: SharedString, + icon: SharedString, + range: Range, + editor: WeakEntity, + loading: Option>, + image: Option, String>>>>, +} + +impl Render for LoadingContext { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_in_text_selection = self + .editor + .update(cx, |editor, cx| editor.is_range_selected(&self.range, cx)) + .unwrap_or_default(); + ButtonLike::new(("loading-context", self.id)) + .style(ButtonStyle::Filled) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .toggle_state(is_in_text_selection) + .when_some(self.image.clone(), |el, image_task| { + el.hoverable_tooltip(move |_, cx| { + let image = image_task.peek().cloned().transpose().ok().flatten(); + let image_task = image_task.clone(); + cx.new::(|cx| ImageHover { + image, + _task: cx.spawn(async move |this, cx| { + if let Ok(image) = image_task.clone().await { + this.update(cx, |this, cx| { + if this.image.replace(image).is_none() { + cx.notify(); + } + }) + .ok(); + } + }), + }) + .into() + }) + }) + .child( + h_flex() + .gap_1() + .child( + Icon::from_path(self.icon.clone()) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child( + Label::new(self.label.clone()) + .size(LabelSize::Small) + .buffer_font(cx) + .single_line(), + ) + .map(|el| { + if self.loading.is_some() { + el.with_animation( + "loading-context-crease", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.opacity(delta), + ) + .into_any() + } else { + el.into_any() + } + }), + ) + } +} + +struct ImageHover { + image: Option>, + _task: Task<()>, +} + +impl Render for ImageHover { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + if let Some(image) = self.image.clone() { + gpui::img(image).max_w_96().max_h_96().into_any_element() + } else { + gpui::Empty.into_any_element() + } + } +} + +async fn fetch_url_content(http_client: Arc, url: String) -> Result { + #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] + enum ContentType { + Html, + Plaintext, + Json, + } + use html_to_markdown::{TagHandler, convert_html_to_markdown, markdown}; + + let url = if !url.starts_with("https://") && !url.starts_with("http://") { + format!("https://{url}") + } else { + url + }; + + let mut response = http_client.get(&url, AsyncBody::default(), true).await?; + let mut body = Vec::new(); + response + .body_mut() + .read_to_end(&mut body) + .await + .context("error reading response body")?; + + if response.status().is_client_error() { + let text = String::from_utf8_lossy(body.as_slice()); + anyhow::bail!( + "status error {}, response: {text:?}", + response.status().as_u16() + ); + } + + let Some(content_type) = response.headers().get("content-type") else { + anyhow::bail!("missing Content-Type header"); + }; + let content_type = content_type + .to_str() + .context("invalid Content-Type header")?; + let content_type = match content_type { + "text/html" => ContentType::Html, + "text/plain" => ContentType::Plaintext, + "application/json" => ContentType::Json, + _ => ContentType::Html, + }; + + match content_type { + ContentType::Html => { + let mut handlers: Vec = vec![ + Rc::new(RefCell::new(markdown::WebpageChromeRemover)), + Rc::new(RefCell::new(markdown::ParagraphHandler)), + Rc::new(RefCell::new(markdown::HeadingHandler)), + Rc::new(RefCell::new(markdown::ListHandler)), + Rc::new(RefCell::new(markdown::TableHandler::new())), + Rc::new(RefCell::new(markdown::StyledTextHandler)), + ]; + if url.contains("wikipedia.org") { + use html_to_markdown::structure::wikipedia; + + handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover))); + handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler))); + handlers.push(Rc::new( + RefCell::new(wikipedia::WikipediaCodeHandler::new()), + )); + } else { + handlers.push(Rc::new(RefCell::new(markdown::CodeHandler))); + } + convert_html_to_markdown(&body[..], &mut handlers) + } + ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()), + ContentType::Json => { + let json: serde_json::Value = serde_json::from_slice(&body)?; + + Ok(format!( + "```json\n{}\n```", + serde_json::to_string_pretty(&json)? + )) + } + } +} diff --git a/crates/agent_ui/src/terminal_inline_assistant.rs b/crates/agent_ui/src/terminal_inline_assistant.rs index 9e653dcce1dcf1487af9998662b57ea4f998c7de..c6da11a35af22c4052cd580e58c896e19a1faf78 100644 --- a/crates/agent_ui/src/terminal_inline_assistant.rs +++ b/crates/agent_ui/src/terminal_inline_assistant.rs @@ -1,6 +1,5 @@ use crate::{ context::load_context, - context_store::ContextStore, inline_prompt_editor::{ CodegenStatus, PromptEditor, PromptEditorEvent, TerminalInlineAssistId, }, @@ -73,8 +72,8 @@ impl TerminalInlineAssistant { terminal_view: &Entity, workspace: WeakEntity, project: WeakEntity, + thread_store: Entity, prompt_store: Option>, - thread_store: Option>, initial_prompt: Option, window: &mut Window, cx: &mut App, @@ -87,7 +86,6 @@ impl TerminalInlineAssistant { cx, ) }); - let context_store = cx.new(|_cx| ContextStore::new(project)); let codegen = cx.new(|_| TerminalCodegen::new(terminal, self.telemetry.clone())); let prompt_editor = cx.new(|cx| { @@ -97,10 +95,10 @@ impl TerminalInlineAssistant { prompt_buffer.clone(), codegen, self.fs.clone(), - context_store.clone(), - workspace.clone(), thread_store.clone(), - prompt_store.as_ref().map(|s| s.downgrade()), + prompt_store.clone(), + project.clone(), + workspace.clone(), window, cx, ) @@ -119,8 +117,6 @@ impl TerminalInlineAssistant { terminal_view, prompt_editor, workspace.clone(), - context_store, - prompt_store, window, cx, ); @@ -227,6 +223,10 @@ impl TerminalInlineAssistant { assist_id: TerminalInlineAssistId, cx: &mut App, ) -> Result> { + let ConfiguredModel { model, .. } = LanguageModelRegistry::read_global(cx) + .inline_assistant_model() + .context("No inline assistant model")?; + let assist = self.assists.get(&assist_id).context("invalid assist")?; let shell = std::env::var("SHELL").ok(); @@ -243,35 +243,20 @@ impl TerminalInlineAssistant { .ok() .unwrap_or_default(); + let prompt_editor = assist.prompt_editor.clone().context("invalid assist")?; + let prompt = self.prompt_builder.generate_terminal_assistant_prompt( - &assist - .prompt_editor - .clone() - .context("invalid assist")? - .read(cx) - .prompt(cx), + &prompt_editor.read(cx).prompt(cx), shell.as_deref(), working_directory.as_deref(), &latest_output, )?; - let contexts = assist - .context_store - .read(cx) - .context() - .cloned() - .collect::>(); - let context_load_task = assist.workspace.update(cx, |workspace, cx| { - let project = workspace.project(); - load_context(contexts, project, &assist.prompt_store, cx) - })?; - - let ConfiguredModel { model, .. } = LanguageModelRegistry::read_global(cx) - .inline_assistant_model() - .context("No inline assistant model")?; - let temperature = AgentSettings::temperature_for_model(&model, cx); + let mention_set = prompt_editor.read(cx).mention_set().clone(); + let load_context_task = load_context(&mention_set, cx); + Ok(cx.background_spawn(async move { let mut request_message = LanguageModelRequestMessage { role: Role::User, @@ -279,9 +264,9 @@ impl TerminalInlineAssistant { cache: false, }; - context_load_task - .await - .add_to_request_message(&mut request_message); + if let Some(context) = load_context_task.await { + context.add_to_request_message(&mut request_message); + } request_message.content.push(prompt.into()); @@ -409,8 +394,6 @@ struct TerminalInlineAssist { prompt_editor: Option>>, codegen: Entity, workspace: WeakEntity, - context_store: Entity, - prompt_store: Option>, _subscriptions: Vec, } @@ -420,8 +403,6 @@ impl TerminalInlineAssist { terminal: &Entity, prompt_editor: Entity>, workspace: WeakEntity, - context_store: Entity, - prompt_store: Option>, window: &mut Window, cx: &mut App, ) -> Self { @@ -431,8 +412,6 @@ impl TerminalInlineAssist { prompt_editor: Some(prompt_editor.clone()), codegen: codegen.clone(), workspace, - context_store, - prompt_store, _subscriptions: vec![ window.subscribe(&prompt_editor, cx, |prompt_editor, event, window, cx| { TerminalInlineAssistant::update_global(cx, |this, cx| { diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs index f556f8eece8efef77f4a6c286fee032cbfcb42df..e604df416e2725a6f1b7bff8eed883a8cc36e184 100644 --- a/crates/agent_ui/src/ui.rs +++ b/crates/agent_ui/src/ui.rs @@ -2,7 +2,6 @@ mod acp_onboarding_modal; mod agent_notification; mod burn_mode_tooltip; mod claude_code_onboarding_modal; -mod context_pill; mod end_trial_upsell; mod hold_for_default; mod onboarding_modal; @@ -13,7 +12,6 @@ pub use acp_onboarding_modal::*; pub use agent_notification::*; pub use burn_mode_tooltip::*; pub use claude_code_onboarding_modal::*; -pub use context_pill::*; pub use end_trial_upsell::*; pub use hold_for_default::*; pub use onboarding_modal::*; diff --git a/crates/agent_ui/src/ui/context_pill.rs b/crates/agent_ui/src/ui/context_pill.rs deleted file mode 100644 index 89bf618a16d3fb8e7abc5afaf34ee6e8bb43ab67..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/ui/context_pill.rs +++ /dev/null @@ -1,858 +0,0 @@ -use std::{ops::Range, path::Path, rc::Rc, sync::Arc, time::Duration}; - -use file_icons::FileIcons; -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 rope::Point; -use ui::{IconButtonShape, Tooltip, prelude::*, tooltip_container}; -use util::paths::PathStyle; - -use crate::context::{ - AgentContextHandle, ContextId, ContextKind, DirectoryContextHandle, FetchedUrlContext, - FileContextHandle, ImageContext, ImageStatus, RulesContextHandle, SelectionContextHandle, - SymbolContextHandle, TextThreadContextHandle, ThreadContextHandle, -}; - -#[derive(IntoElement)] -pub enum ContextPill { - Added { - context: AddedContext, - dupe_name: bool, - focused: bool, - on_click: Option>, - on_remove: Option>, - }, - Suggested { - name: SharedString, - icon_path: Option, - kind: ContextKind, - focused: bool, - on_click: Option>, - }, -} - -impl ContextPill { - pub fn added( - context: AddedContext, - dupe_name: bool, - focused: bool, - on_remove: Option>, - ) -> Self { - Self::Added { - context, - dupe_name, - on_remove, - focused, - on_click: None, - } - } - - pub fn suggested( - name: SharedString, - icon_path: Option, - kind: ContextKind, - focused: bool, - ) -> Self { - Self::Suggested { - name, - icon_path, - kind, - focused, - on_click: None, - } - } - - pub fn on_click(mut self, listener: Rc) -> Self { - match &mut self { - ContextPill::Added { on_click, .. } => { - *on_click = Some(listener); - } - ContextPill::Suggested { on_click, .. } => { - *on_click = Some(listener); - } - } - self - } - - pub fn id(&self) -> ElementId { - match self { - Self::Added { context, .. } => context.handle.element_id("context-pill".into()), - Self::Suggested { .. } => "suggested-context-pill".into(), - } - } - - pub fn icon(&self) -> Icon { - match self { - Self::Suggested { - icon_path: Some(icon_path), - .. - } => Icon::from_path(icon_path), - Self::Suggested { kind, .. } => Icon::new(kind.icon()), - Self::Added { context, .. } => context.icon(), - } - } -} - -impl RenderOnce for ContextPill { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let color = cx.theme().colors(); - - let base_pill = h_flex() - .id(self.id()) - .pl_1() - .pb(px(1.)) - .border_1() - .rounded_sm() - .gap_1() - .child(self.icon().size(IconSize::XSmall).color(Color::Muted)); - - match &self { - ContextPill::Added { - context, - dupe_name, - on_remove, - focused, - on_click, - } => { - let status_is_error = matches!(context.status, ContextStatus::Error { .. }); - let status_is_warning = matches!(context.status, ContextStatus::Warning { .. }); - - base_pill - .pr(if on_remove.is_some() { px(2.) } else { px(4.) }) - .map(|pill| { - if status_is_error { - pill.bg(cx.theme().status().error_background) - .border_color(cx.theme().status().error_border) - } else if status_is_warning { - pill.bg(cx.theme().status().warning_background) - .border_color(cx.theme().status().warning_border) - } else if *focused { - pill.bg(color.element_background) - .border_color(color.border_focused) - } else { - pill.bg(color.element_background) - .border_color(color.border.opacity(0.5)) - } - }) - .child( - h_flex() - .id("context-data") - .gap_1() - .child( - div().max_w_64().child( - Label::new(context.name.clone()) - .size(LabelSize::Small) - .truncate(), - ), - ) - .when_some(context.parent.as_ref(), |element, parent_name| { - if *dupe_name { - element.child( - Label::new(parent_name.clone()) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - } else { - element - } - }) - .when_some(context.tooltip.as_ref(), |element, tooltip| { - element.tooltip(Tooltip::text(tooltip.clone())) - }) - .map(|element| match &context.status { - ContextStatus::Ready => element - .when_some( - context.render_hover.as_ref(), - |element, render_hover| { - let render_hover = render_hover.clone(); - element.hoverable_tooltip(move |window, cx| { - render_hover(window, cx) - }) - }, - ) - .into_any(), - ContextStatus::Loading { message } => element - .tooltip(ui::Tooltip::text(message.clone())) - .with_animation( - "pulsating-ctx-pill", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.4, 0.8)), - |label, delta| label.opacity(delta), - ) - .into_any_element(), - ContextStatus::Warning { message } - | ContextStatus::Error { message } => element - .tooltip(ui::Tooltip::text(message.clone())) - .into_any_element(), - }), - ) - .when_some(on_remove.as_ref(), |element, on_remove| { - element.child( - IconButton::new( - context.handle.element_id("remove".into()), - IconName::Close, - ) - .shape(IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .tooltip(Tooltip::text("Remove Context")) - .on_click({ - let on_remove = on_remove.clone(); - move |event, window, cx| on_remove(event, window, cx) - }), - ) - }) - .when_some(on_click.as_ref(), |element, on_click| { - let on_click = on_click.clone(); - element.cursor_pointer().on_click(move |event, window, cx| { - on_click(event, window, cx); - cx.stop_propagation(); - }) - }) - .into_any_element() - } - ContextPill::Suggested { - name, - icon_path: _, - kind: _, - focused, - on_click, - } => base_pill - .cursor_pointer() - .pr_1() - .border_dashed() - .map(|pill| { - if *focused { - pill.border_color(color.border_focused) - .bg(color.element_background.opacity(0.5)) - } else { - pill.border_color(color.border) - } - }) - .hover(|style| style.bg(color.element_hover.opacity(0.5))) - .child( - div().max_w_64().child( - Label::new(name.clone()) - .size(LabelSize::Small) - .color(Color::Muted) - .truncate(), - ), - ) - .tooltip(|_window, cx| { - Tooltip::with_meta("Suggested Context", None, "Click to add it", cx) - }) - .when_some(on_click.as_ref(), |element, on_click| { - let on_click = on_click.clone(); - element.on_click(move |event, window, cx| { - on_click(event, window, cx); - cx.stop_propagation(); - }) - }) - .into_any(), - } - } -} - -pub enum ContextStatus { - Ready, - Loading { message: SharedString }, - Error { message: SharedString }, - Warning { message: SharedString }, -} - -#[derive(RegisterComponent)] -pub struct AddedContext { - pub handle: AgentContextHandle, - pub kind: ContextKind, - pub name: SharedString, - pub parent: Option, - pub tooltip: Option, - pub icon_path: Option, - pub status: ContextStatus, - pub render_hover: Option AnyView + 'static>>, -} - -impl AddedContext { - pub fn icon(&self) -> Icon { - match &self.status { - ContextStatus::Warning { .. } => Icon::new(IconName::Warning).color(Color::Warning), - ContextStatus::Error { .. } => Icon::new(IconName::XCircle).color(Color::Error), - _ => { - if let Some(icon_path) = &self.icon_path { - Icon::from_path(icon_path) - } else { - Icon::new(self.kind.icon()) - } - } - } - } - /// Creates an `AddedContext` by retrieving relevant details of `AgentContext`. This returns a - /// `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_pending( - handle: AgentContextHandle, - prompt_store: Option<&Entity>, - project: &Project, - model: Option<&Arc>, - cx: &App, - ) -> Option { - match handle { - AgentContextHandle::File(handle) => { - Self::pending_file(handle, project.path_style(cx), cx) - } - AgentContextHandle::Directory(handle) => Self::pending_directory(handle, project, cx), - AgentContextHandle::Symbol(handle) => { - Self::pending_symbol(handle, project.path_style(cx), cx) - } - AgentContextHandle::Selection(handle) => { - Self::pending_selection(handle, project.path_style(cx), cx) - } - AgentContextHandle::FetchedUrl(handle) => Some(Self::fetched_url(handle)), - AgentContextHandle::Thread(handle) => Some(Self::pending_thread(handle, cx)), - AgentContextHandle::TextThread(handle) => Some(Self::pending_text_thread(handle, cx)), - AgentContextHandle::Rules(handle) => Self::pending_rules(handle, prompt_store, cx), - AgentContextHandle::Image(handle) => { - Some(Self::image(handle, model, project.path_style(cx), cx)) - } - } - } - - fn pending_file( - handle: FileContextHandle, - path_style: PathStyle, - cx: &App, - ) -> Option { - let full_path = handle - .buffer - .read(cx) - .file()? - .full_path(cx) - .to_string_lossy() - .to_string(); - Some(Self::file(handle, &full_path, path_style, cx)) - } - - fn file( - handle: FileContextHandle, - full_path: &str, - path_style: PathStyle, - cx: &App, - ) -> AddedContext { - let (name, parent) = extract_file_name_and_directory_from_full_path(full_path, path_style); - AddedContext { - kind: ContextKind::File, - name, - parent, - tooltip: Some(SharedString::new(full_path)), - icon_path: FileIcons::get_icon(Path::new(full_path), cx), - status: ContextStatus::Ready, - render_hover: None, - handle: AgentContextHandle::File(handle), - } - } - - fn pending_directory( - handle: DirectoryContextHandle, - project: &Project, - cx: &App, - ) -> Option { - 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) - .to_string_lossy() - .to_string(); - Some(Self::directory(handle, &full_path, project.path_style(cx))) - } - - fn directory( - handle: DirectoryContextHandle, - full_path: &str, - path_style: PathStyle, - ) -> AddedContext { - let (name, parent) = extract_file_name_and_directory_from_full_path(full_path, path_style); - AddedContext { - kind: ContextKind::Directory, - name, - parent, - tooltip: Some(SharedString::new(full_path)), - icon_path: None, - status: ContextStatus::Ready, - render_hover: None, - handle: AgentContextHandle::Directory(handle), - } - } - - fn pending_symbol( - handle: SymbolContextHandle, - path_style: PathStyle, - cx: &App, - ) -> Option { - let excerpt = ContextFileExcerpt::new( - &handle.full_path(cx)?.to_string_lossy(), - handle.enclosing_line_range(cx), - path_style, - 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 pending_selection( - handle: SelectionContextHandle, - path_style: PathStyle, - cx: &App, - ) -> Option { - let excerpt = ContextFileExcerpt::new( - &handle.full_path(cx)?.to_string_lossy(), - handle.line_range(cx), - path_style, - 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 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_summary() { - ContextStatus::Loading { - message: "Summarizing…".into(), - } - } else { - ContextStatus::Ready - }, - render_hover: { - let thread = handle.thread.clone(); - Some(Rc::new(move |_, cx| { - let text = thread - .update(cx, |thread, cx| thread.summary(cx)) - .now_or_never() - .flatten() - .unwrap_or_else(|| SharedString::from(thread.read(cx).to_markdown())); - ContextPillHover::new_text(text, cx).into() - })) - }, - handle: AgentContextHandle::Thread(handle), - } - } - - fn pending_text_thread(handle: TextThreadContextHandle, cx: &App) -> AddedContext { - AddedContext { - kind: ContextKind::TextThread, - name: handle.title(cx), - parent: None, - tooltip: None, - icon_path: None, - status: ContextStatus::Ready, - render_hover: { - let text_thread = handle.text_thread.clone(); - Some(Rc::new(move |_, cx| { - let text = text_thread.read(cx).to_xml(cx); - ContextPillHover::new_text(text.into(), cx).into() - })) - }, - handle: AgentContextHandle::TextThread(handle), - } - } - - fn pending_rules( - handle: RulesContextHandle, - prompt_store: Option<&Entity>, - cx: &App, - ) -> Option { - 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, - parent: None, - tooltip: None, - icon_path: None, - status: ContextStatus::Ready, - render_hover: None, - handle: AgentContextHandle::Rules(handle), - }) - } - - fn image( - context: ImageContext, - model: Option<&Arc>, - path_style: PathStyle, - cx: &App, - ) -> AddedContext { - let (name, parent, icon_path) = if let Some(full_path) = context.full_path.as_ref() { - let (name, parent) = - extract_file_name_and_directory_from_full_path(full_path, path_style); - let icon_path = FileIcons::get_icon(Path::new(full_path), cx); - (name, parent, icon_path) - } else { - ("Image".into(), None, None) - }; - - let status = match context.status(model) { - ImageStatus::Loading => ContextStatus::Loading { - message: "Loading…".into(), - }, - ImageStatus::Error => ContextStatus::Error { - message: "Failed to load Image".into(), - }, - ImageStatus::Warning => ContextStatus::Warning { - message: format!( - "{} doesn't support attaching Images as Context", - model.map(|m| m.name().0).unwrap_or_else(|| "Model".into()) - ) - .into(), - }, - ImageStatus::Ready => ContextStatus::Ready, - }; - - AddedContext { - kind: ContextKind::Image, - name, - parent, - tooltip: None, - icon_path, - status, - 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() - }) - .into() - } - })), - handle: AgentContextHandle::Image(context), - } - } -} - -fn extract_file_name_and_directory_from_full_path( - path: &str, - path_style: PathStyle, -) -> (SharedString, Option) { - let (parent, file_name) = path_style.split(path); - let parent = parent.and_then(|parent| { - let parent = parent.trim_end_matches(path_style.separator()); - let (_, parent) = path_style.split(parent); - if parent.is_empty() { - None - } else { - Some(SharedString::new(parent)) - } - }); - (SharedString::new(file_name), parent) -} - -#[derive(Debug, Clone)] -struct ContextFileExcerpt { - pub file_name_and_range: SharedString, - pub full_path_and_range: SharedString, - pub parent_name: Option, - pub icon_path: Option, -} - -impl ContextFileExcerpt { - pub fn new(full_path: &str, line_range: Range, path_style: PathStyle, cx: &App) -> Self { - let (parent, file_name) = path_style.split(full_path); - let line_range_text = format!(" ({}-{})", line_range.start.row + 1, line_range.end.row + 1); - let mut full_path_and_range = full_path.to_owned(); - full_path_and_range.push_str(&line_range_text); - let mut file_name_and_range = file_name.to_owned(); - file_name_and_range.push_str(&line_range_text); - - let parent_name = parent.and_then(|parent| { - let parent = parent.trim_end_matches(path_style.separator()); - let (_, parent) = path_style.split(parent); - if parent.is_empty() { - None - } else { - Some(SharedString::new(parent)) - } - }); - - let icon_path = FileIcons::get_icon(Path::new(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 { - 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() - }) - } -} - -struct ContextPillHover { - render_hover: Box AnyElement>, -} - -impl ContextPillHover { - fn new( - cx: &mut App, - render_hover: impl Fn(&mut Window, &mut App) -> AnyElement + 'static, - ) -> Entity { - cx.new(|_| Self { - render_hover: Box::new(render_hover), - }) - } - - fn new_text(content: SharedString, cx: &mut App) -> Entity { - Self::new(cx, move |_, _| { - div() - .id("context-pill-hover-contents") - .overflow_scroll() - .max_w_128() - .max_h_96() - .child(content.clone()) - .into_any_element() - }) - } -} - -impl Render for ContextPillHover { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - tooltip_container(cx, move |this, cx| { - this.occlude() - .on_mouse_move(|_, _, cx| cx.stop_propagation()) - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .child((self.render_hover)(window, cx)) - }) - } -} - -impl Component for AddedContext { - fn scope() -> ComponentScope { - ComponentScope::Agent - } - - fn sort_name() -> &'static str { - "AddedContext" - } - - fn preview(_window: &mut Window, cx: &mut App) -> Option { - let mut next_context_id = ContextId::zero(); - let image_ready = ( - "Ready", - AddedContext::image( - ImageContext { - context_id: next_context_id.post_inc(), - project_path: None, - full_path: None, - original_image: Arc::new(Image::empty()), - image_task: Task::ready(Some(LanguageModelImage::empty())).shared(), - }, - None, - PathStyle::local(), - cx, - ), - ); - - let image_loading = ( - "Loading", - AddedContext::image( - ImageContext { - context_id: next_context_id.post_inc(), - project_path: None, - full_path: None, - 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(), - }, - None, - PathStyle::local(), - cx, - ), - ); - - let image_error = ( - "Error", - AddedContext::image( - ImageContext { - context_id: next_context_id.post_inc(), - project_path: None, - full_path: None, - original_image: Arc::new(Image::empty()), - image_task: Task::ready(None).shared(), - }, - None, - PathStyle::local(), - cx, - ), - ); - - Some( - v_flex() - .gap_6() - .children( - vec![image_ready, image_loading, image_error] - .into_iter() - .map(|(text, context)| { - single_example( - text, - ContextPill::added(context, false, false, None).into_any_element(), - ) - }), - ) - .into_any(), - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use gpui::App; - use language_model::{LanguageModel, fake_provider::FakeLanguageModel}; - use std::sync::Arc; - - #[gpui::test] - fn test_image_context_warning_for_unsupported_model(cx: &mut App) { - let model: Arc = Arc::new(FakeLanguageModel::default()); - assert!(!model.supports_images()); - - let image_context = ImageContext { - context_id: ContextId::zero(), - project_path: None, - original_image: Arc::new(Image::empty()), - image_task: Task::ready(Some(LanguageModelImage::empty())).shared(), - full_path: None, - }; - - let added_context = - AddedContext::image(image_context, Some(&model), PathStyle::local(), cx); - - assert!(matches!( - added_context.status, - ContextStatus::Warning { .. } - )); - - assert!(matches!(added_context.kind, ContextKind::Image)); - assert_eq!(added_context.name.as_ref(), "Image"); - assert!(added_context.parent.is_none()); - assert!(added_context.icon_path.is_none()); - } - - #[gpui::test] - fn test_image_context_ready_for_no_model(cx: &mut App) { - let image_context = ImageContext { - context_id: ContextId::zero(), - project_path: None, - original_image: Arc::new(Image::empty()), - image_task: Task::ready(Some(LanguageModelImage::empty())).shared(), - full_path: None, - }; - - let added_context = AddedContext::image(image_context, None, PathStyle::local(), cx); - - assert!( - matches!(added_context.status, ContextStatus::Ready), - "Expected ready status when no model provided" - ); - - assert!(matches!(added_context.kind, ContextKind::Image)); - assert_eq!(added_context.name.as_ref(), "Image"); - assert!(added_context.parent.is_none()); - assert!(added_context.icon_path.is_none()); - } -}