diff --git a/Cargo.lock b/Cargo.lock index f0fd3049c0..cb087f43b7 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", 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..da4d82712a 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, } @@ -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..b18cbfe18e 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/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 4156ec44d2..260aaaf550 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -124,12 +124,12 @@ impl UserMessage { } UserMessageContent::Mention { uri, content } => { match uri { - MentionUri::File(path) => { + MentionUri::File { abs_path, .. } => { write!( &mut symbol_context, "\n{}", MarkdownCodeBlock { - tag: &codeblock_tag(&path, None), + tag: &codeblock_tag(&abs_path, None), text: &content.to_string(), } ) 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..720ee23b00 100644 --- a/crates/agent_ui/src/acp/completion_provider.rs +++ b/crates/agent_ui/src/acp/completion_provider.rs @@ -1,5 +1,5 @@ use std::ops::Range; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -8,7 +8,7 @@ use anyhow::{Context as _, Result, anyhow}; use collections::{HashMap, HashSet}; use editor::display_map::CreaseId; use editor::{CompletionProvider, Editor, ExcerptId, ToOffset as _}; -use file_icons::FileIcons; + use futures::future::try_join_all; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{App, Entity, Task, WeakEntity}; @@ -28,10 +28,7 @@ use url::Url; use workspace::Workspace; use workspace::notifications::NotifyResultExt; -use agent::{ - context::RULES_ICON, - thread_store::{TextThreadStore, ThreadStore}, -}; +use agent::thread_store::{TextThreadStore, ThreadStore}; use crate::context_picker::fetch_context_picker::fetch_url_content; use crate::context_picker::file_context_picker::{FileMatch, search_files}; @@ -66,6 +63,11 @@ impl MentionSet { self.uri_by_crease_id.drain().map(|(id, _)| id) } + pub fn clear(&mut self) { + self.fetch_results.clear(); + self.uri_by_crease_id.clear(); + } + pub fn contents( &self, project: Entity, @@ -79,12 +81,13 @@ impl MentionSet { .iter() .map(|(&crease_id, uri)| { match uri { - MentionUri::File(path) => { + MentionUri::File { abs_path, .. } => { + // TODO directories let uri = uri.clone(); - let path = path.to_path_buf(); + let abs_path = abs_path.to_path_buf(); let buffer_task = project.update(cx, |project, cx| { let path = project - .find_project_path(path, cx) + .find_project_path(abs_path, cx) .context("Failed to find project path")?; anyhow::Ok(project.open_buffer(path, cx)) }); @@ -508,9 +511,14 @@ impl ContextPickerCompletionProvider { }) .unwrap_or_default(); let line_range = point_range.start.row..point_range.end.row; + + let uri = MentionUri::Selection { + path: path.clone(), + line_range: line_range.clone(), + }; let crease = crate::context_picker::crease_for_mention( selection_name(&path, &line_range).into(), - IconName::Reader.path().into(), + uri.icon_path(cx), range, editor.downgrade(), ); @@ -528,10 +536,7 @@ impl ContextPickerCompletionProvider { crease_ids.try_into().unwrap() }); - mention_set.lock().insert( - crease_id, - MentionUri::Selection { path, line_range }, - ); + mention_set.lock().insert(crease_id, uri); current_offset += text_len + 1; } @@ -569,13 +574,8 @@ impl ContextPickerCompletionProvider { recent: bool, editor: Entity, mention_set: Arc>, + cx: &mut App, ) -> 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(), @@ -586,6 +586,13 @@ impl ContextPickerCompletionProvider { name: title.to_string(), }, }; + + let icon_for_completion = if recent { + IconName::HistoryRerun.path().into() + } else { + uri.icon_path(cx) + }; + let new_text = format!("{} ", uri.as_link()); let new_text_len = new_text.len(); @@ -596,9 +603,9 @@ impl ContextPickerCompletionProvider { documentation: None, insert_text_mode: None, source: project::CompletionSource::Custom, - icon_path: Some(icon_for_completion.path().into()), + icon_path: Some(icon_for_completion.clone()), confirm: Some(confirm_completion_callback( - IconName::Thread.path().into(), + uri.icon_path(cx), thread_entry.title().clone(), excerpt_id, source_range.start, @@ -616,6 +623,7 @@ impl ContextPickerCompletionProvider { source_range: Range, editor: Entity, mention_set: Arc>, + cx: &mut App, ) -> Completion { let uri = MentionUri::Rule { id: rule.prompt_id.into(), @@ -623,6 +631,7 @@ impl ContextPickerCompletionProvider { }; let new_text = format!("{} ", uri.as_link()); let new_text_len = new_text.len(); + let icon_path = uri.icon_path(cx); Completion { replace_range: source_range.clone(), new_text, @@ -630,9 +639,9 @@ impl ContextPickerCompletionProvider { documentation: None, insert_text_mode: None, source: project::CompletionSource::Custom, - icon_path: Some(RULES_ICON.path().into()), + icon_path: Some(icon_path.clone()), confirm: Some(confirm_completion_callback( - RULES_ICON.path().into(), + icon_path, rule.title.clone(), excerpt_id, source_range.start, @@ -654,7 +663,7 @@ impl ContextPickerCompletionProvider { editor: Entity, mention_set: Arc>, project: Entity, - cx: &App, + cx: &mut App, ) -> Option { let (file_name, directory) = crate::context_picker::file_context_picker::extract_file_name_and_directory( @@ -664,27 +673,21 @@ impl ContextPickerCompletionProvider { let label = build_code_label_for_full_path(&file_name, directory.as_ref().map(|s| s.as_ref()), cx); - let full_path = if let Some(directory) = directory { - format!("{}{}", directory, file_name) - } else { - file_name.to_string() + + let abs_path = project.read(cx).absolute_path(&project_path, cx)?; + + let file_uri = MentionUri::File { + abs_path, + is_directory, }; - let crease_icon_path = if is_directory { - FileIcons::get_folder_icon(false, cx).unwrap_or_else(|| IconName::Folder.path().into()) - } else { - FileIcons::get_icon(Path::new(&full_path), cx) - .unwrap_or_else(|| IconName::File.path().into()) - }; + let crease_icon_path = file_uri.icon_path(cx); let completion_icon_path = if is_recent { IconName::HistoryRerun.path().into() } else { 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_len = new_text.len(); Some(Completion { @@ -729,16 +732,17 @@ impl ContextPickerCompletionProvider { }; let new_text = format!("{} ", uri.as_link()); let new_text_len = new_text.len(); + let icon_path = uri.icon_path(cx); Some(Completion { replace_range: source_range.clone(), new_text, label, documentation: None, source: project::CompletionSource::Custom, - icon_path: Some(IconName::Code.path().into()), + icon_path: Some(icon_path.clone()), insert_text_mode: None, confirm: Some(confirm_completion_callback( - IconName::Code.path().into(), + icon_path, symbol.name.clone().into(), excerpt_id, source_range.start, @@ -757,16 +761,23 @@ impl ContextPickerCompletionProvider { editor: Entity, mention_set: Arc>, http_client: Arc, + cx: &mut App, ) -> Option { let new_text = format!("@fetch {} ", url_to_fetch.clone()); let new_text_len = new_text.len(); + let mention_uri = MentionUri::Fetch { + url: url::Url::parse(url_to_fetch.as_ref()) + .or_else(|_| url::Url::parse(&format!("https://{url_to_fetch}"))) + .ok()?, + }; + let icon_path = mention_uri.icon_path(cx); 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()), + icon_path: Some(icon_path.clone()), insert_text_mode: None, confirm: Some({ let start = source_range.start; @@ -774,6 +785,7 @@ impl ContextPickerCompletionProvider { let editor = editor.clone(); let url_to_fetch = url_to_fetch.clone(); let source_range = source_range.clone(); + let icon_path = icon_path.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}"))) @@ -781,12 +793,12 @@ impl ContextPickerCompletionProvider { 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(); + let icon_path = icon_path.clone(); window.defer(cx, move |window, cx| { let url = url.clone(); @@ -795,7 +807,7 @@ impl ContextPickerCompletionProvider { start, content_len, url.to_string().into(), - IconName::ToolWeb.path().into(), + icon_path, editor.clone(), window, cx, @@ -814,8 +826,10 @@ impl ContextPickerCompletionProvider { .await .notify_async_err(cx) { - mention_set.lock().add_fetch_result(url, content); - mention_set.lock().insert(crease_id, mention_uri.clone()); + mention_set.lock().add_fetch_result(url.clone(), content); + mention_set + .lock() + .insert(crease_id, MentionUri::Fetch { url }); } else { // Remove crease if we failed to fetch editor @@ -911,8 +925,8 @@ impl CompletionProvider for ContextPickerCompletionProvider { for uri in mention_set.uri_by_crease_id.values() { match uri { - MentionUri::File(path) => { - excluded_paths.insert(path.clone()); + MentionUri::File { abs_path, .. } => { + excluded_paths.insert(abs_path.clone()); } MentionUri::Thread { id, .. } => { excluded_threads.insert(id.clone()); @@ -1001,6 +1015,7 @@ impl CompletionProvider for ContextPickerCompletionProvider { is_recent, editor.clone(), mention_set.clone(), + cx, )), Match::Rules(user_rules) => Some(Self::completion_for_rules( @@ -1009,6 +1024,7 @@ impl CompletionProvider for ContextPickerCompletionProvider { source_range.clone(), editor.clone(), mention_set.clone(), + cx, )), Match::Fetch(url) => Self::completion_for_fetch( @@ -1018,6 +1034,7 @@ impl CompletionProvider for ContextPickerCompletionProvider { editor.clone(), mention_set.clone(), http_client.clone(), + cx, ), Match::Entry(EntryMatch { entry, .. }) => Self::completion_for_entry( @@ -1179,7 +1196,7 @@ mod tests { use serde_json::json; use settings::SettingsStore; use smol::stream::StreamExt as _; - use std::{ops::Deref, rc::Rc}; + use std::{ops::Deref, path::Path, rc::Rc}; use util::path; use workspace::{AppState, Item}; 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..fc34420d4e --- /dev/null +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -0,0 +1,469 @@ +use crate::acp::completion_provider::ContextPickerCompletionProvider; +use crate::acp::completion_provider::MentionSet; +use acp_thread::MentionUri; +use agent::TextThreadStore; +use agent::ThreadStore; +use agent_client_protocol as acp; +use anyhow::Result; +use collections::HashSet; +use editor::{ + AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorMode, + EditorStyle, MultiBuffer, +}; +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::fmt::Write; +use std::rc::Rc; +use std::sync::Arc; +use theme::ThemeSettings; +use ui::{ + ActiveTheme, App, InteractiveElement, IntoElement, ParentElement, Render, Styled, TextSize, + Window, div, +}; +use util::ResultExt; +use workspace::Workspace; +use zed_actions::agent::Chat; + +pub struct MessageEditor { + editor: Entity, + project: Entity, + thread_store: Entity, + text_thread_store: Entity, + mention_set: Arc>, +} + +pub enum MessageEditorEvent { + Send, + Cancel, +} + +impl EventEmitter for MessageEditor {} + +impl MessageEditor { + pub fn new( + workspace: WeakEntity, + project: Entity, + thread_store: Entity, + text_thread_store: 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, + 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 + }); + + Self { + editor, + project, + mention_set, + thread_store, + text_thread_store, + } + } + + pub fn is_empty(&self, cx: &App) -> bool { + self.editor.read(cx).is_empty(cx) + } + + pub fn contents( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Task>> { + let contents = self.mention_set.lock().contents( + self.project.clone(), + self.thread_store.clone(), + self.text_thread_store.clone(), + window, + 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().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()); + } + } + }); + + 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 Some(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, + ) else { + continue; + }; + + 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_uri) = MentionUri::parse(&resource.uri).log_err() { + let start = text.len(); + write!(&mut text, "{}", mention_uri.as_link()).ok(); + let end = text.len(); + mentions.push((start..end, mention_uri)); + } + } + 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, mention_uri) in mentions { + let anchor = snapshot.anchor_before(range.start); + let crease_id = crate::context_picker::insert_crease_for_mention( + anchor.excerpt_id, + anchor.text_anchor, + range.end - range.start, + mention_uri.name().into(), + mention_uri.icon_path(cx), + self.editor.clone(), + window, + cx, + ); + + if let Some(crease_id) = crease_id { + self.mention_set.lock().insert(crease_id, mention_uri); + } + } + 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::{TextThreadStore, ThreadStore}; + 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 thread_store = cx.new(|cx| ThreadStore::fake(project.clone(), cx)); + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + + let message_editor = cx.update(|window, cx| { + cx.new(|cx| { + MessageEditor::new( + workspace.downgrade(), + project.clone(), + thread_store.clone(), + text_thread_store.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 @file ", 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(window, 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 5f67dc15b8..2a72cc6f48 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -12,34 +12,25 @@ 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, SelectionEffects}; 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 project::Project; use prompt_store::PromptId; use rope::Point; use settings::{Settings as _, SettingsStore}; -use std::fmt::Write as _; -use std::path::PathBuf; -use std::{ - cell::RefCell, collections::BTreeMap, path::Path, process::ExitStatus, rc::Rc, sync::Arc, - 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 +38,12 @@ 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::agent::{Chat, ToggleModelSelector}; use zed_actions::assistant::OpenRulesLibrary; 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,6 +51,9 @@ 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, @@ -71,11 +63,8 @@ pub struct AcpThreadView { 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 +77,16 @@ 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 { + index: usize, + message_id: UserMessageId, + editor: Entity, + _subscription: Subscription, } enum ThreadState { @@ -117,83 +113,30 @@ impl AcpThreadView { 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(), + thread_store.clone(), + text_thread_store.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(), @@ -204,9 +147,6 @@ impl AcpThreadView { 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 +157,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 +310,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 +330,118 @@ 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) { + let contents = self + .message_editor + .update(cx, |message_editor, cx| message_editor.contents(window, cx)); + self.send_impl(contents, window, cx) + } + + fn send_impl( + &mut self, + contents: Task>>, + window: &mut Window, + cx: &mut Context, + ) { self.last_error.take(); + self.editing_message.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 task = cx.spawn_in(window, async move |this, cx| { + let contents = contents.await?; - let thread_store = self.thread_store.clone(); - let text_thread_store = self.text_thread_store.clone(); - - let contents = - self.mention_set - .lock() - .contents(project, thread_store, text_thread_store, window, cx); - - cx.spawn_in(window, async move |this, cx| { - let contents = match contents.await { - 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( - &mut self, - _: &PreviousHistoryMessage, - 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); - }); - return; - } - - 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, - ); + fn cancel_editing(&mut self, _: &ClickEvent, _window: &mut Window, cx: &mut Context) { + self.editing_message.take(); + cx.notify(); } - 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 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 }; + + 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(window, 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 +471,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, @@ -968,12 +747,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 }) => { @@ -1035,7 +830,7 @@ impl AcpThreadView { }; let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); - if index == total_entries - 1 && !is_generating { + let primary = if index == total_entries - 1 && !is_generating { v_flex() .w_full() .child(primary) @@ -1043,6 +838,28 @@ impl AcpThreadView { .into_any_element() } else { primary + }; + + if let Some(editing) = self.editing_message.as_ref() + && editing.index < index + { + let backdrop = div() + .id(("backdrop", index)) + .size_full() + .absolute() + .inset_0() + .bg(cx.theme().colors().panel_background) + .opacity(0.8) + .block_mouse_except_scroll() + .on_click(cx.listener(Self::cancel_editing)); + + div() + .relative() + .child(backdrop) + .child(primary) + .into_any_element() + } else { + primary } } @@ -2561,34 +2378,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() @@ -2633,6 +2423,129 @@ 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; + }; + + self.list_state.scroll_to_reveal_item(index); + + let chunks = message.chunks.clone(); + let editor = cx.new(|cx| { + let mut editor = MessageEditor::new( + self.workspace.clone(), + self.project.clone(), + self.thread_store.clone(), + self.text_thread_store.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); + } + }); + editor.focus_handle(cx).focus(window); + + self.editing_message.replace(EditingMessage { + index: index, + message_id: message_id.clone(), + editor, + _subscription: subscription, + }); + cx.notify(); + } + + fn render_edit_message_editor(&self, editing: &EditingMessage, cx: &Context) -> Div { + v_flex() + .w_full() + .gap_2() + .child(editing.editor.clone()) + .child( + h_flex() + .gap_1() + .child( + Icon::new(IconName::Warning) + .color(Color::Warning) + .size(IconSize::XSmall), + ) + .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() + .flex_1() + .justify_end() + .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 @@ -2649,7 +2562,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 { @@ -2659,7 +2572,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() } } @@ -2723,10 +2636,10 @@ impl AcpThreadView { if let Some(mention) = MentionUri::parse(&url).log_err() { workspace.update(cx, |workspace, cx| match mention { - MentionUri::File(path) => { + MentionUri::File { abs_path, .. } => { let project = workspace.project(); let Some((path, entry)) = project.update(cx, |project, cx| { - let path = project.find_project_path(path, cx)?; + let path = project.find_project_path(abs_path, cx)?; let entry = project.entry_for_path(&path, cx)?; Some((path, entry)) }) else { @@ -3175,57 +3088,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); + }) } } @@ -3242,9 +3109,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 { @@ -3540,13 +3404,16 @@ fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { } #[cfg(test)] -mod tests { +pub(crate) mod tests { + use std::{path::Path, sync::Arc}; + use agent::{TextThreadStore, ThreadStore}; use agent_client_protocol::SessionId; use editor::EditorSettings; use fs::FakeFs; use futures::future::try_join_all; use gpui::{SemanticVersion, TestAppContext, VisualTestContext}; + use parking_lot::Mutex; use rand::Rng; use settings::SettingsStore; @@ -3576,7 +3443,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(); @@ -3603,7 +3470,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(); @@ -3649,7 +3516,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(); @@ -3683,9 +3550,6 @@ mod tests { project, thread_store.clone(), text_thread_store.clone(), - Rc::new(RefCell::new(MessageHistory::default())), - 1, - None, window, cx, ) @@ -3899,7 +3763,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 e47cbe3714..73915195f5 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"; @@ -1016,9 +1012,6 @@ impl AgentPanel { project, thread_store.clone(), text_thread_store.clone(), - message_history, - MIN_EDITOR_LINES, - Some(MAX_EDITOR_LINES), window, cx, ) @@ -1575,8 +1568,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/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