Cargo.lock 🔗
@@ -10,6 +10,7 @@ dependencies = [
"anyhow",
"async-trait",
"base64 0.22.1",
+ "buffer_diff",
"chrono",
"collections",
"editor",
Agus Zubiaga created
Cargo.lock | 1
crates/acp/Cargo.toml | 1
crates/acp/src/acp.rs | 95 ++++++++++++++++++--
crates/acp/src/thread_view.rs | 167 +++++++++++++++++++++++++++++++-----
4 files changed, 229 insertions(+), 35 deletions(-)
@@ -10,6 +10,7 @@ dependencies = [
"anyhow",
"async-trait",
"base64 0.22.1",
+ "buffer_diff",
"chrono",
"collections",
"editor",
@@ -20,6 +20,7 @@ agentic-coding-protocol = { path = "../../../agentic-coding-protocol" }
anyhow.workspace = true
async-trait.workspace = true
base64.workspace = true
+buffer_diff.workspace = true
chrono.workspace = true
collections.workspace = true
editor.workspace = true
@@ -3,10 +3,12 @@ mod thread_view;
use agentic_coding_protocol::{self as acp, Role};
use anyhow::{Context as _, Result};
+use buffer_diff::BufferDiff;
use chrono::{DateTime, Utc};
+use editor::MultiBuffer;
use futures::channel::oneshot;
use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Task};
-use language::LanguageRegistry;
+use language::{Buffer, LanguageRegistry};
use markdown::Markdown;
use project::Project;
use std::{mem, ops::Range, path::PathBuf, sync::Arc};
@@ -138,11 +140,24 @@ pub enum ToolCallStatus {
Allowed {
// todo! should this be variants in crate::ToolCallStatus instead?
status: acp::ToolCallStatus,
- content: Option<Entity<Markdown>>,
+ content: Option<ToolCallContent>,
},
Rejected,
}
+#[derive(Debug)]
+pub enum ToolCallContent {
+ Markdown {
+ markdown: Entity<Markdown>,
+ },
+ Diff {
+ path: PathBuf,
+ diff: Entity<BufferDiff>,
+ buffer: Entity<MultiBuffer>,
+ _task: Task<Result<()>>,
+ },
+}
+
/// A `ThreadEntryId` that is known to be a ToolCall
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ToolCallId(ThreadEntryId);
@@ -375,14 +390,68 @@ impl AcpThread {
match &mut entry.content {
AgentThreadEntryContent::ToolCall(call) => match &mut call.status {
ToolCallStatus::Allowed { content, status } => {
- *content = new_content.map(|new_content| {
- let acp::ToolCallContent::Markdown { markdown } = new_content;
-
- cx.new(|cx| {
- Markdown::new(markdown.into(), Some(language_registry), None, cx)
- })
+ *content = new_content.map(|new_content| match new_content {
+ acp::ToolCallContent::Markdown { markdown } => ToolCallContent::Markdown {
+ markdown: cx.new(|cx| {
+ Markdown::new(
+ markdown.into(),
+ Some(language_registry.clone()),
+ None,
+ cx,
+ )
+ }),
+ },
+ acp::ToolCallContent::Diff {
+ path,
+ old_text,
+ new_text,
+ } => {
+ let buffer = cx.new(|cx| Buffer::local(new_text, cx));
+ let text_snapshot = buffer.read(cx).text_snapshot();
+ let buffer_diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx));
+
+ let multibuffer = cx.new(|cx| {
+ let mut multibuffer = MultiBuffer::singleton(buffer.clone(), cx);
+ multibuffer.add_diff(buffer_diff.clone(), cx);
+ multibuffer
+ });
+
+ ToolCallContent::Diff {
+ path: path.clone(),
+ diff: buffer_diff.clone(),
+ buffer: multibuffer,
+ _task: cx.spawn(async move |_this, cx| {
+ let diff_snapshot = BufferDiff::update_diff(
+ buffer_diff.clone(),
+ text_snapshot.clone(),
+ old_text.map(|o| o.into()),
+ true,
+ true,
+ None,
+ Some(language_registry.clone()),
+ cx,
+ )
+ .await?;
+
+ buffer_diff.update(cx, |diff, cx| {
+ diff.set_snapshot(diff_snapshot, &text_snapshot, cx)
+ })?;
+
+ if let Some(language) = language_registry
+ .language_for_file_path(&path)
+ .await
+ .log_err()
+ {
+ buffer.update(cx, |buffer, cx| {
+ buffer.set_language(Some(language), cx)
+ })?;
+ }
+
+ anyhow::Ok(())
+ }),
+ }
+ }
});
-
*status = new_status;
}
ToolCallStatus::WaitingForConfirmation { .. } => {
@@ -610,14 +679,18 @@ mod tests {
thread.read_with(cx, |thread, cx| {
let AgentThreadEntryContent::ToolCall(ToolCall {
- status: ToolCallStatus::Allowed { content, .. },
+ status:
+ ToolCallStatus::Allowed {
+ content: Some(ToolCallContent::Markdown { markdown }),
+ ..
+ },
..
}) = &thread.entries()[1].content
else {
panic!();
};
- content.as_ref().unwrap().read_with(cx, |md, _cx| {
+ markdown.read_with(cx, |md, _cx| {
assert!(
md.source().contains("Hello, world!"),
r#"Expected '{}' to contain "Hello, world!""#,
@@ -4,7 +4,7 @@ use std::time::Duration;
use agentic_coding_protocol::{self as acp, ToolCallConfirmation};
use anyhow::Result;
-use editor::{Editor, MultiBuffer};
+use editor::{Editor, EditorMode, MinimapVisibility, MultiBuffer};
use gpui::{
Animation, AnimationExt, App, EdgesRefinement, Empty, Entity, Focusable, ListState,
SharedString, StyleRefinement, Subscription, TextStyleRefinement, Transformation,
@@ -12,6 +12,7 @@ use gpui::{
};
use gpui::{FocusHandle, Task};
use language::Buffer;
+use language::language_settings::SoftWrap;
use markdown::{HeadingLevelStyles, MarkdownElement, MarkdownStyle};
use project::Project;
use settings::Settings as _;
@@ -23,17 +24,23 @@ use zed_actions::agent::Chat;
use crate::{
AcpServer, AcpThread, AcpThreadEvent, AgentThreadEntryContent, MessageChunk, Role, ThreadEntry,
- ToolCall, ToolCallId, ToolCallStatus,
+ ToolCall, ToolCallContent, ToolCallId, ToolCallStatus,
};
pub struct AcpThreadView {
thread_state: ThreadState,
- // todo! use full message editor from agent2
+ // todo! reconsider structure. currently pretty sparse, but easy to clean up if we need to delete entries.
+ thread_entry_views: Vec<Option<ThreadEntryView>>,
message_editor: Entity<Editor>,
list_state: ListState,
send_task: Option<Task<Result<()>>>,
}
+#[derive(Debug)]
+enum ThreadEntryView {
+ Diff { editor: Entity<Editor> },
+}
+
enum ThreadState {
Loading {
_task: Task<()>,
@@ -78,13 +85,14 @@ impl AcpThreadView {
else {
return Empty.into_any();
};
- this.render_entry(entry, window, cx)
+ this.render_entry(item, entry, window, cx)
}
}),
);
Self {
thread_state: Self::initial_state(project, window, cx),
+ thread_entry_views: Vec::new(),
message_editor,
send_task: None,
list_state: list_state,
@@ -126,21 +134,11 @@ impl AcpThreadView {
let agent = AcpServer::stdio(child, project, cx);
let result = agent.clone().create_thread(cx).await;
- this.update(cx, |this, cx| {
+ this.update_in(cx, |this, window, cx| {
match result {
Ok(thread) => {
- let subscription = cx.subscribe(&thread, |this, _, event, cx| {
- let count = this.list_state.item_count();
- match event {
- AcpThreadEvent::NewEntry => {
- this.list_state.splice(count..count, 1);
- }
- AcpThreadEvent::EntryUpdated(index) => {
- this.list_state.splice(*index..*index + 1, 1);
- }
- }
- cx.notify();
- });
+ let subscription =
+ cx.subscribe_in(&thread, window, Self::handle_thread_event);
this.list_state
.splice(0..0, thread.read(cx).entries().len());
@@ -212,6 +210,108 @@ impl AcpThreadView {
});
}
+ fn handle_thread_event(
+ &mut self,
+ thread: &Entity<AcpThread>,
+ event: &AcpThreadEvent,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let count = self.list_state.item_count();
+ match event {
+ AcpThreadEvent::NewEntry => {
+ self.sync_thread_entry_view(thread.read(cx).entries.len() - 1, window, cx);
+ self.list_state.splice(count..count, 1);
+ }
+ AcpThreadEvent::EntryUpdated(index) => {
+ let index = *index;
+ self.sync_thread_entry_view(index, window, cx);
+ self.list_state.splice(index..index + 1, 1);
+ }
+ }
+ cx.notify();
+ }
+
+ fn sync_thread_entry_view(
+ &mut self,
+ entry_ix: usize,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(buffer) = self.entry_diff_buffer(entry_ix, cx) else {
+ return;
+ };
+
+ if let Some(Some(ThreadEntryView::Diff { .. })) = self.thread_entry_views.get(entry_ix) {
+ return;
+ }
+ // todo! should we do this on the fly from render?
+
+ let editor = cx.new(|cx| {
+ let mut editor = Editor::new(
+ EditorMode::Full {
+ scale_ui_elements_with_buffer_font_size: false,
+ show_active_line_background: false,
+ sized_by_content: true,
+ },
+ buffer.clone(),
+ None,
+ window,
+ cx,
+ );
+ editor.set_show_gutter(false, cx);
+ editor.disable_inline_diagnostics();
+ editor.disable_expand_excerpt_buttons(cx);
+ editor.set_show_vertical_scrollbar(false, cx);
+ editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
+ editor.set_soft_wrap_mode(SoftWrap::None, cx);
+ editor.scroll_manager.set_forbid_vertical_scroll(true);
+ editor.set_show_indent_guides(false, cx);
+ editor.set_read_only(true);
+ editor.set_show_breakpoints(false, cx);
+ editor.set_show_code_actions(false, cx);
+ editor.set_show_git_diff_gutter(false, cx);
+ editor.set_expand_all_diff_hunks(cx);
+ editor.set_text_style_refinement(TextStyleRefinement {
+ font_size: Some(
+ TextSize::Small
+ .rems(cx)
+ .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
+ .into(),
+ ),
+ ..Default::default()
+ });
+ editor
+ });
+
+ if entry_ix >= self.thread_entry_views.len() {
+ self.thread_entry_views
+ .resize_with(entry_ix + 1, Default::default);
+ }
+
+ self.thread_entry_views[entry_ix] = Some(ThreadEntryView::Diff {
+ editor: editor.clone(),
+ });
+ }
+
+ fn entry_diff_buffer(&self, entry_ix: usize, cx: &App) -> Option<Entity<MultiBuffer>> {
+ let entry = self.thread()?.read(cx).entries().get(entry_ix)?;
+
+ if let AgentThreadEntryContent::ToolCall(ToolCall {
+ status:
+ crate::ToolCallStatus::Allowed {
+ content: Some(ToolCallContent::Diff { buffer, .. }),
+ ..
+ },
+ ..
+ }) = &entry.content
+ {
+ Some(buffer.clone())
+ } else {
+ None
+ }
+ }
+
fn authorize_tool_call(
&mut self,
id: ToolCallId,
@@ -229,6 +329,7 @@ impl AcpThreadView {
fn render_entry(
&self,
+ index: usize,
entry: &ThreadEntry,
window: &mut Window,
cx: &Context<Self>,
@@ -277,12 +378,18 @@ impl AcpThreadView {
AgentThreadEntryContent::ToolCall(tool_call) => div()
.px_2()
.py_4()
- .child(self.render_tool_call(tool_call, window, cx))
+ .child(self.render_tool_call(index, tool_call, window, cx))
.into_any(),
}
}
- fn render_tool_call(&self, tool_call: &ToolCall, window: &Window, cx: &Context<Self>) -> Div {
+ fn render_tool_call(
+ &self,
+ entry_ix: usize,
+ tool_call: &ToolCall,
+ window: &Window,
+ cx: &Context<Self>,
+ ) -> Div {
let status_icon = match &tool_call.status {
ToolCallStatus::WaitingForConfirmation { .. } => Empty.into_element().into_any(),
ToolCallStatus::Allowed {
@@ -318,16 +425,28 @@ impl AcpThreadView {
ToolCallStatus::WaitingForConfirmation { confirmation, .. } => {
Some(self.render_tool_call_confirmation(tool_call.id, confirmation, cx))
}
- ToolCallStatus::Allowed { content, .. } => content.clone().map(|content| {
+ ToolCallStatus::Allowed { content, .. } => content.as_ref().map(|content| {
div()
.border_color(cx.theme().colors().border)
.border_t_1()
.px_2()
.py_1p5()
- .child(MarkdownElement::new(
- content,
- default_markdown_style(window, cx),
- ))
+ .child(match content {
+ ToolCallContent::Markdown { markdown } => MarkdownElement::new(
+ markdown.clone(),
+ default_markdown_style(window, cx),
+ )
+ .into_any_element(),
+ ToolCallContent::Diff { .. } => {
+ if let Some(Some(ThreadEntryView::Diff { editor })) =
+ self.thread_entry_views.get(entry_ix)
+ {
+ editor.clone().into_any_element()
+ } else {
+ Empty.into_any()
+ }
+ }
+ })
.into_any_element()
}),
ToolCallStatus::Rejected => None,