Allow editing Agent2 messages (#36155)
Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra <me@as-cii.com> Co-authored-by: Agus Zubiaga <agus@zed.dev>
This commit is contained in:
parent
ffac8c5128
commit
e5402d5464
14 changed files with 956 additions and 621 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -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 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<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};
|
||||
|
||||
|
|
469
crates/agent_ui/src/acp/message_editor.rs
Normal file
469
crates/agent_ui/src/acp/message_editor.rs
Normal file
|
@ -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>) {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 mut ix = 0;
|
||||
let mut chunks: Vec<acp::ContentBlock> = 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<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,
|
||||
)
|
||||
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue