Cargo.lock 🔗
@@ -14,6 +14,7 @@ dependencies = [
"collections",
"editor",
"env_logger 0.11.8",
+ "file_icons",
"futures 0.3.31",
"gpui",
"indoc",
Conrad Irwin , Antonio Scandurra , and Agus Zubiaga created
Release Notes:
- N/A
---------
Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Agus Zubiaga <agus@zed.dev>
Cargo.lock | 1
assets/keymaps/default-linux.json | 2
assets/keymaps/default-macos.json | 2
crates/acp_thread/Cargo.toml | 1
crates/acp_thread/src/acp_thread.rs | 15
crates/acp_thread/src/mention.rs | 94 ++
crates/agent2/src/thread.rs | 4
crates/agent_ui/src/acp.rs | 3
crates/agent_ui/src/acp/completion_provider.rs | 113 +-
crates/agent_ui/src/acp/message_editor.rs | 469 ++++++++++++
crates/agent_ui/src/acp/message_history.rs | 88 --
crates/agent_ui/src/acp/thread_view.rs | 760 ++++++++-----------
crates/agent_ui/src/agent_panel.rs | 15
crates/zed_actions/src/lib.rs | 4
14 files changed, 953 insertions(+), 618 deletions(-)
@@ -14,6 +14,7 @@ dependencies = [
"collections",
"editor",
"env_logger 0.11.8",
+ "file_icons",
"futures 0.3.31",
"gpui",
"indoc",
@@ -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"
@@ -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"
@@ -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
@@ -32,6 +32,7 @@ use util::ResultExt;
pub struct UserMessage {
pub id: Option<UserMessageId>,
pub content: ContentBlock,
+ pub chunks: Vec<acp::ContentBlock>,
pub checkpoint: Option<GitStoreCheckpoint>,
}
@@ -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,
@@ -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";
@@ -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(),
}
)
@@ -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;
@@ -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<Project>,
@@ -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<Editor>,
mention_set: Arc<Mutex<MentionSet>>,
+ 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<Anchor>,
editor: Entity<Editor>,
mention_set: Arc<Mutex<MentionSet>>,
+ 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<Editor>,
mention_set: Arc<Mutex<MentionSet>>,
project: Entity<Project>,
- cx: &App,
+ cx: &mut App,
) -> Option<Completion> {
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 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 abs_path = project.read(cx).absolute_path(&project_path, cx)?;
+
+ let file_uri = MentionUri::File {
+ abs_path,
+ is_directory,
};
+
+ 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<Editor>,
mention_set: Arc<Mutex<MentionSet>>,
http_client: Arc<HttpClientWithUrl>,
+ cx: &mut App,
) -> Option<Completion> {
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};
@@ -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<Editor>,
+ project: Entity<Project>,
+ thread_store: Entity<ThreadStore>,
+ text_thread_store: Entity<TextThreadStore>,
+ mention_set: Arc<Mutex<MentionSet>>,
+}
+
+pub enum MessageEditorEvent {
+ Send,
+ Cancel,
+}
+
+impl EventEmitter<MessageEditorEvent> for MessageEditor {}
+
+impl MessageEditor {
+ pub fn new(
+ workspace: WeakEntity<Workspace>,
+ project: Entity<Project>,
+ thread_store: Entity<ThreadStore>,
+ text_thread_store: Entity<TextThreadStore>,
+ mode: EditorMode,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> 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<Self>,
+ ) -> Task<Result<Vec<acp::ContentBlock>>> {
+ 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<acp::ContentBlock> = 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>) {
+ 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<Self>) {
+ cx.emit(MessageEditorEvent::Send)
+ }
+
+ fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
+ cx.emit(MessageEditorEvent::Cancel)
+ }
+
+ pub fn insert_dragged_files(
+ &self,
+ paths: Vec<project::ProjectPath>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ 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>) {
+ 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<Self>,
+ ) {
+ 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>) {
+ 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<Self>) -> 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::<Vec<_>>()
+ .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 { .. }]);
+ }
+}
@@ -1,88 +0,0 @@
-pub struct MessageHistory<T> {
- items: Vec<T>,
- current: Option<usize>,
-}
-
-impl<T> Default for MessageHistory<T> {
- fn default() -> Self {
- MessageHistory {
- items: Vec::new(),
- current: None,
- }
- }
-}
-
-impl<T> MessageHistory<T> {
- 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"));
- }
-}
@@ -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<dyn AgentServer>,
workspace: WeakEntity<Workspace>,
@@ -71,11 +63,8 @@ pub struct AcpThreadView {
thread_state: ThreadState,
diff_editors: HashMap<EntityId, Entity<Editor>>,
terminal_views: HashMap<EntityId, Entity<TerminalView>>,
- message_editor: Entity<Editor>,
+ message_editor: Entity<MessageEditor>,
model_selector: Option<Entity<AcpModelSelectorPopover>>,
- message_set_from_history: Option<BufferSnapshot>,
- _message_editor_subscription: Subscription,
- mention_set: Arc<Mutex<MentionSet>>,
notifications: Vec<WindowHandle<AgentNotification>>,
notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
last_error: Option<Entity<Markdown>>,
@@ -88,9 +77,16 @@ pub struct AcpThreadView {
plan_expanded: bool,
editor_expanded: bool,
terminal_expanded: bool,
- message_history: Rc<RefCell<MessageHistory<Vec<acp::ContentBlock>>>>,
+ editing_message: Option<EditingMessage>,
_cancel_task: Option<Task<()>>,
- _subscriptions: [Subscription; 1],
+ _subscriptions: [Subscription; 2],
+}
+
+struct EditingMessage {
+ index: usize,
+ message_id: UserMessageId,
+ editor: Entity<MessageEditor>,
+ _subscription: Subscription,
}
enum ThreadState {
@@ -117,83 +113,30 @@ impl AcpThreadView {
project: Entity<Project>,
thread_store: Entity<ThreadStore>,
text_thread_store: Entity<TextThreadStore>,
- message_history: Rc<RefCell<MessageHistory<Vec<acp::ContentBlock>>>>,
- min_lines: usize,
- max_lines: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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::<SettingsStore>(window, Self::settings_changed);
+ let subscriptions = [
+ cx.observe_global_in::<SettingsStore>(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<Self>) {
+ pub fn cancel_generation(&mut self, cx: &mut Context<Self>) {
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>) {
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<Self>) {
- self.last_error.take();
+ pub fn on_message_editor_event(
+ &mut self,
+ _: &Entity<MessageEditor>,
+ event: &MessageEditorEvent,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ match event {
+ MessageEditorEvent::Send => self.send(window, cx),
+ MessageEditorEvent::Cancel => self.cancel_generation(cx),
+ }
+ }
- let mut ix = 0;
- let mut chunks: Vec<acp::ContentBlock> = Vec::new();
- let project = self.project.clone();
+ fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ let contents = self
+ .message_editor
+ .update(cx, |message_editor, cx| message_editor.contents(window, cx));
+ self.send_impl(contents, window, cx)
+ }
- let thread_store = self.thread_store.clone();
- let text_thread_store = self.text_thread_store.clone();
+ fn send_impl(
+ &mut self,
+ contents: Task<anyhow::Result<Vec<acp::ContentBlock>>>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.last_error.take();
+ self.editing_message.take();
- let contents =
- self.mention_set
- .lock()
- .contents(project, thread_store, text_thread_store, window, cx);
+ let Some(thread) = self.thread().cloned() else {
+ return;
+ };
+ let task = cx.spawn_in(window, async move |this, cx| {
+ let contents = contents.await?;
- 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<Self>,
- ) {
- 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>) {
+ self.editing_message.take();
+ cx.notify();
}
- fn next_history_message(
- &mut self,
- _: &NextHistoryMessage,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- 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<Self>) {
+ 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<Self>) {
@@ -606,92 +471,6 @@ impl AcpThreadView {
})
}
- fn set_draft_message(
- message_editor: Entity<Editor>,
- mention_set: Arc<Mutex<MentionSet>>,
- project: Entity<Project>,
- message: Option<&[acp::ContentBlock]>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Option<BufferSnapshot> {
- 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<AcpThread>,
@@ -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<Self>) {
+ 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<Self>) -> 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<Self>,
+ ) -> 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<Self>) -> 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<project::ProjectPath>,
_added_worktrees: Vec<Entity<project::Worktree>>,
window: &mut Window,
- cx: &mut Context<'_, Self>,
+ cx: &mut Context<Self>,
) {
- 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,
)
@@ -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<Subscription>,
local_timezone: UtcOffset,
active_view: ActiveView,
- acp_message_history:
- Rc<RefCell<crate::acp::MessageHistory<Vec<agent_client_protocol::ContentBlock>>>>,
previous_view: Option<ActiveView>,
history_store: Entity<HistoryStore>,
history: Entity<ThreadHistory>,
@@ -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);
}
@@ -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