diff --git a/Cargo.lock b/Cargo.lock index f0fd3049c0..47446b6a13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,7 @@ dependencies = [ "collections", "editor", "env_logger 0.11.8", + "file_icons", "futures 0.3.31", "gpui", "indoc", @@ -394,7 +395,6 @@ dependencies = [ "ui", "ui_input", "unindent", - "url", "urlencoding", "util", "uuid", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index dda26f406b..01c0b4e969 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -331,8 +331,6 @@ "use_key_equivalents": true, "bindings": { "enter": "agent::Chat", - "up": "agent::PreviousHistoryMessage", - "down": "agent::NextHistoryMessage", "shift-ctrl-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll" diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 3966efd8df..e5b7fff9e1 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -383,8 +383,6 @@ "use_key_equivalents": true, "bindings": { "enter": "agent::Chat", - "up": "agent::PreviousHistoryMessage", - "down": "agent::NextHistoryMessage", "shift-ctrl-r": "agent::OpenAgentDiff", "cmd-shift-y": "agent::KeepAll", "cmd-shift-n": "agent::RejectAll" diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index 2ac15de08f..2d0fe2d264 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -23,6 +23,7 @@ anyhow.workspace = true buffer_diff.workspace = true collections.workspace = true editor.workspace = true +file_icons.workspace = true futures.workspace = true gpui.workspace = true itertools.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index a5b512f31a..b82fe74f1b 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -32,6 +32,7 @@ use util::ResultExt; pub struct UserMessage { pub id: Option, pub content: ContentBlock, + pub chunks: Vec, pub checkpoint: Option, } @@ -399,7 +400,7 @@ impl ContentBlock { } } - let new_content = self.block_string_contents(block); + let new_content = self.extract_content_from_block(block); match self { ContentBlock::Empty => { @@ -409,7 +410,7 @@ impl ContentBlock { markdown.update(cx, |markdown, cx| markdown.append(&new_content, cx)); } ContentBlock::ResourceLink { resource_link } => { - let existing_content = Self::resource_link_md(&resource_link.uri); + let existing_content = Self::resource_link_to_content(&resource_link.uri); let combined = format!("{}\n{}", existing_content, new_content); *self = Self::create_markdown_block(combined, language_registry, cx); @@ -417,6 +418,14 @@ impl ContentBlock { } } + fn resource_link_to_content(uri: &str) -> String { + if let Some(uri) = MentionUri::parse(&uri).log_err() { + uri.as_link().to_string() + } else { + uri.to_string() + } + } + fn create_markdown_block( content: String, language_registry: &Arc, @@ -428,11 +437,11 @@ impl ContentBlock { } } - fn block_string_contents(&self, block: acp::ContentBlock) -> String { + fn extract_content_from_block(&self, block: acp::ContentBlock) -> String { match block { acp::ContentBlock::Text(text_content) => text_content.text.clone(), acp::ContentBlock::ResourceLink(resource_link) => { - Self::resource_link_md(&resource_link.uri) + Self::resource_link_to_content(&resource_link.uri) } acp::ContentBlock::Resource(acp::EmbeddedResource { resource: @@ -441,21 +450,13 @@ impl ContentBlock { .. }), .. - }) => Self::resource_link_md(&uri), + }) => Self::resource_link_to_content(&uri), acp::ContentBlock::Image(_) | acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => String::new(), } } - fn resource_link_md(uri: &str) -> String { - if let Some(uri) = MentionUri::parse(&uri).log_err() { - uri.as_link().to_string() - } else { - uri.to_string() - } - } - fn to_markdown<'a>(&'a self, cx: &'a App) -> &'a str { match self { ContentBlock::Empty => "", @@ -804,18 +805,25 @@ impl AcpThread { let entries_len = self.entries.len(); if let Some(last_entry) = self.entries.last_mut() - && let AgentThreadEntry::UserMessage(UserMessage { id, content, .. }) = last_entry + && let AgentThreadEntry::UserMessage(UserMessage { + id, + content, + chunks, + .. + }) = last_entry { *id = message_id.or(id.take()); - content.append(chunk, &language_registry, cx); + content.append(chunk.clone(), &language_registry, cx); + chunks.push(chunk); let idx = entries_len - 1; cx.emit(AcpThreadEvent::EntryUpdated(idx)); } else { - let content = ContentBlock::new(chunk, &language_registry, cx); + let content = ContentBlock::new(chunk.clone(), &language_registry, cx); self.push_entry( AgentThreadEntry::UserMessage(UserMessage { id: message_id, content, + chunks: vec![chunk], checkpoint: None, }), cx, @@ -1150,6 +1158,7 @@ impl AcpThread { AgentThreadEntry::UserMessage(UserMessage { id: message_id.clone(), content: block, + chunks: message.clone(), checkpoint: None, }), cx, diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index 03174608fb..5dc6e13786 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -1,16 +1,21 @@ use agent::ThreadId; use anyhow::{Context as _, Result, bail}; +use file_icons::FileIcons; use prompt_store::{PromptId, UserPromptId}; use std::{ fmt, ops::Range, path::{Path, PathBuf}, }; +use ui::{App, IconName, SharedString}; use url::Url; #[derive(Clone, Debug, PartialEq, Eq)] pub enum MentionUri { - File(PathBuf), + File { + abs_path: PathBuf, + is_directory: bool, + }, Symbol { path: PathBuf, name: String, @@ -75,8 +80,12 @@ impl MentionUri { } else { let file_path = PathBuf::from(format!("{}{}", url.host_str().unwrap_or(""), path)); + let is_directory = input.ends_with("/"); - Ok(Self::File(file_path)) + Ok(Self::File { + abs_path: file_path, + is_directory, + }) } } "zed" => { @@ -108,9 +117,9 @@ impl MentionUri { } } - fn name(&self) -> String { + pub fn name(&self) -> String { match self { - MentionUri::File(path) => path + MentionUri::File { abs_path, .. } => abs_path .file_name() .unwrap_or_default() .to_string_lossy() @@ -126,15 +135,45 @@ impl MentionUri { } } + pub fn icon_path(&self, cx: &mut App) -> SharedString { + match self { + MentionUri::File { + abs_path, + is_directory, + } => { + if *is_directory { + FileIcons::get_folder_icon(false, cx) + .unwrap_or_else(|| IconName::Folder.path().into()) + } else { + FileIcons::get_icon(&abs_path, cx) + .unwrap_or_else(|| IconName::File.path().into()) + } + } + MentionUri::Symbol { .. } => IconName::Code.path().into(), + MentionUri::Thread { .. } => IconName::Thread.path().into(), + MentionUri::TextThread { .. } => IconName::Thread.path().into(), + MentionUri::Rule { .. } => IconName::Reader.path().into(), + MentionUri::Selection { .. } => IconName::Reader.path().into(), + MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(), + } + } + pub fn as_link<'a>(&'a self) -> MentionLink<'a> { MentionLink(self) } pub fn to_uri(&self) -> Url { match self { - MentionUri::File(path) => { + MentionUri::File { + abs_path, + is_directory, + } => { let mut url = Url::parse("file:///").unwrap(); - url.set_path(&path.to_string_lossy()); + let mut path = abs_path.to_string_lossy().to_string(); + if *is_directory && !path.ends_with("/") { + path.push_str("/"); + } + url.set_path(&path); url } MentionUri::Symbol { @@ -226,12 +265,53 @@ mod tests { let file_uri = "file:///path/to/file.rs"; let parsed = MentionUri::parse(file_uri).unwrap(); match &parsed { - MentionUri::File(path) => assert_eq!(path.to_str().unwrap(), "/path/to/file.rs"), + MentionUri::File { + abs_path, + is_directory, + } => { + assert_eq!(abs_path.to_str().unwrap(), "/path/to/file.rs"); + assert!(!is_directory); + } _ => panic!("Expected File variant"), } assert_eq!(parsed.to_uri().to_string(), file_uri); } + #[test] + fn test_parse_directory_uri() { + let file_uri = "file:///path/to/dir/"; + let parsed = MentionUri::parse(file_uri).unwrap(); + match &parsed { + MentionUri::File { + abs_path, + is_directory, + } => { + assert_eq!(abs_path.to_str().unwrap(), "/path/to/dir"); + assert!(is_directory); + } + _ => panic!("Expected File variant"), + } + assert_eq!(parsed.to_uri().to_string(), file_uri); + } + + #[test] + fn test_to_directory_uri_with_slash() { + let uri = MentionUri::File { + abs_path: PathBuf::from("/path/to/dir/"), + is_directory: true, + }; + assert_eq!(uri.to_uri().to_string(), "file:///path/to/dir/"); + } + + #[test] + fn test_to_directory_uri_without_slash() { + let uri = MentionUri::File { + abs_path: PathBuf::from("/path/to/dir"), + is_directory: true, + }; + assert_eq!(uri.to_uri().to_string(), "file:///path/to/dir/"); + } + #[test] fn test_parse_symbol_uri() { let symbol_uri = "file:///path/to/file.rs?symbol=MySymbol#L10:20"; diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index 12c94a522d..cc7cb50c91 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -205,22 +205,6 @@ impl ThreadStore { (this, ready_rx) } - #[cfg(any(test, feature = "test-support"))] - pub fn fake(project: Entity, cx: &mut App) -> Self { - Self { - project, - tools: cx.new(|_| ToolWorkingSet::default()), - prompt_builder: Arc::new(PromptBuilder::new(None).unwrap()), - prompt_store: None, - context_server_tool_ids: HashMap::default(), - threads: Vec::new(), - project_context: SharedProjectContext::default(), - reload_system_prompt_tx: mpsc::channel(0).0, - _reload_system_prompt_task: Task::ready(()), - _subscriptions: vec![], - } - } - fn handle_project_event( &mut self, _project: Entity, diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index b48f9001ac..204b489124 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -25,8 +25,8 @@ use schemars::{JsonSchema, Schema}; use serde::{Deserialize, Serialize}; use settings::{Settings, update_settings_file}; use smol::stream::StreamExt; +use std::fmt::Write; use std::{cell::RefCell, collections::BTreeMap, path::Path, rc::Rc, sync::Arc}; -use std::{fmt::Write, ops::Range}; use util::{ResultExt, markdown::MarkdownCodeBlock}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -79,9 +79,9 @@ impl UserMessage { } UserMessageContent::Mention { uri, content } => { if !content.is_empty() { - let _ = write!(&mut markdown, "{}\n\n{}\n", uri.as_link(), content); + markdown.push_str(&format!("{}\n\n{}\n", uri.to_link(), content)); } else { - let _ = write!(&mut markdown, "{}\n", uri.as_link()); + markdown.push_str(&format!("{}\n", uri.to_link())); } } } @@ -104,14 +104,12 @@ impl UserMessage { const OPEN_FILES_TAG: &str = ""; const OPEN_SYMBOLS_TAG: &str = ""; const OPEN_THREADS_TAG: &str = ""; - const OPEN_FETCH_TAG: &str = ""; const OPEN_RULES_TAG: &str = "\nThe user has specified the following rules that should be applied:\n"; let mut file_context = OPEN_FILES_TAG.to_string(); let mut symbol_context = OPEN_SYMBOLS_TAG.to_string(); let mut thread_context = OPEN_THREADS_TAG.to_string(); - let mut fetch_context = OPEN_FETCH_TAG.to_string(); let mut rules_context = OPEN_RULES_TAG.to_string(); for chunk in &self.content { @@ -124,40 +122,21 @@ impl UserMessage { } UserMessageContent::Mention { uri, content } => { match uri { - MentionUri::File(path) => { + MentionUri::File(path) | MentionUri::Symbol(path, _) => { write!( &mut symbol_context, "\n{}", MarkdownCodeBlock { - tag: &codeblock_tag(&path, None), + tag: &codeblock_tag(&path), text: &content.to_string(), } ) .ok(); } - MentionUri::Symbol { - path, line_range, .. - } - | MentionUri::Selection { - path, line_range, .. - } => { - write!( - &mut rules_context, - "\n{}", - MarkdownCodeBlock { - tag: &codeblock_tag(&path, Some(line_range)), - text: &content - } - ) - .ok(); - } - MentionUri::Thread { .. } => { + MentionUri::Thread(_session_id) => { write!(&mut thread_context, "\n{}\n", content).ok(); } - MentionUri::TextThread { .. } => { - write!(&mut thread_context, "\n{}\n", content).ok(); - } - MentionUri::Rule { .. } => { + MentionUri::Rule(_user_prompt_id) => { write!( &mut rules_context, "\n{}", @@ -168,12 +147,9 @@ impl UserMessage { ) .ok(); } - MentionUri::Fetch { url } => { - write!(&mut fetch_context, "\nFetch: {}\n\n{}", url, content).ok(); - } } - language_model::MessageContent::Text(uri.as_link().to_string()) + language_model::MessageContent::Text(uri.to_link()) } }; @@ -203,13 +179,6 @@ impl UserMessage { .push(language_model::MessageContent::Text(thread_context)); } - if fetch_context.len() > OPEN_FETCH_TAG.len() { - fetch_context.push_str("\n"); - message - .content - .push(language_model::MessageContent::Text(fetch_context)); - } - if rules_context.len() > OPEN_RULES_TAG.len() { rules_context.push_str("\n"); message @@ -231,26 +200,6 @@ impl UserMessage { } } -fn codeblock_tag(full_path: &Path, line_range: Option<&Range>) -> String { - let mut result = String::new(); - - if let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) { - let _ = write!(result, "{} ", extension); - } - - let _ = write!(result, "{}", full_path.display()); - - if let Some(range) = line_range { - if range.start == range.end { - let _ = write!(result, ":{}", range.start + 1); - } else { - let _ = write!(result, ":{}-{}", range.start + 1, range.end + 1); - } - } - - result -} - impl AgentMessage { pub fn to_markdown(&self) -> String { let mut markdown = String::from("## Assistant\n\n"); @@ -1418,6 +1367,18 @@ impl std::ops::DerefMut for ToolCallEventStreamReceiver { } } +fn codeblock_tag(full_path: &Path) -> String { + let mut result = String::new(); + + if let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) { + let _ = write!(result, "{} ", extension); + } + + let _ = write!(result, "{}", full_path.display()); + + result +} + impl From<&str> for UserMessageContent { fn from(text: &str) -> Self { Self::Text(text.into()) diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index b6a5710aa4..de0a27c2cb 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -93,7 +93,6 @@ time.workspace = true time_format.workspace = true ui.workspace = true ui_input.workspace = true -url.workspace = true urlencoding.workspace = true util.workspace = true uuid.workspace = true @@ -103,8 +102,6 @@ workspace.workspace = true zed_actions.workspace = true [dev-dependencies] -agent = { workspace = true, features = ["test-support"] } -assistant_context = { workspace = true, features = ["test-support"] } assistant_tools.workspace = true buffer_diff = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } diff --git a/crates/agent_ui/src/acp.rs b/crates/agent_ui/src/acp.rs index b9814adb2d..630aa730a6 100644 --- a/crates/agent_ui/src/acp.rs +++ b/crates/agent_ui/src/acp.rs @@ -1,10 +1,9 @@ mod completion_provider; -mod message_history; +mod message_editor; mod model_selector; mod model_selector_popover; mod thread_view; -pub use message_history::MessageHistory; pub use model_selector::AcpModelSelector; pub use model_selector_popover::AcpModelSelectorPopover; pub use thread_view::AcpThreadView; diff --git a/crates/agent_ui/src/acp/completion_provider.rs b/crates/agent_ui/src/acp/completion_provider.rs index 46c8aa92f1..e1dfaca050 100644 --- a/crates/agent_ui/src/acp/completion_provider.rs +++ b/crates/agent_ui/src/acp/completion_provider.rs @@ -3,184 +3,75 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::AtomicBool; -use acp_thread::{MentionUri, selection_name}; -use anyhow::{Context as _, Result, anyhow}; -use collections::{HashMap, HashSet}; +use acp_thread::MentionUri; +use anyhow::{Context as _, Result}; +use collections::HashMap; use editor::display_map::CreaseId; -use editor::{CompletionProvider, Editor, ExcerptId, ToOffset as _}; +use editor::{CompletionProvider, Editor, ExcerptId}; use file_icons::FileIcons; use futures::future::try_join_all; -use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{App, Entity, Task, WeakEntity}; -use http_client::HttpClientWithUrl; -use itertools::Itertools as _; use language::{Buffer, CodeLabel, HighlightId}; use lsp::CompletionContext; use parking_lot::Mutex; -use project::{ - Completion, CompletionIntent, CompletionResponse, Project, ProjectPath, Symbol, WorktreeId, -}; -use prompt_store::PromptStore; +use project::{Completion, CompletionIntent, CompletionResponse, Project, ProjectPath, WorktreeId}; use rope::Point; -use text::{Anchor, OffsetRangeExt as _, ToPoint as _}; +use text::{Anchor, ToPoint}; use ui::prelude::*; -use url::Url; use workspace::Workspace; -use workspace::notifications::NotifyResultExt; -use agent::{ - context::RULES_ICON, - thread_store::{TextThreadStore, ThreadStore}, -}; - -use crate::context_picker::fetch_context_picker::fetch_url_content; -use crate::context_picker::file_context_picker::{FileMatch, search_files}; -use crate::context_picker::rules_context_picker::{RulesContextEntry, search_rules}; -use crate::context_picker::symbol_context_picker::SymbolMatch; -use crate::context_picker::symbol_context_picker::search_symbols; -use crate::context_picker::thread_context_picker::{ - ThreadContextEntry, ThreadMatch, search_threads, -}; -use crate::context_picker::{ - ContextPickerAction, ContextPickerEntry, ContextPickerMode, RecentEntry, - available_context_picker_entries, recent_context_picker_entries, selection_ranges, -}; +use crate::context_picker::MentionLink; +use crate::context_picker::file_context_picker::{extract_file_name_and_directory, search_files}; #[derive(Default)] pub struct MentionSet { - uri_by_crease_id: HashMap, - fetch_results: HashMap, + paths_by_crease_id: HashMap, } impl MentionSet { - pub fn insert(&mut self, crease_id: CreaseId, uri: MentionUri) { - self.uri_by_crease_id.insert(crease_id, uri); - } - - pub fn add_fetch_result(&mut self, url: Url, content: String) { - self.fetch_results.insert(url, content); + pub fn insert(&mut self, crease_id: CreaseId, path: PathBuf) { + self.paths_by_crease_id + .insert(crease_id, MentionUri::File(path)); } pub fn drain(&mut self) -> impl Iterator { - self.fetch_results.clear(); - self.uri_by_crease_id.drain().map(|(id, _)| id) + self.paths_by_crease_id.drain().map(|(id, _)| id) + } + + pub fn clear(&mut self) { + self.paths_by_crease_id.clear(); } pub fn contents( &self, project: Entity, - thread_store: Entity, - text_thread_store: Entity, - window: &mut Window, cx: &mut App, ) -> Task>> { let contents = self - .uri_by_crease_id + .paths_by_crease_id .iter() - .map(|(&crease_id, uri)| { - match uri { - MentionUri::File(path) => { - let uri = uri.clone(); - let path = path.to_path_buf(); - let buffer_task = project.update(cx, |project, cx| { - let path = project - .find_project_path(path, cx) - .context("Failed to find project path")?; - anyhow::Ok(project.open_buffer(path, cx)) - }); + .map(|(crease_id, uri)| match uri { + MentionUri::File(path) => { + let crease_id = *crease_id; + let uri = uri.clone(); + let path = path.to_path_buf(); + let buffer_task = project.update(cx, |project, cx| { + let path = project + .find_project_path(path, cx) + .context("Failed to find project path")?; + anyhow::Ok(project.open_buffer(path, cx)) + }); - cx.spawn(async move |cx| { - let buffer = buffer_task?.await?; - let content = buffer.read_with(cx, |buffer, _cx| buffer.text())?; + cx.spawn(async move |cx| { + let buffer = buffer_task?.await?; + let content = buffer.read_with(cx, |buffer, _cx| buffer.text())?; - anyhow::Ok((crease_id, Mention { uri, content })) - }) - } - MentionUri::Symbol { - path, line_range, .. - } - | MentionUri::Selection { - path, line_range, .. - } => { - let uri = uri.clone(); - let path_buf = path.clone(); - let line_range = line_range.clone(); - - let buffer_task = project.update(cx, |project, cx| { - let path = project - .find_project_path(&path_buf, cx) - .context("Failed to find project path")?; - anyhow::Ok(project.open_buffer(path, cx)) - }); - - cx.spawn(async move |cx| { - let buffer = buffer_task?.await?; - let content = buffer.read_with(cx, |buffer, _cx| { - buffer - .text_for_range( - Point::new(line_range.start, 0) - ..Point::new( - line_range.end, - buffer.line_len(line_range.end), - ), - ) - .collect() - })?; - - anyhow::Ok((crease_id, Mention { uri, content })) - }) - } - MentionUri::Thread { id: thread_id, .. } => { - let open_task = thread_store.update(cx, |thread_store, cx| { - thread_store.open_thread(&thread_id, window, cx) - }); - - let uri = uri.clone(); - cx.spawn(async move |cx| { - let thread = open_task.await?; - let content = thread.read_with(cx, |thread, _cx| { - thread.latest_detailed_summary_or_text().to_string() - })?; - - anyhow::Ok((crease_id, Mention { uri, content })) - }) - } - MentionUri::TextThread { path, .. } => { - let context = text_thread_store.update(cx, |text_thread_store, cx| { - text_thread_store.open_local_context(path.as_path().into(), cx) - }); - let uri = uri.clone(); - cx.spawn(async move |cx| { - let context = context.await?; - let xml = context.update(cx, |context, cx| context.to_xml(cx))?; - anyhow::Ok((crease_id, Mention { uri, content: xml })) - }) - } - MentionUri::Rule { id: prompt_id, .. } => { - let Some(prompt_store) = thread_store.read(cx).prompt_store().clone() - else { - return Task::ready(Err(anyhow!("missing prompt store"))); - }; - let text_task = prompt_store.read(cx).load(*prompt_id, cx); - let uri = uri.clone(); - cx.spawn(async move |_| { - // TODO: report load errors instead of just logging - let text = text_task.await?; - anyhow::Ok((crease_id, Mention { uri, content: text })) - }) - } - MentionUri::Fetch { url } => { - let Some(content) = self.fetch_results.get(&url) else { - return Task::ready(Err(anyhow!("missing fetch result"))); - }; - Task::ready(Ok(( - crease_id, - Mention { - uri: uri.clone(), - content: content.clone(), - }, - ))) - } + anyhow::Ok((crease_id, Mention { uri, content })) + }) + } + _ => { + // TODO + unimplemented!() } }) .collect::>(); @@ -192,458 +83,30 @@ impl MentionSet { } } -#[derive(Debug)] pub struct Mention { pub uri: MentionUri, pub content: String, } -pub(crate) enum Match { - File(FileMatch), - Symbol(SymbolMatch), - Thread(ThreadMatch), - Fetch(SharedString), - Rules(RulesContextEntry), - Entry(EntryMatch), -} - -pub struct EntryMatch { - mat: Option, - entry: ContextPickerEntry, -} - -impl Match { - pub fn score(&self) -> f64 { - match self { - Match::File(file) => file.mat.score, - Match::Entry(mode) => mode.mat.as_ref().map(|mat| mat.score).unwrap_or(1.), - Match::Thread(_) => 1., - Match::Symbol(_) => 1., - Match::Rules(_) => 1., - Match::Fetch(_) => 1., - } - } -} - -fn search( - mode: Option, - query: String, - cancellation_flag: Arc, - recent_entries: Vec, - prompt_store: Option>, - thread_store: WeakEntity, - text_thread_context_store: WeakEntity, - workspace: Entity, - cx: &mut App, -) -> Task> { - match mode { - Some(ContextPickerMode::File) => { - let search_files_task = - search_files(query.clone(), cancellation_flag.clone(), &workspace, cx); - cx.background_spawn(async move { - search_files_task - .await - .into_iter() - .map(Match::File) - .collect() - }) - } - - Some(ContextPickerMode::Symbol) => { - let search_symbols_task = - search_symbols(query.clone(), cancellation_flag.clone(), &workspace, cx); - cx.background_spawn(async move { - search_symbols_task - .await - .into_iter() - .map(Match::Symbol) - .collect() - }) - } - - Some(ContextPickerMode::Thread) => { - if let Some((thread_store, context_store)) = thread_store - .upgrade() - .zip(text_thread_context_store.upgrade()) - { - let search_threads_task = search_threads( - query.clone(), - cancellation_flag.clone(), - thread_store, - context_store, - cx, - ); - cx.background_spawn(async move { - search_threads_task - .await - .into_iter() - .map(Match::Thread) - .collect() - }) - } else { - Task::ready(Vec::new()) - } - } - - Some(ContextPickerMode::Fetch) => { - if !query.is_empty() { - Task::ready(vec![Match::Fetch(query.into())]) - } else { - Task::ready(Vec::new()) - } - } - - Some(ContextPickerMode::Rules) => { - if let Some(prompt_store) = prompt_store.as_ref() { - let search_rules_task = - search_rules(query.clone(), cancellation_flag.clone(), prompt_store, cx); - cx.background_spawn(async move { - search_rules_task - .await - .into_iter() - .map(Match::Rules) - .collect::>() - }) - } else { - Task::ready(Vec::new()) - } - } - - None => { - if query.is_empty() { - let mut matches = recent_entries - .into_iter() - .map(|entry| match entry { - RecentEntry::File { - project_path, - path_prefix, - } => Match::File(FileMatch { - mat: fuzzy::PathMatch { - score: 1., - positions: Vec::new(), - worktree_id: project_path.worktree_id.to_usize(), - path: project_path.path, - path_prefix, - is_dir: false, - distance_to_relative_ancestor: 0, - }, - is_recent: true, - }), - RecentEntry::Thread(thread_context_entry) => Match::Thread(ThreadMatch { - thread: thread_context_entry, - is_recent: true, - }), - }) - .collect::>(); - - matches.extend( - available_context_picker_entries( - &prompt_store, - &Some(thread_store.clone()), - &workspace, - cx, - ) - .into_iter() - .map(|mode| { - Match::Entry(EntryMatch { - entry: mode, - mat: None, - }) - }), - ); - - Task::ready(matches) - } else { - let executor = cx.background_executor().clone(); - - let search_files_task = - search_files(query.clone(), cancellation_flag.clone(), &workspace, cx); - - let entries = available_context_picker_entries( - &prompt_store, - &Some(thread_store.clone()), - &workspace, - cx, - ); - let entry_candidates = entries - .iter() - .enumerate() - .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword())) - .collect::>(); - - cx.background_spawn(async move { - let mut matches = search_files_task - .await - .into_iter() - .map(Match::File) - .collect::>(); - - let entry_matches = fuzzy::match_strings( - &entry_candidates, - &query, - false, - true, - 100, - &Arc::new(AtomicBool::default()), - executor, - ) - .await; - - matches.extend(entry_matches.into_iter().map(|mat| { - Match::Entry(EntryMatch { - entry: entries[mat.candidate_id], - mat: Some(mat), - }) - })); - - matches.sort_by(|a, b| { - b.score() - .partial_cmp(&a.score()) - .unwrap_or(std::cmp::Ordering::Equal) - }); - - matches - }) - } - } - } -} - pub struct ContextPickerCompletionProvider { - mention_set: Arc>, workspace: WeakEntity, - thread_store: WeakEntity, - text_thread_store: WeakEntity, editor: WeakEntity, + mention_set: Arc>, } impl ContextPickerCompletionProvider { pub fn new( mention_set: Arc>, workspace: WeakEntity, - thread_store: WeakEntity, - text_thread_store: WeakEntity, editor: WeakEntity, ) -> Self { Self { mention_set, workspace, - thread_store, - text_thread_store, editor, } } - fn completion_for_entry( - entry: ContextPickerEntry, - excerpt_id: ExcerptId, - source_range: Range, - editor: Entity, - mention_set: Arc>, - workspace: &Entity, - cx: &mut App, - ) -> Option { - match entry { - ContextPickerEntry::Mode(mode) => Some(Completion { - replace_range: source_range.clone(), - new_text: format!("@{} ", mode.keyword()), - label: CodeLabel::plain(mode.label().to_string(), None), - icon_path: Some(mode.icon().path().into()), - documentation: None, - source: project::CompletionSource::Custom, - insert_text_mode: None, - // This ensures that when a user accepts this completion, the - // completion menu will still be shown after "@category " is - // inserted - confirm: Some(Arc::new(|_, _, _| true)), - }), - ContextPickerEntry::Action(action) => { - let (new_text, on_action) = match action { - ContextPickerAction::AddSelections => { - let selections = selection_ranges(workspace, cx); - - const PLACEHOLDER: &str = "selection "; - - let new_text = std::iter::repeat(PLACEHOLDER) - .take(selections.len()) - .chain(std::iter::once("")) - .join(" "); - - let callback = Arc::new({ - let mention_set = mention_set.clone(); - let selections = selections.clone(); - move |_, window: &mut Window, cx: &mut App| { - let editor = editor.clone(); - let mention_set = mention_set.clone(); - let selections = selections.clone(); - window.defer(cx, move |window, cx| { - let mut current_offset = 0; - - for (buffer, selection_range) in selections { - let snapshot = - editor.read(cx).buffer().read(cx).snapshot(cx); - let Some(start) = snapshot - .anchor_in_excerpt(excerpt_id, source_range.start) - else { - return; - }; - - let offset = start.to_offset(&snapshot) + current_offset; - let text_len = PLACEHOLDER.len() - 1; - - let range = snapshot.anchor_after(offset) - ..snapshot.anchor_after(offset + text_len); - - let path = buffer - .read(cx) - .file() - .map_or(PathBuf::from("untitled"), |file| { - file.path().to_path_buf() - }); - - let point_range = snapshot - .as_singleton() - .map(|(_, _, snapshot)| { - selection_range.to_point(&snapshot) - }) - .unwrap_or_default(); - let line_range = point_range.start.row..point_range.end.row; - let crease = crate::context_picker::crease_for_mention( - selection_name(&path, &line_range).into(), - IconName::Reader.path().into(), - range, - editor.downgrade(), - ); - - let [crease_id]: [_; 1] = - editor.update(cx, |editor, cx| { - let crease_ids = - editor.insert_creases(vec![crease.clone()], cx); - editor.fold_creases( - vec![crease], - false, - window, - cx, - ); - crease_ids.try_into().unwrap() - }); - - mention_set.lock().insert( - crease_id, - MentionUri::Selection { path, line_range }, - ); - - current_offset += text_len + 1; - } - }); - - false - } - }); - - (new_text, callback) - } - }; - - Some(Completion { - replace_range: source_range.clone(), - new_text, - label: CodeLabel::plain(action.label().to_string(), None), - icon_path: Some(action.icon().path().into()), - documentation: None, - source: project::CompletionSource::Custom, - insert_text_mode: None, - // This ensures that when a user accepts this completion, the - // completion menu will still be shown after "@category " is - // inserted - confirm: Some(on_action), - }) - } - } - } - - fn completion_for_thread( - thread_entry: ThreadContextEntry, - excerpt_id: ExcerptId, - source_range: Range, - recent: bool, - editor: Entity, - mention_set: Arc>, - ) -> Completion { - let icon_for_completion = if recent { - IconName::HistoryRerun - } else { - IconName::Thread - }; - - let uri = match &thread_entry { - ThreadContextEntry::Thread { id, title } => MentionUri::Thread { - id: id.clone(), - name: title.to_string(), - }, - ThreadContextEntry::Context { path, title } => MentionUri::TextThread { - path: path.to_path_buf(), - name: title.to_string(), - }, - }; - let new_text = format!("{} ", uri.as_link()); - - let new_text_len = new_text.len(); - Completion { - replace_range: source_range.clone(), - new_text, - label: CodeLabel::plain(thread_entry.title().to_string(), None), - documentation: None, - insert_text_mode: None, - source: project::CompletionSource::Custom, - icon_path: Some(icon_for_completion.path().into()), - confirm: Some(confirm_completion_callback( - IconName::Thread.path().into(), - thread_entry.title().clone(), - excerpt_id, - source_range.start, - new_text_len - 1, - editor.clone(), - mention_set, - uri, - )), - } - } - - fn completion_for_rules( - rule: RulesContextEntry, - excerpt_id: ExcerptId, - source_range: Range, - editor: Entity, - mention_set: Arc>, - ) -> Completion { - let uri = MentionUri::Rule { - id: rule.prompt_id.into(), - name: rule.title.to_string(), - }; - let new_text = format!("{} ", uri.as_link()); - let new_text_len = new_text.len(); - Completion { - replace_range: source_range.clone(), - new_text, - label: CodeLabel::plain(rule.title.to_string(), None), - documentation: None, - insert_text_mode: None, - source: project::CompletionSource::Custom, - icon_path: Some(RULES_ICON.path().into()), - confirm: Some(confirm_completion_callback( - RULES_ICON.path().into(), - rule.title.clone(), - excerpt_id, - source_range.start, - new_text_len - 1, - editor.clone(), - mention_set, - uri, - )), - } - } - pub(crate) fn completion_for_path( project_path: ProjectPath, path_prefix: &str, @@ -655,12 +118,9 @@ impl ContextPickerCompletionProvider { mention_set: Arc>, project: Entity, cx: &App, - ) -> Option { + ) -> Completion { let (file_name, directory) = - crate::context_picker::file_context_picker::extract_file_name_and_directory( - &project_path.path, - path_prefix, - ); + extract_file_name_and_directory(&project_path.path, path_prefix); let label = build_code_label_for_full_path(&file_name, directory.as_ref().map(|s| s.as_ref()), cx); @@ -682,12 +142,9 @@ impl ContextPickerCompletionProvider { crease_icon_path.clone() }; - let abs_path = project.read(cx).absolute_path(&project_path, cx)?; - - let file_uri = MentionUri::File(abs_path); - let new_text = format!("{} ", file_uri.as_link()); + let new_text = format!("{} ", MentionLink::for_file(&file_name, &full_path)); let new_text_len = new_text.len(); - Some(Completion { + Completion { replace_range: source_range.clone(), new_text, label, @@ -698,153 +155,15 @@ impl ContextPickerCompletionProvider { confirm: Some(confirm_completion_callback( crease_icon_path, file_name, + project_path, excerpt_id, source_range.start, new_text_len - 1, editor, - mention_set.clone(), - file_uri, + mention_set, + project, )), - }) - } - - fn completion_for_symbol( - symbol: Symbol, - excerpt_id: ExcerptId, - source_range: Range, - editor: Entity, - mention_set: Arc>, - workspace: Entity, - cx: &mut App, - ) -> Option { - let project = workspace.read(cx).project().clone(); - - let label = CodeLabel::plain(symbol.name.clone(), None); - - let abs_path = project.read(cx).absolute_path(&symbol.path, cx)?; - let uri = MentionUri::Symbol { - path: abs_path, - name: symbol.name.clone(), - line_range: symbol.range.start.0.row..symbol.range.end.0.row, - }; - let new_text = format!("{} ", uri.as_link()); - let new_text_len = new_text.len(); - Some(Completion { - replace_range: source_range.clone(), - new_text, - label, - documentation: None, - source: project::CompletionSource::Custom, - icon_path: Some(IconName::Code.path().into()), - insert_text_mode: None, - confirm: Some(confirm_completion_callback( - IconName::Code.path().into(), - symbol.name.clone().into(), - excerpt_id, - source_range.start, - new_text_len - 1, - editor.clone(), - mention_set.clone(), - uri, - )), - }) - } - - fn completion_for_fetch( - source_range: Range, - url_to_fetch: SharedString, - excerpt_id: ExcerptId, - editor: Entity, - mention_set: Arc>, - http_client: Arc, - ) -> Option { - let new_text = format!("@fetch {} ", url_to_fetch.clone()); - let new_text_len = new_text.len(); - Some(Completion { - replace_range: source_range.clone(), - new_text, - label: CodeLabel::plain(url_to_fetch.to_string(), None), - documentation: None, - source: project::CompletionSource::Custom, - icon_path: Some(IconName::ToolWeb.path().into()), - insert_text_mode: None, - confirm: Some({ - let start = source_range.start; - let content_len = new_text_len - 1; - let editor = editor.clone(); - let url_to_fetch = url_to_fetch.clone(); - let source_range = source_range.clone(); - Arc::new(move |_, window, cx| { - let Some(url) = url::Url::parse(url_to_fetch.as_ref()) - .or_else(|_| url::Url::parse(&format!("https://{url_to_fetch}"))) - .notify_app_err(cx) - else { - return false; - }; - let mention_uri = MentionUri::Fetch { url: url.clone() }; - - let editor = editor.clone(); - let mention_set = mention_set.clone(); - let http_client = http_client.clone(); - let source_range = source_range.clone(); - window.defer(cx, move |window, cx| { - let url = url.clone(); - - let Some(crease_id) = crate::context_picker::insert_crease_for_mention( - excerpt_id, - start, - content_len, - url.to_string().into(), - IconName::ToolWeb.path().into(), - editor.clone(), - window, - cx, - ) else { - return; - }; - - let editor = editor.clone(); - let mention_set = mention_set.clone(); - let http_client = http_client.clone(); - let source_range = source_range.clone(); - window - .spawn(cx, async move |cx| { - if let Some(content) = - fetch_url_content(http_client, url.to_string()) - .await - .notify_async_err(cx) - { - mention_set.lock().add_fetch_result(url, content); - mention_set.lock().insert(crease_id, mention_uri.clone()); - } else { - // Remove crease if we failed to fetch - editor - .update(cx, |editor, cx| { - let snapshot = editor.buffer().read(cx).snapshot(cx); - let Some(anchor) = snapshot - .anchor_in_excerpt(excerpt_id, source_range.start) - else { - return; - }; - editor.display_map.update(cx, |display_map, cx| { - display_map.unfold_intersecting( - vec![anchor..anchor], - true, - cx, - ); - }); - editor.remove_creases([crease_id], cx); - }) - .ok(); - } - Some(()) - }) - .detach(); - }); - false - }) - }), - }) + } } } @@ -891,66 +210,16 @@ impl CompletionProvider for ContextPickerCompletionProvider { }; let project = workspace.read(cx).project().clone(); - let http_client = workspace.read(cx).client().http_client(); let snapshot = buffer.read(cx).snapshot(); let source_range = snapshot.anchor_before(state.source_range.start) ..snapshot.anchor_after(state.source_range.end); - let thread_store = self.thread_store.clone(); - let text_thread_store = self.text_thread_store.clone(); let editor = self.editor.clone(); - - let MentionCompletion { mode, argument, .. } = state; + let mention_set = self.mention_set.clone(); + let MentionCompletion { argument, .. } = state; let query = argument.unwrap_or_else(|| "".to_string()); - let (exclude_paths, exclude_threads) = { - let mention_set = self.mention_set.lock(); - - let mut excluded_paths = HashSet::default(); - let mut excluded_threads = HashSet::default(); - - for uri in mention_set.uri_by_crease_id.values() { - match uri { - MentionUri::File(path) => { - excluded_paths.insert(path.clone()); - } - MentionUri::Thread { id, .. } => { - excluded_threads.insert(id.clone()); - } - _ => {} - } - } - - (excluded_paths, excluded_threads) - }; - - let recent_entries = recent_context_picker_entries( - Some(thread_store.clone()), - Some(text_thread_store.clone()), - workspace.clone(), - &exclude_paths, - &exclude_threads, - cx, - ); - - let prompt_store = thread_store - .read_with(cx, |thread_store, _cx| thread_store.prompt_store().clone()) - .ok() - .flatten(); - - let search_task = search( - mode, - query, - Arc::::default(), - recent_entries, - prompt_store, - thread_store.clone(), - text_thread_store.clone(), - workspace.clone(), - cx, - ); - - let mention_set = self.mention_set.clone(); + let search_task = search_files(query.clone(), Arc::::default(), &workspace, cx); cx.spawn(async move |_, cx| { let matches = search_task.await; @@ -961,74 +230,25 @@ impl CompletionProvider for ContextPickerCompletionProvider { let completions = cx.update(|cx| { matches .into_iter() - .filter_map(|mat| match mat { - Match::File(FileMatch { mat, is_recent }) => { - let project_path = ProjectPath { - worktree_id: WorktreeId::from_usize(mat.worktree_id), - path: mat.path.clone(), - }; + .map(|mat| { + let path_match = &mat.mat; + let project_path = ProjectPath { + worktree_id: WorktreeId::from_usize(path_match.worktree_id), + path: path_match.path.clone(), + }; - Self::completion_for_path( - project_path, - &mat.path_prefix, - is_recent, - mat.is_dir, - excerpt_id, - source_range.clone(), - editor.clone(), - mention_set.clone(), - project.clone(), - cx, - ) - } - - Match::Symbol(SymbolMatch { symbol, .. }) => Self::completion_for_symbol( - symbol, + Self::completion_for_path( + project_path, + &path_match.path_prefix, + mat.is_recent, + path_match.is_dir, excerpt_id, source_range.clone(), editor.clone(), mention_set.clone(), - workspace.clone(), + project.clone(), cx, - ), - - Match::Thread(ThreadMatch { - thread, is_recent, .. - }) => Some(Self::completion_for_thread( - thread, - excerpt_id, - source_range.clone(), - is_recent, - editor.clone(), - mention_set.clone(), - )), - - Match::Rules(user_rules) => Some(Self::completion_for_rules( - user_rules, - excerpt_id, - source_range.clone(), - editor.clone(), - mention_set.clone(), - )), - - Match::Fetch(url) => Self::completion_for_fetch( - source_range.clone(), - url, - excerpt_id, - editor.clone(), - mention_set.clone(), - http_client.clone(), - ), - - Match::Entry(EntryMatch { entry, .. }) => Self::completion_for_entry( - entry, - excerpt_id, - source_range.clone(), - editor.clone(), - mention_set.clone(), - &workspace, - cx, - ), + ) }) .collect() })?; @@ -1080,21 +300,23 @@ impl CompletionProvider for ContextPickerCompletionProvider { fn confirm_completion_callback( crease_icon_path: SharedString, crease_text: SharedString, + project_path: ProjectPath, excerpt_id: ExcerptId, start: Anchor, content_len: usize, editor: Entity, mention_set: Arc>, - mention_uri: MentionUri, + project: Entity, ) -> Arc bool + Send + Sync> { Arc::new(move |_, window, cx| { let crease_text = crease_text.clone(); let crease_icon_path = crease_icon_path.clone(); let editor = editor.clone(); + let project_path = project_path.clone(); let mention_set = mention_set.clone(); - let mention_uri = mention_uri.clone(); + let project = project.clone(); window.defer(cx, move |window, cx| { - if let Some(crease_id) = crate::context_picker::insert_crease_for_mention( + let crease_id = crate::context_picker::insert_crease_for_mention( excerpt_id, start, content_len, @@ -1103,8 +325,14 @@ fn confirm_completion_callback( editor.clone(), window, cx, - ) { - mention_set.lock().insert(crease_id, mention_uri.clone()); + ); + + let Some(path) = project.read(cx).absolute_path(&project_path, cx) else { + return; + }; + + if let Some(crease_id) = crease_id { + mention_set.lock().insert(crease_id, path); } }); false @@ -1114,7 +342,6 @@ fn confirm_completion_callback( #[derive(Debug, Default, PartialEq)] struct MentionCompletion { source_range: Range, - mode: Option, argument: Option, } @@ -1134,37 +361,17 @@ impl MentionCompletion { } let rest_of_line = &line[last_mention_start + 1..]; - - let mut mode = None; let mut argument = None; let mut parts = rest_of_line.split_whitespace(); let mut end = last_mention_start + 1; - if let Some(mode_text) = parts.next() { - end += mode_text.len(); - - if let Some(parsed_mode) = ContextPickerMode::try_from(mode_text).ok() { - mode = Some(parsed_mode); - } else { - argument = Some(mode_text.to_string()); - } - match rest_of_line[mode_text.len()..].find(|c: char| !c.is_whitespace()) { - Some(whitespace_count) => { - if let Some(argument_text) = parts.next() { - argument = Some(argument_text.to_string()); - end += whitespace_count + argument_text.len(); - } - } - None => { - // Rest of line is entirely whitespace - end += rest_of_line.len() - mode_text.len(); - } - } + if let Some(argument_text) = parts.next() { + end += argument_text.len(); + argument = Some(argument_text.to_string()); } Some(Self { source_range: last_mention_start + offset_to_line..end + offset_to_line, - mode, argument, }) } @@ -1173,12 +380,10 @@ impl MentionCompletion { #[cfg(test)] mod tests { use super::*; - use editor::AnchorRangeExt; use gpui::{EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext}; use project::{Project, ProjectPath}; use serde_json::json; use settings::SettingsStore; - use smol::stream::StreamExt as _; use std::{ops::Deref, rc::Rc}; use util::path; use workspace::{AppState, Item}; @@ -1191,61 +396,14 @@ mod tests { MentionCompletion::try_parse("Lorem @", 0), Some(MentionCompletion { source_range: 6..7, - mode: None, argument: None, }) ); - assert_eq!( - MentionCompletion::try_parse("Lorem @file", 0), - Some(MentionCompletion { - source_range: 6..11, - mode: Some(ContextPickerMode::File), - argument: None, - }) - ); - - assert_eq!( - MentionCompletion::try_parse("Lorem @file ", 0), - Some(MentionCompletion { - source_range: 6..12, - mode: Some(ContextPickerMode::File), - argument: None, - }) - ); - - assert_eq!( - MentionCompletion::try_parse("Lorem @file main.rs", 0), - Some(MentionCompletion { - source_range: 6..19, - mode: Some(ContextPickerMode::File), - argument: Some("main.rs".to_string()), - }) - ); - - assert_eq!( - MentionCompletion::try_parse("Lorem @file main.rs ", 0), - Some(MentionCompletion { - source_range: 6..19, - mode: Some(ContextPickerMode::File), - argument: Some("main.rs".to_string()), - }) - ); - - assert_eq!( - MentionCompletion::try_parse("Lorem @file main.rs Ipsum", 0), - Some(MentionCompletion { - source_range: 6..19, - mode: Some(ContextPickerMode::File), - argument: Some("main.rs".to_string()), - }) - ); - assert_eq!( MentionCompletion::try_parse("Lorem @main", 0), Some(MentionCompletion { source_range: 6..11, - mode: None, argument: Some("main".to_string()), }) ); @@ -1302,16 +460,16 @@ mod tests { json!({ "editor": "", "a": { - "one.txt": "1", - "two.txt": "2", - "three.txt": "3", - "four.txt": "4" + "one.txt": "", + "two.txt": "", + "three.txt": "", + "four.txt": "" }, "b": { - "five.txt": "5", - "six.txt": "6", - "seven.txt": "7", - "eight.txt": "8", + "five.txt": "", + "six.txt": "", + "seven.txt": "", + "eight.txt": "", } }), ) @@ -1386,17 +544,12 @@ mod tests { let mention_set = Arc::new(Mutex::new(MentionSet::default())); - let thread_store = cx.new(|cx| ThreadStore::fake(project.clone(), cx)); - let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); - let editor_entity = editor.downgrade(); editor.update_in(&mut cx, |editor, window, cx| { window.focus(&editor.focus_handle(cx)); editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new( mention_set.clone(), workspace.downgrade(), - thread_store.downgrade(), - text_thread_store.downgrade(), editor_entity, )))); }); @@ -1420,10 +573,22 @@ mod tests { "seven.txt dir/b/", "six.txt dir/b/", "five.txt dir/b/", - "Files & Directories", - "Symbols", - "Threads", - "Fetch" + "four.txt dir/a/", + "three.txt dir/a/", + "two.txt dir/a/", + "one.txt dir/a/", + "dir ", + "a dir/", + "four.txt dir/a/", + "one.txt dir/a/", + "three.txt dir/a/", + "two.txt dir/a/", + "b dir/", + "eight.txt dir/b/", + "five.txt dir/b/", + "seven.txt dir/b/", + "six.txt dir/b/", + "editor dir/" ] ); }); @@ -1441,264 +606,8 @@ mod tests { cx.run_until_parked(); editor.update(&mut cx, |editor, cx| { - assert_eq!(editor.text(cx), "Lorem @file "); - assert!(editor.has_visible_completions_menu()); + assert_eq!(editor.text(cx), "Lorem [@four.txt](@file:dir/a/four.txt) "); }); - - cx.simulate_input("one"); - - editor.update(&mut cx, |editor, cx| { - assert_eq!(editor.text(cx), "Lorem @file one"); - assert!(editor.has_visible_completions_menu()); - assert_eq!(current_completion_labels(editor), vec!["one.txt dir/a/"]); - }); - - editor.update_in(&mut cx, |editor, window, cx| { - assert!(editor.has_visible_completions_menu()); - editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); - }); - - editor.update(&mut cx, |editor, cx| { - assert_eq!(editor.text(cx), "Lorem [@one.txt](file:///dir/a/one.txt) "); - assert!(!editor.has_visible_completions_menu()); - assert_eq!( - fold_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 39)] - ); - }); - - let contents = cx - .update(|window, cx| { - mention_set.lock().contents( - project.clone(), - thread_store.clone(), - text_thread_store.clone(), - window, - cx, - ) - }) - .await - .unwrap() - .into_values() - .collect::>(); - - assert_eq!(contents.len(), 1); - assert_eq!(contents[0].content, "1"); - assert_eq!( - contents[0].uri.to_uri().to_string(), - "file:///dir/a/one.txt" - ); - - cx.simulate_input(" "); - - editor.update(&mut cx, |editor, cx| { - assert_eq!(editor.text(cx), "Lorem [@one.txt](file:///dir/a/one.txt) "); - assert!(!editor.has_visible_completions_menu()); - assert_eq!( - fold_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 39)] - ); - }); - - cx.simulate_input("Ipsum "); - - editor.update(&mut cx, |editor, cx| { - assert_eq!( - editor.text(cx), - "Lorem [@one.txt](file:///dir/a/one.txt) Ipsum ", - ); - assert!(!editor.has_visible_completions_menu()); - assert_eq!( - fold_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 39)] - ); - }); - - cx.simulate_input("@file "); - - editor.update(&mut cx, |editor, cx| { - assert_eq!( - editor.text(cx), - "Lorem [@one.txt](file:///dir/a/one.txt) Ipsum @file ", - ); - assert!(editor.has_visible_completions_menu()); - assert_eq!( - fold_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 39)] - ); - }); - - editor.update_in(&mut cx, |editor, window, cx| { - editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); - }); - - cx.run_until_parked(); - - let contents = cx - .update(|window, cx| { - mention_set.lock().contents( - project.clone(), - thread_store.clone(), - text_thread_store.clone(), - window, - cx, - ) - }) - .await - .unwrap() - .into_values() - .collect::>(); - - assert_eq!(contents.len(), 2); - let new_mention = contents - .iter() - .find(|mention| mention.uri.to_uri().to_string() == "file:///dir/b/eight.txt") - .unwrap(); - assert_eq!(new_mention.content, "8"); - - editor.update(&mut cx, |editor, cx| { - assert_eq!( - editor.text(cx), - "Lorem [@one.txt](file:///dir/a/one.txt) Ipsum [@eight.txt](file:///dir/b/eight.txt) " - ); - assert!(!editor.has_visible_completions_menu()); - assert_eq!( - fold_ranges(editor, cx), - vec![ - Point::new(0, 6)..Point::new(0, 39), - Point::new(0, 47)..Point::new(0, 84) - ] - ); - }); - - let plain_text_language = Arc::new(language::Language::new( - language::LanguageConfig { - name: "Plain Text".into(), - matcher: language::LanguageMatcher { - path_suffixes: vec!["txt".to_string()], - ..Default::default() - }, - ..Default::default() - }, - None, - )); - - // Register the language and fake LSP - let language_registry = project.read_with(&cx, |project, _| project.languages().clone()); - language_registry.add(plain_text_language); - - let mut fake_language_servers = language_registry.register_fake_lsp( - "Plain Text", - language::FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - workspace_symbol_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() - }, - ..Default::default() - }, - ); - - // Open the buffer to trigger LSP initialization - let buffer = project - .update(&mut cx, |project, cx| { - project.open_local_buffer(path!("/dir/a/one.txt"), cx) - }) - .await - .unwrap(); - - // Register the buffer with language servers - let _handle = project.update(&mut cx, |project, cx| { - project.register_buffer_with_language_servers(&buffer, cx) - }); - - cx.run_until_parked(); - - let fake_language_server = fake_language_servers.next().await.unwrap(); - fake_language_server.set_request_handler::( - |_, _| async move { - Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![ - #[allow(deprecated)] - lsp::SymbolInformation { - name: "MySymbol".into(), - location: lsp::Location { - uri: lsp::Url::from_file_path(path!("/dir/a/one.txt")).unwrap(), - range: lsp::Range::new( - lsp::Position::new(0, 0), - lsp::Position::new(0, 1), - ), - }, - kind: lsp::SymbolKind::CONSTANT, - tags: None, - container_name: None, - deprecated: None, - }, - ]))) - }, - ); - - cx.simulate_input("@symbol "); - - editor.update(&mut cx, |editor, cx| { - assert_eq!( - editor.text(cx), - "Lorem [@one.txt](file:///dir/a/one.txt) Ipsum [@eight.txt](file:///dir/b/eight.txt) @symbol " - ); - assert!(editor.has_visible_completions_menu()); - assert_eq!( - current_completion_labels(editor), - &[ - "MySymbol", - ] - ); - }); - - editor.update_in(&mut cx, |editor, window, cx| { - editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); - }); - - let contents = cx - .update(|window, cx| { - mention_set.lock().contents( - project.clone(), - thread_store, - text_thread_store, - window, - cx, - ) - }) - .await - .unwrap() - .into_values() - .collect::>(); - - assert_eq!(contents.len(), 3); - let new_mention = contents - .iter() - .find(|mention| { - mention.uri.to_uri().to_string() == "file:///dir/a/one.txt?symbol=MySymbol#L1:1" - }) - .unwrap(); - assert_eq!(new_mention.content, "1"); - - cx.run_until_parked(); - - editor.read_with(&mut cx, |editor, cx| { - assert_eq!( - editor.text(cx), - "Lorem [@one.txt](file:///dir/a/one.txt) Ipsum [@eight.txt](file:///dir/b/eight.txt) [@MySymbol](file:///dir/a/one.txt?symbol=MySymbol#L1:1) " - ); - }); - } - - fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec> { - let snapshot = editor.buffer().read(cx).snapshot(cx); - editor.display_map.update(cx, |display_map, cx| { - display_map - .snapshot(cx) - .folds_in_range(0..snapshot.len()) - .map(|fold| fold.range.to_point(&snapshot)) - .collect() - }) } fn current_completion_labels(editor: &Editor) -> Vec { diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs new file mode 100644 index 0000000000..400a54b491 --- /dev/null +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -0,0 +1,456 @@ +use crate::acp::completion_provider::ContextPickerCompletionProvider; +use crate::acp::completion_provider::MentionSet; +use acp_thread::MentionUri; +use agent_client_protocol as acp; +use anyhow::Result; +use collections::HashSet; +use editor::{ + AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorMode, + EditorStyle, MultiBuffer, +}; +use file_icons::FileIcons; +use gpui::{ + AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, Task, TextStyle, WeakEntity, +}; +use language::Buffer; +use language::Language; +use parking_lot::Mutex; +use project::{CompletionIntent, Project}; +use settings::Settings; +use std::path::Path; +use std::rc::Rc; +use std::sync::Arc; +use theme::ThemeSettings; +use ui::{ + ActiveTheme, App, IconName, InteractiveElement, IntoElement, ParentElement, Render, + SharedString, Styled, TextSize, Window, div, +}; +use util::ResultExt; +use workspace::Workspace; +use zed_actions::agent::Chat; + +pub struct MessageEditor { + editor: Entity, + project: Entity, + mention_set: Arc>, +} + +pub enum MessageEditorEvent { + Send, + Cancel, +} + +impl EventEmitter for MessageEditor {} + +impl MessageEditor { + pub fn new( + workspace: WeakEntity, + project: Entity, + mode: EditorMode, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let language = Language::new( + language::LanguageConfig { + completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']), + ..Default::default() + }, + None, + ); + + let mention_set = Arc::new(Mutex::new(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)); + + let mut editor = Editor::new(mode, buffer, None, window, cx); + editor.set_placeholder_text("Message the agent - @ to include files", cx); + editor.set_show_indent_guides(false, cx); + editor.set_soft_wrap(); + editor.set_use_modal_editing(true); + editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new( + mention_set.clone(), + workspace, + cx.weak_entity(), + )))); + editor.set_context_menu_options(ContextMenuOptions { + min_entries_visible: 12, + max_entries_visible: 12, + placement: Some(ContextMenuPlacement::Above), + }); + editor + }); + + Self { + editor, + project, + mention_set, + } + } + + pub fn is_empty(&self, cx: &App) -> bool { + self.editor.read(cx).is_empty(cx) + } + + pub fn contents(&self, cx: &mut Context) -> Task>> { + let contents = self.mention_set.lock().contents(self.project.clone(), cx); + let editor = self.editor.clone(); + + cx.spawn(async move |_, cx| { + let contents = contents.await?; + + editor.update(cx, |editor, cx| { + let mut ix = 0; + let mut chunks: Vec = Vec::new(); + let text = editor.text(cx); + editor.display_map.update(cx, |map, cx| { + let snapshot = map.snapshot(cx); + for (crease_id, crease) in snapshot.crease_snapshot.creases() { + // Skip creases that have been edited out of the message buffer. + if !crease.range().start.is_valid(&snapshot.buffer_snapshot) { + continue; + } + + if let Some(mention) = contents.get(&crease_id) { + let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot); + if crease_range.start > ix { + chunks.push(text[ix..crease_range.start].into()); + } + chunks.push(acp::ContentBlock::Resource(acp::EmbeddedResource { + annotations: None, + resource: acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents { + mime_type: None, + text: mention.content.clone(), + uri: mention.uri.to_uri(), + }, + ), + })); + ix = crease_range.end; + } + } + + if ix < text.len() { + let last_chunk = text[ix..].trim_end(); + if !last_chunk.is_empty() { + chunks.push(last_chunk.into()); + } + } + }); + + chunks + }) + }) + } + + pub fn clear(&mut self, window: &mut Window, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.clear(window, cx); + editor.remove_creases(self.mention_set.lock().drain(), cx) + }); + } + + fn chat(&mut self, _: &Chat, _: &mut Window, cx: &mut Context) { + cx.emit(MessageEditorEvent::Send) + } + + fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { + cx.emit(MessageEditorEvent::Cancel) + } + + pub fn insert_dragged_files( + &self, + paths: Vec, + window: &mut Window, + cx: &mut Context, + ) { + let buffer = self.editor.read(cx).buffer().clone(); + let Some((&excerpt_id, _, _)) = buffer.read(cx).snapshot(cx).as_singleton() else { + return; + }; + let Some(buffer) = buffer.read(cx).as_singleton() else { + return; + }; + for path in paths { + let Some(entry) = self.project.read(cx).entry_for_path(&path, cx) else { + continue; + }; + let Some(abs_path) = self.project.read(cx).absolute_path(&path, cx) else { + continue; + }; + + let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len())); + let path_prefix = abs_path + .file_name() + .unwrap_or(path.path.as_os_str()) + .display() + .to_string(); + let completion = ContextPickerCompletionProvider::completion_for_path( + path, + &path_prefix, + false, + entry.is_dir(), + excerpt_id, + anchor..anchor, + self.editor.clone(), + self.mention_set.clone(), + self.project.clone(), + cx, + ); + + self.editor.update(cx, |message_editor, cx| { + message_editor.edit( + [( + multi_buffer::Anchor::max()..multi_buffer::Anchor::max(), + completion.new_text, + )], + cx, + ); + }); + if let Some(confirm) = completion.confirm.clone() { + confirm(CompletionIntent::Complete, window, cx); + } + } + } + + pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.set_mode(mode); + cx.notify() + }); + } + + pub fn set_message( + &mut self, + message: &[acp::ContentBlock], + window: &mut Window, + cx: &mut Context, + ) { + let mut text = String::new(); + let mut mentions = Vec::new(); + + for chunk in message { + match chunk { + acp::ContentBlock::Text(text_content) => { + text.push_str(&text_content.text); + } + acp::ContentBlock::Resource(acp::EmbeddedResource { + resource: acp::EmbeddedResourceResource::TextResourceContents(resource), + .. + }) => { + if let Some(mention) = MentionUri::parse(&resource.uri).log_err() { + let project_path = self + .project + .read(cx) + .project_path_for_absolute_path(&abs_path, cx); + let start = text.len(); + write!(text, "{}", mention.as_link()); + let end = text.len(); + mentions.push((start..end, project_path, filename)); + } + } + acp::ContentBlock::Image(_) + | acp::ContentBlock::Audio(_) + | acp::ContentBlock::Resource(_) + | acp::ContentBlock::ResourceLink(_) => {} + } + } + + let snapshot = self.editor.update(cx, |editor, cx| { + editor.set_text(text, window, cx); + editor.buffer().read(cx).snapshot(cx) + }); + + self.mention_set.lock().clear(); + for (range, project_path, filename) in mentions { + let crease_icon_path = if project_path.path.is_dir() { + FileIcons::get_folder_icon(false, cx) + .unwrap_or_else(|| IconName::Folder.path().into()) + } else { + FileIcons::get_icon(Path::new(project_path.path.as_ref()), cx) + .unwrap_or_else(|| IconName::File.path().into()) + }; + + let anchor = snapshot.anchor_before(range.start); + if let Some(project_path) = self.project.read(cx).absolute_path(&project_path, cx) { + let crease_id = crate::context_picker::insert_crease_for_mention( + anchor.excerpt_id, + anchor.text_anchor, + range.end - range.start, + filename, + crease_icon_path, + self.editor.clone(), + window, + cx, + ); + + if let Some(crease_id) = crease_id { + self.mention_set.lock().insert(crease_id, project_path); + } + } + } + cx.notify(); + } + + #[cfg(test)] + pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.set_text(text, window, cx); + }); + } +} + +impl Focusable for MessageEditor { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.editor.focus_handle(cx) + } +} + +impl Render for MessageEditor { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .key_context("MessageEditor") + .on_action(cx.listener(Self::chat)) + .on_action(cx.listener(Self::cancel)) + .flex_1() + .child({ + let settings = ThemeSettings::get_global(cx); + let font_size = TextSize::Small + .rems(cx) + .to_pixels(settings.agent_font_size(cx)); + let line_height = settings.buffer_line_height.value() * font_size; + + let text_style = TextStyle { + color: cx.theme().colors().text, + font_family: settings.buffer_font.family.clone(), + font_fallbacks: settings.buffer_font.fallbacks.clone(), + font_features: settings.buffer_font.features.clone(), + font_size: font_size.into(), + line_height: line_height.into(), + ..Default::default() + }; + + EditorElement::new( + &self.editor, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + syntax: cx.theme().syntax().clone(), + ..Default::default() + }, + ) + }) + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use agent_client_protocol as acp; + use editor::EditorMode; + use fs::FakeFs; + use gpui::{AppContext, TestAppContext}; + use lsp::{CompletionContext, CompletionTriggerKind}; + use project::{CompletionIntent, Project}; + use serde_json::json; + use util::path; + use workspace::Workspace; + + use crate::acp::{message_editor::MessageEditor, thread_view::tests::init_test}; + + #[gpui::test] + async fn test_at_mention_removal(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({"file": ""})).await; + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let message_editor = cx.update(|window, cx| { + cx.new(|cx| { + MessageEditor::new( + workspace.downgrade(), + project.clone(), + EditorMode::AutoHeight { + min_lines: 1, + max_lines: None, + }, + window, + cx, + ) + }) + }); + let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone()); + + cx.run_until_parked(); + + let excerpt_id = editor.update(cx, |editor, cx| { + editor + .buffer() + .read(cx) + .excerpt_ids() + .into_iter() + .next() + .unwrap() + }); + let completions = editor.update_in(cx, |editor, window, cx| { + editor.set_text("Hello @", window, cx); + let buffer = editor.buffer().read(cx).as_singleton().unwrap(); + let completion_provider = editor.completion_provider().unwrap(); + completion_provider.completions( + excerpt_id, + &buffer, + text::Anchor::MAX, + CompletionContext { + trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER, + trigger_character: Some("@".into()), + }, + window, + cx, + ) + }); + let [_, completion]: [_; 2] = completions + .await + .unwrap() + .into_iter() + .flat_map(|response| response.completions) + .collect::>() + .try_into() + .unwrap(); + + editor.update_in(cx, |editor, window, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let start = snapshot + .anchor_in_excerpt(excerpt_id, completion.replace_range.start) + .unwrap(); + let end = snapshot + .anchor_in_excerpt(excerpt_id, completion.replace_range.end) + .unwrap(); + editor.edit([(start..end, completion.new_text)], cx); + (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx); + }); + + cx.run_until_parked(); + + // Backspace over the inserted crease (and the following space). + editor.update_in(cx, |editor, window, cx| { + editor.backspace(&Default::default(), window, cx); + editor.backspace(&Default::default(), window, cx); + }); + + let content = message_editor + .update_in(cx, |message_editor, _window, cx| { + message_editor.contents(cx) + }) + .await + .unwrap(); + + // We don't send a resource link for the deleted crease. + pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]); + } +} diff --git a/crates/agent_ui/src/acp/message_history.rs b/crates/agent_ui/src/acp/message_history.rs deleted file mode 100644 index c8280573a0..0000000000 --- a/crates/agent_ui/src/acp/message_history.rs +++ /dev/null @@ -1,88 +0,0 @@ -pub struct MessageHistory { - items: Vec, - current: Option, -} - -impl Default for MessageHistory { - fn default() -> Self { - MessageHistory { - items: Vec::new(), - current: None, - } - } -} - -impl MessageHistory { - pub fn push(&mut self, message: T) { - self.current.take(); - self.items.push(message); - } - - pub fn reset_position(&mut self) { - self.current.take(); - } - - pub fn prev(&mut self) -> Option<&T> { - if self.items.is_empty() { - return None; - } - - let new_ix = self - .current - .get_or_insert(self.items.len()) - .saturating_sub(1); - - self.current = Some(new_ix); - self.items.get(new_ix) - } - - pub fn next(&mut self) -> Option<&T> { - let current = self.current.as_mut()?; - *current += 1; - - self.items.get(*current).or_else(|| { - self.current.take(); - None - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_prev_next() { - let mut history = MessageHistory::default(); - - // Test empty history - assert_eq!(history.prev(), None); - assert_eq!(history.next(), None); - - // Add some messages - history.push("first"); - history.push("second"); - history.push("third"); - - // Test prev navigation - assert_eq!(history.prev(), Some(&"third")); - assert_eq!(history.prev(), Some(&"second")); - assert_eq!(history.prev(), Some(&"first")); - assert_eq!(history.prev(), Some(&"first")); - - assert_eq!(history.next(), Some(&"second")); - - // Test mixed navigation - history.push("fourth"); - assert_eq!(history.prev(), Some(&"fourth")); - assert_eq!(history.prev(), Some(&"third")); - assert_eq!(history.next(), Some(&"fourth")); - assert_eq!(history.next(), None); - - // Test that push resets navigation - history.prev(); - history.prev(); - history.push("fifth"); - assert_eq!(history.prev(), Some(&"fifth")); - } -} diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 3aefae7265..cdd8a51221 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1,45 +1,33 @@ use acp_thread::{ AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, - LoadError, MentionUri, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus, + LoadError, MentionUri, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus, UserMessageId, }; use acp_thread::{AgentConnection, Plan}; use action_log::ActionLog; -use agent::{TextThreadStore, ThreadStore}; use agent_client_protocol as acp; use agent_servers::AgentServer; use agent_settings::{AgentSettings, NotifyWhenAgentWaiting}; use audio::{Audio, Sound}; use buffer_diff::BufferDiff; use collections::{HashMap, HashSet}; -use editor::scroll::Autoscroll; -use editor::{ - AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorMode, - EditorStyle, MinimapVisibility, MultiBuffer, PathKey, SelectionEffects, -}; +use editor::{Editor, EditorMode, MinimapVisibility, MultiBuffer, PathKey}; use file_icons::FileIcons; use gpui::{ - Action, Animation, AnimationExt, App, BorderStyle, EdgesRefinement, Empty, Entity, EntityId, - FocusHandle, Focusable, Hsla, Length, ListOffset, ListState, MouseButton, PlatformDisplay, - SharedString, Stateful, StyleRefinement, Subscription, Task, TextStyle, TextStyleRefinement, - Transformation, UnderlineStyle, WeakEntity, Window, WindowHandle, div, linear_color_stop, - linear_gradient, list, percentage, point, prelude::*, pulsating_between, + Action, Animation, AnimationExt, App, BorderStyle, ClickEvent, EdgesRefinement, Empty, Entity, + EntityId, FocusHandle, Focusable, Hsla, Length, ListOffset, ListState, MouseButton, + PlatformDisplay, SharedString, Stateful, StyleRefinement, Subscription, Task, TextStyle, + TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, Window, WindowHandle, div, + linear_color_stop, linear_gradient, list, percentage, point, prelude::*, pulsating_between, }; +use language::Buffer; use language::language_settings::SoftWrap; -use language::{Buffer, Language}; use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle}; -use parking_lot::Mutex; -use project::{CompletionIntent, Project}; -use prompt_store::PromptId; +use project::Project; use rope::Point; use settings::{Settings as _, SettingsStore}; -use std::fmt::Write as _; -use std::path::PathBuf; -use std::{ - cell::RefCell, collections::BTreeMap, path::Path, process::ExitStatus, rc::Rc, sync::Arc, - time::Duration, -}; +use std::{collections::BTreeMap, process::ExitStatus, rc::Rc, time::Duration}; use terminal_view::TerminalView; -use text::{Anchor, BufferSnapshot}; +use text::Anchor; use theme::ThemeSettings; use ui::{ Disclosure, Divider, DividerColor, KeyBinding, PopoverMenuHandle, Scrollbar, ScrollbarState, @@ -47,14 +35,11 @@ use ui::{ }; use util::{ResultExt, size::format_file_size, time::duration_alt_display}; use workspace::{CollaboratorId, Workspace}; -use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage, ToggleModelSelector}; -use zed_actions::assistant::OpenRulesLibrary; +use zed_actions::agent::{Chat, ToggleModelSelector}; use crate::acp::AcpModelSelectorPopover; -use crate::acp::completion_provider::{ContextPickerCompletionProvider, MentionSet}; -use crate::acp::message_history::MessageHistory; +use crate::acp::message_editor::{MessageEditor, MessageEditorEvent}; use crate::agent_diff::AgentDiff; -use crate::message_editor::{MAX_EDITOR_LINES, MIN_EDITOR_LINES}; use crate::ui::{AgentNotification, AgentNotificationEvent}; use crate::{ AgentDiffPane, AgentPanel, ExpandMessageEditor, Follow, KeepAll, OpenAgentDiff, RejectAll, @@ -62,20 +47,18 @@ use crate::{ const RESPONSE_PADDING_X: Pixels = px(19.); +pub const MIN_EDITOR_LINES: usize = 4; +pub const MAX_EDITOR_LINES: usize = 8; + pub struct AcpThreadView { agent: Rc, workspace: WeakEntity, project: Entity, - thread_store: Entity, - text_thread_store: Entity, thread_state: ThreadState, diff_editors: HashMap>, terminal_views: HashMap>, - message_editor: Entity, + message_editor: Entity, model_selector: Option>, - message_set_from_history: Option, - _message_editor_subscription: Subscription, - mention_set: Arc>, notifications: Vec>, notification_subscriptions: HashMap, Vec>, last_error: Option>, @@ -88,9 +71,15 @@ pub struct AcpThreadView { plan_expanded: bool, editor_expanded: bool, terminal_expanded: bool, - message_history: Rc>>>, + editing_message: Option, _cancel_task: Option>, - _subscriptions: [Subscription; 1], + _subscriptions: [Subscription; 2], +} + +struct EditingMessage { + message_id: UserMessageId, + editor: Entity, + _subscription: Subscription, } enum ThreadState { @@ -115,98 +104,36 @@ impl AcpThreadView { agent: Rc, workspace: WeakEntity, project: Entity, - thread_store: Entity, - text_thread_store: Entity, - message_history: Rc>>>, - min_lines: usize, - max_lines: Option, window: &mut Window, cx: &mut Context, ) -> Self { - let language = Language::new( - language::LanguageConfig { - completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']), - ..Default::default() - }, - None, - ); - - let mention_set = Arc::new(Mutex::new(MentionSet::default())); - let message_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)); - - let mut editor = Editor::new( + MessageEditor::new( + workspace.clone(), + project.clone(), editor::EditorMode::AutoHeight { - min_lines, - max_lines: max_lines, + min_lines: MIN_EDITOR_LINES, + max_lines: Some(MAX_EDITOR_LINES), }, - buffer, - None, window, cx, - ); - editor.set_placeholder_text("Message the agent - @ to include files", cx); - editor.set_show_indent_guides(false, cx); - editor.set_soft_wrap(); - editor.set_use_modal_editing(true); - editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new( - mention_set.clone(), - workspace.clone(), - thread_store.downgrade(), - text_thread_store.downgrade(), - cx.weak_entity(), - )))); - editor.set_context_menu_options(ContextMenuOptions { - min_entries_visible: 12, - max_entries_visible: 12, - placement: Some(ContextMenuPlacement::Above), - }); - editor + ) }); - let message_editor_subscription = - cx.subscribe(&message_editor, |this, editor, event, cx| { - if let editor::EditorEvent::BufferEdited = &event { - let buffer = editor - .read(cx) - .buffer() - .read(cx) - .as_singleton() - .unwrap() - .read(cx) - .snapshot(); - if let Some(message) = this.message_set_from_history.clone() - && message.version() != buffer.version() - { - this.message_set_from_history = None; - } - - if this.message_set_from_history.is_none() { - this.message_history.borrow_mut().reset_position(); - } - } - }); - - let mention_set = mention_set.clone(); - let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0)); - let subscription = cx.observe_global_in::(window, Self::settings_changed); + let subscriptions = [ + cx.observe_global_in::(window, Self::settings_changed), + cx.subscribe_in(&message_editor, window, Self::on_message_editor_event), + ]; Self { agent: agent.clone(), workspace: workspace.clone(), project: project.clone(), - thread_store, - text_thread_store, thread_state: Self::initial_state(agent, workspace, project, window, cx), message_editor, model_selector: None, - message_set_from_history: None, - _message_editor_subscription: message_editor_subscription, - mention_set, notifications: Vec::new(), notification_subscriptions: HashMap::default(), diff_editors: Default::default(), @@ -217,12 +144,12 @@ impl AcpThreadView { auth_task: None, expanded_tool_calls: HashSet::default(), expanded_thinking_blocks: HashSet::default(), + editing_message: None, edits_expanded: false, plan_expanded: false, editor_expanded: false, terminal_expanded: true, - message_history, - _subscriptions: [subscription], + _subscriptions: subscriptions, _cancel_task: None, } } @@ -370,7 +297,7 @@ impl AcpThreadView { } } - pub fn cancel(&mut self, cx: &mut Context) { + pub fn cancel_generation(&mut self, cx: &mut Context) { self.last_error.take(); if let Some(thread) = self.thread() { @@ -390,193 +317,151 @@ impl AcpThreadView { fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context) { self.editor_expanded = is_expanded; - self.message_editor.update(cx, |editor, _| { - if self.editor_expanded { - editor.set_mode(EditorMode::Full { - scale_ui_elements_with_buffer_font_size: false, - show_active_line_background: false, - sized_by_content: false, - }) + self.message_editor.update(cx, |editor, cx| { + if is_expanded { + editor.set_mode( + EditorMode::Full { + scale_ui_elements_with_buffer_font_size: false, + show_active_line_background: false, + sized_by_content: false, + }, + cx, + ) } else { - editor.set_mode(EditorMode::AutoHeight { - min_lines: MIN_EDITOR_LINES, - max_lines: Some(MAX_EDITOR_LINES), - }) + editor.set_mode( + EditorMode::AutoHeight { + min_lines: MIN_EDITOR_LINES, + max_lines: Some(MAX_EDITOR_LINES), + }, + cx, + ) } }); cx.notify(); } - fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context) { + pub fn on_message_editor_event( + &mut self, + _: &Entity, + event: &MessageEditorEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + MessageEditorEvent::Send => self.send(window, cx), + MessageEditorEvent::Cancel => self.cancel_generation(cx), + } + } + + fn send(&mut self, window: &mut Window, cx: &mut Context) { self.last_error.take(); - let mut ix = 0; - let mut chunks: Vec = Vec::new(); - let project = self.project.clone(); + let Some(thread) = self.thread().cloned() else { + return; + }; - let thread_store = self.thread_store.clone(); - let text_thread_store = self.text_thread_store.clone(); + let contents = self + .message_editor + .update(cx, |message_editor, cx| message_editor.contents(cx)); + let task = cx.spawn_in(window, async move |this, cx| { + let contents = contents.await?; - let contents = - self.mention_set - .lock() - .contents(project, thread_store, text_thread_store, window, cx); - - cx.spawn_in(window, async move |this, cx| { - let contents = match contents.await { - Ok(contents) => contents, - Err(e) => { - this.update(cx, |this, cx| { - this.last_error = - Some(cx.new(|cx| Markdown::new(e.to_string().into(), None, None, cx))); - }) - .ok(); - return; - } - }; + if contents.is_empty() { + return Ok(()); + } this.update_in(cx, |this, window, cx| { - this.message_editor.update(cx, |editor, cx| { - let text = editor.text(cx); - editor.display_map.update(cx, |map, cx| { - let snapshot = map.snapshot(cx); - for (crease_id, crease) in snapshot.crease_snapshot.creases() { - // Skip creases that have been edited out of the message buffer. - if !crease.range().start.is_valid(&snapshot.buffer_snapshot) { - continue; - } - - if let Some(mention) = contents.get(&crease_id) { - let crease_range = - crease.range().to_offset(&snapshot.buffer_snapshot); - if crease_range.start > ix { - chunks.push(text[ix..crease_range.start].into()); - } - chunks.push(acp::ContentBlock::Resource(acp::EmbeddedResource { - annotations: None, - resource: acp::EmbeddedResourceResource::TextResourceContents( - acp::TextResourceContents { - mime_type: None, - text: mention.content.clone(), - uri: mention.uri.to_uri().to_string(), - }, - ), - })); - ix = crease_range.end; - } - } - - if ix < text.len() { - let last_chunk = text[ix..].trim_end(); - if !last_chunk.is_empty() { - chunks.push(last_chunk.into()); - } - } - }) - }); - - if chunks.is_empty() { - return; - } - - let Some(thread) = this.thread() else { - return; - }; - let task = thread.update(cx, |thread, cx| thread.send(chunks.clone(), cx)); - - cx.spawn(async move |this, cx| { - let result = task.await; - - this.update(cx, |this, cx| { - if let Err(err) = result { - this.last_error = - Some(cx.new(|cx| { - Markdown::new(err.to_string().into(), None, None, cx) - })) - } - }) - }) - .detach(); - - let mention_set = this.mention_set.clone(); - this.set_editor_is_expanded(false, cx); - - this.message_editor.update(cx, |editor, cx| { - editor.clear(window, cx); - editor.remove_creases(mention_set.lock().drain(), cx) - }); - this.scroll_to_bottom(cx); + this.message_editor.update(cx, |message_editor, cx| { + message_editor.clear(window, cx); + }); + })?; + let send = thread.update(cx, |thread, cx| thread.send(contents, cx))?; + send.await + }); - this.message_history.borrow_mut().push(chunks); - }) - .ok(); + cx.spawn(async move |this, cx| { + if let Err(e) = task.await { + this.update(cx, |this, cx| { + this.last_error = + Some(cx.new(|cx| Markdown::new(e.to_string().into(), None, None, cx))); + cx.notify() + }) + .ok(); + } }) .detach(); } - fn previous_history_message( + fn send_impl( &mut self, - _: &PreviousHistoryMessage, + contents: Task>>, window: &mut Window, cx: &mut Context, ) { - if self.message_set_from_history.is_none() && !self.message_editor.read(cx).is_empty(cx) { - self.message_editor.update(cx, |editor, cx| { - editor.move_up(&Default::default(), window, cx); - }); + let Some(thread) = self.thread().cloned() else { return; - } + }; + let task = cx.spawn_in(window, async move |this, cx| { + let contents = contents.await?; - self.message_set_from_history = Self::set_draft_message( - self.message_editor.clone(), - self.mention_set.clone(), - self.project.clone(), - self.message_history - .borrow_mut() - .prev() - .map(|blocks| blocks.as_slice()), - window, - cx, - ); + if contents.is_empty() { + return Ok(()); + } + + this.update_in(cx, |this, window, cx| { + this.set_editor_is_expanded(false, cx); + this.scroll_to_bottom(cx); + this.message_editor.update(cx, |message_editor, cx| { + message_editor.clear(window, cx); + }); + })?; + let send = thread.update(cx, |thread, cx| thread.send(contents, cx))?; + send.await + }); + + cx.spawn(async move |this, cx| { + if let Err(e) = task.await { + this.update(cx, |this, cx| { + this.last_error = + Some(cx.new(|cx| Markdown::new(e.to_string().into(), None, None, cx))); + cx.notify() + }) + .ok(); + } + }) + .detach(); } - fn next_history_message( - &mut self, - _: &NextHistoryMessage, - window: &mut Window, - cx: &mut Context, - ) { - if self.message_set_from_history.is_none() { - self.message_editor.update(cx, |editor, cx| { - editor.move_down(&Default::default(), window, cx); - }); + fn cancel_editing(&mut self, _: &ClickEvent, _window: &mut Window, cx: &mut Context) { + self.editing_message.take(); + cx.notify(); + } + + fn regenerate(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { + let Some(editing_message) = self.editing_message.take() else { return; - } - - let mut message_history = self.message_history.borrow_mut(); - let next_history = message_history.next(); - - let set_draft_message = Self::set_draft_message( - self.message_editor.clone(), - self.mention_set.clone(), - self.project.clone(), - Some( - next_history - .map(|blocks| blocks.as_slice()) - .unwrap_or_else(|| &[]), - ), - window, - cx, - ); - // If we reset the text to an empty string because we ran out of history, - // we don't want to mark it as coming from the history - self.message_set_from_history = if next_history.is_some() { - set_draft_message - } else { - None }; + + self.last_error.take(); + + let Some(thread) = self.thread().cloned() else { + return; + }; + + let rewind = thread.update(cx, |thread, cx| { + thread.rewind(editing_message.message_id, cx) + }); + + let contents = editing_message + .editor + .update(cx, |message_editor, cx| message_editor.contents(cx)); + let task = cx.foreground_executor().spawn(async move { + rewind.await?; + contents.await + }); + self.send_impl(task, window, cx); } fn open_agent_diff(&mut self, _: &OpenAgentDiff, window: &mut Window, cx: &mut Context) { @@ -606,92 +491,6 @@ impl AcpThreadView { }) } - fn set_draft_message( - message_editor: Entity, - mention_set: Arc>, - project: Entity, - message: Option<&[acp::ContentBlock]>, - window: &mut Window, - cx: &mut Context, - ) -> Option { - cx.notify(); - - let message = message?; - - let mut text = String::new(); - let mut mentions = Vec::new(); - - for chunk in message { - match chunk { - acp::ContentBlock::Text(text_content) => { - text.push_str(&text_content.text); - } - acp::ContentBlock::Resource(acp::EmbeddedResource { - resource: acp::EmbeddedResourceResource::TextResourceContents(resource), - .. - }) => { - let path = PathBuf::from(&resource.uri); - let project_path = project.read(cx).project_path_for_absolute_path(&path, cx); - let start = text.len(); - let _ = write!(&mut text, "{}", MentionUri::File(path).to_uri()); - let end = text.len(); - if let Some(project_path) = project_path { - let filename: SharedString = project_path - .path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string() - .into(); - mentions.push((start..end, project_path, filename)); - } - } - acp::ContentBlock::Image(_) - | acp::ContentBlock::Audio(_) - | acp::ContentBlock::Resource(_) - | acp::ContentBlock::ResourceLink(_) => {} - } - } - - let snapshot = message_editor.update(cx, |editor, cx| { - editor.set_text(text, window, cx); - editor.buffer().read(cx).snapshot(cx) - }); - - for (range, project_path, filename) in mentions { - let crease_icon_path = if project_path.path.is_dir() { - FileIcons::get_folder_icon(false, cx) - .unwrap_or_else(|| IconName::Folder.path().into()) - } else { - FileIcons::get_icon(Path::new(project_path.path.as_ref()), cx) - .unwrap_or_else(|| IconName::File.path().into()) - }; - - let anchor = snapshot.anchor_before(range.start); - if let Some(project_path) = project.read(cx).absolute_path(&project_path, cx) { - let crease_id = crate::context_picker::insert_crease_for_mention( - anchor.excerpt_id, - anchor.text_anchor, - range.end - range.start, - filename, - crease_icon_path, - message_editor.clone(), - window, - cx, - ); - - if let Some(crease_id) = crease_id { - mention_set - .lock() - .insert(crease_id, MentionUri::File(project_path)); - } - } - } - - let snapshot = snapshot.as_singleton().unwrap().2.clone(); - Some(snapshot.text) - } - fn handle_thread_event( &mut self, thread: &Entity, @@ -921,6 +720,16 @@ impl AcpThreadView { cx.notify(); } + fn rewind(&mut self, message_id: &UserMessageId, cx: &mut Context) { + let Some(thread) = self.thread() else { + return; + }; + thread + .update(cx, |thread, cx| thread.rewind(message_id.clone(), cx)) + .detach_and_log_err(cx); + cx.notify(); + } + fn render_entry( &self, index: usize, @@ -931,8 +740,23 @@ impl AcpThreadView { ) -> AnyElement { let primary = match &entry { AgentThreadEntry::UserMessage(message) => div() + .id(("user_message", index)) .py_4() .px_2() + .children(message.id.clone().and_then(|message_id| { + message.checkpoint.as_ref()?; + + Some( + Button::new("restore-checkpoint", "Restore Checkpoint") + .icon(IconName::Undo) + .icon_size(IconSize::XSmall) + .icon_position(IconPosition::Start) + .label_size(LabelSize::XSmall) + .on_click(cx.listener(move |this, _, _window, cx| { + this.rewind(&message_id, cx); + })), + ) + })) .child( v_flex() .p_3() @@ -943,12 +767,28 @@ impl AcpThreadView { .border_1() .border_color(cx.theme().colors().border) .text_xs() - .children(message.content.markdown().map(|md| { - self.render_markdown( - md.clone(), - user_message_markdown_style(window, cx), - ) - })), + .id("message") + .on_click(cx.listener({ + move |this, _, window, cx| this.start_editing_message(index, window, cx) + })) + .children( + if let Some(editing) = self.editing_message.as_ref() + && Some(&editing.message_id) == message.id.as_ref() + { + Some( + self.render_edit_message_editor(editing, cx) + .into_any_element(), + ) + } else { + message.content.markdown().map(|md| { + self.render_markdown( + md.clone(), + user_message_markdown_style(window, cx), + ) + .into_any_element() + }) + }, + ), ) .into_any(), AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => { @@ -2536,34 +2376,7 @@ impl AcpThreadView { .size_full() .pt_1() .pr_2p5() - .child(div().flex_1().child({ - let settings = ThemeSettings::get_global(cx); - let font_size = TextSize::Small - .rems(cx) - .to_pixels(settings.agent_font_size(cx)); - let line_height = settings.buffer_line_height.value() * font_size; - - let text_style = TextStyle { - color: cx.theme().colors().text, - font_family: settings.buffer_font.family.clone(), - font_fallbacks: settings.buffer_font.fallbacks.clone(), - font_features: settings.buffer_font.features.clone(), - font_size: font_size.into(), - line_height: line_height.into(), - ..Default::default() - }; - - EditorElement::new( - &self.message_editor, - EditorStyle { - background: editor_bg_color, - local_player: cx.theme().players().local(), - text: text_style, - syntax: cx.theme().syntax().clone(), - ..Default::default() - }, - ) - })) + .child(self.message_editor.clone()) .child( h_flex() .absolute() @@ -2608,6 +2421,112 @@ impl AcpThreadView { .into_any() } + fn start_editing_message(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + let Some(thread) = self.thread() else { + return; + }; + let Some(AgentThreadEntry::UserMessage(message)) = thread.read(cx).entries().get(index) + else { + return; + }; + let Some(message_id) = message.id.clone() else { + return; + }; + let chunks = message.chunks.clone(); + let editor = cx.new(|cx| { + let mut editor = MessageEditor::new( + self.workspace.clone(), + self.project.clone(), + editor::EditorMode::AutoHeight { + min_lines: 1, + max_lines: None, + }, + window, + cx, + ); + editor.set_message(&chunks, window, cx); + editor + }); + let subscription = + cx.subscribe_in(&editor, window, |this, _, event, window, cx| match event { + MessageEditorEvent::Send => { + this.regenerate(&Default::default(), window, cx); + } + MessageEditorEvent::Cancel => { + this.cancel_editing(&Default::default(), window, cx); + } + }); + + self.editing_message.replace(EditingMessage { + message_id: message_id.clone(), + editor, + _subscription: subscription, + }); + cx.notify(); + } + + fn render_edit_message_editor(&self, editing: &EditingMessage, cx: &Context) -> Div { + v_flex().child(editing.editor.clone()).child( + h_flex() + .gap_1() + .child(Icon::new(IconName::Warning)) + .child( + Label::new("Editing will restart the thread from this point.") + .color(Color::Muted) + .size(LabelSize::XSmall), + ) + .child(self.render_editing_message_editor_buttons(editing, cx)), + ) + } + + fn render_editing_message_editor_buttons( + &self, + editing: &EditingMessage, + cx: &Context, + ) -> Div { + h_flex() + .gap_0p5() + .child( + IconButton::new("cancel-edit-message", IconName::Close) + .shape(ui::IconButtonShape::Square) + .icon_color(Color::Error) + .icon_size(IconSize::Small) + .tooltip({ + let focus_handle = editing.editor.focus_handle(cx); + move |window, cx| { + Tooltip::for_action_in( + "Cancel Edit", + &menu::Cancel, + &focus_handle, + window, + cx, + ) + } + }) + .on_click(cx.listener(Self::cancel_editing)), + ) + .child( + IconButton::new("confirm-edit-message", IconName::Return) + .disabled(editing.editor.read(cx).is_empty(cx)) + .shape(ui::IconButtonShape::Square) + .icon_color(Color::Muted) + .icon_size(IconSize::Small) + .tooltip({ + let focus_handle = editing.editor.focus_handle(cx); + move |window, cx| { + Tooltip::for_action_in( + "Regenerate", + &menu::Confirm, + &focus_handle, + window, + cx, + ) + } + }) + .on_click(cx.listener(Self::regenerate)), + ) + } + fn render_send_button(&self, cx: &mut Context) -> AnyElement { if self.thread().map_or(true, |thread| { thread.read(cx).status() == ThreadStatus::Idle @@ -2624,7 +2543,7 @@ impl AcpThreadView { button.tooltip(Tooltip::text("Type a message to submit")) }) .on_click(cx.listener(|this, _, window, cx| { - this.chat(&Chat, window, cx); + this.send(window, cx); })) .into_any_element() } else { @@ -2634,7 +2553,7 @@ impl AcpThreadView { .tooltip(move |window, cx| { Tooltip::for_action("Stop Generation", &editor::actions::Cancel, window, cx) }) - .on_click(cx.listener(|this, _event, _, cx| this.cancel(cx))) + .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx))) .into_any_element() } } @@ -2718,72 +2637,9 @@ impl AcpThreadView { .detach_and_log_err(cx); } } - MentionUri::Symbol { - path, line_range, .. - } - | MentionUri::Selection { path, line_range } => { - let project = workspace.project(); - let Some((path, _)) = project.update(cx, |project, cx| { - let path = project.find_project_path(path, cx)?; - let entry = project.entry_for_path(&path, cx)?; - Some((path, entry)) - }) else { - return; - }; - - let item = workspace.open_path(path, None, true, window, cx); - window - .spawn(cx, async move |cx| { - let Some(editor) = item.await?.downcast::() else { - return Ok(()); - }; - let range = - Point::new(line_range.start, 0)..Point::new(line_range.start, 0); - editor - .update_in(cx, |editor, window, cx| { - editor.change_selections( - SelectionEffects::scroll(Autoscroll::center()), - window, - cx, - |s| s.select_ranges(vec![range]), - ); - }) - .ok(); - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - MentionUri::Thread { id, .. } => { - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel - .open_thread_by_id(&id, window, cx) - .detach_and_log_err(cx) - }); - } - } - MentionUri::TextThread { path, .. } => { - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel - .open_saved_prompt_editor(path.as_path().into(), window, cx) - .detach_and_log_err(cx); - }); - } - } - MentionUri::Rule { id, .. } => { - let PromptId::User { uuid } = id else { - return; - }; - window.dispatch_action( - Box::new(OpenRulesLibrary { - prompt_to_select: Some(uuid.0), - }), - cx, - ) - } - MentionUri::Fetch { url } => { - cx.open_url(url.as_str()); + _ => { + // TODO + unimplemented!() } }) } else { @@ -3150,57 +3006,11 @@ impl AcpThreadView { paths: Vec, _added_worktrees: Vec>, window: &mut Window, - cx: &mut Context<'_, Self>, + cx: &mut Context, ) { - let buffer = self.message_editor.read(cx).buffer().clone(); - let Some((&excerpt_id, _, _)) = buffer.read(cx).snapshot(cx).as_singleton() else { - return; - }; - let Some(buffer) = buffer.read(cx).as_singleton() else { - return; - }; - for path in paths { - let Some(entry) = self.project.read(cx).entry_for_path(&path, cx) else { - continue; - }; - let Some(abs_path) = self.project.read(cx).absolute_path(&path, cx) else { - continue; - }; - - let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len())); - let path_prefix = abs_path - .file_name() - .unwrap_or(path.path.as_os_str()) - .display() - .to_string(); - let Some(completion) = ContextPickerCompletionProvider::completion_for_path( - path, - &path_prefix, - false, - entry.is_dir(), - excerpt_id, - anchor..anchor, - self.message_editor.clone(), - self.mention_set.clone(), - self.project.clone(), - cx, - ) else { - continue; - }; - - self.message_editor.update(cx, |message_editor, cx| { - message_editor.edit( - [( - multi_buffer::Anchor::max()..multi_buffer::Anchor::max(), - completion.new_text, - )], - cx, - ); - }); - if let Some(confirm) = completion.confirm.clone() { - confirm(CompletionIntent::Complete, window, cx); - } - } + self.message_editor.update(cx, |message_editor, cx| { + message_editor.insert_dragged_files(paths, window, cx); + }) } } @@ -3217,9 +3027,6 @@ impl Render for AcpThreadView { v_flex() .size_full() .key_context("AcpThread") - .on_action(cx.listener(Self::chat)) - .on_action(cx.listener(Self::previous_history_message)) - .on_action(cx.listener(Self::next_history_message)) .on_action(cx.listener(Self::open_agent_diff)) .bg(cx.theme().colors().panel_background) .child(match &self.thread_state { @@ -3515,13 +3322,15 @@ fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { } #[cfg(test)] -mod tests { - use agent::{TextThreadStore, ThreadStore}; +pub(crate) mod tests { + use std::{path::Path, sync::Arc}; + use agent_client_protocol::SessionId; use editor::EditorSettings; use fs::FakeFs; use futures::future::try_join_all; use gpui::{SemanticVersion, TestAppContext, VisualTestContext}; + use parking_lot::Mutex; use rand::Rng; use settings::SettingsStore; @@ -3551,7 +3360,7 @@ mod tests { cx.deactivate_window(); thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.chat(&Chat, window, cx); + thread_view.send(window, cx); }); cx.run_until_parked(); @@ -3578,7 +3387,7 @@ mod tests { cx.deactivate_window(); thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.chat(&Chat, window, cx); + thread_view.send(window, cx); }); cx.run_until_parked(); @@ -3624,7 +3433,7 @@ mod tests { cx.deactivate_window(); thread_view.update_in(cx, |thread_view, window, cx| { - thread_view.chat(&Chat, window, cx); + thread_view.send(window, cx); }); cx.run_until_parked(); @@ -3645,25 +3454,9 @@ mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let thread_store = - cx.update(|_window, cx| cx.new(|cx| ThreadStore::fake(project.clone(), cx))); - let text_thread_store = - cx.update(|_window, cx| cx.new(|cx| TextThreadStore::fake(project.clone(), cx))); - let thread_view = cx.update(|window, cx| { cx.new(|cx| { - AcpThreadView::new( - Rc::new(agent), - workspace.downgrade(), - project, - thread_store.clone(), - text_thread_store.clone(), - Rc::new(RefCell::new(MessageHistory::default())), - 1, - None, - window, - cx, - ) + AcpThreadView::new(Rc::new(agent), workspace.downgrade(), project, window, cx) }) }); cx.run_until_parked(); @@ -3874,7 +3667,7 @@ mod tests { } } - fn init_test(cx: &mut TestAppContext) { + pub(crate) fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 9aeb7867ac..453460726f 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1,4 +1,3 @@ -use std::cell::RefCell; use std::ops::{Not, Range}; use std::path::Path; use std::rc::Rc; @@ -11,7 +10,6 @@ use serde::{Deserialize, Serialize}; use crate::NewExternalAgentThread; use crate::agent_diff::AgentDiffThread; -use crate::message_editor::{MAX_EDITOR_LINES, MIN_EDITOR_LINES}; use crate::{ AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode, DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread, @@ -477,8 +475,6 @@ pub struct AgentPanel { configuration_subscription: Option, local_timezone: UtcOffset, active_view: ActiveView, - acp_message_history: - Rc>>>, previous_view: Option, history_store: Entity, history: Entity, @@ -766,7 +762,6 @@ impl AgentPanel { .unwrap(), inline_assist_context_store, previous_view: None, - acp_message_history: Default::default(), history_store: history_store.clone(), history: cx.new(|cx| ThreadHistory::new(weak_self, history_store, window, cx)), hovered_recent_history_item: None, @@ -824,7 +819,9 @@ impl AgentPanel { thread.update(cx, |thread, cx| thread.cancel_last_completion(window, cx)); } ActiveView::ExternalAgentThread { thread_view, .. } => { - thread_view.update(cx, |thread_element, cx| thread_element.cancel(cx)); + thread_view.update(cx, |thread_element, cx| { + thread_element.cancel_generation(cx) + }); } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} } @@ -963,7 +960,6 @@ impl AgentPanel { ) { let workspace = self.workspace.clone(); let project = self.project.clone(); - let message_history = self.acp_message_history.clone(); let fs = self.fs.clone(); const LAST_USED_EXTERNAL_AGENT_KEY: &str = "agent_panel__last_used_external_agent"; @@ -973,9 +969,6 @@ impl AgentPanel { agent: crate::ExternalAgent, } - let thread_store = self.thread_store.clone(); - let text_thread_store = self.context_store.clone(); - cx.spawn_in(window, async move |this, cx| { let server: Rc = match agent_choice { Some(agent) => { @@ -1010,18 +1003,7 @@ impl AgentPanel { this.update_in(cx, |this, window, cx| { let thread_view = cx.new(|cx| { - crate::acp::AcpThreadView::new( - server, - workspace.clone(), - project, - thread_store.clone(), - text_thread_store.clone(), - message_history, - MIN_EDITOR_LINES, - Some(MAX_EDITOR_LINES), - window, - cx, - ) + crate::acp::AcpThreadView::new(server, workspace.clone(), project, window, cx) }); this.set_active_view(ActiveView::ExternalAgentThread { thread_view }, window, cx); @@ -1575,8 +1557,6 @@ impl AgentPanel { self.active_view = new_view; } - self.acp_message_history.borrow_mut().reset_position(); - self.focus_handle(cx).focus(window); } diff --git a/crates/agent_ui/src/context_picker.rs b/crates/agent_ui/src/context_picker.rs index 7dc00bfae2..58f11313e6 100644 --- a/crates/agent_ui/src/context_picker.rs +++ b/crates/agent_ui/src/context_picker.rs @@ -1,16 +1,15 @@ mod completion_provider; -pub(crate) mod fetch_context_picker; +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; +mod rules_context_picker; +mod symbol_context_picker; +mod thread_context_picker; use std::ops::Range; use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Result, anyhow}; -use collections::HashSet; pub use completion_provider::ContextPickerCompletionProvider; use editor::display_map::{Crease, CreaseId, CreaseMetadata, FoldId}; use editor::{Anchor, AnchorRangeExt as _, Editor, ExcerptId, FoldPlaceholder, ToOffset}; @@ -46,7 +45,7 @@ use agent::{ }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ContextPickerEntry { +enum ContextPickerEntry { Mode(ContextPickerMode), Action(ContextPickerAction), } @@ -75,7 +74,7 @@ impl ContextPickerEntry { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ContextPickerMode { +enum ContextPickerMode { File, Symbol, Fetch, @@ -84,7 +83,7 @@ pub(crate) enum ContextPickerMode { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ContextPickerAction { +enum ContextPickerAction { AddSelections, } @@ -532,7 +531,7 @@ impl ContextPicker { return vec![]; }; - recent_context_picker_entries_with_store( + recent_context_picker_entries( context_store, self.thread_store.clone(), self.text_thread_store.clone(), @@ -586,8 +585,7 @@ impl Render for ContextPicker { }) } } - -pub(crate) enum RecentEntry { +enum RecentEntry { File { project_path: ProjectPath, path_prefix: Arc, @@ -595,7 +593,7 @@ pub(crate) enum RecentEntry { Thread(ThreadContextEntry), } -pub(crate) fn available_context_picker_entries( +fn available_context_picker_entries( prompt_store: &Option>, thread_store: &Option>, workspace: &Entity, @@ -632,56 +630,24 @@ pub(crate) fn available_context_picker_entries( entries } -fn recent_context_picker_entries_with_store( +fn recent_context_picker_entries( context_store: Entity, thread_store: Option>, text_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, - text_thread_store, - workspace, - &exclude_paths, - exclude_threads, - cx, - ) -} - -pub(crate) fn recent_context_picker_entries( - thread_store: Option>, - text_thread_store: Option>, - workspace: Entity, - exclude_paths: &HashSet, - exclude_threads: &HashSet, - cx: &App, ) -> Vec { let mut recent = Vec::with_capacity(6); + let mut current_files = context_store.read(cx).file_paths(cx); + current_files.extend(exclude_path); let workspace = workspace.read(cx); let project = workspace.project().read(cx); recent.extend( workspace .recent_navigation_history_iter(cx) - .filter(|(_, abs_path)| { - abs_path - .as_ref() - .map_or(true, |path| !exclude_paths.contains(path.as_path())) - }) + .filter(|(path, _)| !current_files.contains(path)) .take(4) .filter_map(|(project_path, _)| { project @@ -693,6 +659,8 @@ pub(crate) fn recent_context_picker_entries( }), ); + let current_threads = context_store.read(cx).thread_ids(); + let active_thread_id = workspace .panel::(cx) .and_then(|panel| Some(panel.read(cx).active_thread(cx)?.read(cx).id())); @@ -704,7 +672,7 @@ pub(crate) fn recent_context_picker_entries( let mut threads = unordered_thread_entries(thread_store, text_thread_store, cx) .filter(|(_, thread)| match thread { ThreadContextEntry::Thread { id, .. } => { - Some(id) != active_thread_id && !exclude_threads.contains(id) + Some(id) != active_thread_id && !current_threads.contains(id) } ThreadContextEntry::Context { .. } => true, }) @@ -742,7 +710,7 @@ fn add_selections_as_context( }) } -pub(crate) fn selection_ranges( +fn selection_ranges( workspace: &Entity, cx: &mut App, ) -> Vec<(Entity, Range)> { diff --git a/crates/agent_ui/src/context_picker/completion_provider.rs b/crates/agent_ui/src/context_picker/completion_provider.rs index 962c0df03d..8123b3437d 100644 --- a/crates/agent_ui/src/context_picker/completion_provider.rs +++ b/crates/agent_ui/src/context_picker/completion_provider.rs @@ -35,7 +35,7 @@ use super::symbol_context_picker::search_symbols; use super::thread_context_picker::{ThreadContextEntry, ThreadMatch, search_threads}; use super::{ ContextPickerAction, ContextPickerEntry, ContextPickerMode, MentionLink, RecentEntry, - available_context_picker_entries, recent_context_picker_entries_with_store, selection_ranges, + available_context_picker_entries, recent_context_picker_entries, selection_ranges, }; use crate::message_editor::ContextCreasesAddon; @@ -787,7 +787,7 @@ impl CompletionProvider for ContextPickerCompletionProvider { .and_then(|b| b.read(cx).file()) .map(|file| ProjectPath::from_file(file.as_ref(), cx)); - let recent_entries = recent_context_picker_entries_with_store( + let recent_entries = recent_context_picker_entries( context_store.clone(), thread_store.clone(), text_thread_store.clone(), diff --git a/crates/assistant_context/Cargo.toml b/crates/assistant_context/Cargo.toml index 45c0072418..8f5ff98790 100644 --- a/crates/assistant_context/Cargo.toml +++ b/crates/assistant_context/Cargo.toml @@ -11,9 +11,6 @@ workspace = true [lib] path = "src/assistant_context.rs" -[features] -test-support = [] - [dependencies] agent_settings.workspace = true anyhow.workspace = true diff --git a/crates/assistant_context/src/context_store.rs b/crates/assistant_context/src/context_store.rs index 622d8867a7..3090a7b234 100644 --- a/crates/assistant_context/src/context_store.rs +++ b/crates/assistant_context/src/context_store.rs @@ -138,27 +138,6 @@ impl ContextStore { }) } - #[cfg(any(test, feature = "test-support"))] - pub fn fake(project: Entity, cx: &mut Context) -> Self { - Self { - contexts: Default::default(), - contexts_metadata: Default::default(), - context_server_slash_command_ids: Default::default(), - host_contexts: Default::default(), - fs: project.read(cx).fs().clone(), - languages: project.read(cx).languages().clone(), - slash_commands: Arc::default(), - telemetry: project.read(cx).client().telemetry().clone(), - _watch_updates: Task::ready(None), - client: project.read(cx).client(), - project, - project_is_shared: false, - client_subscription: None, - _project_subscriptions: Default::default(), - prompt_builder: Arc::new(PromptBuilder::new(None).unwrap()), - } - } - async fn handle_advertise_contexts( this: Entity, envelope: TypedEnvelope, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index c77262143d..8a9398e71f 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -12176,8 +12176,6 @@ impl Editor { let clipboard_text = Cow::Borrowed(text); self.transact(window, cx, |this, window, cx| { - let had_active_edit_prediction = this.has_active_edit_prediction(); - if let Some(mut clipboard_selections) = clipboard_selections { let old_selections = this.selections.all::(cx); let all_selections_were_entire_line = @@ -12250,11 +12248,6 @@ impl Editor { } else { this.insert(&clipboard_text, window, cx); } - - let trigger_in_words = - this.show_edit_predictions_in_menu() || !had_active_edit_prediction; - - this.trigger_completion_on_input(&text, trigger_in_words, window, cx); }); } diff --git a/crates/onboarding/src/editing_page.rs b/crates/onboarding/src/editing_page.rs index d941a0315a..aa7f4eee74 100644 --- a/crates/onboarding/src/editing_page.rs +++ b/crates/onboarding/src/editing_page.rs @@ -721,7 +721,7 @@ fn render_popular_settings_section( .items_start() .justify_between() .child( - v_flex().child(Label::new("Minimap")).child( + v_flex().child(Label::new("Mini Map")).child( Label::new("See a high-level overview of your source code.") .color(Color::Muted), ), diff --git a/crates/prompt_store/src/prompt_store.rs b/crates/prompt_store/src/prompt_store.rs index 06a65b97cd..1a739d7766 100644 --- a/crates/prompt_store/src/prompt_store.rs +++ b/crates/prompt_store/src/prompt_store.rs @@ -58,6 +58,15 @@ pub enum PromptId { EditWorkflow, } +impl std::fmt::Display for PromptId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PromptId::User { uuid } => write!(f, "{}", uuid.0), + PromptId::EditWorkflow => write!(f, "Edit workflow"), + } + } +} + impl PromptId { pub fn new() -> PromptId { UserPromptId::new().into() @@ -90,15 +99,6 @@ impl From for UserPromptId { } } -impl std::fmt::Display for PromptId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PromptId::User { uuid } => write!(f, "{}", uuid.0), - PromptId::EditWorkflow => write!(f, "Edit workflow"), - } - } -} - pub struct PromptStore { env: heed::Env, metadata_cache: RwLock, diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index 6c7ab0b374..9df55a2fb1 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -35,7 +35,6 @@ pub fn app_menus() -> Vec { ], }), MenuItem::separator(), - #[cfg(target_os = "macos")] MenuItem::os_submenu("Services", gpui::SystemMenuType::Services), MenuItem::separator(), MenuItem::action("Extensions", zed_actions::Extensions::default()), diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 64891b6973..9455369e9a 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -285,10 +285,6 @@ pub mod agent { ResetOnboarding, /// Starts a chat conversation with the agent. Chat, - /// Displays the previous message in the history. - PreviousHistoryMessage, - /// Displays the next message in the history. - NextHistoryMessage, /// Toggles the language model selector dropdown. #[action(deprecated_aliases = ["assistant::ToggleModelSelector", "assistant2::ToggleModelSelector"])] ToggleModelSelector