From ec376e0b619f81942040c31b32688f3a54567b2d Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 24 Jun 2025 12:26:40 +0200 Subject: [PATCH 01/54] Sketch out new Agent traits Co-authored-by: Antonio Scandurra --- Cargo.lock | 11 ++ Cargo.toml | 3 + crates/agent2/Cargo.toml | 28 +++++ crates/agent2/LICENSE-GPL | 1 + crates/agent2/src/agent2.rs | 231 ++++++++++++++++++++++++++++++++++++ 5 files changed, 274 insertions(+) create mode 100644 crates/agent2/Cargo.toml create mode 120000 crates/agent2/LICENSE-GPL create mode 100644 crates/agent2/src/agent2.rs diff --git a/Cargo.lock b/Cargo.lock index 922fed0ae4..fbad211c16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,6 +105,17 @@ dependencies = [ "zstd", ] +[[package]] +name = "agent2" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "futures 0.3.31", + "gpui", + "uuid", +] + [[package]] name = "agent_settings" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 8de3ad9f74..9ef922b1d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/activity_indicator", "crates/agent_ui", "crates/agent", + "crates/agent2", "crates/agent_settings", "crates/anthropic", "crates/askpass", @@ -215,6 +216,7 @@ edition = "2024" activity_indicator = { path = "crates/activity_indicator" } agent = { path = "crates/agent" } +agent2 = { path = "crates/agent2" } agent_ui = { path = "crates/agent_ui" } agent_settings = { path = "crates/agent_settings" } ai = { path = "crates/ai" } @@ -394,6 +396,7 @@ zlog_settings = { path = "crates/zlog_settings" } # External crates # +agentic-coding-protocol = { path = "../agentic-coding-protocol" } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" } any_vec = "0.14" diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml new file mode 100644 index 0000000000..e5643cb9ee --- /dev/null +++ b/crates/agent2/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "agent2" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/agent2.rs" +doctest = false + +[features] +test-support = [ + "gpui/test-support", +] + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +futures.workspace = true +gpui.workspace = true +uuid.workspace = true + +[dev-dependencies] +gpui = { workspace = true, "features" = ["test-support"] } diff --git a/crates/agent2/LICENSE-GPL b/crates/agent2/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/agent2/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs new file mode 100644 index 0000000000..80c91afd5e --- /dev/null +++ b/crates/agent2/src/agent2.rs @@ -0,0 +1,231 @@ +use anyhow::{Result, anyhow}; +use chrono::{DateTime, Utc}; +use futures::{StreamExt, stream::BoxStream}; +use gpui::{AppContext, AsyncApp, Context, Entity, Task, WeakEntity}; +use std::{ops::Range, path::PathBuf, sync::Arc}; +use uuid::Uuid; + +pub trait Agent: 'static { + type Thread: AgentThread; + + fn threads(&self) -> impl Future>>; + fn create_thread(&self) -> impl Future>; + fn open_thread(&self, id: ThreadId) -> impl Future>; +} + +pub trait AgentThread: 'static { + fn entries(&self) -> impl Future>>; + fn send(&self, message: Message) -> impl Future>; + fn on_message( + &self, + handler: impl AsyncFn(Role, BoxStream<'static, Result>) -> Result<()>, + ); +} + +pub struct ThreadId(Uuid); + +pub struct FileVersion(u64); + +pub struct AgentThreadSummary { + pub id: ThreadId, + pub title: String, + pub created_at: DateTime, +} + +pub struct FileContent { + pub path: PathBuf, + pub version: FileVersion, + pub content: String, +} + +pub enum Role { + User, + Assistant, +} + +pub struct Message { + pub role: Role, + pub chunks: Vec, +} + +pub enum MessageChunk { + Text { + chunk: String, + }, + File { + content: FileContent, + }, + Directory { + path: PathBuf, + contents: Vec, + }, + Symbol { + path: PathBuf, + range: Range, + version: FileVersion, + name: String, + content: String, + }, + Thread { + title: String, + content: Vec, + }, + Fetch { + url: String, + content: String, + }, +} + +pub enum AgentThreadEntry { + Message(Message), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ThreadEntryId(usize); + +impl ThreadEntryId { + pub fn post_inc(&mut self) -> Self { + let id = *self; + self.0 += 1; + id + } +} + +pub struct ThreadEntry { + pub id: ThreadEntryId, + pub entry: AgentThreadEntry, +} + +pub struct ThreadStore { + agent: Arc, + threads: Vec, +} + +impl ThreadStore { + pub async fn load(agent: Arc, cx: &mut AsyncApp) -> Result> { + let threads = agent.threads().await?; + cx.new(|cx| Self { agent, threads }) + } + + /// Returns the threads in reverse chronological order. + pub fn threads(&self) -> &[AgentThreadSummary] { + &self.threads + } + + /// Opens a thread with the given ID. + pub fn open_thread( + &self, + id: ThreadId, + cx: &mut Context, + ) -> Task>>> { + let agent = self.agent.clone(); + cx.spawn(async move |_, cx| { + let agent_thread = agent.open_thread(id).await?; + Thread::load(Arc::new(agent_thread), cx).await + }) + } + + /// Creates a new thread. + pub fn create_thread(&self, cx: &mut Context) -> Task>>> { + let agent = self.agent.clone(); + cx.spawn(async move |_, cx| { + let agent_thread = agent.create_thread().await?; + Thread::load(Arc::new(agent_thread), cx).await + }) + } +} + +pub struct Thread { + agent_thread: Arc, + entries: Vec, + next_entry_id: ThreadEntryId, +} + +impl Thread { + pub async fn load(agent_thread: Arc, cx: &mut AsyncApp) -> Result> { + let entries = agent_thread.entries().await?; + cx.new(|cx| Self::new(agent_thread, entries, cx)) + } + + pub fn new( + agent_thread: Arc, + entries: Vec, + cx: &mut Context, + ) -> Self { + agent_thread.on_message({ + let this = cx.weak_entity(); + let cx = cx.to_async(); + async move |role, chunks| { + Self::handle_message(this.clone(), role, chunks, &mut cx.clone()).await + } + }); + let mut next_entry_id = ThreadEntryId(0); + Self { + agent_thread, + entries: entries + .into_iter() + .map(|entry| ThreadEntry { + id: next_entry_id.post_inc(), + entry, + }) + .collect(), + next_entry_id, + } + } + + async fn handle_message( + this: WeakEntity, + role: Role, + mut chunks: BoxStream<'static, Result>, + cx: &mut AsyncApp, + ) -> Result<()> { + let entry_id = this.update(cx, |this, cx| { + let entry_id = this.next_entry_id.post_inc(); + this.entries.push(ThreadEntry { + id: entry_id, + entry: AgentThreadEntry::Message(Message { + role, + chunks: Vec::new(), + }), + }); + cx.notify(); + entry_id + })?; + + while let Some(chunk) = chunks.next().await { + match chunk { + Ok(chunk) => { + this.update(cx, |this, cx| { + let ix = this + .entries + .binary_search_by_key(&entry_id, |entry| entry.id) + .map_err(|_| anyhow!("message not found"))?; + let AgentThreadEntry::Message(message) = &mut this.entries[ix].entry else { + unreachable!() + }; + message.chunks.push(chunk); + cx.notify(); + anyhow::Ok(()) + })??; + } + Err(err) => todo!("show error"), + } + } + + Ok(()) + } + + pub fn entries(&self) -> &[ThreadEntry] { + &self.entries + } + + pub fn send(&mut self, message: Message, cx: &mut Context) -> Task> { + let agent_thread = self.agent_thread.clone(); + cx.spawn(async move |_, cx| agent_thread.send(message).await) + } +} + +#[cfg(test)] +mod tests { + use super::*; +} From c1e53b7fa585a8ad9f5b37ee85a3056dc0253a78 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 24 Jun 2025 12:31:04 +0200 Subject: [PATCH 02/54] wip: test Co-authored-by: Antonio Scandurra --- crates/agent2/src/agent2.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index 80c91afd5e..1af7925bd8 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -227,5 +227,25 @@ impl Thread { #[cfg(test)] mod tests { + use std::path::Path; + use super::*; + use gpui::{BackgroundExecutor, TestAppContext}; + + #[gpui::test] + async fn test_basic(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let agent = GeminiAgent::start("~/gemini-cli/change-me.js", &cx.executor()) + .await + .unwrap(); + let thread_store = ThreadStore::load(Arc::new(agent), &mut cx.to_async()).await.unwrap(); + } + + struct GeminiAgent {} + + impl GeminiAgent { + pub fn start(path: impl AsRef, executor: &BackgroundExecutor) -> Task> { + executor.spawn(async move { Ok(GeminiAgent {}) }) + } + } } From 549eb4d826341e9cdd8bcce74d0ddeb929d76821 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 24 Jun 2025 14:50:48 +0200 Subject: [PATCH 03/54] wip: request / response in send loop Co-authored-by: Antonio Scandurra Co-authored-by: Agus Zubiaga --- Cargo.lock | 3 + crates/agent2/Cargo.toml | 3 + crates/agent2/src/agent2.rs | 197 +++++++++++++++++++++++++++++++----- 3 files changed, 177 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fbad211c16..9ba2b4d862 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -113,6 +113,9 @@ dependencies = [ "chrono", "futures 0.3.31", "gpui", + "project", + "serde_json", + "util", "uuid", ] diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index e5643cb9ee..7bad050216 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -22,7 +22,10 @@ anyhow.workspace = true chrono.workspace = true futures.workspace = true gpui.workspace = true +project.workspace = true uuid.workspace = true [dev-dependencies] gpui = { workspace = true, "features" = ["test-support"] } +serde_json.workspace = true +util.workspace = true diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index 1af7925bd8..30377f2cfb 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -1,7 +1,8 @@ use anyhow::{Result, anyhow}; use chrono::{DateTime, Utc}; -use futures::{StreamExt, stream::BoxStream}; +use futures::{StreamExt, channel::oneshot, stream::BoxStream}; use gpui::{AppContext, AsyncApp, Context, Entity, Task, WeakEntity}; +use project::Project; use std::{ops::Range, path::PathBuf, sync::Arc}; use uuid::Uuid; @@ -15,11 +16,36 @@ pub trait Agent: 'static { pub trait AgentThread: 'static { fn entries(&self) -> impl Future>>; - fn send(&self, message: Message) -> impl Future>; - fn on_message( + fn send( &self, - handler: impl AsyncFn(Role, BoxStream<'static, Result>) -> Result<()>, - ); + message: Message, + ) -> impl Future>>>; +} + +pub enum ResponseEvent { + MessageResponse(MessageResponse), + ReadFileRequest(ReadFileRequest), + // GlobSearchRequest(SearchRequest), + // RegexSearchRequest(RegexSearchRequest), + // RunCommandRequest(RunCommandRequest), + // WebSearchResponse(WebSearchResponse), +} + +pub struct MessageResponse { + role: Role, + chunks: BoxStream<'static, Result>, +} + +pub struct ReadFileRequest { + path: PathBuf, + range: Range, + response_tx: oneshot::Sender>, +} + +impl ReadFileRequest { + pub fn respond(self, content: Result) { + self.response_tx.send(content).ok(); + } } pub struct ThreadId(Uuid); @@ -97,14 +123,23 @@ pub struct ThreadEntry { } pub struct ThreadStore { - agent: Arc, threads: Vec, + agent: Arc, + project: Entity, } impl ThreadStore { - pub async fn load(agent: Arc, cx: &mut AsyncApp) -> Result> { + pub async fn load( + agent: Arc, + project: Entity, + cx: &mut AsyncApp, + ) -> Result> { let threads = agent.threads().await?; - cx.new(|cx| Self { agent, threads }) + cx.new(|cx| Self { + threads, + agent, + project, + }) } /// Returns the threads in reverse chronological order. @@ -119,49 +154,49 @@ impl ThreadStore { cx: &mut Context, ) -> Task>>> { let agent = self.agent.clone(); + let project = self.project.clone(); cx.spawn(async move |_, cx| { let agent_thread = agent.open_thread(id).await?; - Thread::load(Arc::new(agent_thread), cx).await + Thread::load(Arc::new(agent_thread), project, cx).await }) } /// Creates a new thread. pub fn create_thread(&self, cx: &mut Context) -> Task>>> { let agent = self.agent.clone(); + let project = self.project.clone(); cx.spawn(async move |_, cx| { let agent_thread = agent.create_thread().await?; - Thread::load(Arc::new(agent_thread), cx).await + Thread::load(Arc::new(agent_thread), project, cx).await }) } } pub struct Thread { - agent_thread: Arc, - entries: Vec, next_entry_id: ThreadEntryId, + entries: Vec, + agent_thread: Arc, + project: Entity, } impl Thread { - pub async fn load(agent_thread: Arc, cx: &mut AsyncApp) -> Result> { + pub async fn load( + agent_thread: Arc, + project: Entity, + cx: &mut AsyncApp, + ) -> Result> { let entries = agent_thread.entries().await?; - cx.new(|cx| Self::new(agent_thread, entries, cx)) + cx.new(|cx| Self::new(agent_thread, entries, project, cx)) } pub fn new( agent_thread: Arc, entries: Vec, + project: Entity, cx: &mut Context, ) -> Self { - agent_thread.on_message({ - let this = cx.weak_entity(); - let cx = cx.to_async(); - async move |role, chunks| { - Self::handle_message(this.clone(), role, chunks, &mut cx.clone()).await - } - }); let mut next_entry_id = ThreadEntryId(0); Self { - agent_thread, entries: entries .into_iter() .map(|entry| ThreadEntry { @@ -170,6 +205,8 @@ impl Thread { }) .collect(), next_entry_id, + agent_thread, + project, } } @@ -221,24 +258,101 @@ impl Thread { pub fn send(&mut self, message: Message, cx: &mut Context) -> Task> { let agent_thread = self.agent_thread.clone(); - cx.spawn(async move |_, cx| agent_thread.send(message).await) + cx.spawn(async move |this, cx| { + let mut events = agent_thread.send(message).await?; + while let Some(event) = events.next().await { + match event { + Ok(ResponseEvent::MessageResponse(message)) => { + this.update(cx, |this, cx| this.handle_message_response(message, cx))? + .await?; + } + Ok(ResponseEvent::ReadFileRequest(request)) => { + this.update(cx, |this, cx| this.handle_read_file_request(request, cx))? + .await?; + } + Err(_) => todo!(), + } + } + Ok(()) + }) + } + + fn handle_message_response( + &mut self, + mut message: MessageResponse, + cx: &mut Context, + ) -> Task> { + let entry_id = self.next_entry_id.post_inc(); + self.entries.push(ThreadEntry { + id: entry_id, + entry: AgentThreadEntry::Message(Message { + role: message.role, + chunks: Vec::new(), + }), + }); + cx.notify(); + + cx.spawn(async move |this, cx| { + while let Some(chunk) = message.chunks.next().await { + match chunk { + Ok(chunk) => { + this.update(cx, |this, cx| { + let ix = this + .entries + .binary_search_by_key(&entry_id, |entry| entry.id) + .map_err(|_| anyhow!("message not found"))?; + let AgentThreadEntry::Message(message) = &mut this.entries[ix].entry + else { + unreachable!() + }; + message.chunks.push(chunk); + cx.notify(); + anyhow::Ok(()) + })??; + } + Err(err) => todo!("show error"), + } + } + + Ok(()) + }) + } + + fn handle_read_file_request( + &mut self, + request: ReadFileRequest, + cx: &mut Context, + ) -> Task> { + todo!() } } #[cfg(test)] mod tests { - use std::path::Path; - use super::*; use gpui::{BackgroundExecutor, TestAppContext}; + use project::FakeFs; + use serde_json::json; + use std::path::Path; + use util::path; #[gpui::test] async fn test_basic(cx: &mut TestAppContext) { cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/test"), + json!({"foo": "foo", "bar": "bar", "baz": "baz"}), + ) + .await; + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; let agent = GeminiAgent::start("~/gemini-cli/change-me.js", &cx.executor()) .await .unwrap(); - let thread_store = ThreadStore::load(Arc::new(agent), &mut cx.to_async()).await.unwrap(); + let thread_store = ThreadStore::load(Arc::new(agent), project, &mut cx.to_async()) + .await + .unwrap(); } struct GeminiAgent {} @@ -248,4 +362,35 @@ mod tests { executor.spawn(async move { Ok(GeminiAgent {}) }) } } + + impl Agent for GeminiAgent { + type Thread = GeminiAgentThread; + + async fn threads(&self) -> Result> { + todo!() + } + + async fn create_thread(&self) -> Result { + todo!() + } + + async fn open_thread(&self, id: ThreadId) -> Result { + todo!() + } + } + + struct GeminiAgentThread {} + + impl AgentThread for GeminiAgentThread { + async fn entries(&self) -> Result> { + todo!() + } + + async fn send( + &self, + message: Message, + ) -> Result>> { + todo!() + } + } } From f1bd531a3287a6beb103b0980550e6c96affa9ed Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 24 Jun 2025 16:30:29 -0300 Subject: [PATCH 04/54] Handle pending requests Co-authored-by: Ben Brandt --- Cargo.lock | 1 + crates/agent2/Cargo.toml | 1 + crates/agent2/src/agent2.rs | 63 ++++++++++++++++++++++++++++--------- 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9ba2b4d862..d089d533dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,6 +117,7 @@ dependencies = [ "serde_json", "util", "uuid", + "workspace-hack", ] [[package]] diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index 7bad050216..b61acffb78 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -24,6 +24,7 @@ futures.workspace = true gpui.workspace = true project.workspace = true uuid.workspace = true +workspace-hack.workspace = true [dev-dependencies] gpui = { workspace = true, "features" = ["test-support"] } diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index 30377f2cfb..64d717209e 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -1,9 +1,14 @@ use anyhow::{Result, anyhow}; use chrono::{DateTime, Utc}; -use futures::{StreamExt, channel::oneshot, stream::BoxStream}; +use futures::{ + FutureExt, StreamExt, + channel::{mpsc, oneshot}, + select_biased, + stream::{BoxStream, FuturesUnordered}, +}; use gpui::{AppContext, AsyncApp, Context, Entity, Task, WeakEntity}; use project::Project; -use std::{ops::Range, path::PathBuf, sync::Arc}; +use std::{future, ops::Range, path::PathBuf, pin::pin, sync::Arc}; use uuid::Uuid; pub trait Agent: 'static { @@ -19,7 +24,7 @@ pub trait AgentThread: 'static { fn send( &self, message: Message, - ) -> impl Future>>>; + ) -> impl Future>>>; } pub enum ResponseEvent { @@ -111,7 +116,7 @@ pub struct ThreadEntryId(usize); impl ThreadEntryId { pub fn post_inc(&mut self) -> Self { - let id = *self; + let ed = *self; self.0 += 1; id } @@ -260,19 +265,47 @@ impl Thread { let agent_thread = self.agent_thread.clone(); cx.spawn(async move |this, cx| { let mut events = agent_thread.send(message).await?; - while let Some(event) = events.next().await { - match event { - Ok(ResponseEvent::MessageResponse(message)) => { - this.update(cx, |this, cx| this.handle_message_response(message, cx))? - .await?; + let mut pending_event_handlers = FuturesUnordered::new(); + + loop { + let mut next_event_handler_result = pin!(async { + if pending_event_handlers.is_empty() { + future::pending::<()>().await; } - Ok(ResponseEvent::ReadFileRequest(request)) => { - this.update(cx, |this, cx| this.handle_read_file_request(request, cx))? - .await?; + + pending_event_handlers.next().await + }.fuse()); + + select_biased! { + event = events.next() => { + let Some(event) = event else { + while let Some(result) = pending_event_handlers.next().await { + result?; + } + + break; + }; + + let task = match event { + Ok(ResponseEvent::MessageResponse(message)) => { + this.update(cx, |this, cx| this.handle_message_response(message, cx))? + } + Ok(ResponseEvent::ReadFileRequest(request)) => { + this.update(cx, |this, cx| this.handle_read_file_request(request, cx))? + } + Err(_) => todo!(), + }; + pending_event_handlers.push(task); + } + result = next_event_handler_result => { + // Event handlers should only return errors that are + // unrecoverable and should therefore stop this turn of + // the agentic loop. + result.unwrap()?; } - Err(_) => todo!(), } } + Ok(()) }) } @@ -388,8 +421,8 @@ mod tests { async fn send( &self, - message: Message, - ) -> Result>> { + _message: Message, + ) -> Result>> { todo!() } } From 318709b60d18ca89e544ea1c4b61f3d0ace016a2 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 24 Jun 2025 16:51:43 -0300 Subject: [PATCH 05/54] Fix typo Co-authored-by: Ben Brandt Co-authored-by: Max Brunsfeld --- crates/agent2/src/agent2.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index 64d717209e..782232cf07 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -116,7 +116,7 @@ pub struct ThreadEntryId(usize); impl ThreadEntryId { pub fn post_inc(&mut self) -> Self { - let ed = *self; + let id = *self; self.0 += 1; id } From b094a636cf16192b633a0ea736c782fa172ca9ab Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 24 Jun 2025 18:18:36 -0300 Subject: [PATCH 06/54] Checkpoint: Wiring up acp crate Co-authored-by: Conrad Irwin Co-authored-by: Co-authored-by: Ben Brandt Co-authored-by: Max --- Cargo.lock | 166 +++++++++++++++++++++++------------- crates/agent2/Cargo.toml | 9 ++ crates/agent2/src/acp.rs | 105 +++++++++++++++++++++++ crates/agent2/src/agent2.rs | 56 ++++++++++-- 4 files changed, 267 insertions(+), 69 deletions(-) create mode 100644 crates/agent2/src/acp.rs diff --git a/Cargo.lock b/Cargo.lock index d089d533dc..18bd13d9ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,7 +86,7 @@ dependencies = [ "rand 0.8.5", "ref-cast", "rope", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "settings", @@ -109,12 +109,18 @@ dependencies = [ name = "agent2" version = "0.1.0" dependencies = [ + "agentic-coding-protocol", "anyhow", + "async-trait", "chrono", + "collections", "futures 0.3.31", "gpui", + "language", "project", "serde_json", + "settings", + "smol", "util", "uuid", "workspace-hack", @@ -137,7 +143,7 @@ dependencies = [ "ollama", "open_ai", "paths", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "serde_json_lenient", @@ -203,7 +209,7 @@ dependencies = [ "release_channel", "rope", "rules_library", - "schemars", + "schemars 0.8.22", "search", "serde", "serde_json", @@ -232,6 +238,20 @@ dependencies = [ "zed_llm_client", ] +[[package]] +name = "agentic-coding-protocol" +version = "0.0.1" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "futures 0.3.31", + "parking_lot", + "schemars 1.0.1", + "serde", + "serde_json", +] + [[package]] name = "ahash" version = "0.7.8" @@ -428,7 +448,7 @@ dependencies = [ "chrono", "futures 0.3.31", "http_client", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "strum 0.27.1", @@ -755,7 +775,7 @@ dependencies = [ "regex", "reqwest_client", "rust-embed", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "settings", @@ -1216,7 +1236,7 @@ dependencies = [ "log", "paths", "release_channel", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "settings", @@ -1926,7 +1946,7 @@ dependencies = [ "aws-sdk-bedrockruntime", "aws-smithy-types", "futures 0.3.31", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "strum 0.27.1", @@ -2440,7 +2460,7 @@ dependencies = [ "log", "postage", "project", - "schemars", + "schemars 0.8.22", "serde", "serde_derive", "settings", @@ -2896,7 +2916,7 @@ dependencies = [ "release_channel", "rpc", "rustls-pki-types", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "settings", @@ -3171,7 +3191,7 @@ dependencies = [ "release_channel", "rich_text", "rpc", - "schemars", + "schemars 0.8.22", "serde", "serde_derive", "serde_json", @@ -3360,7 +3380,7 @@ dependencies = [ "log", "parking_lot", "postage", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "smol", @@ -4135,7 +4155,7 @@ dependencies = [ "parking_lot", "paths", "proto", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "settings", @@ -4155,7 +4175,7 @@ name = "dap-types" version = "0.0.1" source = "git+https://github.com/zed-industries/dap-types?rev=b40956a7f4d1939da67429d941389ee306a3a308#b40956a7f4d1939da67429d941389ee306a3a308" dependencies = [ - "schemars", + "schemars 0.8.22", "serde", "serde_json", ] @@ -4380,7 +4400,7 @@ dependencies = [ "anyhow", "futures 0.3.31", "http_client", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "workspace-hack", @@ -4833,7 +4853,7 @@ dependencies = [ "rand 0.8.5", "release_channel", "rpc", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "settings", @@ -5307,7 +5327,7 @@ dependencies = [ "release_channel", "remote", "reqwest_client", - "schemars", + "schemars 0.8.22", "semantic_version", "serde", "serde_json", @@ -5508,7 +5528,7 @@ dependencies = [ "picker", "pretty_assertions", "project", - "schemars", + "schemars 0.8.22", "search", "serde", "serde_derive", @@ -6169,7 +6189,7 @@ dependencies = [ "pretty_assertions", "regex", "rope", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "smol", @@ -6211,7 +6231,7 @@ dependencies = [ "indoc", "pretty_assertions", "regex", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "settings", @@ -6254,7 +6274,7 @@ dependencies = [ "postage", "pretty_assertions", "project", - "schemars", + "schemars 0.8.22", "serde", "serde_derive", "serde_json", @@ -7091,7 +7111,7 @@ dependencies = [ "menu", "project", "rope", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "settings", @@ -7112,7 +7132,7 @@ dependencies = [ "anyhow", "futures 0.3.31", "http_client", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "strum 0.27.1", @@ -7213,7 +7233,7 @@ dependencies = [ "reqwest_client", "resvg", "scap", - "schemars", + "schemars 0.8.22", "seahash", "semantic_version", "serde", @@ -8121,7 +8141,7 @@ dependencies = [ "language", "log", "project", - "schemars", + "schemars 0.8.22", "serde", "settings", "theme", @@ -8678,7 +8698,7 @@ dependencies = [ "editor", "gpui", "log", - "schemars", + "schemars 0.8.22", "serde", "settings", "shellexpand 2.1.2", @@ -8873,7 +8893,7 @@ dependencies = [ "rand 0.8.5", "regex", "rpc", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "settings", @@ -8941,7 +8961,7 @@ dependencies = [ "log", "parking_lot", "proto", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "smol", @@ -8987,7 +9007,7 @@ dependencies = [ "project", "proto", "release_channel", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "settings", @@ -9083,7 +9103,7 @@ dependencies = [ "regex", "rope", "rust-embed", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "serde_json_lenient", @@ -9474,7 +9494,7 @@ dependencies = [ "anyhow", "futures 0.3.31", "http_client", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "workspace-hack", @@ -9580,7 +9600,7 @@ dependencies = [ "parking_lot", "postage", "release_channel", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "smol", @@ -10039,7 +10059,7 @@ dependencies = [ "anyhow", "futures 0.3.31", "http_client", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "strum 0.27.1", @@ -10822,7 +10842,7 @@ dependencies = [ "anyhow", "futures 0.3.31", "http_client", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "workspace-hack", @@ -10893,7 +10913,7 @@ dependencies = [ "anyhow", "futures 0.3.31", "http_client", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "strum 0.27.1", @@ -10907,7 +10927,7 @@ dependencies = [ "anyhow", "futures 0.3.31", "http_client", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "workspace-hack", @@ -11083,7 +11103,7 @@ dependencies = [ "outline", "pretty_assertions", "project", - "schemars", + "schemars 0.8.22", "search", "serde", "serde_json", @@ -11856,7 +11876,7 @@ dependencies = [ "env_logger 0.11.8", "gpui", "menu", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "ui", @@ -12285,7 +12305,7 @@ dependencies = [ "release_channel", "remote", "rpc", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "settings", @@ -12328,7 +12348,7 @@ dependencies = [ "menu", "pretty_assertions", "project", - "schemars", + "schemars 0.8.22", "search", "serde", "serde_derive", @@ -12984,7 +13004,7 @@ dependencies = [ "project", "release_channel", "remote", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "settings", @@ -13172,7 +13192,7 @@ dependencies = [ "prost 0.9.0", "release_channel", "rpc", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "shlex", @@ -13287,7 +13307,7 @@ dependencies = [ "picker", "project", "runtimelib", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "settings", @@ -14057,7 +14077,7 @@ dependencies = [ "anyhow", "clap", "env_logger 0.11.8", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "theme", @@ -14072,7 +14092,21 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "indexmap", - "schemars_derive", + "schemars_derive 0.8.22", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe8c9d1c68d67dd9f97ecbc6f932b60eb289c5dbddd8aa1405484a8fd2fcd984" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive 1.0.1", "serde", "serde_json", ] @@ -14089,6 +14123,18 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "schemars_derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ca9fcb757952f8e8629b9ab066fc62da523c46c2b247b1708a3be06dd82530b" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.101", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -14261,7 +14307,7 @@ dependencies = [ "language", "menu", "project", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "settings", @@ -14562,7 +14608,7 @@ dependencies = [ "pretty_assertions", "release_channel", "rust-embed", - "schemars", + "schemars 0.8.22", "serde", "serde_derive", "serde_json", @@ -14586,7 +14632,7 @@ dependencies = [ "fs", "gpui", "log", - "schemars", + "schemars 0.8.22", "serde", "settings", "theme", @@ -14890,7 +14936,7 @@ dependencies = [ "indoc", "parking_lot", "paths", - "schemars", + "schemars 0.8.22", "serde", "serde_json_lenient", "snippet", @@ -15726,7 +15772,7 @@ dependencies = [ "menu", "picker", "project", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "settings", @@ -15807,7 +15853,7 @@ dependencies = [ "parking_lot", "pretty_assertions", "proto", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "serde_json_lenient", @@ -15913,7 +15959,7 @@ dependencies = [ "rand 0.8.5", "regex", "release_channel", - "schemars", + "schemars 0.8.22", "serde", "serde_derive", "settings", @@ -15960,7 +16006,7 @@ dependencies = [ "project", "rand 0.8.5", "regex", - "schemars", + "schemars 0.8.22", "search", "serde", "serde_json", @@ -16015,7 +16061,7 @@ dependencies = [ "palette", "parking_lot", "refineable", - "schemars", + "schemars 0.8.22", "serde", "serde_derive", "serde_json", @@ -16302,7 +16348,7 @@ dependencies = [ "project", "remote", "rpc", - "schemars", + "schemars 0.8.22", "serde", "settings", "smallvec", @@ -17480,7 +17526,7 @@ dependencies = [ "project_panel", "regex", "release_channel", - "schemars", + "schemars 0.8.22", "search", "serde", "serde_derive", @@ -18333,7 +18379,7 @@ dependencies = [ "language", "picker", "project", - "schemars", + "schemars 0.8.22", "serde", "settings", "telemetry", @@ -19375,7 +19421,7 @@ dependencies = [ "postage", "project", "remote", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "session", @@ -19607,7 +19653,7 @@ dependencies = [ "pretty_assertions", "rand 0.8.5", "rpc", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "settings", @@ -20069,7 +20115,7 @@ name = "zed_actions" version = "0.1.0" dependencies = [ "gpui", - "schemars", + "schemars 0.8.22", "serde", "uuid", "workspace-hack", @@ -20396,7 +20442,7 @@ version = "0.1.0" dependencies = [ "anyhow", "gpui", - "schemars", + "schemars 0.8.22", "serde", "settings", "workspace-hack", diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index b61acffb78..2b24041716 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -15,18 +15,27 @@ doctest = false [features] test-support = [ "gpui/test-support", + "project/test-support", ] [dependencies] anyhow.workspace = true +async-trait.workspace = true +collections.workspace = true chrono.workspace = true futures.workspace = true +language.workspace = true gpui.workspace = true project.workspace = true +smol.workspace = true uuid.workspace = true workspace-hack.workspace = true +util.workspace = true +agentic-coding-protocol = { path = "../../../agentic-coding-protocol" } [dev-dependencies] gpui = { workspace = true, "features" = ["test-support"] } +project = { workspace = true, "features" = ["test-support"] } serde_json.workspace = true util.workspace = true +settings.workspace = true diff --git a/crates/agent2/src/acp.rs b/crates/agent2/src/acp.rs new file mode 100644 index 0000000000..6ee02fd0d1 --- /dev/null +++ b/crates/agent2/src/acp.rs @@ -0,0 +1,105 @@ +use crate::{Agent, AgentThread, AgentThreadEntry, AgentThreadSummary, ResponseEvent, ThreadId}; +use agentic_coding_protocol as acp; +use anyhow::{Context as _, Result}; +use async_trait::async_trait; +use futures::channel::mpsc::UnboundedReceiver; +use gpui::{AppContext, AsyncApp, Entity, Task}; +use project::Project; +use smol::process::Child; +use util::ResultExt; + +pub struct AcpAgent { + connection: acp::Connection, + _handler_task: Task<()>, + _io_task: Task<()>, +} + +struct AcpClientDelegate { + project: Entity, + cx: AsyncApp, + // sent_buffer_versions: HashMap, HashMap>, +} + +#[async_trait] +impl acp::Client for AcpClientDelegate { + async fn read_file(&self, request: acp::ReadFileParams) -> Result { + let cx = &mut self.cx.clone(); + let buffer = self + .project + .update(cx, |project, cx| { + let path = project + .project_path_for_absolute_path(request.path, cx) + .context("Failed to get project path")?; + project.open_buffer(path, cx) + })? + .await?; + + anyhow::Ok(buffer.update(cx, |buffer, cx| acp::ReadFileResponse { + content: buffer.text(), + // todo! + version: 0, + })) + } +} + +impl AcpAgent { + pub fn stdio(process: Child, project: Entity, cx: AsyncApp) -> Self { + let stdin = process.stdin.expect("process didn't have stdin"); + let stdout = process.stdout.expect("process didn't have stdout"); + + let (connection, handler_fut, io_fut) = + acp::Connection::client_to_agent(AcpClientDelegate { project, cx }, stdin, stdout); + + let io_task = cx.background_spawn(async move { + io_fut.await.log_err(); + }); + + Self { + connection, + _handler_task: cx.foreground_executor().spawn(handler_fut), + _io_task: io_task, + } + } +} + +impl Agent for AcpAgent { + type Thread = AcpAgentThread; + + async fn threads(&self) -> Result> { + let threads = self.connection.request(acp::ListThreadsParams).await?; + threads + .threads + .into_iter() + .map(|thread| { + Ok(AgentThreadSummary { + id: ThreadId(thread.id.0), + title: thread.title, + created_at: thread.created_at, + }) + }) + .collect() + } + + async fn create_thread(&self) -> Result { + todo!() + } + + async fn open_thread(&self, id: crate::ThreadId) -> Result { + todo!() + } +} + +struct AcpAgentThread {} + +impl AgentThread for AcpAgentThread { + async fn entries(&self) -> Result> { + todo!() + } + + async fn send( + &self, + message: crate::Message, + ) -> Result>> { + todo!() + } +} diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index 782232cf07..188411bbad 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -1,3 +1,5 @@ +mod acp; + use anyhow::{Result, anyhow}; use chrono::{DateTime, Utc}; use futures::{ @@ -9,7 +11,6 @@ use futures::{ use gpui::{AppContext, AsyncApp, Context, Entity, Task, WeakEntity}; use project::Project; use std::{future, ops::Range, path::PathBuf, pin::pin, sync::Arc}; -use uuid::Uuid; pub trait Agent: 'static { type Thread: AgentThread; @@ -53,7 +54,7 @@ impl ReadFileRequest { } } -pub struct ThreadId(Uuid); +pub struct ThreadId(String); pub struct FileVersion(u64); @@ -363,14 +364,27 @@ impl Thread { #[cfg(test)] mod tests { use super::*; + use agentic_coding_protocol::Client; use gpui::{BackgroundExecutor, TestAppContext}; use project::FakeFs; use serde_json::json; - use std::path::Path; + use settings::SettingsStore; + use smol::process::Child; + use std::env; use util::path; + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + Project::init_settings(cx); + }); + } + #[gpui::test] async fn test_basic(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); let fs = FakeFs::new(cx.executor()); @@ -380,19 +394,43 @@ mod tests { ) .await; let project = Project::test(fs, [path!("/test").as_ref()], cx).await; - let agent = GeminiAgent::start("~/gemini-cli/change-me.js", &cx.executor()) - .await - .unwrap(); + let agent = GeminiAgent::start(&cx.executor()).await.unwrap(); let thread_store = ThreadStore::load(Arc::new(agent), project, &mut cx.to_async()) .await .unwrap(); } - struct GeminiAgent {} + struct TestClient; + + #[async_trait] + impl Client for TestClient { + async fn read_file(&self, _request: ReadFileParams) -> Result { + Ok(ReadFileResponse { + version: FileVersion(0), + content: "the content".into(), + }) + } + } + + struct GeminiAgent { + child: Child, + _task: Task<()>, + } impl GeminiAgent { - pub fn start(path: impl AsRef, executor: &BackgroundExecutor) -> Task> { - executor.spawn(async move { Ok(GeminiAgent {}) }) + pub fn start(executor: &BackgroundExecutor) -> Task> { + executor.spawn(async move { + // todo! + let child = util::command::new_smol_command("node") + .arg("../gemini-cli/packages/cli") + .arg("--acp") + .env("GEMINI_API_KEY", env::var("GEMINI_API_KEY").unwrap()) + .kill_on_drop(true) + .spawn() + .unwrap(); + + Ok(GeminiAgent { child }) + }) } } From de779a45cec054de7974718965046ab9dc2e4290 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 24 Jun 2025 20:07:41 -0700 Subject: [PATCH 07/54] Get one test passing w/ gemini cli --- Cargo.lock | 2 + crates/agent2/Cargo.toml | 1 + crates/agent2/src/acp.rs | 42 ++++++++++++------- crates/agent2/src/agent2.rs | 84 ++++++++----------------------------- 4 files changed, 47 insertions(+), 82 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 18bd13d9ca..043b3289c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,6 +114,7 @@ dependencies = [ "async-trait", "chrono", "collections", + "env_logger 0.11.8", "futures 0.3.31", "gpui", "language", @@ -246,6 +247,7 @@ dependencies = [ "async-trait", "chrono", "futures 0.3.31", + "log", "parking_lot", "schemars 1.0.1", "serde", diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index 2b24041716..c3e55405e2 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -34,6 +34,7 @@ util.workspace = true agentic-coding-protocol = { path = "../../../agentic-coding-protocol" } [dev-dependencies] +env_logger.workspace = true gpui = { workspace = true, "features" = ["test-support"] } project = { workspace = true, "features" = ["test-support"] } serde_json.workspace = true diff --git a/crates/agent2/src/acp.rs b/crates/agent2/src/acp.rs index 6ee02fd0d1..a9fbc7ac28 100644 --- a/crates/agent2/src/acp.rs +++ b/crates/agent2/src/acp.rs @@ -1,3 +1,5 @@ +use std::path::Path; + use crate::{Agent, AgentThread, AgentThreadEntry, AgentThreadSummary, ResponseEvent, ThreadId}; use agentic_coding_protocol as acp; use anyhow::{Context as _, Result}; @@ -9,7 +11,7 @@ use smol::process::Child; use util::ResultExt; pub struct AcpAgent { - connection: acp::Connection, + connection: acp::AgentConnection, _handler_task: Task<()>, _io_task: Task<()>, } @@ -20,7 +22,7 @@ struct AcpClientDelegate { // sent_buffer_versions: HashMap, HashMap>, } -#[async_trait] +#[async_trait(?Send)] impl acp::Client for AcpClientDelegate { async fn read_file(&self, request: acp::ReadFileParams) -> Result { let cx = &mut self.cx.clone(); @@ -28,30 +30,40 @@ impl acp::Client for AcpClientDelegate { .project .update(cx, |project, cx| { let path = project - .project_path_for_absolute_path(request.path, cx) + .project_path_for_absolute_path(Path::new(&request.path), cx) .context("Failed to get project path")?; - project.open_buffer(path, cx) - })? + anyhow::Ok(project.open_buffer(path, cx)) + })?? .await?; - anyhow::Ok(buffer.update(cx, |buffer, cx| acp::ReadFileResponse { + buffer.update(cx, |buffer, _| acp::ReadFileResponse { content: buffer.text(), - // todo! - version: 0, - })) + version: acp::FileVersion(0), + }) + } + + async fn glob_search(&self, request: acp::GlobSearchParams) -> Result { + todo!() } } impl AcpAgent { - pub fn stdio(process: Child, project: Entity, cx: AsyncApp) -> Self { - let stdin = process.stdin.expect("process didn't have stdin"); - let stdout = process.stdout.expect("process didn't have stdout"); + pub fn stdio(mut process: Child, project: Entity, cx: AsyncApp) -> Self { + let stdin = process.stdin.take().expect("process didn't have stdin"); + let stdout = process.stdout.take().expect("process didn't have stdout"); - let (connection, handler_fut, io_fut) = - acp::Connection::client_to_agent(AcpClientDelegate { project, cx }, stdin, stdout); + let (connection, handler_fut, io_fut) = acp::AgentConnection::connect_to_agent( + AcpClientDelegate { + project, + cx: cx.clone(), + }, + stdin, + stdout, + ); let io_task = cx.background_spawn(async move { io_fut.await.log_err(); + process.status().await.log_err(); }); Self { @@ -89,7 +101,7 @@ impl Agent for AcpAgent { } } -struct AcpAgentThread {} +pub struct AcpAgentThread {} impl AgentThread for AcpAgentThread { async fn entries(&self) -> Result> { diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index 188411bbad..1f968bfe2e 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -364,16 +364,16 @@ impl Thread { #[cfg(test)] mod tests { use super::*; - use agentic_coding_protocol::Client; - use gpui::{BackgroundExecutor, TestAppContext}; + use crate::acp::AcpAgent; + use gpui::TestAppContext; use project::FakeFs; use serde_json::json; use settings::SettingsStore; - use smol::process::Child; - use std::env; + use std::{env, process::Stdio}; use util::path; fn init_test(cx: &mut TestAppContext) { + env_logger::init(); cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); @@ -394,74 +394,24 @@ mod tests { ) .await; let project = Project::test(fs, [path!("/test").as_ref()], cx).await; - let agent = GeminiAgent::start(&cx.executor()).await.unwrap(); + let agent = gemini_agent(project.clone(), cx.to_async()).unwrap(); let thread_store = ThreadStore::load(Arc::new(agent), project, &mut cx.to_async()) .await .unwrap(); } - struct TestClient; + pub fn gemini_agent(project: Entity, cx: AsyncApp) -> Result { + let child = util::command::new_smol_command("node") + .arg("../../../gemini-cli/packages/cli") + .arg("--acp") + .env("GEMINI_API_KEY", env::var("GEMINI_API_KEY").unwrap()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .unwrap(); - #[async_trait] - impl Client for TestClient { - async fn read_file(&self, _request: ReadFileParams) -> Result { - Ok(ReadFileResponse { - version: FileVersion(0), - content: "the content".into(), - }) - } - } - - struct GeminiAgent { - child: Child, - _task: Task<()>, - } - - impl GeminiAgent { - pub fn start(executor: &BackgroundExecutor) -> Task> { - executor.spawn(async move { - // todo! - let child = util::command::new_smol_command("node") - .arg("../gemini-cli/packages/cli") - .arg("--acp") - .env("GEMINI_API_KEY", env::var("GEMINI_API_KEY").unwrap()) - .kill_on_drop(true) - .spawn() - .unwrap(); - - Ok(GeminiAgent { child }) - }) - } - } - - impl Agent for GeminiAgent { - type Thread = GeminiAgentThread; - - async fn threads(&self) -> Result> { - todo!() - } - - async fn create_thread(&self) -> Result { - todo!() - } - - async fn open_thread(&self, id: ThreadId) -> Result { - todo!() - } - } - - struct GeminiAgentThread {} - - impl AgentThread for GeminiAgentThread { - async fn entries(&self) -> Result> { - todo!() - } - - async fn send( - &self, - _message: Message, - ) -> Result>> { - todo!() - } + Ok(AcpAgent::stdio(child, project, cx)) } } From 24b72be15429a9ecaad3e590a5fd4f4054fe616d Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 25 Jun 2025 10:11:50 +0200 Subject: [PATCH 08/54] Add debug/clone to structs for testing --- crates/agent2/src/agent2.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index 1f968bfe2e..d98fb05d68 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -42,6 +42,7 @@ pub struct MessageResponse { chunks: BoxStream<'static, Result>, } +#[derive(Debug)] pub struct ReadFileRequest { path: PathBuf, range: Range, @@ -54,32 +55,39 @@ impl ReadFileRequest { } } +#[derive(Debug, Clone)] pub struct ThreadId(String); +#[derive(Debug, Clone, Copy)] pub struct FileVersion(u64); +#[derive(Debug, Clone)] pub struct AgentThreadSummary { pub id: ThreadId, pub title: String, pub created_at: DateTime, } +#[derive(Debug, Clone)] pub struct FileContent { pub path: PathBuf, pub version: FileVersion, pub content: String, } +#[derive(Debug, Clone)] pub enum Role { User, Assistant, } +#[derive(Debug, Clone)] pub struct Message { pub role: Role, pub chunks: Vec, } +#[derive(Debug, Clone)] pub enum MessageChunk { Text { chunk: String, @@ -108,6 +116,7 @@ pub enum MessageChunk { }, } +#[derive(Debug, Clone)] pub enum AgentThreadEntry { Message(Message), } @@ -123,6 +132,7 @@ impl ThreadEntryId { } } +#[derive(Debug, Clone)] pub struct ThreadEntry { pub id: ThreadEntryId, pub entry: AgentThreadEntry, From d47a920c05fbd70bf059f94edfabdf57175e6ffa Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 25 Jun 2025 13:10:43 +0200 Subject: [PATCH 09/54] Implement ACP threads The `create_thread` and `get_threads` methods are now implemented for the ACP agent. A test is added to verify the file reading flow. --- crates/agent2/src/acp.rs | 43 ++++++++--- crates/agent2/src/agent2.rs | 140 +++++++++++++++++++----------------- 2 files changed, 106 insertions(+), 77 deletions(-) diff --git a/crates/agent2/src/acp.rs b/crates/agent2/src/acp.rs index a9fbc7ac28..d6a1befb3c 100644 --- a/crates/agent2/src/acp.rs +++ b/crates/agent2/src/acp.rs @@ -1,7 +1,9 @@ use std::path::Path; -use crate::{Agent, AgentThread, AgentThreadEntry, AgentThreadSummary, ResponseEvent, ThreadId}; -use agentic_coding_protocol as acp; +use crate::{ + Agent, AgentThread, AgentThreadEntryContent, AgentThreadSummary, ResponseEvent, ThreadId, +}; +use agentic_coding_protocol::{self as acp}; use anyhow::{Context as _, Result}; use async_trait::async_trait; use futures::channel::mpsc::UnboundedReceiver; @@ -45,6 +47,10 @@ impl acp::Client for AcpClientDelegate { async fn glob_search(&self, request: acp::GlobSearchParams) -> Result { todo!() } + + async fn end_turn(&self, request: acp::EndTurnParams) -> Result { + todo!() + } } impl AcpAgent { @@ -78,33 +84,38 @@ impl Agent for AcpAgent { type Thread = AcpAgentThread; async fn threads(&self) -> Result> { - let threads = self.connection.request(acp::ListThreadsParams).await?; - threads + let response = self.connection.request(acp::GetThreadsParams).await?; + response .threads .into_iter() .map(|thread| { Ok(AgentThreadSummary { - id: ThreadId(thread.id.0), + id: thread.id.into(), title: thread.title, - created_at: thread.created_at, + created_at: thread.modified_at, }) }) .collect() } async fn create_thread(&self) -> Result { - todo!() + let response = self.connection.request(acp::CreateThreadParams).await?; + Ok(AcpAgentThread { + id: response.thread_id, + }) } - async fn open_thread(&self, id: crate::ThreadId) -> Result { + async fn open_thread(&self, id: ThreadId) -> Result { todo!() } } -pub struct AcpAgentThread {} +pub struct AcpAgentThread { + id: acp::ThreadId, +} impl AgentThread for AcpAgentThread { - async fn entries(&self) -> Result> { + async fn entries(&self) -> Result> { todo!() } @@ -115,3 +126,15 @@ impl AgentThread for AcpAgentThread { todo!() } } + +impl From for ThreadId { + fn from(thread_id: acp::ThreadId) -> Self { + Self(thread_id.0) + } +} + +impl From for acp::ThreadId { + fn from(thread_id: ThreadId) -> Self { + acp::ThreadId(thread_id.0) + } +} diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index d98fb05d68..cd6ce3aa92 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -8,7 +8,7 @@ use futures::{ select_biased, stream::{BoxStream, FuturesUnordered}, }; -use gpui::{AppContext, AsyncApp, Context, Entity, Task, WeakEntity}; +use gpui::{AppContext, AsyncApp, Context, Entity, Task}; use project::Project; use std::{future, ops::Range, path::PathBuf, pin::pin, sync::Arc}; @@ -21,7 +21,7 @@ pub trait Agent: 'static { } pub trait AgentThread: 'static { - fn entries(&self) -> impl Future>>; + fn entries(&self) -> impl Future>>; fn send( &self, message: Message, @@ -58,36 +58,36 @@ impl ReadFileRequest { #[derive(Debug, Clone)] pub struct ThreadId(String); -#[derive(Debug, Clone, Copy)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct FileVersion(u64); -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct AgentThreadSummary { pub id: ThreadId, pub title: String, pub created_at: DateTime, } -#[derive(Debug, Clone)] +#[derive(Debug, PartialEq, Eq)] pub struct FileContent { pub path: PathBuf, pub version: FileVersion, pub content: String, } -#[derive(Debug, Clone)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum Role { User, Assistant, } -#[derive(Debug, Clone)] +#[derive(Debug, Eq, PartialEq)] pub struct Message { pub role: Role, pub chunks: Vec, } -#[derive(Debug, Clone)] +#[derive(Debug, Eq, PartialEq)] pub enum MessageChunk { Text { chunk: String, @@ -108,7 +108,7 @@ pub enum MessageChunk { }, Thread { title: String, - content: Vec, + content: Vec, }, Fetch { url: String, @@ -116,9 +116,18 @@ pub enum MessageChunk { }, } -#[derive(Debug, Clone)] -pub enum AgentThreadEntry { +impl From<&str> for MessageChunk { + fn from(chunk: &str) -> Self { + MessageChunk::Text { + chunk: chunk.to_string(), + } + } +} + +#[derive(Debug, Eq, PartialEq)] +pub enum AgentThreadEntryContent { Message(Message), + ReadFile { path: PathBuf, content: String }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -132,10 +141,10 @@ impl ThreadEntryId { } } -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct ThreadEntry { pub id: ThreadEntryId, - pub entry: AgentThreadEntry, + pub content: AgentThreadEntryContent, } pub struct ThreadStore { @@ -207,7 +216,7 @@ impl Thread { pub fn new( agent_thread: Arc, - entries: Vec, + entries: Vec, project: Entity, cx: &mut Context, ) -> Self { @@ -217,7 +226,7 @@ impl Thread { .into_iter() .map(|entry| ThreadEntry { id: next_entry_id.post_inc(), - entry, + content: entry, }) .collect(), next_entry_id, @@ -226,48 +235,6 @@ impl Thread { } } - async fn handle_message( - this: WeakEntity, - role: Role, - mut chunks: BoxStream<'static, Result>, - cx: &mut AsyncApp, - ) -> Result<()> { - let entry_id = this.update(cx, |this, cx| { - let entry_id = this.next_entry_id.post_inc(); - this.entries.push(ThreadEntry { - id: entry_id, - entry: AgentThreadEntry::Message(Message { - role, - chunks: Vec::new(), - }), - }); - cx.notify(); - entry_id - })?; - - while let Some(chunk) = chunks.next().await { - match chunk { - Ok(chunk) => { - this.update(cx, |this, cx| { - let ix = this - .entries - .binary_search_by_key(&entry_id, |entry| entry.id) - .map_err(|_| anyhow!("message not found"))?; - let AgentThreadEntry::Message(message) = &mut this.entries[ix].entry else { - unreachable!() - }; - message.chunks.push(chunk); - cx.notify(); - anyhow::Ok(()) - })??; - } - Err(err) => todo!("show error"), - } - } - - Ok(()) - } - pub fn entries(&self) -> &[ThreadEntry] { &self.entries } @@ -279,13 +246,16 @@ impl Thread { let mut pending_event_handlers = FuturesUnordered::new(); loop { - let mut next_event_handler_result = pin!(async { - if pending_event_handlers.is_empty() { - future::pending::<()>().await; - } + let mut next_event_handler_result = pin!( + async { + if pending_event_handlers.is_empty() { + future::pending::<()>().await; + } - pending_event_handlers.next().await - }.fuse()); + pending_event_handlers.next().await + } + .fuse() + ); select_biased! { event = events.next() => { @@ -329,7 +299,7 @@ impl Thread { let entry_id = self.next_entry_id.post_inc(); self.entries.push(ThreadEntry { id: entry_id, - entry: AgentThreadEntry::Message(Message { + content: AgentThreadEntryContent::Message(Message { role: message.role, chunks: Vec::new(), }), @@ -345,7 +315,8 @@ impl Thread { .entries .binary_search_by_key(&entry_id, |entry| entry.id) .map_err(|_| anyhow!("message not found"))?; - let AgentThreadEntry::Message(message) = &mut this.entries[ix].entry + let AgentThreadEntryContent::Message(message) = + &mut this.entries[ix].content else { unreachable!() }; @@ -392,7 +363,7 @@ mod tests { } #[gpui::test] - async fn test_basic(cx: &mut TestAppContext) { + async fn test_gemini(cx: &mut TestAppContext) { init_test(cx); cx.executor().allow_parking(); @@ -400,7 +371,7 @@ mod tests { let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/test"), - json!({"foo": "foo", "bar": "bar", "baz": "baz"}), + json!({"foo": "Lorem ipsum dolor", "bar": "bar", "baz": "baz"}), ) .await; let project = Project::test(fs, [path!("/test").as_ref()], cx).await; @@ -408,12 +379,47 @@ mod tests { let thread_store = ThreadStore::load(Arc::new(agent), project, &mut cx.to_async()) .await .unwrap(); + let thread = thread_store + .update(cx, |thread_store, cx| { + assert_eq!(thread_store.threads().len(), 0); + thread_store.create_thread(cx) + }) + .await + .unwrap(); + thread + .update(cx, |thread, cx| { + thread.send( + Message { + role: Role::User, + chunks: vec![ + "Read the 'test/foo' file and output all of its contents.".into(), + ], + }, + cx, + ) + }) + .await + .unwrap(); + thread.read_with(cx, |thread, cx| { + assert!( + thread.entries().iter().any(|entry| { + entry.content + == AgentThreadEntryContent::ReadFile { + path: "test/foo".into(), + content: "Lorem ipsum dolor".into(), + } + }), + "Thread does not contain entry. Actual: {:?}", + thread.entries() + ); + }); } pub fn gemini_agent(project: Entity, cx: AsyncApp) -> Result { let child = util::command::new_smol_command("node") .arg("../../../gemini-cli/packages/cli") .arg("--acp") + // .args(["--model", "gemini-2.5-flash"]) .env("GEMINI_API_KEY", env::var("GEMINI_API_KEY").unwrap()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) From 5f10be779131f8cf205c10141a01c6245b8f546d Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 25 Jun 2025 14:40:33 +0200 Subject: [PATCH 10/54] Start implementing send Co-authored-by: Antonio Scandurra Co-authored-by: Agus Zubiaga --- Cargo.lock | 1 + crates/agent2/Cargo.toml | 1 + crates/agent2/src/acp.rs | 109 ++++++++++++++++++++++++++++++++---- crates/agent2/src/agent2.rs | 8 +-- 4 files changed, 105 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 043b3289c3..085b679e0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,6 +118,7 @@ dependencies = [ "futures 0.3.31", "gpui", "language", + "parking_lot", "project", "serde_json", "settings", diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index c3e55405e2..a4009759af 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -26,6 +26,7 @@ chrono.workspace = true futures.workspace = true language.workspace = true gpui.workspace = true +parking_lot.workspace = true project.workspace = true smol.workspace = true uuid.workspace = true diff --git a/crates/agent2/src/acp.rs b/crates/agent2/src/acp.rs index d6a1befb3c..192e81a9c0 100644 --- a/crates/agent2/src/acp.rs +++ b/crates/agent2/src/acp.rs @@ -1,19 +1,26 @@ -use std::path::Path; +use std::{ + path::Path, + sync::{Arc, Weak}, +}; use crate::{ - Agent, AgentThread, AgentThreadEntryContent, AgentThreadSummary, ResponseEvent, ThreadId, + Agent, AgentThread, AgentThreadEntryContent, AgentThreadSummary, Message, MessageChunk, + ResponseEvent, Role, ThreadId, }; use agentic_coding_protocol::{self as acp}; use anyhow::{Context as _, Result}; use async_trait::async_trait; +use collections::HashMap; use futures::channel::mpsc::UnboundedReceiver; use gpui::{AppContext, AsyncApp, Entity, Task}; +use parking_lot::Mutex; use project::Project; use smol::process::Child; use util::ResultExt; pub struct AcpAgent { - connection: acp::AgentConnection, + connection: Arc, + threads: Mutex>>, _handler_task: Task<()>, _io_task: Task<()>, } @@ -26,6 +33,13 @@ struct AcpClientDelegate { #[async_trait(?Send)] impl acp::Client for AcpClientDelegate { + async fn stream_message_chunk( + &self, + request: acp::StreamMessageChunkParams, + ) -> Result { + Ok(acp::StreamMessageChunkResponse) + } + async fn read_file(&self, request: acp::ReadFileParams) -> Result { let cx = &mut self.cx.clone(); let buffer = self @@ -73,7 +87,8 @@ impl AcpAgent { }); Self { - connection, + connection: Arc::new(connection), + threads: Mutex::default(), _handler_task: cx.foreground_executor().spawn(handler_fut), _io_task: io_task, } @@ -98,31 +113,105 @@ impl Agent for AcpAgent { .collect() } - async fn create_thread(&self) -> Result { + async fn create_thread(&self) -> Result> { let response = self.connection.request(acp::CreateThreadParams).await?; - Ok(AcpAgentThread { - id: response.thread_id, - }) + let thread = Arc::new(AcpAgentThread { + id: response.thread_id.clone(), + connection: self.connection.clone(), + state: Mutex::new(AcpAgentThreadState { turn: None }), + }); + self.threads + .lock() + .insert(response.thread_id, Arc::downgrade(&thread)); + Ok(thread) } - async fn open_thread(&self, id: ThreadId) -> Result { + async fn open_thread(&self, id: ThreadId) -> Result> { todo!() } } pub struct AcpAgentThread { id: acp::ThreadId, + connection: Arc, + state: Mutex, } +struct AcpAgentThreadState { + turn: Option, +} + +struct AcpAgentThreadTurn {} + impl AgentThread for AcpAgentThread { async fn entries(&self) -> Result> { - todo!() + let response = self + .connection + .request(acp::GetThreadEntriesParams { + thread_id: self.id.clone(), + }) + .await?; + + Ok(response + .entries + .into_iter() + .map(|entry| match entry { + acp::ThreadEntry::Message { message } => { + AgentThreadEntryContent::Message(Message { + role: match message.role { + acp::Role::User => Role::User, + acp::Role::Assistant => Role::Assistant, + }, + chunks: message + .chunks + .into_iter() + .map(|chunk| match chunk { + acp::MessageChunk::Text { chunk } => MessageChunk::Text { chunk }, + }) + .collect(), + }) + } + acp::ThreadEntry::ReadFile { path, content } => { + AgentThreadEntryContent::ReadFile { path, content } + } + }) + .collect()) } async fn send( &self, message: crate::Message, ) -> Result>> { + let response = self + .connection + .request(acp::SendMessageParams { + thread_id: self.id.clone(), + message: acp::Message { + role: match message.role { + Role::User => acp::Role::User, + Role::Assistant => acp::Role::Assistant, + }, + chunks: message + .chunks + .into_iter() + .map(|chunk| match chunk { + MessageChunk::Text { chunk } => acp::MessageChunk::Text { chunk }, + MessageChunk::File { content } => todo!(), + MessageChunk::Directory { path, contents } => todo!(), + MessageChunk::Symbol { + path, + range, + version, + name, + content, + } => todo!(), + MessageChunk::Thread { title, content } => todo!(), + MessageChunk::Fetch { url, content } => todo!(), + }) + .collect(), + }, + }) + .await?; todo!() } } diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index cd6ce3aa92..f23b381640 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -16,8 +16,8 @@ pub trait Agent: 'static { type Thread: AgentThread; fn threads(&self) -> impl Future>>; - fn create_thread(&self) -> impl Future>; - fn open_thread(&self, id: ThreadId) -> impl Future>; + fn create_thread(&self) -> impl Future>>; + fn open_thread(&self, id: ThreadId) -> impl Future>>; } pub trait AgentThread: 'static { @@ -182,7 +182,7 @@ impl ThreadStore { let project = self.project.clone(); cx.spawn(async move |_, cx| { let agent_thread = agent.open_thread(id).await?; - Thread::load(Arc::new(agent_thread), project, cx).await + Thread::load(agent_thread, project, cx).await }) } @@ -192,7 +192,7 @@ impl ThreadStore { let project = self.project.clone(); cx.spawn(async move |_, cx| { let agent_thread = agent.create_thread().await?; - Thread::load(Arc::new(agent_thread), project, cx).await + Thread::load(agent_thread, project, cx).await }) } } From f4e2d38c29ac9192011a8cb6172d2537cc7c3b31 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 25 Jun 2025 13:54:31 -0300 Subject: [PATCH 11/54] --wip-- --- Cargo.lock | 1 + crates/agent2/Cargo.toml | 9 +- crates/agent2/src/acp.rs | 176 ++++++++++++++++++++++++++++-------- crates/agent2/src/agent2.rs | 57 ++++++------ 4 files changed, 174 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 085b679e0d..5caf3a3c2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,6 +112,7 @@ dependencies = [ "agentic-coding-protocol", "anyhow", "async-trait", + "base64 0.22.1", "chrono", "collections", "env_logger 0.11.8", diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index a4009759af..bcb4379b67 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -19,20 +19,21 @@ test-support = [ ] [dependencies] +agentic-coding-protocol = { path = "../../../agentic-coding-protocol" } anyhow.workspace = true async-trait.workspace = true -collections.workspace = true +base64.workspace = true chrono.workspace = true +collections.workspace = true futures.workspace = true -language.workspace = true gpui.workspace = true +language.workspace = true parking_lot.workspace = true project.workspace = true smol.workspace = true +util.workspace = true uuid.workspace = true workspace-hack.workspace = true -util.workspace = true -agentic-coding-protocol = { path = "../../../agentic-coding-protocol" } [dev-dependencies] env_logger.workspace = true diff --git a/crates/agent2/src/acp.rs b/crates/agent2/src/acp.rs index 192e81a9c0..e2b2e1bd1a 100644 --- a/crates/agent2/src/acp.rs +++ b/crates/agent2/src/acp.rs @@ -1,18 +1,19 @@ use std::{ + io::{Cursor, Write as _}, path::Path, sync::{Arc, Weak}, }; use crate::{ Agent, AgentThread, AgentThreadEntryContent, AgentThreadSummary, Message, MessageChunk, - ResponseEvent, Role, ThreadId, + ResponseEvent, Role, Thread, ThreadEntry, ThreadId, }; -use agentic_coding_protocol::{self as acp}; +use agentic_coding_protocol::{self as acp, TurnId}; use anyhow::{Context as _, Result}; use async_trait::async_trait; use collections::HashMap; use futures::channel::mpsc::UnboundedReceiver; -use gpui::{AppContext, AsyncApp, Entity, Task}; +use gpui::{AppContext, AsyncApp, Entity, Task, WeakEntity}; use parking_lot::Mutex; use project::Project; use smol::process::Child; @@ -20,19 +21,41 @@ use util::ResultExt; pub struct AcpAgent { connection: Arc, - threads: Mutex>>, + threads: Arc>>>, _handler_task: Task<()>, _io_task: Task<()>, } struct AcpClientDelegate { project: Entity, + threads: Arc>>>, cx: AsyncApp, // sent_buffer_versions: HashMap, HashMap>, } #[async_trait(?Send)] impl acp::Client for AcpClientDelegate { + async fn stat(&self, params: acp::StatParams) -> Result { + let cx = &mut self.cx.clone(); + self.project.update(cx, |project, cx| { + let path = project + .project_path_for_absolute_path(Path::new(¶ms.path), cx) + .context("Failed to get project path")?; + + match project.entry_for_path(&path, cx) { + // todo! refresh entry? + None => Ok(acp::StatResponse { + exists: false, + is_directory: false, + }), + Some(entry) => Ok(acp::StatResponse { + exists: entry.is_created(), + is_directory: entry.is_dir(), + }), + } + })? + } + async fn stream_message_chunk( &self, request: acp::StreamMessageChunkParams, @@ -40,7 +63,10 @@ impl acp::Client for AcpClientDelegate { Ok(acp::StreamMessageChunkResponse) } - async fn read_file(&self, request: acp::ReadFileParams) -> Result { + async fn read_text_file( + &self, + request: acp::ReadTextFileParams, + ) -> Result { let cx = &mut self.cx.clone(); let buffer = self .project @@ -52,8 +78,77 @@ impl acp::Client for AcpClientDelegate { })?? .await?; - buffer.update(cx, |buffer, _| acp::ReadFileResponse { - content: buffer.text(), + buffer.update(cx, |buffer, _| { + let start = language::Point::new(request.line_offset.unwrap_or(0), 0); + let end = match request.line_limit { + None => buffer.max_point(), + Some(limit) => start + language::Point::new(limit + 1, 0), + }; + + let content = buffer.text_for_range(start..end).collect(); + + if let Some(thread) = self.threads.lock().get(&request.thread_id) { + thread.update(cx, |thread, cx| { + thread.push_entry(ThreadEntry { + content: AgentThreadEntryContent::ReadFile { + path: request.path.clone(), + content: content.clone(), + }, + }); + }) + } + + acp::ReadTextFileResponse { + content, + version: acp::FileVersion(0), + } + }) + } + + async fn read_binary_file( + &self, + request: acp::ReadBinaryFileParams, + ) -> Result { + let cx = &mut self.cx.clone(); + let file = self + .project + .update(cx, |project, cx| { + let (worktree, path) = project + .find_worktree(Path::new(&request.path), cx) + .context("Failed to get project path")?; + + let task = worktree.update(cx, |worktree, cx| worktree.load_binary_file(&path, cx)); + anyhow::Ok(task) + })?? + .await?; + + // todo! test + let content = cx + .background_spawn(async move { + let start = request.byte_offset.unwrap_or(0) as usize; + let end = request + .byte_limit + .map(|limit| (start + limit as usize).min(file.content.len())) + .unwrap_or(file.content.len()); + + let range_content = &file.content[start..end]; + + let mut base64_content = Vec::new(); + let mut base64_encoder = base64::write::EncoderWriter::new( + Cursor::new(&mut base64_content), + &base64::engine::general_purpose::STANDARD, + ); + base64_encoder.write_all(range_content)?; + drop(base64_encoder); + + // SAFETY: The base64 encoder should not produce non-UTF8. + unsafe { anyhow::Ok(String::from_utf8_unchecked(base64_content)) } + }) + .await?; + + Ok(acp::ReadBinaryFileResponse { + content, + // todo! version: acp::FileVersion(0), }) } @@ -95,9 +190,8 @@ impl AcpAgent { } } +#[async_trait] impl Agent for AcpAgent { - type Thread = AcpAgentThread; - async fn threads(&self) -> Result> { let response = self.connection.request(acp::GetThreadsParams).await?; response @@ -118,7 +212,10 @@ impl Agent for AcpAgent { let thread = Arc::new(AcpAgentThread { id: response.thread_id.clone(), connection: self.connection.clone(), - state: Mutex::new(AcpAgentThreadState { turn: None }), + state: Mutex::new(AcpAgentThreadState { + turn: None, + next_turn_id: TurnId::default(), + }), }); self.threads .lock() @@ -126,25 +223,11 @@ impl Agent for AcpAgent { Ok(thread) } - async fn open_thread(&self, id: ThreadId) -> Result> { + async fn open_thread(&self, id: ThreadId) -> Result { todo!() } -} -pub struct AcpAgentThread { - id: acp::ThreadId, - connection: Arc, - state: Mutex, -} - -struct AcpAgentThreadState { - turn: Option, -} - -struct AcpAgentThreadTurn {} - -impl AgentThread for AcpAgentThread { - async fn entries(&self) -> Result> { + async fn thread_entries(&self, thread_id: ThreadId) -> Result> { let response = self .connection .request(acp::GetThreadEntriesParams { @@ -178,14 +261,22 @@ impl AgentThread for AcpAgentThread { .collect()) } - async fn send( + async fn send_thread_message( &self, + thread_id: ThreadId, message: crate::Message, ) -> Result>> { + let turn_id = { + let mut state = self.state.lock(); + let turn_id = state.next_turn_id.post_inc(); + state.turn = Some(AcpAgentThreadTurn { id: turn_id }); + turn_id + }; let response = self .connection .request(acp::SendMessageParams { thread_id: self.id.clone(), + turn_id, message: acp::Message { role: match message.role { Role::User => acp::Role::User, @@ -196,17 +287,11 @@ impl AgentThread for AcpAgentThread { .into_iter() .map(|chunk| match chunk { MessageChunk::Text { chunk } => acp::MessageChunk::Text { chunk }, - MessageChunk::File { content } => todo!(), - MessageChunk::Directory { path, contents } => todo!(), - MessageChunk::Symbol { - path, - range, - version, - name, - content, - } => todo!(), - MessageChunk::Thread { title, content } => todo!(), - MessageChunk::Fetch { url, content } => todo!(), + MessageChunk::File { .. } => todo!(), + MessageChunk::Directory { .. } => todo!(), + MessageChunk::Symbol { .. } => todo!(), + MessageChunk::Thread { .. } => todo!(), + MessageChunk::Fetch { .. } => todo!(), }) .collect(), }, @@ -216,6 +301,21 @@ impl AgentThread for AcpAgentThread { } } +pub struct AcpAgentThread { + id: acp::ThreadId, + connection: Arc, + state: Mutex, +} + +struct AcpAgentThreadState { + next_turn_id: acp::TurnId, + turn: Option, +} + +struct AcpAgentThreadTurn { + id: acp::TurnId, +} + impl From for ThreadId { fn from(thread_id: acp::ThreadId) -> Self { Self(thread_id.0) diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index f23b381640..9c77a441cd 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -1,6 +1,7 @@ mod acp; use anyhow::{Result, anyhow}; +use async_trait::async_trait; use chrono::{DateTime, Utc}; use futures::{ FutureExt, StreamExt, @@ -8,24 +9,21 @@ use futures::{ select_biased, stream::{BoxStream, FuturesUnordered}, }; -use gpui::{AppContext, AsyncApp, Context, Entity, Task}; +use gpui::{AppContext, AsyncApp, Context, Entity, SharedString, Task}; use project::Project; use std::{future, ops::Range, path::PathBuf, pin::pin, sync::Arc}; +#[async_trait] pub trait Agent: 'static { - type Thread: AgentThread; - - fn threads(&self) -> impl Future>>; - fn create_thread(&self) -> impl Future>>; - fn open_thread(&self, id: ThreadId) -> impl Future>>; -} - -pub trait AgentThread: 'static { - fn entries(&self) -> impl Future>>; - fn send( + async fn threads(&self) -> Result>; + async fn create_thread(&self) -> Result>; + async fn open_thread(&self, id: ThreadId) -> Result>; + async fn thread_entries(&self, id: ThreadId) -> Result>; + async fn send_thread_message( &self, + thread_id: ThreadId, message: Message, - ) -> impl Future>>>; + ) -> Result>>; } pub enum ResponseEvent { @@ -56,7 +54,7 @@ impl ReadFileRequest { } #[derive(Debug, Clone)] -pub struct ThreadId(String); +pub struct ThreadId(SharedString); #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct FileVersion(u64); @@ -177,7 +175,7 @@ impl ThreadStore { &self, id: ThreadId, cx: &mut Context, - ) -> Task>>> { + ) -> Task>> { let agent = self.agent.clone(); let project = self.project.clone(); cx.spawn(async move |_, cx| { @@ -187,7 +185,7 @@ impl ThreadStore { } /// Creates a new thread. - pub fn create_thread(&self, cx: &mut Context) -> Task>>> { + pub fn create_thread(&self, cx: &mut Context) -> Task>> { let agent = self.agent.clone(); let project = self.project.clone(); cx.spawn(async move |_, cx| { @@ -197,25 +195,28 @@ impl ThreadStore { } } -pub struct Thread { +pub struct Thread { + id: ThreadId, next_entry_id: ThreadEntryId, entries: Vec, - agent_thread: Arc, + agent: Arc, project: Entity, } -impl Thread { +impl Thread { pub async fn load( - agent_thread: Arc, + agent: Arc, + thread_id: ThreadId, project: Entity, cx: &mut AsyncApp, ) -> Result> { - let entries = agent_thread.entries().await?; - cx.new(|cx| Self::new(agent_thread, entries, project, cx)) + let entries = agent.thread_entries(thread_id.clone()).await?; + cx.new(|cx| Self::new(agent, thread_id, entries, project, cx)) } pub fn new( - agent_thread: Arc, + agent: Arc, + thread_id: ThreadId, entries: Vec, project: Entity, cx: &mut Context, @@ -229,8 +230,9 @@ impl Thread { content: entry, }) .collect(), + agent, + id: thread_id, next_entry_id, - agent_thread, project, } } @@ -240,9 +242,10 @@ impl Thread { } pub fn send(&mut self, message: Message, cx: &mut Context) -> Task> { - let agent_thread = self.agent_thread.clone(); + let agent = self.agent.clone(); + let id = self.id; cx.spawn(async move |this, cx| { - let mut events = agent_thread.send(message).await?; + let mut events = agent.send_thread_message(id, message).await?; let mut pending_event_handlers = FuturesUnordered::new(); loop { @@ -400,7 +403,7 @@ mod tests { }) .await .unwrap(); - thread.read_with(cx, |thread, cx| { + thread.read_with(cx, |thread, _| { assert!( thread.entries().iter().any(|entry| { entry.content @@ -419,7 +422,7 @@ mod tests { let child = util::command::new_smol_command("node") .arg("../../../gemini-cli/packages/cli") .arg("--acp") - // .args(["--model", "gemini-2.5-flash"]) + .args(["--model", "gemini-2.5-flash"]) .env("GEMINI_API_KEY", env::var("GEMINI_API_KEY").unwrap()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) From adbccb1ad09cd6d33a8d67b39adc578fb4b62468 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 25 Jun 2025 10:30:52 -0700 Subject: [PATCH 12/54] Get agent2 compiling Co-authored-by: Conrad Irwin Co-authored-by: Antonio Scandurra --- crates/agent2/src/acp.rs | 153 +++++++++++++++++++----------------- crates/agent2/src/agent2.rs | 55 +++++++------ 2 files changed, 109 insertions(+), 99 deletions(-) diff --git a/crates/agent2/src/acp.rs b/crates/agent2/src/acp.rs index e2b2e1bd1a..529b33e828 100644 --- a/crates/agent2/src/acp.rs +++ b/crates/agent2/src/acp.rs @@ -1,19 +1,15 @@ -use std::{ - io::{Cursor, Write as _}, - path::Path, - sync::{Arc, Weak}, -}; +use std::{io::Write as _, path::Path, sync::Arc}; use crate::{ - Agent, AgentThread, AgentThreadEntryContent, AgentThreadSummary, Message, MessageChunk, - ResponseEvent, Role, Thread, ThreadEntry, ThreadId, + Agent, AgentThreadEntryContent, AgentThreadSummary, Message, MessageChunk, ResponseEvent, Role, + Thread, ThreadEntryId, ThreadId, }; -use agentic_coding_protocol::{self as acp, TurnId}; -use anyhow::{Context as _, Result}; +use agentic_coding_protocol as acp; +use anyhow::{Context as _, Result, anyhow}; use async_trait::async_trait; use collections::HashMap; use futures::channel::mpsc::UnboundedReceiver; -use gpui::{AppContext, AsyncApp, Entity, Task, WeakEntity}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, Task, WeakEntity}; use parking_lot::Mutex; use project::Project; use smol::process::Child; @@ -21,18 +17,43 @@ use util::ResultExt; pub struct AcpAgent { connection: Arc, - threads: Arc>>>, + threads: Arc>>>, + project: Entity, _handler_task: Task<()>, _io_task: Task<()>, } struct AcpClientDelegate { project: Entity, - threads: Arc>>>, + threads: Arc>>>, cx: AsyncApp, // sent_buffer_versions: HashMap, HashMap>, } +impl AcpClientDelegate { + fn new(project: Entity, cx: AsyncApp) -> Self { + Self { + project, + threads: Default::default(), + cx: cx, + } + } + + fn update_thread( + &self, + thread_id: &ThreadId, + cx: &mut App, + callback: impl FnMut(&mut Thread, &mut Context) -> R, + ) -> Option { + let thread = self.threads.lock().get(&thread_id)?.clone(); + let Some(thread) = thread.upgrade() else { + self.threads.lock().remove(&thread_id); + return None; + }; + Some(thread.update(cx, callback)) + } +} + #[async_trait(?Send)] impl acp::Client for AcpClientDelegate { async fn stat(&self, params: acp::StatParams) -> Result { @@ -58,7 +79,7 @@ impl acp::Client for AcpClientDelegate { async fn stream_message_chunk( &self, - request: acp::StreamMessageChunkParams, + chunk: acp::StreamMessageChunkParams, ) -> Result { Ok(acp::StreamMessageChunkResponse) } @@ -78,25 +99,23 @@ impl acp::Client for AcpClientDelegate { })?? .await?; - buffer.update(cx, |buffer, _| { + buffer.update(cx, |buffer, cx| { let start = language::Point::new(request.line_offset.unwrap_or(0), 0); let end = match request.line_limit { None => buffer.max_point(), Some(limit) => start + language::Point::new(limit + 1, 0), }; - let content = buffer.text_for_range(start..end).collect(); - - if let Some(thread) = self.threads.lock().get(&request.thread_id) { - thread.update(cx, |thread, cx| { - thread.push_entry(ThreadEntry { - content: AgentThreadEntryContent::ReadFile { - path: request.path.clone(), - content: content.clone(), - }, - }); - }) - } + let content: String = buffer.text_for_range(start..end).collect(); + self.update_thread(&request.thread_id.into(), cx, |thread, cx| { + thread.push_entry( + AgentThreadEntryContent::ReadFile { + path: request.path.clone(), + content: content.clone(), + }, + cx, + ); + }); acp::ReadTextFileResponse { content, @@ -135,7 +154,7 @@ impl acp::Client for AcpClientDelegate { let mut base64_content = Vec::new(); let mut base64_encoder = base64::write::EncoderWriter::new( - Cursor::new(&mut base64_content), + std::io::Cursor::new(&mut base64_content), &base64::engine::general_purpose::STANDARD, ); base64_encoder.write_all(range_content)?; @@ -168,10 +187,7 @@ impl AcpAgent { let stdout = process.stdout.take().expect("process didn't have stdout"); let (connection, handler_fut, io_fut) = acp::AgentConnection::connect_to_agent( - AcpClientDelegate { - project, - cx: cx.clone(), - }, + AcpClientDelegate::new(project.clone(), cx.clone()), stdin, stdout, ); @@ -182,17 +198,18 @@ impl AcpAgent { }); Self { + project, connection: Arc::new(connection), - threads: Mutex::default(), + threads: Default::default(), _handler_task: cx.foreground_executor().spawn(handler_fut), _io_task: io_task, } } } -#[async_trait] +#[async_trait(?Send)] impl Agent for AcpAgent { - async fn threads(&self) -> Result> { + async fn threads(&self, cx: &mut AsyncApp) -> Result> { let response = self.connection.request(acp::GetThreadsParams).await?; response .threads @@ -207,31 +224,34 @@ impl Agent for AcpAgent { .collect() } - async fn create_thread(&self) -> Result> { + async fn create_thread(self: Arc, cx: &mut AsyncApp) -> Result> { let response = self.connection.request(acp::CreateThreadParams).await?; - let thread = Arc::new(AcpAgentThread { - id: response.thread_id.clone(), - connection: self.connection.clone(), - state: Mutex::new(AcpAgentThreadState { - turn: None, - next_turn_id: TurnId::default(), - }), - }); - self.threads - .lock() - .insert(response.thread_id, Arc::downgrade(&thread)); + let thread_id: ThreadId = response.thread_id.into(); + let agent = self.clone(); + let thread = cx.new(|_| Thread { + id: thread_id.clone(), + next_entry_id: ThreadEntryId(0), + entries: Vec::default(), + project: self.project.clone(), + agent, + })?; + self.threads.lock().insert(thread_id, thread.downgrade()); Ok(thread) } - async fn open_thread(&self, id: ThreadId) -> Result { + async fn open_thread(&self, id: ThreadId, cx: &mut AsyncApp) -> Result> { todo!() } - async fn thread_entries(&self, thread_id: ThreadId) -> Result> { + async fn thread_entries( + &self, + thread_id: ThreadId, + cx: &mut AsyncApp, + ) -> Result> { let response = self .connection .request(acp::GetThreadEntriesParams { - thread_id: self.id.clone(), + thread_id: thread_id.clone().into(), }) .await?; @@ -265,18 +285,18 @@ impl Agent for AcpAgent { &self, thread_id: ThreadId, message: crate::Message, + cx: &mut AsyncApp, ) -> Result>> { - let turn_id = { - let mut state = self.state.lock(); - let turn_id = state.next_turn_id.post_inc(); - state.turn = Some(AcpAgentThreadTurn { id: turn_id }); - turn_id - }; + let thread = self + .threads + .lock() + .get(&thread_id) + .cloned() + .ok_or_else(|| anyhow!("no such thread"))?; let response = self .connection .request(acp::SendMessageParams { - thread_id: self.id.clone(), - turn_id, + thread_id: thread_id.clone().into(), message: acp::Message { role: match message.role { Role::User => acp::Role::User, @@ -301,29 +321,14 @@ impl Agent for AcpAgent { } } -pub struct AcpAgentThread { - id: acp::ThreadId, - connection: Arc, - state: Mutex, -} - -struct AcpAgentThreadState { - next_turn_id: acp::TurnId, - turn: Option, -} - -struct AcpAgentThreadTurn { - id: acp::TurnId, -} - impl From for ThreadId { fn from(thread_id: acp::ThreadId) -> Self { - Self(thread_id.0) + Self(thread_id.0.into()) } } impl From for acp::ThreadId { fn from(thread_id: ThreadId) -> Self { - acp::ThreadId(thread_id.0) + acp::ThreadId(thread_id.0.to_string()) } } diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index 9c77a441cd..309fcc2728 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -13,16 +13,21 @@ use gpui::{AppContext, AsyncApp, Context, Entity, SharedString, Task}; use project::Project; use std::{future, ops::Range, path::PathBuf, pin::pin, sync::Arc}; -#[async_trait] +#[async_trait(?Send)] pub trait Agent: 'static { - async fn threads(&self) -> Result>; - async fn create_thread(&self) -> Result>; - async fn open_thread(&self, id: ThreadId) -> Result>; - async fn thread_entries(&self, id: ThreadId) -> Result>; + async fn threads(&self, cx: &mut AsyncApp) -> Result>; + async fn create_thread(self: Arc, cx: &mut AsyncApp) -> Result>; + async fn open_thread(&self, id: ThreadId, cx: &mut AsyncApp) -> Result>; + async fn thread_entries( + &self, + id: ThreadId, + cx: &mut AsyncApp, + ) -> Result>; async fn send_thread_message( &self, thread_id: ThreadId, message: Message, + cx: &mut AsyncApp, ) -> Result>>; } @@ -53,7 +58,7 @@ impl ReadFileRequest { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ThreadId(SharedString); #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -145,20 +150,20 @@ pub struct ThreadEntry { pub content: AgentThreadEntryContent, } -pub struct ThreadStore { +pub struct ThreadStore { threads: Vec, - agent: Arc, + agent: Arc, project: Entity, } -impl ThreadStore { +impl ThreadStore { pub async fn load( - agent: Arc, + agent: Arc, project: Entity, cx: &mut AsyncApp, ) -> Result> { - let threads = agent.threads().await?; - cx.new(|cx| Self { + let threads = agent.threads(cx).await?; + cx.new(|_cx| Self { threads, agent, project, @@ -177,21 +182,13 @@ impl ThreadStore { cx: &mut Context, ) -> Task>> { let agent = self.agent.clone(); - let project = self.project.clone(); - cx.spawn(async move |_, cx| { - let agent_thread = agent.open_thread(id).await?; - Thread::load(agent_thread, project, cx).await - }) + cx.spawn(async move |_, cx| agent.open_thread(id, cx).await) } /// Creates a new thread. pub fn create_thread(&self, cx: &mut Context) -> Task>> { let agent = self.agent.clone(); - let project = self.project.clone(); - cx.spawn(async move |_, cx| { - let agent_thread = agent.create_thread().await?; - Thread::load(agent_thread, project, cx).await - }) + cx.spawn(async move |_, cx| agent.create_thread(cx).await) } } @@ -210,7 +207,7 @@ impl Thread { project: Entity, cx: &mut AsyncApp, ) -> Result> { - let entries = agent.thread_entries(thread_id.clone()).await?; + let entries = agent.thread_entries(thread_id.clone(), cx).await?; cx.new(|cx| Self::new(agent, thread_id, entries, project, cx)) } @@ -241,11 +238,19 @@ impl Thread { &self.entries } + pub fn push_entry(&mut self, entry: AgentThreadEntryContent, cx: &mut Context) { + self.entries.push(ThreadEntry { + id: self.next_entry_id.post_inc(), + content: entry, + }); + cx.notify(); + } + pub fn send(&mut self, message: Message, cx: &mut Context) -> Task> { let agent = self.agent.clone(); - let id = self.id; + let id = self.id.clone(); cx.spawn(async move |this, cx| { - let mut events = agent.send_thread_message(id, message).await?; + let mut events = agent.send_thread_message(id, message, cx).await?; let mut pending_event_handlers = FuturesUnordered::new(); loop { From 8b9ad1cfaef399905cb1b625d33a0e3f5e373c88 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 25 Jun 2025 15:18:42 -0600 Subject: [PATCH 13/54] passing roundtrip test Co-authored-by: Ben Brandt Co-authored-by: Max Brunsfeld Co-authored-by: Agus Zubiaga --- crates/agent2/src/acp.rs | 25 ++++--- crates/agent2/src/agent2.rs | 142 ++---------------------------------- 2 files changed, 22 insertions(+), 145 deletions(-) diff --git a/crates/agent2/src/acp.rs b/crates/agent2/src/acp.rs index 529b33e828..3981dda23b 100644 --- a/crates/agent2/src/acp.rs +++ b/crates/agent2/src/acp.rs @@ -1,14 +1,13 @@ use std::{io::Write as _, path::Path, sync::Arc}; use crate::{ - Agent, AgentThreadEntryContent, AgentThreadSummary, Message, MessageChunk, ResponseEvent, Role, - Thread, ThreadEntryId, ThreadId, + Agent, AgentThreadEntryContent, AgentThreadSummary, Message, MessageChunk, Role, Thread, + ThreadEntryId, ThreadId, }; use agentic_coding_protocol as acp; use anyhow::{Context as _, Result, anyhow}; use async_trait::async_trait; use collections::HashMap; -use futures::channel::mpsc::UnboundedReceiver; use gpui::{App, AppContext, AsyncApp, Context, Entity, Task, WeakEntity}; use parking_lot::Mutex; use project::Project; @@ -31,10 +30,14 @@ struct AcpClientDelegate { } impl AcpClientDelegate { - fn new(project: Entity, cx: AsyncApp) -> Self { + fn new( + project: Entity, + threads: Arc>>>, + cx: AsyncApp, + ) -> Self { Self { project, - threads: Default::default(), + threads, cx: cx, } } @@ -186,8 +189,9 @@ impl AcpAgent { let stdin = process.stdin.take().expect("process didn't have stdin"); let stdout = process.stdout.take().expect("process didn't have stdout"); + let threads: Arc>>> = Default::default(); let (connection, handler_fut, io_fut) = acp::AgentConnection::connect_to_agent( - AcpClientDelegate::new(project.clone(), cx.clone()), + AcpClientDelegate::new(project.clone(), threads.clone(), cx.clone()), stdin, stdout, ); @@ -200,7 +204,7 @@ impl AcpAgent { Self { project, connection: Arc::new(connection), - threads: Default::default(), + threads, _handler_task: cx.foreground_executor().spawn(handler_fut), _io_task: io_task, } @@ -286,15 +290,14 @@ impl Agent for AcpAgent { thread_id: ThreadId, message: crate::Message, cx: &mut AsyncApp, - ) -> Result>> { + ) -> Result<()> { let thread = self .threads .lock() .get(&thread_id) .cloned() .ok_or_else(|| anyhow!("no such thread"))?; - let response = self - .connection + self.connection .request(acp::SendMessageParams { thread_id: thread_id.clone().into(), message: acp::Message { @@ -317,7 +320,7 @@ impl Agent for AcpAgent { }, }) .await?; - todo!() + Ok(()) } } diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index 309fcc2728..519e23f35e 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -3,15 +3,9 @@ mod acp; use anyhow::{Result, anyhow}; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use futures::{ - FutureExt, StreamExt, - channel::{mpsc, oneshot}, - select_biased, - stream::{BoxStream, FuturesUnordered}, -}; use gpui::{AppContext, AsyncApp, Context, Entity, SharedString, Task}; use project::Project; -use std::{future, ops::Range, path::PathBuf, pin::pin, sync::Arc}; +use std::{ops::Range, path::PathBuf, sync::Arc}; #[async_trait(?Send)] pub trait Agent: 'static { @@ -28,34 +22,7 @@ pub trait Agent: 'static { thread_id: ThreadId, message: Message, cx: &mut AsyncApp, - ) -> Result>>; -} - -pub enum ResponseEvent { - MessageResponse(MessageResponse), - ReadFileRequest(ReadFileRequest), - // GlobSearchRequest(SearchRequest), - // RegexSearchRequest(RegexSearchRequest), - // RunCommandRequest(RunCommandRequest), - // WebSearchResponse(WebSearchResponse), -} - -pub struct MessageResponse { - role: Role, - chunks: BoxStream<'static, Result>, -} - -#[derive(Debug)] -pub struct ReadFileRequest { - path: PathBuf, - range: Range, - response_tx: oneshot::Sender>, -} - -impl ReadFileRequest { - pub fn respond(self, content: Result) { - self.response_tx.send(content).ok(); - } + ) -> Result<()>; } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -250,104 +217,10 @@ impl Thread { let agent = self.agent.clone(); let id = self.id.clone(); cx.spawn(async move |this, cx| { - let mut events = agent.send_thread_message(id, message, cx).await?; - let mut pending_event_handlers = FuturesUnordered::new(); - - loop { - let mut next_event_handler_result = pin!( - async { - if pending_event_handlers.is_empty() { - future::pending::<()>().await; - } - - pending_event_handlers.next().await - } - .fuse() - ); - - select_biased! { - event = events.next() => { - let Some(event) = event else { - while let Some(result) = pending_event_handlers.next().await { - result?; - } - - break; - }; - - let task = match event { - Ok(ResponseEvent::MessageResponse(message)) => { - this.update(cx, |this, cx| this.handle_message_response(message, cx))? - } - Ok(ResponseEvent::ReadFileRequest(request)) => { - this.update(cx, |this, cx| this.handle_read_file_request(request, cx))? - } - Err(_) => todo!(), - }; - pending_event_handlers.push(task); - } - result = next_event_handler_result => { - // Event handlers should only return errors that are - // unrecoverable and should therefore stop this turn of - // the agentic loop. - result.unwrap()?; - } - } - } - + agent.send_thread_message(id, message, cx).await?; Ok(()) }) } - - fn handle_message_response( - &mut self, - mut message: MessageResponse, - cx: &mut Context, - ) -> Task> { - let entry_id = self.next_entry_id.post_inc(); - self.entries.push(ThreadEntry { - id: entry_id, - content: AgentThreadEntryContent::Message(Message { - role: message.role, - chunks: Vec::new(), - }), - }); - cx.notify(); - - cx.spawn(async move |this, cx| { - while let Some(chunk) = message.chunks.next().await { - match chunk { - Ok(chunk) => { - this.update(cx, |this, cx| { - let ix = this - .entries - .binary_search_by_key(&entry_id, |entry| entry.id) - .map_err(|_| anyhow!("message not found"))?; - let AgentThreadEntryContent::Message(message) = - &mut this.entries[ix].content - else { - unreachable!() - }; - message.chunks.push(chunk); - cx.notify(); - anyhow::Ok(()) - })??; - } - Err(err) => todo!("show error"), - } - } - - Ok(()) - }) - } - - fn handle_read_file_request( - &mut self, - request: ReadFileRequest, - cx: &mut Context, - ) -> Task> { - todo!() - } } #[cfg(test)] @@ -367,6 +240,7 @@ mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); Project::init_settings(cx); + language::init(cx); }); } @@ -378,11 +252,11 @@ mod tests { let fs = FakeFs::new(cx.executor()); fs.insert_tree( - path!("/test"), + path!("/tmp"), json!({"foo": "Lorem ipsum dolor", "bar": "bar", "baz": "baz"}), ) .await; - let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + let project = Project::test(fs, [path!("/tmp").as_ref()], cx).await; let agent = gemini_agent(project.clone(), cx.to_async()).unwrap(); let thread_store = ThreadStore::load(Arc::new(agent), project, &mut cx.to_async()) .await @@ -400,7 +274,7 @@ mod tests { Message { role: Role::User, chunks: vec![ - "Read the 'test/foo' file and output all of its contents.".into(), + "Read the '/tmp/foo' file and output all of its contents.".into(), ], }, cx, @@ -413,7 +287,7 @@ mod tests { thread.entries().iter().any(|entry| { entry.content == AgentThreadEntryContent::ReadFile { - path: "test/foo".into(), + path: "/tmp/foo".into(), content: "Lorem ipsum dolor".into(), } }), From a74ffd9ee4094ea82289bc9026d596b9ab718523 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 25 Jun 2025 14:59:07 -0700 Subject: [PATCH 14/54] In test, start gemini in the right directory Co-authored-by: Conrad Irwin --- crates/agent2/src/agent2.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index 519e23f35e..d4e976f488 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -231,7 +231,7 @@ mod tests { use project::FakeFs; use serde_json::json; use settings::SettingsStore; - use std::{env, process::Stdio}; + use std::{env, path::Path, process::Stdio}; use util::path; fn init_test(cx: &mut TestAppContext) { @@ -252,11 +252,11 @@ mod tests { let fs = FakeFs::new(cx.executor()); fs.insert_tree( - path!("/tmp"), + path!("/private/tmp"), json!({"foo": "Lorem ipsum dolor", "bar": "bar", "baz": "baz"}), ) .await; - let project = Project::test(fs, [path!("/tmp").as_ref()], cx).await; + let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await; let agent = gemini_agent(project.clone(), cx.to_async()).unwrap(); let thread_store = ThreadStore::load(Arc::new(agent), project, &mut cx.to_async()) .await @@ -274,7 +274,8 @@ mod tests { Message { role: Role::User, chunks: vec![ - "Read the '/tmp/foo' file and output all of its contents.".into(), + "Read the '/private/tmp/foo' file and output all of its contents." + .into(), ], }, cx, @@ -287,7 +288,7 @@ mod tests { thread.entries().iter().any(|entry| { entry.content == AgentThreadEntryContent::ReadFile { - path: "/tmp/foo".into(), + path: "/private/tmp/foo".into(), content: "Lorem ipsum dolor".into(), } }), @@ -298,11 +299,13 @@ mod tests { } pub fn gemini_agent(project: Entity, cx: AsyncApp) -> Result { + let cli_path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../gemini-cli/packages/cli"); let child = util::command::new_smol_command("node") - .arg("../../../gemini-cli/packages/cli") + .arg(cli_path) .arg("--acp") .args(["--model", "gemini-2.5-flash"]) - .env("GEMINI_API_KEY", env::var("GEMINI_API_KEY").unwrap()) + .current_dir("/private/tmp") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) From 33ee0c3093792358ec6cc6ec10e207b285ab70d2 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 25 Jun 2025 20:23:18 -0700 Subject: [PATCH 15/54] Return an Arc from AcpAgent::stdio --- crates/agent2/src/acp.rs | 18 ++++-------------- crates/agent2/src/agent2.rs | 8 ++++---- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/crates/agent2/src/acp.rs b/crates/agent2/src/acp.rs index 3981dda23b..0269144800 100644 --- a/crates/agent2/src/acp.rs +++ b/crates/agent2/src/acp.rs @@ -5,7 +5,7 @@ use crate::{ ThreadEntryId, ThreadId, }; use agentic_coding_protocol as acp; -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Context as _, Result}; use async_trait::async_trait; use collections::HashMap; use gpui::{App, AppContext, AsyncApp, Context, Entity, Task, WeakEntity}; @@ -178,14 +178,10 @@ impl acp::Client for AcpClientDelegate { async fn glob_search(&self, request: acp::GlobSearchParams) -> Result { todo!() } - - async fn end_turn(&self, request: acp::EndTurnParams) -> Result { - todo!() - } } impl AcpAgent { - pub fn stdio(mut process: Child, project: Entity, cx: AsyncApp) -> Self { + pub fn stdio(mut process: Child, project: Entity, cx: &mut AsyncApp) -> Arc { let stdin = process.stdin.take().expect("process didn't have stdin"); let stdout = process.stdout.take().expect("process didn't have stdout"); @@ -201,13 +197,13 @@ impl AcpAgent { process.status().await.log_err(); }); - Self { + Arc::new(Self { project, connection: Arc::new(connection), threads, _handler_task: cx.foreground_executor().spawn(handler_fut), _io_task: io_task, - } + }) } } @@ -291,12 +287,6 @@ impl Agent for AcpAgent { message: crate::Message, cx: &mut AsyncApp, ) -> Result<()> { - let thread = self - .threads - .lock() - .get(&thread_id) - .cloned() - .ok_or_else(|| anyhow!("no such thread"))?; self.connection .request(acp::SendMessageParams { thread_id: thread_id.clone().into(), diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index d4e976f488..6d2dfc843b 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -183,7 +183,7 @@ impl Thread { thread_id: ThreadId, entries: Vec, project: Entity, - cx: &mut Context, + _: &mut Context, ) -> Self { let mut next_entry_id = ThreadEntryId(0); Self { @@ -216,7 +216,7 @@ impl Thread { pub fn send(&mut self, message: Message, cx: &mut Context) -> Task> { let agent = self.agent.clone(); let id = self.id.clone(); - cx.spawn(async move |this, cx| { + cx.spawn(async move |_, cx| { agent.send_thread_message(id, message, cx).await?; Ok(()) }) @@ -298,7 +298,7 @@ mod tests { }); } - pub fn gemini_agent(project: Entity, cx: AsyncApp) -> Result { + pub fn gemini_agent(project: Entity, mut cx: AsyncApp) -> Result> { let cli_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../gemini-cli/packages/cli"); let child = util::command::new_smol_command("node") @@ -313,6 +313,6 @@ mod tests { .spawn() .unwrap(); - Ok(AcpAgent::stdio(child, project, cx)) + Ok(AcpAgent::stdio(child, project, &mut cx)) } } From 81b4d7e35ad58dabb9db8be25d409760401e7eb1 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 25 Jun 2025 20:23:41 -0700 Subject: [PATCH 16/54] Start on using agent2 from agent_ui --- Cargo.lock | 1 + crates/agent2/src/acp.rs | 1 + crates/agent2/src/agent2.rs | 12 +++++- crates/agent_ui/Cargo.toml | 1 + crates/agent_ui/src/agent_panel.rs | 65 +++++++++++++++++++++++++++++- crates/agent_ui/src/agent_ui.rs | 1 + 6 files changed, 78 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d784065923..352415b173 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,6 +154,7 @@ name = "agent_ui" version = "0.1.0" dependencies = [ "agent", + "agent2", "agent_settings", "anyhow", "assistant_context", diff --git a/crates/agent2/src/acp.rs b/crates/agent2/src/acp.rs index 0269144800..04762f09b0 100644 --- a/crates/agent2/src/acp.rs +++ b/crates/agent2/src/acp.rs @@ -229,6 +229,7 @@ impl Agent for AcpAgent { let thread_id: ThreadId = response.thread_id.into(); let agent = self.clone(); let thread = cx.new(|_| Thread { + title: "The agent2 thread".into(), id: thread_id.clone(), next_entry_id: ThreadEntryId(0), entries: Vec::default(), diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index 6d2dfc843b..f61f86fab7 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -1,12 +1,14 @@ mod acp; -use anyhow::{Result, anyhow}; +use anyhow::Result; use async_trait::async_trait; use chrono::{DateTime, Utc}; use gpui::{AppContext, AsyncApp, Context, Entity, SharedString, Task}; use project::Project; use std::{ops::Range, path::PathBuf, sync::Arc}; +pub use acp::AcpAgent; + #[async_trait(?Send)] pub trait Agent: 'static { async fn threads(&self, cx: &mut AsyncApp) -> Result>; @@ -164,6 +166,7 @@ pub struct Thread { next_entry_id: ThreadEntryId, entries: Vec, agent: Arc, + title: SharedString, project: Entity, } @@ -187,6 +190,7 @@ impl Thread { ) -> Self { let mut next_entry_id = ThreadEntryId(0); Self { + title: "A new agent2 thread".into(), entries: entries .into_iter() .map(|entry| ThreadEntry { @@ -201,6 +205,10 @@ impl Thread { } } + pub fn title(&self) -> SharedString { + self.title.clone() + } + pub fn entries(&self) -> &[ThreadEntry] { &self.entries } @@ -258,7 +266,7 @@ mod tests { .await; let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await; let agent = gemini_agent(project.clone(), cx.to_async()).unwrap(); - let thread_store = ThreadStore::load(Arc::new(agent), project, &mut cx.to_async()) + let thread_store = ThreadStore::load(agent, project, &mut cx.to_async()) .await .unwrap(); let thread = thread_store diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 070e8eb585..33e5aa8de6 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -20,6 +20,7 @@ test-support = [ [dependencies] agent.workspace = true +agent2.workspace = true agent_settings.workspace = true anyhow.workspace = true assistant_context.workspace = true diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index eed50f1842..37962905d0 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -4,9 +4,11 @@ use std::rc::Rc; use std::sync::Arc; use std::time::Duration; +use agent2::{AcpAgent, Agent as _}; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use serde::{Deserialize, Serialize}; +use crate::NewGeminiThread; use crate::language_model_selector::ToggleModelSelector; use crate::{ AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode, @@ -109,6 +111,12 @@ pub fn init(cx: &mut App) { panel.update(cx, |panel, cx| panel.new_prompt_editor(window, cx)); } }) + .register_action(|workspace, _: &NewGeminiThread, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + workspace.focus_panel::(window, cx); + panel.update(cx, |panel, cx| panel.new_gemini_thread(window, cx)); + } + }) .register_action(|workspace, action: &OpenRulesLibrary, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); @@ -183,6 +191,9 @@ enum ActiveView { buffer_search_bar: Entity, _subscriptions: Vec, }, + Agent2Thread { + thread: Entity, + }, History, Configuration, } @@ -196,7 +207,9 @@ enum WhichFontSize { impl ActiveView { pub fn which_font_size_used(&self) -> WhichFontSize { match self { - ActiveView::Thread { .. } | ActiveView::History => WhichFontSize::AgentFont, + ActiveView::Thread { .. } | ActiveView::Agent2Thread { .. } | ActiveView::History => { + WhichFontSize::AgentFont + } ActiveView::TextThread { .. } => WhichFontSize::BufferFont, ActiveView::Configuration => WhichFontSize::None, } @@ -867,6 +880,42 @@ impl AgentPanel { context_editor.focus_handle(cx).focus(window); } + fn new_gemini_thread(&mut self, window: &mut Window, cx: &mut Context) { + let Some(root_dir) = self + .project + .read(cx) + .visible_worktrees(cx) + .next() + .map(|worktree| worktree.read(cx).abs_path()) + else { + return; + }; + + let cli_path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../gemini-cli/packages/cli"); + let child = util::command::new_smol_command("node") + .arg(cli_path) + .arg("--acp") + .args(["--model", "gemini-2.5-flash"]) + .current_dir(root_dir) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .unwrap(); + + let project = self.project.clone(); + cx.spawn_in(window, async move |this, cx| { + let agent = AcpAgent::stdio(child, project, cx); + let thread = agent.create_thread(cx).await?; + this.update_in(cx, |this, window, cx| { + this.set_active_view(ActiveView::Agent2Thread { thread }, window, cx); + }) + }) + .detach(); + } + fn deploy_rules_library( &mut self, action: &OpenRulesLibrary, @@ -1465,6 +1514,7 @@ impl Focusable for AgentPanel { fn focus_handle(&self, cx: &App) -> FocusHandle { match &self.active_view { ActiveView::Thread { .. } => self.message_editor.focus_handle(cx), + ActiveView::Agent2Thread { .. } => self.message_editor.focus_handle(cx), ActiveView::History => self.history.focus_handle(cx), ActiveView::TextThread { context_editor, .. } => context_editor.focus_handle(cx), ActiveView::Configuration => { @@ -1618,6 +1668,9 @@ impl AgentPanel { .into_any_element(), } } + ActiveView::Agent2Thread { thread } => Label::new(thread.read(cx).title()) + .truncate() + .into_any_element(), ActiveView::TextThread { title_editor, context_editor, @@ -1785,6 +1838,7 @@ impl AgentPanel { menu = menu .action("New Thread", NewThread::default().boxed_clone()) .action("New Text Thread", NewTextThread.boxed_clone()) + .action("New Gemini Thread", NewGeminiThread.boxed_clone()) .when(!is_empty, |menu| { menu.action( "New From Summary", @@ -3023,6 +3077,9 @@ impl AgentPanel { .detach(); }); } + ActiveView::Agent2Thread { .. } => { + unimplemented!() + } ActiveView::TextThread { context_editor, .. } => { context_editor.update(cx, |context_editor, cx| { TextThreadEditor::insert_dragged_files( @@ -3115,6 +3172,12 @@ impl Render for AgentPanel { .child(h_flex().child(self.message_editor.clone())) .children(self.render_last_error(cx)) .child(self.render_drag_target(cx)), + ActiveView::Agent2Thread { .. } => parent + .relative() + .child(self.render_active_thread_or_empty_state(window, cx)) + .child(h_flex().child(self.message_editor.clone())) + .children(self.render_last_error(cx)) + .child(self.render_drag_target(cx)), ActiveView::History => parent.child(self.history.clone()), ActiveView::TextThread { context_editor, diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 4babe4f676..3f77b2fa70 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -55,6 +55,7 @@ actions!( agent, [ NewTextThread, + NewGeminiThread, ToggleContextPicker, ToggleNavigationMenu, ToggleOptionsMenu, From 47c875f6b5d404d319151866726ed48092e87957 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 26 Jun 2025 12:25:23 +0200 Subject: [PATCH 17/54] Pass GEMINI_API_KEY to agent process if available --- crates/agent2/src/agent2.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index f61f86fab7..98fd802cb2 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -309,7 +309,8 @@ mod tests { pub fn gemini_agent(project: Entity, mut cx: AsyncApp) -> Result> { let cli_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../gemini-cli/packages/cli"); - let child = util::command::new_smol_command("node") + let mut command = util::command::new_smol_command("node"); + command .arg(cli_path) .arg("--acp") .args(["--model", "gemini-2.5-flash"]) @@ -317,9 +318,13 @@ mod tests { .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) - .kill_on_drop(true) - .spawn() - .unwrap(); + .kill_on_drop(true); + + if let Ok(gemini_key) = std::env::var("GEMINI_API_KEY") { + command.env("GEMINI_API_KEY", gemini_key); + } + + let child = command.spawn().unwrap(); Ok(AcpAgent::stdio(child, project, &mut cx)) } From 75bcaf743c75761c2dd8103a7a0317b30456d477 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 26 Jun 2025 13:59:41 +0200 Subject: [PATCH 18/54] Put user messages into thread --- crates/agent2/src/acp.rs | 9 ++++++--- crates/agent2/src/agent2.rs | 38 +++++++++++++++++++++++++------------ 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/crates/agent2/src/acp.rs b/crates/agent2/src/acp.rs index 04762f09b0..c9c17ecbd7 100644 --- a/crates/agent2/src/acp.rs +++ b/crates/agent2/src/acp.rs @@ -270,7 +270,9 @@ impl Agent for AcpAgent { .chunks .into_iter() .map(|chunk| match chunk { - acp::MessageChunk::Text { chunk } => MessageChunk::Text { chunk }, + acp::MessageChunk::Text { chunk } => MessageChunk::Text { + chunk: chunk.into(), + }, }) .collect(), }) @@ -300,11 +302,12 @@ impl Agent for AcpAgent { .chunks .into_iter() .map(|chunk| match chunk { - MessageChunk::Text { chunk } => acp::MessageChunk::Text { chunk }, + MessageChunk::Text { chunk } => acp::MessageChunk::Text { + chunk: chunk.into(), + }, MessageChunk::File { .. } => todo!(), MessageChunk::Directory { .. } => todo!(), MessageChunk::Symbol { .. } => todo!(), - MessageChunk::Thread { .. } => todo!(), MessageChunk::Fetch { .. } => todo!(), }) .collect(), diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index 98fd802cb2..7e429ae082 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -40,11 +40,11 @@ pub struct AgentThreadSummary { pub created_at: DateTime, } -#[derive(Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct FileContent { pub path: PathBuf, pub version: FileVersion, - pub content: String, + pub content: SharedString, } #[derive(Copy, Clone, Debug, Eq, PartialEq)] @@ -53,16 +53,16 @@ pub enum Role { Assistant, } -#[derive(Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct Message { pub role: Role, pub chunks: Vec, } -#[derive(Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum MessageChunk { Text { - chunk: String, + chunk: SharedString, }, File { content: FileContent, @@ -75,28 +75,28 @@ pub enum MessageChunk { path: PathBuf, range: Range, version: FileVersion, - name: String, - content: String, + name: SharedString, + content: SharedString, }, Thread { - title: String, + title: SharedString, content: Vec, }, Fetch { - url: String, - content: String, + url: SharedString, + content: SharedString, }, } impl From<&str> for MessageChunk { fn from(chunk: &str) -> Self { MessageChunk::Text { - chunk: chunk.to_string(), + chunk: chunk.to_string().into(), } } } -#[derive(Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum AgentThreadEntryContent { Message(Message), ReadFile { path: PathBuf, content: String }, @@ -224,6 +224,7 @@ impl Thread { pub fn send(&mut self, message: Message, cx: &mut Context) -> Task> { let agent = self.agent.clone(); let id = self.id.clone(); + self.push_entry(AgentThreadEntryContent::Message(message.clone()), cx); cx.spawn(async move |_, cx| { agent.send_thread_message(id, message, cx).await?; Ok(()) @@ -291,6 +292,19 @@ mod tests { }) .await .unwrap(); + + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.entries[0].content, + AgentThreadEntryContent::Message(Message { + role: Role::User, + .. + }) + )); + }); + + cx.run_until_parked(); + thread.read_with(cx, |thread, _| { assert!( thread.entries().iter().any(|entry| { From 678a42e920bd09e18093a892747554a95f5dc8cc Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 26 Jun 2025 14:00:21 +0200 Subject: [PATCH 19/54] Fix missing variant --- crates/agent2/src/agent2.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index 7e429ae082..d071b67a4d 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -78,10 +78,6 @@ pub enum MessageChunk { name: SharedString, content: SharedString, }, - Thread { - title: SharedString, - content: Vec, - }, Fetch { url: SharedString, content: SharedString, From fc59d9cbf3f583b057dc98e9cf47f78818e15633 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 26 Jun 2025 14:22:13 +0200 Subject: [PATCH 20/54] Clean up tests Co-authored-by: Agus Zubiaga Co-authored-by: Antonio Scandurra --- crates/agent2/src/agent2.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index d071b67a4d..f044cbd4bd 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -297,11 +297,6 @@ mod tests { .. }) )); - }); - - cx.run_until_parked(); - - thread.read_with(cx, |thread, _| { assert!( thread.entries().iter().any(|entry| { entry.content From 79c37284e083165043f500ca1d9a6bbea7119277 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 26 Jun 2025 11:36:05 -0300 Subject: [PATCH 21/54] Move ActiveThread into ActiveView::Thread Co-authored-by: Antonio Scandurra --- crates/agent_ui/src/agent_panel.rs | 686 ++++++++++++++------------ crates/agent_ui/src/context_picker.rs | 2 +- crates/agent_ui/src/context_strip.rs | 2 +- 3 files changed, 375 insertions(+), 315 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 37962905d0..f9b934b238 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -128,8 +128,16 @@ pub fn init(cx: &mut App) { .register_action(|workspace, _: &OpenAgentDiff, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); - let thread = panel.read(cx).thread.read(cx).thread().clone(); - AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx); + match &panel.read(cx).active_view { + ActiveView::Thread { thread, .. } => { + let thread = thread.read(cx).thread().clone(); + AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx); + } + ActiveView::Agent2Thread { .. } => todo!(), + ActiveView::TextThread { .. } + | ActiveView::History + | ActiveView::Configuration => {} + } } }) .register_action(|workspace, _: &Follow, window, cx| { @@ -181,19 +189,19 @@ pub fn init(cx: &mut App) { enum ActiveView { Thread { + thread: Entity, change_title_editor: Entity, - thread: WeakEntity, _subscriptions: Vec, }, + Agent2Thread { + thread: Entity, + }, TextThread { context_editor: Entity, title_editor: Entity, buffer_search_bar: Entity, _subscriptions: Vec, }, - Agent2Thread { - thread: Entity, - }, History, Configuration, } @@ -215,8 +223,12 @@ impl ActiveView { } } - pub fn thread(thread: Entity, window: &mut Window, cx: &mut App) -> Self { - let summary = thread.read(cx).summary().or_default(); + pub fn thread( + active_thread: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let summary = active_thread.read(cx).summary(cx).or_default(); let editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); @@ -227,18 +239,20 @@ impl ActiveView { let subscriptions = vec![ window.subscribe(&editor, cx, { { - let thread = thread.clone(); + let thread = active_thread.clone(); move |editor, event, window, cx| match event { EditorEvent::BufferEdited => { let new_summary = editor.read(cx).text(cx); thread.update(cx, |thread, cx| { - thread.set_summary(new_summary, cx); + thread.thread().update(cx, |thread, cx| { + thread.set_summary(new_summary, cx); + }); }) } EditorEvent::Blurred => { if editor.read(cx).text(cx).is_empty() { - let summary = thread.read(cx).summary().or_default(); + let summary = thread.read(cx).summary(cx).or_default(); editor.update(cx, |editor, cx| { editor.set_text(summary, window, cx); @@ -249,9 +263,14 @@ impl ActiveView { } } }), - window.subscribe(&thread, cx, { + cx.subscribe(&active_thread, |_, _, event, cx| match &event { + ActiveThreadEvent::EditingMessageTokenCountChanged => { + cx.notify(); + } + }), + cx.subscribe_in(&active_thread.read(cx).thread().clone(), window, { let editor = editor.clone(); - move |thread, event, window, cx| match event { + move |_, thread, event, window, cx| match event { ThreadEvent::SummaryGenerated => { let summary = thread.read(cx).summary().or_default(); @@ -259,6 +278,9 @@ impl ActiveView { editor.set_text(summary, window, cx); }) } + ThreadEvent::MessageAdded(_) => { + cx.notify(); + } _ => {} } }), @@ -266,7 +288,7 @@ impl ActiveView { Self::Thread { change_title_editor: editor, - thread: thread.downgrade(), + thread: active_thread, _subscriptions: subscriptions, } } @@ -379,9 +401,9 @@ pub struct AgentPanel { fs: Arc, language_registry: Arc, thread_store: Entity, - thread: Entity, + // todo! move to active view? message_editor: Entity, - _active_thread_subscriptions: Vec, + _message_editor_subscription: Subscription, _default_model_subscription: Subscription, context_store: Entity, prompt_store: Option>, @@ -531,11 +553,17 @@ impl AgentPanel { MessageEditorEvent::Changed | MessageEditorEvent::EstimatedTokenCount => { cx.notify(); } - MessageEditorEvent::ScrollThreadToBottom => { - this.thread.update(cx, |thread, cx| { - thread.scroll_to_bottom(cx); - }); - } + MessageEditorEvent::ScrollThreadToBottom => match &this.active_view { + ActiveView::Thread { thread, .. } => { + thread.update(cx, |thread, cx| { + thread.scroll_to_bottom(cx); + }); + } + ActiveView::Agent2Thread { .. } => todo!(), + ActiveView::TextThread { .. } + | ActiveView::History + | ActiveView::Configuration => {} + }, }); let thread_id = thread.read(cx).id().clone(); @@ -550,9 +578,22 @@ impl AgentPanel { cx.observe(&history_store, |_, _, cx| cx.notify()).detach(); + let active_thread = cx.new(|cx| { + ActiveThread::new( + thread.clone(), + thread_store.clone(), + context_store.clone(), + message_editor_context_store.clone(), + language_registry.clone(), + workspace.clone(), + window, + cx, + ) + }); + let panel_type = AgentSettings::get_global(cx).default_view; let active_view = match panel_type { - DefaultView::Thread => ActiveView::thread(thread.clone(), window, cx), + DefaultView::Thread => ActiveView::thread(active_thread, window, cx), DefaultView::TextThread => { let context = context_store.update(cx, |context_store, cx| context_store.create(cx)); @@ -580,33 +621,8 @@ impl AgentPanel { } }; - let thread_subscription = cx.subscribe(&thread, |_, _, event, cx| { - if let ThreadEvent::MessageAdded(_) = &event { - // needed to leave empty state - cx.notify(); - } - }); - let active_thread = cx.new(|cx| { - ActiveThread::new( - thread.clone(), - thread_store.clone(), - context_store.clone(), - message_editor_context_store.clone(), - language_registry.clone(), - workspace.clone(), - window, - cx, - ) - }); AgentDiff::set_active_thread(&workspace, &thread, window, cx); - let active_thread_subscription = - cx.subscribe(&active_thread, |_, _, event, cx| match &event { - ActiveThreadEvent::EditingMessageTokenCountChanged => { - cx.notify(); - } - }); - let weak_panel = weak_self.clone(); window.defer(cx, move |window, cx| { @@ -643,13 +659,19 @@ impl AgentPanel { let _default_model_subscription = cx.subscribe( &LanguageModelRegistry::global(cx), |this, _, event: &language_model::Event, cx| match event { - language_model::Event::DefaultModelChanged => { - this.thread - .read(cx) - .thread() - .clone() - .update(cx, |thread, cx| thread.get_or_init_configured_model(cx)); - } + language_model::Event::DefaultModelChanged => match &this.active_view { + ActiveView::Thread { thread, .. } => { + thread + .read(cx) + .thread() + .clone() + .update(cx, |thread, cx| thread.get_or_init_configured_model(cx)); + } + ActiveView::Agent2Thread { .. } => todo!(), + ActiveView::TextThread { .. } + | ActiveView::History + | ActiveView::Configuration => {} + }, _ => {} }, ); @@ -662,13 +684,8 @@ impl AgentPanel { fs: fs.clone(), language_registry, thread_store: thread_store.clone(), - thread: active_thread, message_editor, - _active_thread_subscriptions: vec![ - thread_subscription, - active_thread_subscription, - message_editor_subscription, - ], + _message_editor_subscription: message_editor_subscription, _default_model_subscription, context_store, prompt_store, @@ -729,8 +746,15 @@ impl AgentPanel { } fn cancel(&mut self, _: &editor::actions::Cancel, window: &mut Window, cx: &mut Context) { - self.thread - .update(cx, |thread, cx| thread.cancel_last_completion(window, cx)); + match &self.active_view { + ActiveView::Thread { thread, .. } => { + thread.update(cx, |thread, cx| thread.cancel_last_completion(window, cx)); + } + ActiveView::Agent2Thread { .. } => { + todo!() + } + ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} + } } fn new_thread(&mut self, action: &NewThread, window: &mut Window, cx: &mut Context) { @@ -744,9 +768,6 @@ impl AgentPanel { .thread_store .update(cx, |this, cx| this.create_thread(cx)); - let thread_view = ActiveView::thread(thread.clone(), window, cx); - self.set_active_view(thread_view, window, cx); - let context_store = cx.new(|_cx| { ContextStore::new( self.project.downgrade(), @@ -754,6 +775,22 @@ impl AgentPanel { ) }); + let active_thread = cx.new(|cx| { + ActiveThread::new( + thread.clone(), + self.thread_store.clone(), + self.context_store.clone(), + context_store.clone(), + self.language_registry.clone(), + self.workspace.clone(), + window, + cx, + ) + }); + + let thread_view = ActiveView::thread(active_thread.clone(), window, cx); + self.set_active_view(thread_view, window, cx); + if let Some(other_thread_id) = action.from_thread_id.clone() { let other_thread_task = self.thread_store.update(cx, |this, cx| { this.open_thread(&other_thread_id, window, cx) @@ -774,34 +811,9 @@ impl AgentPanel { .detach_and_log_err(cx); } - let thread_subscription = cx.subscribe(&thread, |_, _, event, cx| { - if let ThreadEvent::MessageAdded(_) = &event { - // needed to leave empty state - cx.notify(); - } - }); - - self.thread = cx.new(|cx| { - ActiveThread::new( - thread.clone(), - self.thread_store.clone(), - self.context_store.clone(), - context_store.clone(), - self.language_registry.clone(), - self.workspace.clone(), - window, - cx, - ) - }); AgentDiff::set_active_thread(&self.workspace, &thread, window, cx); - let active_thread_subscription = - cx.subscribe(&self.thread, |_, _, event, cx| match &event { - ActiveThreadEvent::EditingMessageTokenCountChanged => { - cx.notify(); - } - }); - + // todo! move message editor to active view? self.message_editor = cx.new(|cx| { MessageEditor::new( self.fs.clone(), @@ -825,23 +837,21 @@ impl AgentPanel { self.message_editor.focus_handle(cx).focus(window); - let message_editor_subscription = - cx.subscribe(&self.message_editor, |this, _, event, cx| match event { + let message_editor_subscription = cx.subscribe(&self.message_editor, { + let active_thread = active_thread.clone(); + move |_, _, event, cx| match event { MessageEditorEvent::Changed | MessageEditorEvent::EstimatedTokenCount => { cx.notify(); } MessageEditorEvent::ScrollThreadToBottom => { - this.thread.update(cx, |thread, cx| { + active_thread.update(cx, |thread, cx| { thread.scroll_to_bottom(cx); }); } - }); + } + }); - self._active_thread_subscriptions = vec![ - thread_subscription, - active_thread_subscription, - message_editor_subscription, - ]; + self._message_editor_subscription = message_editor_subscription; } fn new_prompt_editor(&mut self, window: &mut Window, cx: &mut Context) { @@ -1029,22 +1039,14 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - let thread_view = ActiveView::thread(thread.clone(), window, cx); - self.set_active_view(thread_view, window, cx); let context_store = cx.new(|_cx| { ContextStore::new( self.project.downgrade(), Some(self.thread_store.downgrade()), ) }); - let thread_subscription = cx.subscribe(&thread, |_, _, event, cx| { - if let ThreadEvent::MessageAdded(_) = &event { - // needed to leave empty state - cx.notify(); - } - }); - self.thread = cx.new(|cx| { + let active_thread = cx.new(|cx| { ActiveThread::new( thread.clone(), self.thread_store.clone(), @@ -1056,15 +1058,10 @@ impl AgentPanel { cx, ) }); + let thread_view = ActiveView::thread(active_thread.clone(), window, cx); + self.set_active_view(thread_view, window, cx); AgentDiff::set_active_thread(&self.workspace, &thread, window, cx); - let active_thread_subscription = - cx.subscribe(&self.thread, |_, _, event, cx| match &event { - ActiveThreadEvent::EditingMessageTokenCountChanged => { - cx.notify(); - } - }); - self.message_editor = cx.new(|cx| { MessageEditor::new( self.fs.clone(), @@ -1081,28 +1078,27 @@ impl AgentPanel { }); self.message_editor.focus_handle(cx).focus(window); - let message_editor_subscription = - cx.subscribe(&self.message_editor, |this, _, event, cx| match event { + let message_editor_subscription = cx.subscribe(&self.message_editor, { + let active_thread = active_thread.clone(); + move |_, _, event, cx| match event { MessageEditorEvent::Changed | MessageEditorEvent::EstimatedTokenCount => { cx.notify(); } MessageEditorEvent::ScrollThreadToBottom => { - this.thread.update(cx, |thread, cx| { + active_thread.update(cx, |thread, cx| { thread.scroll_to_bottom(cx); }); } - }); + } + }); - self._active_thread_subscriptions = vec![ - thread_subscription, - active_thread_subscription, - message_editor_subscription, - ]; + self._message_editor_subscription = message_editor_subscription; } pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context) { match self.active_view { ActiveView::Configuration | ActiveView::History => { + // todo! check go back works correctly if let Some(previous_view) = self.previous_view.take() { self.active_view = previous_view; @@ -1115,10 +1111,6 @@ impl AgentPanel { } _ => {} } - } else { - self.active_view = - ActiveView::thread(self.thread.read(cx).thread().clone(), window, cx); - self.message_editor.focus_handle(cx).focus(window); } cx.notify(); } @@ -1224,12 +1216,18 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - let thread = self.thread.read(cx).thread().clone(); - self.workspace - .update(cx, |workspace, cx| { - AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx) - }) - .log_err(); + match &self.active_view { + ActiveView::Thread { thread, .. } => { + let thread = thread.read(cx).thread().clone(); + self.workspace + .update(cx, |workspace, cx| { + AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx) + }) + .log_err(); + } + ActiveView::Agent2Thread { .. } => todo!(), + ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} + } } pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context) { @@ -1271,12 +1269,21 @@ impl AgentPanel { return; }; - let Some(thread) = self.active_thread() else { - return; - }; - - active_thread::open_active_thread_as_markdown(thread, workspace, window, cx) - .detach_and_log_err(cx); + match &self.active_view { + ActiveView::Thread { thread, .. } => { + active_thread::open_active_thread_as_markdown( + thread.read(cx).thread().clone(), + workspace, + window, + cx, + ) + .detach_and_log_err(cx); + } + ActiveView::Agent2Thread { .. } => { + todo!() + } + ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} + } } fn handle_agent_configuration_event( @@ -1306,9 +1313,13 @@ impl AgentPanel { } } - pub(crate) fn active_thread(&self) -> Option> { + pub(crate) fn active_thread(&self, cx: &App) -> Option> { match &self.active_view { - ActiveView::Thread { thread, .. } => thread.upgrade(), + ActiveView::Thread { thread, .. } => Some(thread.read(cx).thread().clone()), + ActiveView::Agent2Thread { .. } => { + // todo! + None + } _ => None, } } @@ -1327,14 +1338,18 @@ impl AgentPanel { } fn continue_conversation(&mut self, window: &mut Window, cx: &mut Context) { - let thread_state = self.thread.read(cx).thread().read(cx); + let ActiveView::Thread { thread, .. } = &self.active_view else { + return; + }; + + let thread_state = thread.read(cx).thread().read(cx); if !thread_state.tool_use_limit_reached() { return; } let model = thread_state.configured_model().map(|cm| cm.model.clone()); if let Some(model) = model { - self.thread.update(cx, |active_thread, cx| { + thread.update(cx, |active_thread, cx| { active_thread.thread().update(cx, |thread, cx| { thread.insert_invisible_continue_message(cx); thread.advance_prompt_id(); @@ -1357,7 +1372,11 @@ impl AgentPanel { _window: &mut Window, cx: &mut Context, ) { - self.thread.update(cx, |active_thread, cx| { + let ActiveView::Thread { thread, .. } = &self.active_view else { + return; + }; + + thread.update(cx, |active_thread, cx| { active_thread.thread().update(cx, |thread, _cx| { let current_mode = thread.completion_mode(); @@ -1402,24 +1421,22 @@ impl AgentPanel { match &self.active_view { ActiveView::Thread { thread, .. } => { - if let Some(thread) = thread.upgrade() { - if thread.read(cx).is_empty() { - let id = thread.read(cx).id().clone(); - self.history_store.update(cx, |store, cx| { - store.remove_recently_opened_thread(id, cx); - }); - } + let thread = thread.read(cx); + if thread.is_empty() { + let id = thread.thread().read(cx).id().clone(); + self.history_store.update(cx, |store, cx| { + store.remove_recently_opened_thread(id, cx); + }); } } + ActiveView::Agent2Thread { .. } => todo!(), _ => {} } match &new_view { ActiveView::Thread { thread, .. } => self.history_store.update(cx, |store, cx| { - if let Some(thread) = thread.upgrade() { - let id = thread.read(cx).id().clone(); - store.push_recently_opened_entry(HistoryEntryId::Thread(id), cx); - } + let id = thread.read(cx).thread().read(cx).id().clone(); + store.push_recently_opened_entry(HistoryEntryId::Thread(id), cx); }), ActiveView::TextThread { context_editor, .. } => { self.history_store.update(cx, |store, cx| { @@ -1428,6 +1445,7 @@ impl AgentPanel { } }) } + ActiveView::Agent2Thread { .. } => todo!(), _ => {} } @@ -1623,14 +1641,17 @@ impl AgentPanel { let content = match &self.active_view { ActiveView::Thread { + thread: active_thread, change_title_editor, .. } => { - let active_thread = self.thread.read(cx); - let state = if active_thread.is_empty() { - &ThreadSummary::Pending - } else { - active_thread.summary(cx) + let state = { + let active_thread = active_thread.read(cx); + if active_thread.is_empty() { + &ThreadSummary::Pending + } else { + active_thread.summary(cx) + } }; match state { @@ -1650,7 +1671,7 @@ impl AgentPanel { .child( ui::IconButton::new("retry-summary-generation", IconName::RotateCcw) .on_click({ - let active_thread = self.thread.clone(); + let active_thread = active_thread.clone(); move |_, _window, cx| { active_thread.update(cx, |thread, cx| { thread.regenerate_summary(cx); @@ -1734,22 +1755,11 @@ impl AgentPanel { } fn render_toolbar(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let active_thread = self.thread.read(cx); let user_store = self.user_store.read(cx); - let thread = active_thread.thread().read(cx); - let thread_id = thread.id().clone(); - let is_empty = active_thread.is_empty(); - let editor_empty = self.message_editor.read(cx).is_editor_fully_empty(cx); let usage = user_store.model_request_usage(); let account_url = zed_urls::account_url(cx); - let show_token_count = match &self.active_view { - ActiveView::Thread { .. } => !is_empty || !editor_empty, - ActiveView::TextThread { .. } => true, - _ => false, - }; - let focus_handle = self.focus_handle(cx); let go_back_button = div().child( @@ -1814,6 +1824,15 @@ impl AgentPanel { "Zoom In" }; + let active_thread = match &self.active_view { + ActiveView::Thread { thread, .. } => Some(thread.read(cx).thread().clone()), + ActiveView::Agent2Thread { .. } => { + // todo! + None + } + ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None, + }; + let agent_extra_menu = PopoverMenu::new("agent-options-menu") .trigger_with_tooltip( IconButton::new("agent-options-menu", IconName::Ellipsis) @@ -1834,18 +1853,24 @@ impl AgentPanel { .anchor(Corner::TopRight) .with_handle(self.assistant_dropdown_menu_handle.clone()) .menu(move |window, cx| { - Some(ContextMenu::build(window, cx, |mut menu, _window, _cx| { + let active_thread = active_thread.clone(); + Some(ContextMenu::build(window, cx, |mut menu, _window, cx| { menu = menu .action("New Thread", NewThread::default().boxed_clone()) .action("New Text Thread", NewTextThread.boxed_clone()) .action("New Gemini Thread", NewGeminiThread.boxed_clone()) - .when(!is_empty, |menu| { - menu.action( - "New From Summary", - Box::new(NewThread { - from_thread_id: Some(thread_id.clone()), - }), - ) + .when_some(active_thread, |this, active_thread| { + let thread = active_thread.read(cx); + if !thread.is_empty() { + this.action( + "New From Summary", + Box::new(NewThread { + from_thread_id: Some(thread.id().clone()), + }), + ) + } else { + this + } }) .separator(); @@ -1932,9 +1957,7 @@ impl AgentPanel { h_flex() .h_full() .gap_2() - .when(show_token_count, |parent| { - parent.children(self.render_token_count(&thread, cx)) - }) + .children(self.render_token_count(cx)) .child( h_flex() .h_full() @@ -1967,26 +1990,43 @@ impl AgentPanel { ) } - fn render_token_count(&self, thread: &Thread, cx: &App) -> Option { + fn render_token_count(&self, cx: &App) -> Option { + let active_thread = match &self.active_view { + ActiveView::Thread { thread, .. } => thread.read(cx), + ActiveView::Agent2Thread { .. } => { + todo!(); + } + ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { + return None; + } + }; + + let editor_empty = self.message_editor.read(cx).is_editor_fully_empty(cx); + + if active_thread.is_empty() && editor_empty { + return None; + } + + let thread = active_thread.thread().read(cx); + let is_generating = thread.is_generating(); let message_editor = self.message_editor.read(cx); let conversation_token_usage = thread.total_token_usage()?; - let (total_token_usage, is_estimating) = if let Some((editing_message_id, unsent_tokens)) = - self.thread.read(cx).editing_message_id() - { - let combined = thread - .token_usage_up_to_message(editing_message_id) - .add(unsent_tokens); + let (total_token_usage, is_estimating) = + if let Some((editing_message_id, unsent_tokens)) = active_thread.editing_message_id() { + let combined = thread + .token_usage_up_to_message(editing_message_id) + .add(unsent_tokens); - (combined, unsent_tokens > 0) - } else { - let unsent_tokens = message_editor.last_estimated_token_count().unwrap_or(0); - let combined = conversation_token_usage.add(unsent_tokens); + (combined, unsent_tokens > 0) + } else { + let unsent_tokens = message_editor.last_estimated_token_count().unwrap_or(0); + let combined = conversation_token_usage.add(unsent_tokens); - (combined, unsent_tokens > 0) - }; + (combined, unsent_tokens > 0) + }; let is_waiting_to_update_token_count = message_editor.is_waiting_to_update_token_count(); @@ -2084,27 +2124,34 @@ impl AgentPanel { } fn should_render_upsell(&self, cx: &mut Context) -> bool { - if !matches!(self.active_view, ActiveView::Thread { .. }) { - return false; - } + match &self.active_view { + ActiveView::Thread { thread, .. } => { + let is_using_zed_provider = thread + .read(cx) + .thread() + .read(cx) + .configured_model() + .map_or(false, |model| { + model.provider.id().0 == ZED_CLOUD_PROVIDER_ID + }); + + if !is_using_zed_provider { + return false; + } + } + ActiveView::Agent2Thread { .. } => { + // todo! + return false; + } + ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { + return false; + } + }; if self.hide_upsell || Upsell::dismissed() { return false; } - let is_using_zed_provider = self - .thread - .read(cx) - .thread() - .read(cx) - .configured_model() - .map_or(false, |model| { - model.provider.id().0 == ZED_CLOUD_PROVIDER_ID - }); - if !is_using_zed_provider { - return false; - } - let plan = self.user_store.read(cx).current_plan(); if matches!(plan, Some(Plan::ZedPro | Plan::ZedProTrial)) { return false; @@ -2406,20 +2453,6 @@ impl AgentPanel { ) } - fn render_active_thread_or_empty_state( - &self, - window: &mut Window, - cx: &mut Context, - ) -> AnyElement { - if self.thread.read(cx).is_empty() { - return self - .render_thread_empty_state(window, cx) - .into_any_element(); - } - - self.thread.clone().into_any_element() - } - fn render_thread_empty_state( &self, window: &mut Window, @@ -2692,23 +2725,25 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) -> Option { - let tool_use_limit_reached = self - .thread - .read(cx) - .thread() - .read(cx) - .tool_use_limit_reached(); + let active_thread = match &self.active_view { + ActiveView::Thread { thread, .. } => thread, + ActiveView::Agent2Thread { .. } => { + // todo! + return None; + } + ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { + return None; + } + }; + + let thread = active_thread.read(cx).thread().read(cx); + + let tool_use_limit_reached = thread.tool_use_limit_reached(); if !tool_use_limit_reached { return None; } - let model = self - .thread - .read(cx) - .thread() - .read(cx) - .configured_model()? - .model; + let model = thread.configured_model()?.model; let focus_handle = self.focus_handle(cx); @@ -2752,14 +2787,17 @@ impl AgentPanel { .map(|kb| kb.size(rems_from_px(10.))), ) .tooltip(Tooltip::text("Enable Burn Mode for unlimited tool use.")) - .on_click(cx.listener(|this, _, window, cx| { - this.thread.update(cx, |active_thread, cx| { - active_thread.thread().update(cx, |thread, _cx| { - thread.set_completion_mode(CompletionMode::Burn); + .on_click({ + let active_thread = active_thread.clone(); + cx.listener(move |this, _, window, cx| { + active_thread.update(cx, |active_thread, cx| { + active_thread.thread().update(cx, |thread, _cx| { + thread.set_completion_mode(CompletionMode::Burn); + }); }); - }); - this.continue_conversation(window, cx); - })), + this.continue_conversation(window, cx); + }) + }), ) }), ); @@ -2767,33 +2805,11 @@ impl AgentPanel { Some(div().px_2().pb_2().child(banner).into_any_element()) } - fn render_last_error(&self, cx: &mut Context) -> Option { - let last_error = self.thread.read(cx).last_error()?; - - Some( - div() - .absolute() - .right_3() - .bottom_12() - .max_w_96() - .py_2() - .px_3() - .elevation_2(cx) - .occlude() - .child(match last_error { - ThreadError::PaymentRequired => self.render_payment_required_error(cx), - ThreadError::ModelRequestLimitReached { plan } => { - self.render_model_request_limit_reached_error(plan, cx) - } - ThreadError::Message { header, message } => { - self.render_error_message(header, message, cx) - } - }) - .into_any(), - ) - } - - fn render_payment_required_error(&self, cx: &mut Context) -> AnyElement { + fn render_payment_required_error( + &self, + thread: &Entity, + cx: &mut Context, + ) -> AnyElement { const ERROR_MESSAGE: &str = "Free tier exceeded. Subscribe and add payment to continue using Zed LLMs. You'll be billed at cost for tokens used."; v_flex() @@ -2818,25 +2834,27 @@ impl AgentPanel { .mt_1() .gap_1() .child(self.create_copy_button(ERROR_MESSAGE)) - .child(Button::new("subscribe", "Subscribe").on_click(cx.listener( - |this, _, _, cx| { - this.thread.update(cx, |this, _cx| { + .child(Button::new("subscribe", "Subscribe").on_click(cx.listener({ + let thread = thread.clone(); + move |_, _, _, cx| { + thread.update(cx, |this, _cx| { this.clear_last_error(); }); cx.open_url(&zed_urls::account_url(cx)); cx.notify(); - }, - ))) - .child(Button::new("dismiss", "Dismiss").on_click(cx.listener( - |this, _, _, cx| { - this.thread.update(cx, |this, _cx| { + } + }))) + .child(Button::new("dismiss", "Dismiss").on_click(cx.listener({ + let thread = thread.clone(); + move |_, _, _, cx| { + thread.update(cx, |this, _cx| { this.clear_last_error(); }); cx.notify(); - }, - ))), + } + }))), ) .into_any() } @@ -2844,6 +2862,7 @@ impl AgentPanel { fn render_model_request_limit_reached_error( &self, plan: Plan, + thread: &Entity, cx: &mut Context, ) -> AnyElement { let error_message = match plan { @@ -2884,26 +2903,28 @@ impl AgentPanel { .gap_1() .child(self.create_copy_button(error_message)) .child( - Button::new("subscribe", call_to_action).on_click(cx.listener( - |this, _, _, cx| { - this.thread.update(cx, |this, _cx| { + Button::new("subscribe", call_to_action).on_click(cx.listener({ + let thread = thread.clone(); + move |_, _, _, cx| { + thread.update(cx, |this, _cx| { this.clear_last_error(); }); cx.open_url(&zed_urls::account_url(cx)); cx.notify(); - }, - )), + } + })), ) - .child(Button::new("dismiss", "Dismiss").on_click(cx.listener( - |this, _, _, cx| { - this.thread.update(cx, |this, _cx| { + .child(Button::new("dismiss", "Dismiss").on_click(cx.listener({ + let thread = thread.clone(); + move |_, _, _, cx| { + thread.update(cx, |this, _cx| { this.clear_last_error(); }); cx.notify(); - }, - ))), + } + }))), ) .into_any() } @@ -2912,6 +2933,7 @@ impl AgentPanel { &self, header: SharedString, message: SharedString, + thread: &Entity, cx: &mut Context, ) -> AnyElement { let message_with_header = format!("{}\n{}", header, message); @@ -2937,15 +2959,16 @@ impl AgentPanel { .mt_1() .gap_1() .child(self.create_copy_button(message_with_header)) - .child(Button::new("dismiss", "Dismiss").on_click(cx.listener( - |this, _, _, cx| { - this.thread.update(cx, |this, _cx| { + .child(Button::new("dismiss", "Dismiss").on_click(cx.listener({ + let thread = thread.clone(); + move |_, _, _, cx| { + thread.update(cx, |this, _cx| { this.clear_last_error(); }); cx.notify(); - }, - ))), + } + }))), ) .into_any() } @@ -3057,8 +3080,8 @@ impl AgentPanel { cx: &mut Context, ) { match &self.active_view { - ActiveView::Thread { .. } => { - let context_store = self.thread.read(cx).context_store().clone(); + ActiveView::Thread { thread, .. } => { + let context_store = thread.read(cx).context_store().clone(); context_store.update(cx, move |context_store, cx| { let mut tasks = Vec::new(); for project_path in &paths { @@ -3153,30 +3176,67 @@ impl Render for AgentPanel { this.continue_conversation(window, cx); })) .on_action(cx.listener(|this, _: &ContinueWithBurnMode, window, cx| { - this.thread.update(cx, |active_thread, cx| { - active_thread.thread().update(cx, |thread, _cx| { - thread.set_completion_mode(CompletionMode::Burn); - }); - }); - this.continue_conversation(window, cx); + match &this.active_view { + ActiveView::Thread { thread, .. } => { + thread.update(cx, |active_thread, cx| { + active_thread.thread().update(cx, |thread, _cx| { + thread.set_completion_mode(CompletionMode::Burn); + }); + }); + this.continue_conversation(window, cx); + } + ActiveView::Agent2Thread { .. } => { + todo!() + } + ActiveView::TextThread { .. } + | ActiveView::History + | ActiveView::Configuration => {} + } })) .on_action(cx.listener(Self::toggle_burn_mode)) .child(self.render_toolbar(window, cx)) .children(self.render_upsell(window, cx)) .children(self.render_trial_end_upsell(window, cx)) .map(|parent| match &self.active_view { - ActiveView::Thread { .. } => parent + ActiveView::Thread { thread, .. } => parent .relative() - .child(self.render_active_thread_or_empty_state(window, cx)) + .child(if thread.read(cx).is_empty() { + self.render_thread_empty_state(window, cx) + .into_any_element() + } else { + thread.clone().into_any_element() + }) .children(self.render_tool_use_limit_reached(window, cx)) .child(h_flex().child(self.message_editor.clone())) - .children(self.render_last_error(cx)) + .when_some(thread.read(cx).last_error(), |this, last_error| { + this.child( + div() + .absolute() + .right_3() + .bottom_12() + .max_w_96() + .py_2() + .px_3() + .elevation_2(cx) + .occlude() + .child(match last_error { + ThreadError::PaymentRequired => { + self.render_payment_required_error(thread, cx) + } + ThreadError::ModelRequestLimitReached { plan } => self + .render_model_request_limit_reached_error(plan, thread, cx), + ThreadError::Message { header, message } => { + self.render_error_message(header, message, thread, cx) + } + }) + .into_any(), + ) + }) .child(self.render_drag_target(cx)), ActiveView::Agent2Thread { .. } => parent .relative() - .child(self.render_active_thread_or_empty_state(window, cx)) + .child("todo!") .child(h_flex().child(self.message_editor.clone())) - .children(self.render_last_error(cx)) .child(self.render_drag_target(cx)), ActiveView::History => parent.child(self.history.clone()), ActiveView::TextThread { diff --git a/crates/agent_ui/src/context_picker.rs b/crates/agent_ui/src/context_picker.rs index 9136307517..b0069a2446 100644 --- a/crates/agent_ui/src/context_picker.rs +++ b/crates/agent_ui/src/context_picker.rs @@ -661,7 +661,7 @@ fn recent_context_picker_entries( let active_thread_id = workspace .panel::(cx) - .and_then(|panel| Some(panel.read(cx).active_thread()?.read(cx).id())); + .and_then(|panel| Some(panel.read(cx).active_thread(cx)?.read(cx).id())); if let Some((thread_store, text_thread_store)) = thread_store .and_then(|store| store.upgrade()) diff --git a/crates/agent_ui/src/context_strip.rs b/crates/agent_ui/src/context_strip.rs index b3890613dc..080ffd2ea0 100644 --- a/crates/agent_ui/src/context_strip.rs +++ b/crates/agent_ui/src/context_strip.rs @@ -161,7 +161,7 @@ impl ContextStrip { let workspace = self.workspace.upgrade()?; let panel = workspace.read(cx).panel::(cx)?.read(cx); - if let Some(active_thread) = panel.active_thread() { + if let Some(active_thread) = panel.active_thread(cx) { let weak_active_thread = active_thread.downgrade(); let active_thread = active_thread.read(cx); From 3b6f30a6fdbd31fe9866eeeb5710337a10d9b49e Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 26 Jun 2025 13:07:02 -0300 Subject: [PATCH 22/54] Add ThreadElement and render it when active Co-authored-by: Smit Barmase --- crates/agent2/src/agent2.rs | 2 ++ crates/agent2/src/thread_element.rs | 27 +++++++++++++++++ crates/agent_ui/src/agent_panel.rs | 45 +++++++++++++++++------------ 3 files changed, 56 insertions(+), 18 deletions(-) create mode 100644 crates/agent2/src/thread_element.rs diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index f044cbd4bd..a7c54d6836 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -1,4 +1,5 @@ mod acp; +mod thread_element; use anyhow::Result; use async_trait::async_trait; @@ -8,6 +9,7 @@ use project::Project; use std::{ops::Range, path::PathBuf, sync::Arc}; pub use acp::AcpAgent; +pub use thread_element::ThreadElement; #[async_trait(?Send)] pub trait Agent: 'static { diff --git a/crates/agent2/src/thread_element.rs b/crates/agent2/src/thread_element.rs new file mode 100644 index 0000000000..e5cef3d795 --- /dev/null +++ b/crates/agent2/src/thread_element.rs @@ -0,0 +1,27 @@ +use gpui::{App, Entity, SharedString, Window, div, prelude::*}; + +use crate::Thread; + +pub struct ThreadElement { + thread: Entity, +} + +impl ThreadElement { + pub fn new(thread: Entity) -> Self { + Self { thread } + } + + pub fn title(&self, cx: &App) -> SharedString { + self.thread.read(cx).title() + } + + pub fn cancel(&self, window: &mut Window, cx: &mut Context) { + // todo! + } +} + +impl Render for ThreadElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + div().child("agent 2") + } +} diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 2ae39aba84..5e1ffb94c3 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -198,7 +198,7 @@ enum ActiveView { _subscriptions: Vec, }, Agent2Thread { - thread: Entity, + thread_element: Entity, }, TextThread { context_editor: Entity, @@ -670,7 +670,9 @@ impl AgentPanel { .clone() .update(cx, |thread, cx| thread.get_or_init_configured_model(cx)); } - ActiveView::Agent2Thread { .. } => todo!(), + ActiveView::Agent2Thread { .. } => { + // todo! + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} @@ -751,8 +753,8 @@ impl AgentPanel { ActiveView::Thread { thread, .. } => { thread.update(cx, |thread, cx| thread.cancel_last_completion(window, cx)); } - ActiveView::Agent2Thread { .. } => { - todo!() + ActiveView::Agent2Thread { thread_element, .. } => { + thread_element.update(cx, |thread_element, cx| thread_element.cancel(window, cx)); } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} } @@ -763,11 +765,9 @@ impl AgentPanel { ActiveView::Thread { message_editor, .. } => Some(message_editor), ActiveView::Agent2Thread { .. } => { // todo! - None + None } - ActiveView::TextThread { .. } - | ActiveView::History - | ActiveView::Configuration => None, + ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None, } } @@ -810,7 +810,7 @@ impl AgentPanel { }) .detach_and_log_err(cx); } - + let active_thread = cx.new(|cx| { ActiveThread::new( thread.clone(), @@ -897,6 +897,7 @@ impl AgentPanel { .next() .map(|worktree| worktree.read(cx).abs_path()) else { + // todo! handle no project return; }; @@ -918,8 +919,9 @@ impl AgentPanel { cx.spawn_in(window, async move |this, cx| { let agent = AcpAgent::stdio(child, project, cx); let thread = agent.create_thread(cx).await?; + let thread_element = cx.new(|_cx| agent2::ThreadElement::new(thread))?; this.update_in(cx, |this, window, cx| { - this.set_active_view(ActiveView::Agent2Thread { thread }, window, cx); + this.set_active_view(ActiveView::Agent2Thread { thread_element }, window, cx); }) }) .detach(); @@ -1412,7 +1414,9 @@ impl AgentPanel { }); } } - ActiveView::Agent2Thread { .. } => todo!(), + ActiveView::Agent2Thread { .. } => { + // todo! + } _ => {} } @@ -1428,7 +1432,9 @@ impl AgentPanel { } }) } - ActiveView::Agent2Thread { .. } => todo!(), + ActiveView::Agent2Thread { .. } => { + // todo! push history entry + } _ => {} } @@ -1675,9 +1681,11 @@ impl AgentPanel { .into_any_element(), } } - ActiveView::Agent2Thread { thread } => Label::new(thread.read(cx).title()) - .truncate() - .into_any_element(), + ActiveView::Agent2Thread { thread_element } => { + Label::new(thread_element.read(cx).title(cx)) + .truncate() + .into_any_element() + } ActiveView::TextThread { title_editor, context_editor, @@ -1984,7 +1992,8 @@ impl AgentPanel { .. } => (thread.read(cx), message_editor.read(cx)), ActiveView::Agent2Thread { .. } => { - todo!(); + // todo! + return None; } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { return None; @@ -3224,9 +3233,9 @@ impl Render for AgentPanel { ) }) .child(self.render_drag_target(cx)), - ActiveView::Agent2Thread { .. } => parent + ActiveView::Agent2Thread { thread_element, .. } => parent .relative() - .child("todo!") + .child(thread_element.clone()) // todo! // .child(h_flex().child(self.message_editor.clone())) .child(self.render_drag_target(cx)), From 3be45822be21cf5fa72a17a5b964b8461198b5a2 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 26 Jun 2025 13:37:23 -0300 Subject: [PATCH 23/54] agent2 basic message editor Co-authored-by: Smit Barmase --- Cargo.lock | 3 + crates/agent2/Cargo.toml | 3 + crates/agent2/src/thread_element.rs | 113 ++++++++++++++++++++++++-- crates/agent_ui/src/agent_panel.rs | 10 +-- crates/agent_ui/src/agent_ui.rs | 1 - crates/agent_ui/src/message_editor.rs | 3 +- crates/zed_actions/src/lib.rs | 7 +- 7 files changed, 123 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6047a8c25f..dee3801374 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,6 +117,7 @@ dependencies = [ "base64 0.22.1", "chrono", "collections", + "editor", "env_logger 0.11.8", "futures 0.3.31", "gpui", @@ -126,9 +127,11 @@ dependencies = [ "serde_json", "settings", "smol", + "ui", "util", "uuid", "workspace-hack", + "zed_actions", ] [[package]] diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml index bcb4379b67..22ff0373fd 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/agent2/Cargo.toml @@ -25,15 +25,18 @@ async-trait.workspace = true base64.workspace = true chrono.workspace = true collections.workspace = true +editor.workspace = true futures.workspace = true gpui.workspace = true language.workspace = true parking_lot.workspace = true project.workspace = true smol.workspace = true +ui.workspace = true util.workspace = true uuid.workspace = true workspace-hack.workspace = true +zed_actions.workspace = true [dev-dependencies] env_logger.workspace = true diff --git a/crates/agent2/src/thread_element.rs b/crates/agent2/src/thread_element.rs index e5cef3d795..9cf936cefd 100644 --- a/crates/agent2/src/thread_element.rs +++ b/crates/agent2/src/thread_element.rs @@ -1,27 +1,124 @@ -use gpui::{App, Entity, SharedString, Window, div, prelude::*}; +use std::sync::Arc; -use crate::Thread; +use anyhow::Result; +use editor::{Editor, MultiBuffer}; +use gpui::{App, Entity, Focusable, SharedString, Window, div, prelude::*}; +use gpui::{FocusHandle, Task}; +use language::Buffer; +use ui::Tooltip; +use ui::prelude::*; +use zed_actions::agent::Chat; + +use crate::{Message, MessageChunk, Role, Thread}; pub struct ThreadElement { thread: Entity, + // todo! use full message editor from agent2 + message_editor: Entity, + send_task: Option>>, } impl ThreadElement { - pub fn new(thread: Entity) -> Self { - Self { thread } + pub fn new(thread: Entity, window: &mut Window, cx: &mut Context) -> Self { + let message_editor = cx.new(|cx| { + let buffer = cx.new(|cx| Buffer::local("", cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + + let mut editor = Editor::new( + editor::EditorMode::AutoHeight { + min_lines: 5, + max_lines: None, + }, + buffer, + None, + window, + cx, + ); + editor.set_placeholder_text("Send a message", cx); + editor.set_soft_wrap(); + editor + }); + + Self { + thread, + message_editor, + send_task: None, + } } pub fn title(&self, cx: &App) -> SharedString { self.thread.read(cx).title() } - pub fn cancel(&self, window: &mut Window, cx: &mut Context) { - // todo! + pub fn cancel(&mut self) { + self.send_task.take(); + } + + fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context) { + let text = self.message_editor.read(cx).text(cx); + if text.is_empty() { + return; + } + + self.send_task = Some(self.thread.update(cx, |thread, cx| { + let message = Message { + role: Role::User, + chunks: vec![MessageChunk::Text { chunk: text.into() }], + }; + thread.send(message, cx) + })); + + self.message_editor.update(cx, |editor, cx| { + editor.clear(window, cx); + }); + } +} + +impl Focusable for ThreadElement { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.message_editor.focus_handle(cx) } } impl Render for ThreadElement { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - div().child("agent 2") + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let text = self.message_editor.read(cx).text(cx); + let is_editor_empty = text.is_empty(); + let focus_handle = self.message_editor.focus_handle(cx); + + v_flex() + .key_context("MessageEditor") + .on_action(cx.listener(Self::chat)) + .child(div().h_full()) + .child( + div() + .bg(cx.theme().colors().editor_background) + .border_t_1() + .border_color(cx.theme().colors().border) + .p_2() + .child(self.message_editor.clone()), + ) + .child( + h_flex().p_2().justify_end().child( + IconButton::new("send-message", IconName::Send) + .icon_color(Color::Accent) + .style(ButtonStyle::Filled) + .disabled(is_editor_empty) + .on_click({ + let focus_handle = focus_handle.clone(); + move |_event, window, cx| { + focus_handle.dispatch_action(&Chat, window, cx); + } + }) + .when(!is_editor_empty, |button| { + button.tooltip(move |window, cx| { + Tooltip::for_action("Send", &Chat, window, cx) + }) + }) + .when(is_editor_empty, |button| { + button.tooltip(Tooltip::text("Type a message to submit")) + }), + ), + ) } } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 5e1ffb94c3..7ca0057c73 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -754,7 +754,7 @@ impl AgentPanel { thread.update(cx, |thread, cx| thread.cancel_last_completion(window, cx)); } ActiveView::Agent2Thread { thread_element, .. } => { - thread_element.update(cx, |thread_element, cx| thread_element.cancel(window, cx)); + thread_element.update(cx, |thread_element, _cx| thread_element.cancel()); } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} } @@ -919,7 +919,8 @@ impl AgentPanel { cx.spawn_in(window, async move |this, cx| { let agent = AcpAgent::stdio(child, project, cx); let thread = agent.create_thread(cx).await?; - let thread_element = cx.new(|_cx| agent2::ThreadElement::new(thread))?; + let thread_element = + cx.new_window_entity(|window, cx| agent2::ThreadElement::new(thread, window, cx))?; this.update_in(cx, |this, window, cx| { this.set_active_view(ActiveView::Agent2Thread { thread_element }, window, cx); }) @@ -1521,10 +1522,7 @@ impl Focusable for AgentPanel { fn focus_handle(&self, cx: &App) -> FocusHandle { match &self.active_view { ActiveView::Thread { message_editor, .. } => message_editor.focus_handle(cx), - ActiveView::Agent2Thread { .. } => { - // todo! add own message editor to agent2 - cx.focus_handle() - } + ActiveView::Agent2Thread { thread_element, .. } => thread_element.focus_handle(cx), ActiveView::History => self.history.focus_handle(cx), ActiveView::TextThread { context_editor, .. } => context_editor.focus_handle(cx), ActiveView::Configuration => { diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 3f77b2fa70..62f1eb7bf6 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -66,7 +66,6 @@ actions!( OpenHistory, AddContextServer, RemoveSelectedThread, - Chat, ChatWithFollow, CycleNextInlineAssist, CyclePreviousInlineAssist, diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 39f83d50cb..a5b7537da0 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -47,13 +47,14 @@ use ui::{ }; use util::ResultExt as _; use workspace::{CollaboratorId, Workspace}; +use zed_actions::agent::Chat; use zed_llm_client::CompletionIntent; use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider, crease_for_mention}; use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind}; use crate::profile_selector::ProfileSelector; use crate::{ - ActiveThread, AgentDiffPane, Chat, ChatWithFollow, ExpandMessageEditor, Follow, KeepAll, + ActiveThread, AgentDiffPane, ChatWithFollow, ExpandMessageEditor, Follow, KeepAll, ModelUsageContext, NewThread, OpenAgentDiff, RejectAll, RemoveAllContext, ToggleBurnMode, ToggleContextPicker, ToggleProfileSelector, register_agent_preview, }; diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index b8c52e27e8..8d31065309 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -198,7 +198,12 @@ pub mod agent { actions!( agent, - [OpenConfiguration, OpenOnboardingModal, ResetOnboarding] + [ + OpenConfiguration, + OpenOnboardingModal, + ResetOnboarding, + Chat + ] ); } From ee1df655692c3003ff09b82a72fa2c91d5f87d32 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 26 Jun 2025 14:05:59 -0300 Subject: [PATCH 24/54] Start displaying messages in new thread element Co-authored-by: Smit Barmase --- crates/agent2/src/acp.rs | 16 +++++++- crates/agent2/src/agent2.rs | 22 ++++++++++ crates/agent2/src/thread_element.rs | 63 +++++++++++++++++++++++++---- 3 files changed, 93 insertions(+), 8 deletions(-) diff --git a/crates/agent2/src/acp.rs b/crates/agent2/src/acp.rs index c9c17ecbd7..d3327c82b3 100644 --- a/crates/agent2/src/acp.rs +++ b/crates/agent2/src/acp.rs @@ -82,8 +82,22 @@ impl acp::Client for AcpClientDelegate { async fn stream_message_chunk( &self, - chunk: acp::StreamMessageChunkParams, + params: acp::StreamMessageChunkParams, ) -> Result { + let cx = &mut self.cx.clone(); + + cx.update(|cx| { + self.update_thread(¶ms.thread_id.into(), cx, |thread, cx| { + let acp::MessageChunk::Text { chunk } = ¶ms.chunk; + thread.push_assistant_chunk( + MessageChunk::Text { + chunk: chunk.into(), + }, + cx, + ) + }); + })?; + Ok(acp::StreamMessageChunkResponse) } diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index a7c54d6836..d901e416a2 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -219,6 +219,28 @@ impl Thread { cx.notify(); } + pub fn push_assistant_chunk(&mut self, chunk: MessageChunk, cx: &mut Context) { + if let Some(last_entry) = self.entries.last_mut() { + if let AgentThreadEntryContent::Message(Message { + ref mut chunks, + role: Role::Assistant, + }) = last_entry.content + { + chunks.push(chunk); + return; + } + } + + self.entries.push(ThreadEntry { + id: self.next_entry_id.post_inc(), + content: AgentThreadEntryContent::Message(Message { + role: Role::Assistant, + chunks: vec![chunk], + }), + }); + cx.notify(); + } + pub fn send(&mut self, message: Message, cx: &mut Context) -> Task> { let agent = self.agent.clone(); let id = self.id.clone(); diff --git a/crates/agent2/src/thread_element.rs b/crates/agent2/src/thread_element.rs index 9cf936cefd..09c8d35718 100644 --- a/crates/agent2/src/thread_element.rs +++ b/crates/agent2/src/thread_element.rs @@ -1,21 +1,20 @@ -use std::sync::Arc; - use anyhow::Result; use editor::{Editor, MultiBuffer}; -use gpui::{App, Entity, Focusable, SharedString, Window, div, prelude::*}; +use gpui::{App, Entity, Focusable, SharedString, Subscription, Window, div, prelude::*}; use gpui::{FocusHandle, Task}; use language::Buffer; use ui::Tooltip; use ui::prelude::*; use zed_actions::agent::Chat; -use crate::{Message, MessageChunk, Role, Thread}; +use crate::{AgentThreadEntryContent, Message, MessageChunk, Role, Thread, ThreadEntry}; pub struct ThreadElement { thread: Entity, // todo! use full message editor from agent2 message_editor: Entity, send_task: Option>>, + _subscription: Subscription, } impl ThreadElement { @@ -39,10 +38,15 @@ impl ThreadElement { editor }); + let subscription = cx.observe(&thread, |_, _, cx| { + cx.notify(); + }); + Self { thread, message_editor, send_task: None, + _subscription: subscription, } } @@ -60,18 +64,48 @@ impl ThreadElement { return; } - self.send_task = Some(self.thread.update(cx, |thread, cx| { + let task = self.thread.update(cx, |thread, cx| { let message = Message { role: Role::User, chunks: vec![MessageChunk::Text { chunk: text.into() }], }; thread.send(message, cx) + }); + + self.send_task = Some(cx.spawn(async move |this, cx| { + task.await?; + + this.update(cx, |this, _cx| { + this.send_task.take(); + }) })); self.message_editor.update(cx, |editor, cx| { editor.clear(window, cx); }); } + + fn render_entry( + &self, + entry: &ThreadEntry, + _window: &mut Window, + _cx: &Context, + ) -> AnyElement { + match &entry.content { + AgentThreadEntryContent::Message(message) => div() + .children(message.chunks.iter().map(|chunk| match chunk { + MessageChunk::Text { chunk } => div().child(chunk.clone()), + _ => todo!(), + })) + .into_any(), + AgentThreadEntryContent::ReadFile { path, content: _ } => { + // todo! + div() + .child(format!("", path.display())) + .into_any() + } + } + } } impl Focusable for ThreadElement { @@ -81,7 +115,7 @@ impl Focusable for ThreadElement { } impl Render for ThreadElement { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let text = self.message_editor.read(cx).text(cx); let is_editor_empty = text.is_empty(); let focus_handle = self.message_editor.focus_handle(cx); @@ -89,7 +123,22 @@ impl Render for ThreadElement { v_flex() .key_context("MessageEditor") .on_action(cx.listener(Self::chat)) - .child(div().h_full()) + .child( + v_flex().h_full().gap_1().children( + self.thread + .read(cx) + .entries() + .iter() + .map(|entry| self.render_entry(entry, window, cx)), + ), + ) + .when(self.send_task.is_some(), |this| { + this.child( + Label::new("Generating...") + .color(Color::Muted) + .size(LabelSize::Small), + ) + }) .child( div() .bg(cx.theme().colors().editor_background) From f383a7626fa7cb1141b943cdb28f24ed316b8404 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 26 Jun 2025 14:16:30 -0300 Subject: [PATCH 25/54] Improve user message Co-authored-by: Smit Barmase --- crates/agent2/src/agent2.rs | 1 + crates/agent2/src/thread_element.rs | 36 ++++++++++++++++++++++------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index d901e416a2..28031f1e05 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -226,6 +226,7 @@ impl Thread { role: Role::Assistant, }) = last_entry.content { + // todo! merge with last chunk if same type chunks.push(chunk); return; } diff --git a/crates/agent2/src/thread_element.rs b/crates/agent2/src/thread_element.rs index 09c8d35718..71b8bde8fd 100644 --- a/crates/agent2/src/thread_element.rs +++ b/crates/agent2/src/thread_element.rs @@ -89,15 +89,34 @@ impl ThreadElement { &self, entry: &ThreadEntry, _window: &mut Window, - _cx: &Context, + cx: &Context, ) -> AnyElement { match &entry.content { - AgentThreadEntryContent::Message(message) => div() - .children(message.chunks.iter().map(|chunk| match chunk { - MessageChunk::Text { chunk } => div().child(chunk.clone()), - _ => todo!(), - })) - .into_any(), + AgentThreadEntryContent::Message(message) => { + let message_body = div() + .children(message.chunks.iter().map(|chunk| match chunk { + MessageChunk::Text { chunk } => { + // todo! markdown + Label::new(chunk.clone()) + } + _ => todo!(), + })) + .into_any(); + + match message.role { + Role::User => div() + .my_1() + .p_2() + .bg(cx.theme().colors().editor_background) + .rounded_lg() + .shadow_md() + .border_1() + .border_color(cx.theme().colors().border) + .child(message_body) + .into_any(), + Role::Assistant => message_body, + } + } AgentThreadEntryContent::ReadFile { path, content: _ } => { // todo! div() @@ -121,6 +140,7 @@ impl Render for ThreadElement { let focus_handle = self.message_editor.focus_handle(cx); v_flex() + .p_2() .key_context("MessageEditor") .on_action(cx.listener(Self::chat)) .child( @@ -133,7 +153,7 @@ impl Render for ThreadElement { ), ) .when(self.send_task.is_some(), |this| { - this.child( + this.my_1().child( Label::new("Generating...") .color(Color::Muted) .size(LabelSize::Small), From ddab1cbd71dea28aa0e4c0eb6c3e8dcd68f7d07a Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 26 Jun 2025 14:23:39 -0300 Subject: [PATCH 26/54] Fix notify and margin Co-authored-by: Smit Barmase --- crates/agent2/src/agent2.rs | 1 + crates/agent2/src/thread_element.rs | 13 +++++++------ crates/agent_ui/src/agent_panel.rs | 3 +-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index 28031f1e05..43ee7bc2ee 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -228,6 +228,7 @@ impl Thread { { // todo! merge with last chunk if same type chunks.push(chunk); + cx.notify(); return; } } diff --git a/crates/agent2/src/thread_element.rs b/crates/agent2/src/thread_element.rs index 71b8bde8fd..3c075ee60a 100644 --- a/crates/agent2/src/thread_element.rs +++ b/crates/agent2/src/thread_element.rs @@ -140,11 +140,10 @@ impl Render for ThreadElement { let focus_handle = self.message_editor.focus_handle(cx); v_flex() - .p_2() .key_context("MessageEditor") .on_action(cx.listener(Self::chat)) .child( - v_flex().h_full().gap_1().children( + v_flex().p_2().h_full().gap_1().children( self.thread .read(cx) .entries() @@ -153,10 +152,12 @@ impl Render for ThreadElement { ), ) .when(self.send_task.is_some(), |this| { - this.my_1().child( - Label::new("Generating...") - .color(Color::Muted) - .size(LabelSize::Small), + this.child( + div().p_2().child( + Label::new("Generating...") + .color(Color::Muted) + .size(LabelSize::Small), + ), ) }) .child( diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 7ca0057c73..7de916301c 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -897,8 +897,7 @@ impl AgentPanel { .next() .map(|worktree| worktree.read(cx).abs_path()) else { - // todo! handle no project - return; + todo!(); }; let cli_path = From c72873109985d27516613d53bb12edd53105edf3 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 26 Jun 2025 14:30:59 -0300 Subject: [PATCH 27/54] Merge last chunk --- crates/agent2/src/agent2.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs index 43ee7bc2ee..7d19439135 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/agent2/src/agent2.rs @@ -64,7 +64,8 @@ pub struct Message { #[derive(Clone, Debug, Eq, PartialEq)] pub enum MessageChunk { Text { - chunk: SharedString, + // todo! should it be shared string? what about streaming? + chunk: String, }, File { content: FileContent, @@ -226,10 +227,17 @@ impl Thread { role: Role::Assistant, }) = last_entry.content { - // todo! merge with last chunk if same type + if let ( + Some(MessageChunk::Text { chunk: old_chunk }), + MessageChunk::Text { chunk: new_chunk }, + ) = (chunks.last_mut(), &chunk) + { + old_chunk.push_str(&new_chunk); + return cx.notify(); + } + chunks.push(chunk); - cx.notify(); - return; + return cx.notify(); } } From 991ba0871105c66c8cc1b0016fd9df3d366d1286 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 26 Jun 2025 14:37:22 -0300 Subject: [PATCH 28/54] Stop button --- crates/agent2/src/thread_element.rs | 56 +++++++++++++++++++---------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/crates/agent2/src/thread_element.rs b/crates/agent2/src/thread_element.rs index 3c075ee60a..e843edcde3 100644 --- a/crates/agent2/src/thread_element.rs +++ b/crates/agent2/src/thread_element.rs @@ -143,6 +143,7 @@ impl Render for ThreadElement { .key_context("MessageEditor") .on_action(cx.listener(Self::chat)) .child( + // todo! use gpui::list v_flex().p_2().h_full().gap_1().children( self.thread .read(cx) @@ -169,26 +170,43 @@ impl Render for ThreadElement { .child(self.message_editor.clone()), ) .child( - h_flex().p_2().justify_end().child( - IconButton::new("send-message", IconName::Send) - .icon_color(Color::Accent) - .style(ButtonStyle::Filled) - .disabled(is_editor_empty) - .on_click({ - let focus_handle = focus_handle.clone(); - move |_event, window, cx| { - focus_handle.dispatch_action(&Chat, window, cx); - } - }) - .when(!is_editor_empty, |button| { - button.tooltip(move |window, cx| { - Tooltip::for_action("Send", &Chat, window, cx) + h_flex() + .p_2() + .justify_end() + .child(if self.send_task.is_some() { + IconButton::new("stop-generation", IconName::StopFilled) + .icon_color(Color::Error) + .style(ButtonStyle::Tinted(ui::TintColor::Error)) + .tooltip(move |window, cx| { + Tooltip::for_action( + "Stop Generation", + &editor::actions::Cancel, + window, + cx, + ) }) - }) - .when(is_editor_empty, |button| { - button.tooltip(Tooltip::text("Type a message to submit")) - }), - ), + .disabled(is_editor_empty) + .on_click(cx.listener(|this, _event, _, _| this.cancel())) + } else { + IconButton::new("send-message", IconName::Send) + .icon_color(Color::Accent) + .style(ButtonStyle::Filled) + .disabled(is_editor_empty) + .on_click({ + let focus_handle = focus_handle.clone(); + move |_event, window, cx| { + focus_handle.dispatch_action(&Chat, window, cx); + } + }) + .when(!is_editor_empty, |button| { + button.tooltip(move |window, cx| { + Tooltip::for_action("Send", &Chat, window, cx) + }) + }) + .when(is_editor_empty, |button| { + button.tooltip(Tooltip::text("Type a message to submit")) + }) + }), ) } } From f12fffd1ba62ae38217f618872500fb94386b4f0 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 1 Jul 2025 18:23:21 +0200 Subject: [PATCH 29/54] WIP --- Cargo.lock | 56 ++++++++++---------- Cargo.toml | 12 ++--- crates/{agent2 => acp}/Cargo.toml | 9 ++-- crates/{agent2 => acp}/LICENSE-GPL | 0 crates/{agent2 => acp}/src/acp.rs | 0 crates/{agent2 => acp}/src/agent2.rs | 18 ------- crates/{agent2 => acp}/src/thread_element.rs | 0 crates/agent_ui/Cargo.toml | 7 +-- crates/agent_ui/src/agent_panel.rs | 46 ++++++++-------- 9 files changed, 62 insertions(+), 86 deletions(-) rename crates/{agent2 => acp}/Cargo.toml (89%) rename crates/{agent2 => acp}/LICENSE-GPL (100%) rename crates/{agent2 => acp}/src/acp.rs (100%) rename crates/{agent2 => acp}/src/agent2.rs (94%) rename crates/{agent2 => acp}/src/thread_element.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index dee3801374..51e5e93712 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,33 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "acp" +version = "0.1.0" +dependencies = [ + "agentic-coding-protocol", + "anyhow", + "async-trait", + "base64 0.22.1", + "chrono", + "collections", + "editor", + "env_logger 0.11.8", + "futures 0.3.31", + "gpui", + "language", + "parking_lot", + "project", + "serde_json", + "settings", + "smol", + "ui", + "util", + "uuid", + "workspace-hack", + "zed_actions", +] + [[package]] name = "activity_indicator" version = "0.1.0" @@ -107,33 +134,6 @@ dependencies = [ "zstd", ] -[[package]] -name = "agent2" -version = "0.1.0" -dependencies = [ - "agentic-coding-protocol", - "anyhow", - "async-trait", - "base64 0.22.1", - "chrono", - "collections", - "editor", - "env_logger 0.11.8", - "futures 0.3.31", - "gpui", - "language", - "parking_lot", - "project", - "serde_json", - "settings", - "smol", - "ui", - "util", - "uuid", - "workspace-hack", - "zed_actions", -] - [[package]] name = "agent_settings" version = "0.1.0" @@ -157,8 +157,8 @@ dependencies = [ name = "agent_ui" version = "0.1.0" dependencies = [ + "acp", "agent", - "agent2", "agent_settings", "anyhow", "assistant_context", diff --git a/Cargo.toml b/Cargo.toml index f8f5c2a6d0..45ed0e1dbe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,9 +2,9 @@ resolver = "2" members = [ "crates/activity_indicator", + "crates/acp", "crates/agent_ui", "crates/agent", - "crates/agent2", "crates/agent_settings", "crates/anthropic", "crates/askpass", @@ -215,9 +215,9 @@ edition = "2024" # Workspace member crates # -activity_indicator = { path = "crates/activity_indicator" } +acp = { path = "crates/acp" } agent = { path = "crates/agent" } -agent2 = { path = "crates/agent2" } +activity_indicator = { path = "crates/activity_indicator" } agent_ui = { path = "crates/agent_ui" } agent_settings = { path = "crates/agent_settings" } ai = { path = "crates/ai" } @@ -481,7 +481,7 @@ json_dotpath = "1.1" jsonschema = "0.30.0" jsonwebtoken = "9.3" jupyter-protocol = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" } -jupyter-websocket-client = { git = "https://github.com/ConradIrwin/runtimed" ,rev = "7130c804216b6914355d15d0b91ea91f6babd734" } +jupyter-websocket-client = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" } libc = "0.2" libsqlite3-sys = { version = "0.30.1", features = ["bundled"] } linkify = "0.10.0" @@ -492,7 +492,7 @@ metal = "0.29" moka = { version = "0.12.10", features = ["sync"] } naga = { version = "25.0", features = ["wgsl-in"] } nanoid = "0.4" -nbformat = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" } +nbformat = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" } nix = "0.29" num-format = "0.4.4" objc = "0.2" @@ -532,7 +532,7 @@ reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "951c77 "stream", ] } rsa = "0.9.6" -runtimelib = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734", default-features = false, features = [ +runtimelib = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734", default-features = false, features = [ "async-dispatcher-runtime", ] } rust-embed = { version = "8.4", features = ["include-exclude"] } diff --git a/crates/agent2/Cargo.toml b/crates/acp/Cargo.toml similarity index 89% rename from crates/agent2/Cargo.toml rename to crates/acp/Cargo.toml index 22ff0373fd..cd8be590b4 100644 --- a/crates/agent2/Cargo.toml +++ b/crates/acp/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "agent2" +name = "acp" version = "0.1.0" edition.workspace = true publish.workspace = true @@ -9,14 +9,11 @@ license = "GPL-3.0-or-later" workspace = true [lib] -path = "src/agent2.rs" +path = "src/acp.rs" doctest = false [features] -test-support = [ - "gpui/test-support", - "project/test-support", -] +test-support = ["gpui/test-support", "project/test-support"] [dependencies] agentic-coding-protocol = { path = "../../../agentic-coding-protocol" } diff --git a/crates/agent2/LICENSE-GPL b/crates/acp/LICENSE-GPL similarity index 100% rename from crates/agent2/LICENSE-GPL rename to crates/acp/LICENSE-GPL diff --git a/crates/agent2/src/acp.rs b/crates/acp/src/acp.rs similarity index 100% rename from crates/agent2/src/acp.rs rename to crates/acp/src/acp.rs diff --git a/crates/agent2/src/agent2.rs b/crates/acp/src/agent2.rs similarity index 94% rename from crates/agent2/src/agent2.rs rename to crates/acp/src/agent2.rs index 7d19439135..ee6e8c9e55 100644 --- a/crates/agent2/src/agent2.rs +++ b/crates/acp/src/agent2.rs @@ -11,24 +11,6 @@ use std::{ops::Range, path::PathBuf, sync::Arc}; pub use acp::AcpAgent; pub use thread_element::ThreadElement; -#[async_trait(?Send)] -pub trait Agent: 'static { - async fn threads(&self, cx: &mut AsyncApp) -> Result>; - async fn create_thread(self: Arc, cx: &mut AsyncApp) -> Result>; - async fn open_thread(&self, id: ThreadId, cx: &mut AsyncApp) -> Result>; - async fn thread_entries( - &self, - id: ThreadId, - cx: &mut AsyncApp, - ) -> Result>; - async fn send_thread_message( - &self, - thread_id: ThreadId, - message: Message, - cx: &mut AsyncApp, - ) -> Result<()>; -} - #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ThreadId(SharedString); diff --git a/crates/agent2/src/thread_element.rs b/crates/acp/src/thread_element.rs similarity index 100% rename from crates/agent2/src/thread_element.rs rename to crates/acp/src/thread_element.rs diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 33e5aa8de6..75c68b1644 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -13,14 +13,11 @@ path = "src/agent_ui.rs" doctest = false [features] -test-support = [ - "gpui/test-support", - "language/test-support", -] +test-support = ["gpui/test-support", "language/test-support"] [dependencies] +acp.workspace = true agent.workspace = true -agent2.workspace = true agent_settings.workspace = true anyhow.workspace = true assistant_context.workspace = true diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 7de916301c..938c1771ba 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -133,7 +133,7 @@ pub fn init(cx: &mut App) { let thread = thread.read(cx).thread().clone(); AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx); } - ActiveView::Agent2Thread { .. } => todo!(), + ActiveView::AcpThread { .. } => todo!(), ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} @@ -197,7 +197,7 @@ enum ActiveView { message_editor: Entity, _subscriptions: Vec, }, - Agent2Thread { + AcpThread { thread_element: Entity, }, TextThread { @@ -219,7 +219,7 @@ enum WhichFontSize { impl ActiveView { pub fn which_font_size_used(&self) -> WhichFontSize { match self { - ActiveView::Thread { .. } | ActiveView::Agent2Thread { .. } | ActiveView::History => { + ActiveView::Thread { .. } | ActiveView::AcpThread { .. } | ActiveView::History => { WhichFontSize::AgentFont } ActiveView::TextThread { .. } => WhichFontSize::BufferFont, @@ -252,7 +252,7 @@ impl ActiveView { thread.scroll_to_bottom(cx); }); } - ActiveView::Agent2Thread { .. } => { + ActiveView::AcpThread { .. } => { // todo! } ActiveView::TextThread { .. } @@ -670,7 +670,7 @@ impl AgentPanel { .clone() .update(cx, |thread, cx| thread.get_or_init_configured_model(cx)); } - ActiveView::Agent2Thread { .. } => { + ActiveView::AcpThread { .. } => { // todo! } ActiveView::TextThread { .. } @@ -753,7 +753,7 @@ impl AgentPanel { ActiveView::Thread { thread, .. } => { thread.update(cx, |thread, cx| thread.cancel_last_completion(window, cx)); } - ActiveView::Agent2Thread { thread_element, .. } => { + ActiveView::AcpThread { thread_element, .. } => { thread_element.update(cx, |thread_element, _cx| thread_element.cancel()); } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} @@ -763,7 +763,7 @@ impl AgentPanel { fn active_message_editor(&self) -> Option<&Entity> { match &self.active_view { ActiveView::Thread { message_editor, .. } => Some(message_editor), - ActiveView::Agent2Thread { .. } => { + ActiveView::AcpThread { .. } => { // todo! None } @@ -921,7 +921,7 @@ impl AgentPanel { let thread_element = cx.new_window_entity(|window, cx| agent2::ThreadElement::new(thread, window, cx))?; this.update_in(cx, |this, window, cx| { - this.set_active_view(ActiveView::Agent2Thread { thread_element }, window, cx); + this.set_active_view(ActiveView::AcpThread { thread_element }, window, cx); }) }) .detach(); @@ -1092,7 +1092,7 @@ impl AgentPanel { ActiveView::Thread { message_editor, .. } => { message_editor.focus_handle(cx).focus(window); } - ActiveView::Agent2Thread { .. } => { + ActiveView::AcpThread { .. } => { todo!() } ActiveView::TextThread { context_editor, .. } => { @@ -1214,7 +1214,7 @@ impl AgentPanel { }) .log_err(); } - ActiveView::Agent2Thread { .. } => todo!(), + ActiveView::AcpThread { .. } => todo!(), ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} } } @@ -1268,7 +1268,7 @@ impl AgentPanel { ) .detach_and_log_err(cx); } - ActiveView::Agent2Thread { .. } => { + ActiveView::AcpThread { .. } => { todo!() } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} @@ -1305,7 +1305,7 @@ impl AgentPanel { pub(crate) fn active_thread(&self, cx: &App) -> Option> { match &self.active_view { ActiveView::Thread { thread, .. } => Some(thread.read(cx).thread().clone()), - ActiveView::Agent2Thread { .. } => { + ActiveView::AcpThread { .. } => { // todo! None } @@ -1414,7 +1414,7 @@ impl AgentPanel { }); } } - ActiveView::Agent2Thread { .. } => { + ActiveView::AcpThread { .. } => { // todo! } _ => {} @@ -1432,7 +1432,7 @@ impl AgentPanel { } }) } - ActiveView::Agent2Thread { .. } => { + ActiveView::AcpThread { .. } => { // todo! push history entry } _ => {} @@ -1521,7 +1521,7 @@ impl Focusable for AgentPanel { fn focus_handle(&self, cx: &App) -> FocusHandle { match &self.active_view { ActiveView::Thread { message_editor, .. } => message_editor.focus_handle(cx), - ActiveView::Agent2Thread { thread_element, .. } => thread_element.focus_handle(cx), + ActiveView::AcpThread { thread_element, .. } => thread_element.focus_handle(cx), ActiveView::History => self.history.focus_handle(cx), ActiveView::TextThread { context_editor, .. } => context_editor.focus_handle(cx), ActiveView::Configuration => { @@ -1678,7 +1678,7 @@ impl AgentPanel { .into_any_element(), } } - ActiveView::Agent2Thread { thread_element } => { + ActiveView::AcpThread { thread_element } => { Label::new(thread_element.read(cx).title(cx)) .truncate() .into_any_element() @@ -1817,7 +1817,7 @@ impl AgentPanel { let active_thread = match &self.active_view { ActiveView::Thread { thread, .. } => Some(thread.read(cx).thread().clone()), - ActiveView::Agent2Thread { .. } => { + ActiveView::AcpThread { .. } => { // todo! None } @@ -1988,7 +1988,7 @@ impl AgentPanel { message_editor, .. } => (thread.read(cx), message_editor.read(cx)), - ActiveView::Agent2Thread { .. } => { + ActiveView::AcpThread { .. } => { // todo! return None; } @@ -2132,7 +2132,7 @@ impl AgentPanel { return false; } } - ActiveView::Agent2Thread { .. } => { + ActiveView::AcpThread { .. } => { // todo! return false; } @@ -2720,7 +2720,7 @@ impl AgentPanel { ) -> Option { let active_thread = match &self.active_view { ActiveView::Thread { thread, .. } => thread, - ActiveView::Agent2Thread { .. } => { + ActiveView::AcpThread { .. } => { // todo! return None; } @@ -3093,7 +3093,7 @@ impl AgentPanel { .detach(); }); } - ActiveView::Agent2Thread { .. } => { + ActiveView::AcpThread { .. } => { unimplemented!() } ActiveView::TextThread { context_editor, .. } => { @@ -3178,7 +3178,7 @@ impl Render for AgentPanel { }); this.continue_conversation(window, cx); } - ActiveView::Agent2Thread { .. } => { + ActiveView::AcpThread { .. } => { todo!() } ActiveView::TextThread { .. } @@ -3230,7 +3230,7 @@ impl Render for AgentPanel { ) }) .child(self.render_drag_target(cx)), - ActiveView::Agent2Thread { thread_element, .. } => parent + ActiveView::AcpThread { thread_element, .. } => parent .relative() .child(thread_element.clone()) // todo! From 92adcb6e6379d49a84a229bd543a11ccb40c33dc Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 1 Jul 2025 19:01:02 +0200 Subject: [PATCH 30/54] WIP --- crates/acp/src/acp.rs | 566 ++++++++---------- crates/acp/src/agent2.rs | 352 ----------- crates/acp/src/server.rs | 282 +++++++++ .../src/{thread_element.rs => thread_view.rs} | 14 +- crates/agent_ui/src/agent_panel.rs | 30 +- 5 files changed, 558 insertions(+), 686 deletions(-) delete mode 100644 crates/acp/src/agent2.rs create mode 100644 crates/acp/src/server.rs rename crates/acp/src/{thread_element.rs => thread_view.rs} (95%) diff --git a/crates/acp/src/acp.rs b/crates/acp/src/acp.rs index d3327c82b3..f6b58fea3a 100644 --- a/crates/acp/src/acp.rs +++ b/crates/acp/src/acp.rs @@ -1,345 +1,289 @@ -use std::{io::Write as _, path::Path, sync::Arc}; +mod server; +mod thread_view; -use crate::{ - Agent, AgentThreadEntryContent, AgentThreadSummary, Message, MessageChunk, Role, Thread, - ThreadEntryId, ThreadId, -}; -use agentic_coding_protocol as acp; -use anyhow::{Context as _, Result}; -use async_trait::async_trait; -use collections::HashMap; -use gpui::{App, AppContext, AsyncApp, Context, Entity, Task, WeakEntity}; -use parking_lot::Mutex; +use anyhow::Result; +use chrono::{DateTime, Utc}; +use gpui::{Context, Entity, SharedString, Task}; use project::Project; -use smol::process::Child; -use util::ResultExt; +use std::{ops::Range, path::PathBuf, sync::Arc}; -pub struct AcpAgent { - connection: Arc, - threads: Arc>>>, - project: Entity, - _handler_task: Task<()>, - _io_task: Task<()>, +pub use server::AcpServer; +pub use thread_view::AcpThreadView; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ThreadId(SharedString); + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct FileVersion(u64); + +#[derive(Debug)] +pub struct AgentThreadSummary { + pub id: ThreadId, + pub title: String, + pub created_at: DateTime, } -struct AcpClientDelegate { - project: Entity, - threads: Arc>>>, - cx: AsyncApp, - // sent_buffer_versions: HashMap, HashMap>, +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FileContent { + pub path: PathBuf, + pub version: FileVersion, + pub content: SharedString, } -impl AcpClientDelegate { - fn new( +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Role { + User, + Assistant, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Message { + pub role: Role, + pub chunks: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MessageChunk { + Text { + // todo! should it be shared string? what about streaming? + chunk: String, + }, + File { + content: FileContent, + }, + Directory { + path: PathBuf, + contents: Vec, + }, + Symbol { + path: PathBuf, + range: Range, + version: FileVersion, + name: SharedString, + content: SharedString, + }, + Fetch { + url: SharedString, + content: SharedString, + }, +} + +impl From<&str> for MessageChunk { + fn from(chunk: &str) -> Self { + MessageChunk::Text { + chunk: chunk.to_string().into(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AgentThreadEntryContent { + Message(Message), + ReadFile { path: PathBuf, content: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ThreadEntryId(usize); + +impl ThreadEntryId { + pub fn post_inc(&mut self) -> Self { + let id = *self; + self.0 += 1; + id + } +} + +#[derive(Debug)] +pub struct ThreadEntry { + pub id: ThreadEntryId, + pub content: AgentThreadEntryContent, +} + +pub struct AcpThread { + id: ThreadId, + next_entry_id: ThreadEntryId, + entries: Vec, + server: Arc, + title: SharedString, + project: Entity, +} + +impl AcpThread { + pub fn new( + server: Arc, + thread_id: ThreadId, + entries: Vec, project: Entity, - threads: Arc>>>, - cx: AsyncApp, + _: &mut Context, ) -> Self { + let mut next_entry_id = ThreadEntryId(0); Self { + title: "A new agent2 thread".into(), + entries: entries + .into_iter() + .map(|entry| ThreadEntry { + id: next_entry_id.post_inc(), + content: entry, + }) + .collect(), + server, + id: thread_id, + next_entry_id, project, - threads, - cx: cx, } } - fn update_thread( - &self, - thread_id: &ThreadId, - cx: &mut App, - callback: impl FnMut(&mut Thread, &mut Context) -> R, - ) -> Option { - let thread = self.threads.lock().get(&thread_id)?.clone(); - let Some(thread) = thread.upgrade() else { - self.threads.lock().remove(&thread_id); - return None; - }; - Some(thread.update(cx, callback)) + pub fn title(&self) -> SharedString { + self.title.clone() + } + + pub fn entries(&self) -> &[ThreadEntry] { + &self.entries + } + + pub fn push_entry(&mut self, entry: AgentThreadEntryContent, cx: &mut Context) { + self.entries.push(ThreadEntry { + id: self.next_entry_id.post_inc(), + content: entry, + }); + cx.notify(); + } + + pub fn push_assistant_chunk(&mut self, chunk: MessageChunk, cx: &mut Context) { + if let Some(last_entry) = self.entries.last_mut() { + if let AgentThreadEntryContent::Message(Message { + ref mut chunks, + role: Role::Assistant, + }) = last_entry.content + { + if let ( + Some(MessageChunk::Text { chunk: old_chunk }), + MessageChunk::Text { chunk: new_chunk }, + ) = (chunks.last_mut(), &chunk) + { + old_chunk.push_str(&new_chunk); + return cx.notify(); + } + + chunks.push(chunk); + return cx.notify(); + } + } + + self.entries.push(ThreadEntry { + id: self.next_entry_id.post_inc(), + content: AgentThreadEntryContent::Message(Message { + role: Role::Assistant, + chunks: vec![chunk], + }), + }); + cx.notify(); + } + + pub fn send(&mut self, message: Message, cx: &mut Context) -> Task> { + let agent = self.server.clone(); + let id = self.id.clone(); + self.push_entry(AgentThreadEntryContent::Message(message.clone()), cx); + cx.spawn(async move |_, cx| { + agent.send_message(id, message, cx).await?; + Ok(()) + }) } } -#[async_trait(?Send)] -impl acp::Client for AcpClientDelegate { - async fn stat(&self, params: acp::StatParams) -> Result { - let cx = &mut self.cx.clone(); - self.project.update(cx, |project, cx| { - let path = project - .project_path_for_absolute_path(Path::new(¶ms.path), cx) - .context("Failed to get project path")?; +#[cfg(test)] +mod tests { + use super::*; + use gpui::{AsyncApp, TestAppContext}; + use project::FakeFs; + use serde_json::json; + use settings::SettingsStore; + use std::{env, path::Path, process::Stdio}; + use util::path; - match project.entry_for_path(&path, cx) { - // todo! refresh entry? - None => Ok(acp::StatResponse { - exists: false, - is_directory: false, - }), - Some(entry) => Ok(acp::StatResponse { - exists: entry.is_created(), - is_directory: entry.is_dir(), - }), - } - })? + fn init_test(cx: &mut TestAppContext) { + env_logger::init(); + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + Project::init_settings(cx); + language::init(cx); + }); } - async fn stream_message_chunk( - &self, - params: acp::StreamMessageChunkParams, - ) -> Result { - let cx = &mut self.cx.clone(); + #[gpui::test] + async fn test_gemini(cx: &mut TestAppContext) { + init_test(cx); - cx.update(|cx| { - self.update_thread(¶ms.thread_id.into(), cx, |thread, cx| { - let acp::MessageChunk::Text { chunk } = ¶ms.chunk; - thread.push_assistant_chunk( - MessageChunk::Text { - chunk: chunk.into(), + cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/private/tmp"), + json!({"foo": "Lorem ipsum dolor", "bar": "bar", "baz": "baz"}), + ) + .await; + let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await; + let server = gemini_acp_server(project.clone(), cx.to_async()).unwrap(); + let thread = server.create_thread(&mut cx.to_async()).await.unwrap(); + thread + .update(cx, |thread, cx| { + thread.send( + Message { + role: Role::User, + chunks: vec![ + "Read the '/private/tmp/foo' file and output all of its contents." + .into(), + ], }, cx, ) - }); - })?; - - Ok(acp::StreamMessageChunkResponse) - } - - async fn read_text_file( - &self, - request: acp::ReadTextFileParams, - ) -> Result { - let cx = &mut self.cx.clone(); - let buffer = self - .project - .update(cx, |project, cx| { - let path = project - .project_path_for_absolute_path(Path::new(&request.path), cx) - .context("Failed to get project path")?; - anyhow::Ok(project.open_buffer(path, cx)) - })?? - .await?; - - buffer.update(cx, |buffer, cx| { - let start = language::Point::new(request.line_offset.unwrap_or(0), 0); - let end = match request.line_limit { - None => buffer.max_point(), - Some(limit) => start + language::Point::new(limit + 1, 0), - }; - - let content: String = buffer.text_for_range(start..end).collect(); - self.update_thread(&request.thread_id.into(), cx, |thread, cx| { - thread.push_entry( - AgentThreadEntryContent::ReadFile { - path: request.path.clone(), - content: content.clone(), - }, - cx, - ); - }); - - acp::ReadTextFileResponse { - content, - version: acp::FileVersion(0), - } - }) - } - - async fn read_binary_file( - &self, - request: acp::ReadBinaryFileParams, - ) -> Result { - let cx = &mut self.cx.clone(); - let file = self - .project - .update(cx, |project, cx| { - let (worktree, path) = project - .find_worktree(Path::new(&request.path), cx) - .context("Failed to get project path")?; - - let task = worktree.update(cx, |worktree, cx| worktree.load_binary_file(&path, cx)); - anyhow::Ok(task) - })?? - .await?; - - // todo! test - let content = cx - .background_spawn(async move { - let start = request.byte_offset.unwrap_or(0) as usize; - let end = request - .byte_limit - .map(|limit| (start + limit as usize).min(file.content.len())) - .unwrap_or(file.content.len()); - - let range_content = &file.content[start..end]; - - let mut base64_content = Vec::new(); - let mut base64_encoder = base64::write::EncoderWriter::new( - std::io::Cursor::new(&mut base64_content), - &base64::engine::general_purpose::STANDARD, - ); - base64_encoder.write_all(range_content)?; - drop(base64_encoder); - - // SAFETY: The base64 encoder should not produce non-UTF8. - unsafe { anyhow::Ok(String::from_utf8_unchecked(base64_content)) } }) - .await?; + .await + .unwrap(); - Ok(acp::ReadBinaryFileResponse { - content, - // todo! - version: acp::FileVersion(0), - }) - } - - async fn glob_search(&self, request: acp::GlobSearchParams) -> Result { - todo!() - } -} - -impl AcpAgent { - pub fn stdio(mut process: Child, project: Entity, cx: &mut AsyncApp) -> Arc { - let stdin = process.stdin.take().expect("process didn't have stdin"); - let stdout = process.stdout.take().expect("process didn't have stdout"); - - let threads: Arc>>> = Default::default(); - let (connection, handler_fut, io_fut) = acp::AgentConnection::connect_to_agent( - AcpClientDelegate::new(project.clone(), threads.clone(), cx.clone()), - stdin, - stdout, - ); - - let io_task = cx.background_spawn(async move { - io_fut.await.log_err(); - process.status().await.log_err(); - }); - - Arc::new(Self { - project, - connection: Arc::new(connection), - threads, - _handler_task: cx.foreground_executor().spawn(handler_fut), - _io_task: io_task, - }) - } -} - -#[async_trait(?Send)] -impl Agent for AcpAgent { - async fn threads(&self, cx: &mut AsyncApp) -> Result> { - let response = self.connection.request(acp::GetThreadsParams).await?; - response - .threads - .into_iter() - .map(|thread| { - Ok(AgentThreadSummary { - id: thread.id.into(), - title: thread.title, - created_at: thread.modified_at, + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.entries[0].content, + AgentThreadEntryContent::Message(Message { + role: Role::User, + .. }) - }) - .collect() + )); + assert!( + thread.entries().iter().any(|entry| { + entry.content + == AgentThreadEntryContent::ReadFile { + path: "/private/tmp/foo".into(), + content: "Lorem ipsum dolor".into(), + } + }), + "Thread does not contain entry. Actual: {:?}", + thread.entries() + ); + }); } - async fn create_thread(self: Arc, cx: &mut AsyncApp) -> Result> { - let response = self.connection.request(acp::CreateThreadParams).await?; - let thread_id: ThreadId = response.thread_id.into(); - let agent = self.clone(); - let thread = cx.new(|_| Thread { - title: "The agent2 thread".into(), - id: thread_id.clone(), - next_entry_id: ThreadEntryId(0), - entries: Vec::default(), - project: self.project.clone(), - agent, - })?; - self.threads.lock().insert(thread_id, thread.downgrade()); - Ok(thread) - } + pub fn gemini_acp_server(project: Entity, mut cx: AsyncApp) -> Result> { + let cli_path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../gemini-cli/packages/cli"); + let mut command = util::command::new_smol_command("node"); + command + .arg(cli_path) + .arg("--acp") + .args(["--model", "gemini-2.5-flash"]) + .current_dir("/private/tmp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true); - async fn open_thread(&self, id: ThreadId, cx: &mut AsyncApp) -> Result> { - todo!() - } + if let Ok(gemini_key) = std::env::var("GEMINI_API_KEY") { + command.env("GEMINI_API_KEY", gemini_key); + } - async fn thread_entries( - &self, - thread_id: ThreadId, - cx: &mut AsyncApp, - ) -> Result> { - let response = self - .connection - .request(acp::GetThreadEntriesParams { - thread_id: thread_id.clone().into(), - }) - .await?; + let child = command.spawn().unwrap(); - Ok(response - .entries - .into_iter() - .map(|entry| match entry { - acp::ThreadEntry::Message { message } => { - AgentThreadEntryContent::Message(Message { - role: match message.role { - acp::Role::User => Role::User, - acp::Role::Assistant => Role::Assistant, - }, - chunks: message - .chunks - .into_iter() - .map(|chunk| match chunk { - acp::MessageChunk::Text { chunk } => MessageChunk::Text { - chunk: chunk.into(), - }, - }) - .collect(), - }) - } - acp::ThreadEntry::ReadFile { path, content } => { - AgentThreadEntryContent::ReadFile { path, content } - } - }) - .collect()) - } - - async fn send_thread_message( - &self, - thread_id: ThreadId, - message: crate::Message, - cx: &mut AsyncApp, - ) -> Result<()> { - self.connection - .request(acp::SendMessageParams { - thread_id: thread_id.clone().into(), - message: acp::Message { - role: match message.role { - Role::User => acp::Role::User, - Role::Assistant => acp::Role::Assistant, - }, - chunks: message - .chunks - .into_iter() - .map(|chunk| match chunk { - MessageChunk::Text { chunk } => acp::MessageChunk::Text { - chunk: chunk.into(), - }, - MessageChunk::File { .. } => todo!(), - MessageChunk::Directory { .. } => todo!(), - MessageChunk::Symbol { .. } => todo!(), - MessageChunk::Fetch { .. } => todo!(), - }) - .collect(), - }, - }) - .await?; - Ok(()) - } -} - -impl From for ThreadId { - fn from(thread_id: acp::ThreadId) -> Self { - Self(thread_id.0.into()) - } -} - -impl From for acp::ThreadId { - fn from(thread_id: ThreadId) -> Self { - acp::ThreadId(thread_id.0.to_string()) + Ok(AcpServer::stdio(child, project, &mut cx)) } } diff --git a/crates/acp/src/agent2.rs b/crates/acp/src/agent2.rs deleted file mode 100644 index ee6e8c9e55..0000000000 --- a/crates/acp/src/agent2.rs +++ /dev/null @@ -1,352 +0,0 @@ -mod acp; -mod thread_element; - -use anyhow::Result; -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use gpui::{AppContext, AsyncApp, Context, Entity, SharedString, Task}; -use project::Project; -use std::{ops::Range, path::PathBuf, sync::Arc}; - -pub use acp::AcpAgent; -pub use thread_element::ThreadElement; - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct ThreadId(SharedString); - -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -pub struct FileVersion(u64); - -#[derive(Debug)] -pub struct AgentThreadSummary { - pub id: ThreadId, - pub title: String, - pub created_at: DateTime, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct FileContent { - pub path: PathBuf, - pub version: FileVersion, - pub content: SharedString, -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub enum Role { - User, - Assistant, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Message { - pub role: Role, - pub chunks: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum MessageChunk { - Text { - // todo! should it be shared string? what about streaming? - chunk: String, - }, - File { - content: FileContent, - }, - Directory { - path: PathBuf, - contents: Vec, - }, - Symbol { - path: PathBuf, - range: Range, - version: FileVersion, - name: SharedString, - content: SharedString, - }, - Fetch { - url: SharedString, - content: SharedString, - }, -} - -impl From<&str> for MessageChunk { - fn from(chunk: &str) -> Self { - MessageChunk::Text { - chunk: chunk.to_string().into(), - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum AgentThreadEntryContent { - Message(Message), - ReadFile { path: PathBuf, content: String }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct ThreadEntryId(usize); - -impl ThreadEntryId { - pub fn post_inc(&mut self) -> Self { - let id = *self; - self.0 += 1; - id - } -} - -#[derive(Debug)] -pub struct ThreadEntry { - pub id: ThreadEntryId, - pub content: AgentThreadEntryContent, -} - -pub struct ThreadStore { - threads: Vec, - agent: Arc, - project: Entity, -} - -impl ThreadStore { - pub async fn load( - agent: Arc, - project: Entity, - cx: &mut AsyncApp, - ) -> Result> { - let threads = agent.threads(cx).await?; - cx.new(|_cx| Self { - threads, - agent, - project, - }) - } - - /// Returns the threads in reverse chronological order. - pub fn threads(&self) -> &[AgentThreadSummary] { - &self.threads - } - - /// Opens a thread with the given ID. - pub fn open_thread( - &self, - id: ThreadId, - cx: &mut Context, - ) -> Task>> { - let agent = self.agent.clone(); - cx.spawn(async move |_, cx| agent.open_thread(id, cx).await) - } - - /// Creates a new thread. - pub fn create_thread(&self, cx: &mut Context) -> Task>> { - let agent = self.agent.clone(); - cx.spawn(async move |_, cx| agent.create_thread(cx).await) - } -} - -pub struct Thread { - id: ThreadId, - next_entry_id: ThreadEntryId, - entries: Vec, - agent: Arc, - title: SharedString, - project: Entity, -} - -impl Thread { - pub async fn load( - agent: Arc, - thread_id: ThreadId, - project: Entity, - cx: &mut AsyncApp, - ) -> Result> { - let entries = agent.thread_entries(thread_id.clone(), cx).await?; - cx.new(|cx| Self::new(agent, thread_id, entries, project, cx)) - } - - pub fn new( - agent: Arc, - thread_id: ThreadId, - entries: Vec, - project: Entity, - _: &mut Context, - ) -> Self { - let mut next_entry_id = ThreadEntryId(0); - Self { - title: "A new agent2 thread".into(), - entries: entries - .into_iter() - .map(|entry| ThreadEntry { - id: next_entry_id.post_inc(), - content: entry, - }) - .collect(), - agent, - id: thread_id, - next_entry_id, - project, - } - } - - pub fn title(&self) -> SharedString { - self.title.clone() - } - - pub fn entries(&self) -> &[ThreadEntry] { - &self.entries - } - - pub fn push_entry(&mut self, entry: AgentThreadEntryContent, cx: &mut Context) { - self.entries.push(ThreadEntry { - id: self.next_entry_id.post_inc(), - content: entry, - }); - cx.notify(); - } - - pub fn push_assistant_chunk(&mut self, chunk: MessageChunk, cx: &mut Context) { - if let Some(last_entry) = self.entries.last_mut() { - if let AgentThreadEntryContent::Message(Message { - ref mut chunks, - role: Role::Assistant, - }) = last_entry.content - { - if let ( - Some(MessageChunk::Text { chunk: old_chunk }), - MessageChunk::Text { chunk: new_chunk }, - ) = (chunks.last_mut(), &chunk) - { - old_chunk.push_str(&new_chunk); - return cx.notify(); - } - - chunks.push(chunk); - return cx.notify(); - } - } - - self.entries.push(ThreadEntry { - id: self.next_entry_id.post_inc(), - content: AgentThreadEntryContent::Message(Message { - role: Role::Assistant, - chunks: vec![chunk], - }), - }); - cx.notify(); - } - - pub fn send(&mut self, message: Message, cx: &mut Context) -> Task> { - let agent = self.agent.clone(); - let id = self.id.clone(); - self.push_entry(AgentThreadEntryContent::Message(message.clone()), cx); - cx.spawn(async move |_, cx| { - agent.send_thread_message(id, message, cx).await?; - Ok(()) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::acp::AcpAgent; - use gpui::TestAppContext; - use project::FakeFs; - use serde_json::json; - use settings::SettingsStore; - use std::{env, path::Path, process::Stdio}; - use util::path; - - fn init_test(cx: &mut TestAppContext) { - env_logger::init(); - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - Project::init_settings(cx); - language::init(cx); - }); - } - - #[gpui::test] - async fn test_gemini(cx: &mut TestAppContext) { - init_test(cx); - - cx.executor().allow_parking(); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/private/tmp"), - json!({"foo": "Lorem ipsum dolor", "bar": "bar", "baz": "baz"}), - ) - .await; - let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await; - let agent = gemini_agent(project.clone(), cx.to_async()).unwrap(); - let thread_store = ThreadStore::load(agent, project, &mut cx.to_async()) - .await - .unwrap(); - let thread = thread_store - .update(cx, |thread_store, cx| { - assert_eq!(thread_store.threads().len(), 0); - thread_store.create_thread(cx) - }) - .await - .unwrap(); - thread - .update(cx, |thread, cx| { - thread.send( - Message { - role: Role::User, - chunks: vec![ - "Read the '/private/tmp/foo' file and output all of its contents." - .into(), - ], - }, - cx, - ) - }) - .await - .unwrap(); - - thread.read_with(cx, |thread, _| { - assert!(matches!( - thread.entries[0].content, - AgentThreadEntryContent::Message(Message { - role: Role::User, - .. - }) - )); - assert!( - thread.entries().iter().any(|entry| { - entry.content - == AgentThreadEntryContent::ReadFile { - path: "/private/tmp/foo".into(), - content: "Lorem ipsum dolor".into(), - } - }), - "Thread does not contain entry. Actual: {:?}", - thread.entries() - ); - }); - } - - pub fn gemini_agent(project: Entity, mut cx: AsyncApp) -> Result> { - let cli_path = - Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../gemini-cli/packages/cli"); - let mut command = util::command::new_smol_command("node"); - command - .arg(cli_path) - .arg("--acp") - .args(["--model", "gemini-2.5-flash"]) - .current_dir("/private/tmp") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) - .kill_on_drop(true); - - if let Ok(gemini_key) = std::env::var("GEMINI_API_KEY") { - command.env("GEMINI_API_KEY", gemini_key); - } - - let child = command.spawn().unwrap(); - - Ok(AcpAgent::stdio(child, project, &mut cx)) - } -} diff --git a/crates/acp/src/server.rs b/crates/acp/src/server.rs new file mode 100644 index 0000000000..57f700c312 --- /dev/null +++ b/crates/acp/src/server.rs @@ -0,0 +1,282 @@ +use crate::{AcpThread, AgentThreadEntryContent, MessageChunk, Role, ThreadEntryId, ThreadId}; +use agentic_coding_protocol as acp; +use anyhow::{Context as _, Result}; +use async_trait::async_trait; +use collections::HashMap; +use gpui::{App, AppContext, AsyncApp, Context, Entity, Task, WeakEntity}; +use parking_lot::Mutex; +use project::Project; +use smol::process::Child; +use std::{io::Write as _, path::Path, sync::Arc}; +use util::ResultExt; + +pub struct AcpServer { + connection: Arc, + threads: Arc>>>, + project: Entity, + _handler_task: Task<()>, + _io_task: Task<()>, +} + +struct AcpClientDelegate { + project: Entity, + threads: Arc>>>, + cx: AsyncApp, + // sent_buffer_versions: HashMap, HashMap>, +} + +impl AcpClientDelegate { + fn new( + project: Entity, + threads: Arc>>>, + cx: AsyncApp, + ) -> Self { + Self { + project, + threads, + cx: cx, + } + } + + fn update_thread( + &self, + thread_id: &ThreadId, + cx: &mut App, + callback: impl FnMut(&mut AcpThread, &mut Context) -> R, + ) -> Option { + let thread = self.threads.lock().get(&thread_id)?.clone(); + let Some(thread) = thread.upgrade() else { + self.threads.lock().remove(&thread_id); + return None; + }; + Some(thread.update(cx, callback)) + } +} + +#[async_trait(?Send)] +impl acp::Client for AcpClientDelegate { + async fn stat(&self, params: acp::StatParams) -> Result { + let cx = &mut self.cx.clone(); + self.project.update(cx, |project, cx| { + let path = project + .project_path_for_absolute_path(Path::new(¶ms.path), cx) + .context("Failed to get project path")?; + + match project.entry_for_path(&path, cx) { + // todo! refresh entry? + None => Ok(acp::StatResponse { + exists: false, + is_directory: false, + }), + Some(entry) => Ok(acp::StatResponse { + exists: entry.is_created(), + is_directory: entry.is_dir(), + }), + } + })? + } + + async fn stream_message_chunk( + &self, + params: acp::StreamMessageChunkParams, + ) -> Result { + dbg!(); + let cx = &mut self.cx.clone(); + + cx.update(|cx| { + self.update_thread(¶ms.thread_id.into(), cx, |thread, cx| { + let acp::MessageChunk::Text { chunk } = ¶ms.chunk; + thread.push_assistant_chunk( + MessageChunk::Text { + chunk: chunk.into(), + }, + cx, + ) + }); + })?; + + Ok(acp::StreamMessageChunkResponse) + } + + async fn read_text_file( + &self, + request: acp::ReadTextFileParams, + ) -> Result { + let cx = &mut self.cx.clone(); + let buffer = self + .project + .update(cx, |project, cx| { + let path = project + .project_path_for_absolute_path(Path::new(&request.path), cx) + .context("Failed to get project path")?; + anyhow::Ok(project.open_buffer(path, cx)) + })?? + .await?; + + buffer.update(cx, |buffer, cx| { + let start = language::Point::new(request.line_offset.unwrap_or(0), 0); + let end = match request.line_limit { + None => buffer.max_point(), + Some(limit) => start + language::Point::new(limit + 1, 0), + }; + + let content: String = buffer.text_for_range(start..end).collect(); + self.update_thread(&request.thread_id.into(), cx, |thread, cx| { + thread.push_entry( + AgentThreadEntryContent::ReadFile { + path: request.path.clone(), + content: content.clone(), + }, + cx, + ); + }); + + acp::ReadTextFileResponse { + content, + version: acp::FileVersion(0), + } + }) + } + + async fn read_binary_file( + &self, + request: acp::ReadBinaryFileParams, + ) -> Result { + let cx = &mut self.cx.clone(); + let file = self + .project + .update(cx, |project, cx| { + let (worktree, path) = project + .find_worktree(Path::new(&request.path), cx) + .context("Failed to get project path")?; + + let task = worktree.update(cx, |worktree, cx| worktree.load_binary_file(&path, cx)); + anyhow::Ok(task) + })?? + .await?; + + // todo! test + let content = cx + .background_spawn(async move { + let start = request.byte_offset.unwrap_or(0) as usize; + let end = request + .byte_limit + .map(|limit| (start + limit as usize).min(file.content.len())) + .unwrap_or(file.content.len()); + + let range_content = &file.content[start..end]; + + let mut base64_content = Vec::new(); + let mut base64_encoder = base64::write::EncoderWriter::new( + std::io::Cursor::new(&mut base64_content), + &base64::engine::general_purpose::STANDARD, + ); + base64_encoder.write_all(range_content)?; + drop(base64_encoder); + + // SAFETY: The base64 encoder should not produce non-UTF8. + unsafe { anyhow::Ok(String::from_utf8_unchecked(base64_content)) } + }) + .await?; + + Ok(acp::ReadBinaryFileResponse { + content, + // todo! + version: acp::FileVersion(0), + }) + } + + async fn glob_search(&self, request: acp::GlobSearchParams) -> Result { + todo!() + } +} + +impl AcpServer { + pub fn stdio(mut process: Child, project: Entity, cx: &mut AsyncApp) -> Arc { + let stdin = process.stdin.take().expect("process didn't have stdin"); + let stdout = process.stdout.take().expect("process didn't have stdout"); + + let threads: Arc>>> = Default::default(); + let (connection, handler_fut, io_fut) = acp::AgentConnection::connect_to_agent( + AcpClientDelegate::new(project.clone(), threads.clone(), cx.clone()), + stdin, + stdout, + ); + + let io_task = cx.background_spawn(async move { + io_fut.await.log_err(); + process.status().await.log_err(); + }); + + Arc::new(Self { + project, + connection: Arc::new(connection), + threads, + _handler_task: cx.foreground_executor().spawn(handler_fut), + _io_task: io_task, + }) + } +} + +impl AcpServer { + pub async fn create_thread(self: Arc, cx: &mut AsyncApp) -> Result> { + let response = self.connection.request(acp::CreateThreadParams).await?; + let thread_id: ThreadId = response.thread_id.into(); + let server = self.clone(); + let thread = cx.new(|_| AcpThread { + title: "The agent2 thread".into(), + id: thread_id.clone(), + next_entry_id: ThreadEntryId(0), + entries: Vec::default(), + project: self.project.clone(), + server, + })?; + self.threads.lock().insert(thread_id, thread.downgrade()); + Ok(thread) + } + + pub async fn send_message( + &self, + thread_id: ThreadId, + message: crate::Message, + cx: &mut AsyncApp, + ) -> Result<()> { + self.connection + .request(acp::SendMessageParams { + thread_id: thread_id.clone().into(), + message: acp::Message { + role: match message.role { + Role::User => acp::Role::User, + Role::Assistant => acp::Role::Assistant, + }, + chunks: message + .chunks + .into_iter() + .map(|chunk| match chunk { + MessageChunk::Text { chunk } => acp::MessageChunk::Text { + chunk: chunk.into(), + }, + MessageChunk::File { .. } => todo!(), + MessageChunk::Directory { .. } => todo!(), + MessageChunk::Symbol { .. } => todo!(), + MessageChunk::Fetch { .. } => todo!(), + }) + .collect(), + }, + }) + .await?; + Ok(()) + } +} + +impl From for ThreadId { + fn from(thread_id: acp::ThreadId) -> Self { + Self(thread_id.0.into()) + } +} + +impl From for acp::ThreadId { + fn from(thread_id: ThreadId) -> Self { + acp::ThreadId(thread_id.0.to_string()) + } +} diff --git a/crates/acp/src/thread_element.rs b/crates/acp/src/thread_view.rs similarity index 95% rename from crates/acp/src/thread_element.rs rename to crates/acp/src/thread_view.rs index e843edcde3..49a4d1fe8b 100644 --- a/crates/acp/src/thread_element.rs +++ b/crates/acp/src/thread_view.rs @@ -7,18 +7,18 @@ use ui::Tooltip; use ui::prelude::*; use zed_actions::agent::Chat; -use crate::{AgentThreadEntryContent, Message, MessageChunk, Role, Thread, ThreadEntry}; +use crate::{AcpThread, AgentThreadEntryContent, Message, MessageChunk, Role, ThreadEntry}; -pub struct ThreadElement { - thread: Entity, +pub struct AcpThreadView { + thread: Entity, // todo! use full message editor from agent2 message_editor: Entity, send_task: Option>>, _subscription: Subscription, } -impl ThreadElement { - pub fn new(thread: Entity, window: &mut Window, cx: &mut Context) -> Self { +impl AcpThreadView { + pub fn new(thread: Entity, window: &mut Window, cx: &mut Context) -> Self { let message_editor = cx.new(|cx| { let buffer = cx.new(|cx| Buffer::local("", cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); @@ -127,13 +127,13 @@ impl ThreadElement { } } -impl Focusable for ThreadElement { +impl Focusable for AcpThreadView { fn focus_handle(&self, cx: &App) -> FocusHandle { self.message_editor.focus_handle(cx) } } -impl Render for ThreadElement { +impl Render for AcpThreadView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let text = self.message_editor.read(cx).text(cx); let is_editor_empty = text.is_empty(); diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index d07521f3ee..149fcb2259 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -4,7 +4,7 @@ use std::rc::Rc; use std::sync::Arc; use std::time::Duration; -use agent2::{AcpAgent, Agent as _}; +use acp::AcpServer; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use serde::{Deserialize, Serialize}; @@ -198,7 +198,7 @@ enum ActiveView { _subscriptions: Vec, }, AcpThread { - thread_element: Entity, + thread_view: Entity, }, TextThread { context_editor: Entity, @@ -753,8 +753,8 @@ impl AgentPanel { ActiveView::Thread { thread, .. } => { thread.update(cx, |thread, cx| thread.cancel_last_completion(window, cx)); } - ActiveView::AcpThread { thread_element, .. } => { - thread_element.update(cx, |thread_element, _cx| thread_element.cancel()); + ActiveView::AcpThread { thread_view, .. } => { + thread_view.update(cx, |thread_element, _cx| thread_element.cancel()); } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} } @@ -916,12 +916,12 @@ impl AgentPanel { let project = self.project.clone(); cx.spawn_in(window, async move |this, cx| { - let agent = AcpAgent::stdio(child, project, cx); + let agent = AcpServer::stdio(child, project, cx); let thread = agent.create_thread(cx).await?; - let thread_element = - cx.new_window_entity(|window, cx| agent2::ThreadElement::new(thread, window, cx))?; + let thread_view = + cx.new_window_entity(|window, cx| acp::AcpThreadView::new(thread, window, cx))?; this.update_in(cx, |this, window, cx| { - this.set_active_view(ActiveView::AcpThread { thread_element }, window, cx); + this.set_active_view(ActiveView::AcpThread { thread_view }, window, cx); }) }) .detach(); @@ -1521,7 +1521,7 @@ impl Focusable for AgentPanel { fn focus_handle(&self, cx: &App) -> FocusHandle { match &self.active_view { ActiveView::Thread { message_editor, .. } => message_editor.focus_handle(cx), - ActiveView::AcpThread { thread_element, .. } => thread_element.focus_handle(cx), + ActiveView::AcpThread { thread_view, .. } => thread_view.focus_handle(cx), ActiveView::History => self.history.focus_handle(cx), ActiveView::TextThread { context_editor, .. } => context_editor.focus_handle(cx), ActiveView::Configuration => { @@ -1678,11 +1678,9 @@ impl AgentPanel { .into_any_element(), } } - ActiveView::AcpThread { thread_element } => { - Label::new(thread_element.read(cx).title(cx)) - .truncate() - .into_any_element() - } + ActiveView::AcpThread { thread_view } => Label::new(thread_view.read(cx).title(cx)) + .truncate() + .into_any_element(), ActiveView::TextThread { title_editor, context_editor, @@ -3188,9 +3186,9 @@ impl Render for AgentPanel { }) .child(h_flex().child(message_editor.clone())) .child(self.render_drag_target(cx)), - ActiveView::AcpThread { thread_element, .. } => parent + ActiveView::AcpThread { thread_view, .. } => parent .relative() - .child(thread_element.clone()) + .child(thread_view.clone()) // todo! // .child(h_flex().child(self.message_editor.clone())) .child(self.render_drag_target(cx)), From 7abf635e20fc47b28ad1764f61e01d14c0d21838 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 1 Jul 2025 11:48:03 -0700 Subject: [PATCH 31/54] Use a list to render items Co-authored-by: Agus Zubiaga --- crates/acp/src/thread_view.rs | 42 ++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs index 49a4d1fe8b..6686c63fe6 100644 --- a/crates/acp/src/thread_view.rs +++ b/crates/acp/src/thread_view.rs @@ -1,6 +1,9 @@ use anyhow::Result; use editor::{Editor, MultiBuffer}; -use gpui::{App, Entity, Focusable, SharedString, Subscription, Window, div, prelude::*}; +use gpui::{ + App, Empty, Entity, Focusable, ListState, SharedString, Subscription, Window, div, list, + prelude::*, +}; use gpui::{FocusHandle, Task}; use language::Buffer; use ui::Tooltip; @@ -13,6 +16,7 @@ pub struct AcpThreadView { thread: Entity, // todo! use full message editor from agent2 message_editor: Entity, + list_state: ListState, send_task: Option>>, _subscription: Subscription, } @@ -38,14 +42,32 @@ impl AcpThreadView { editor }); - let subscription = cx.observe(&thread, |_, _, cx| { + let subscription = cx.observe(&thread, |this, thread, cx| { + let count = this.list_state.item_count(); + // TODO: Incremental updates + this.list_state + .splice(0..count, thread.read(cx).entries.len()); cx.notify(); }); + let list_state = ListState::new( + thread.read(cx).entries.len(), + gpui::ListAlignment::Top, + px(1000.0), + cx.processor({ + move |this: &mut Self, item: usize, window, cx| { + let Some(entry) = this.thread.read(cx).entries.get(item) else { + return Empty.into_any(); + }; + this.render_entry(entry, window, cx) + } + }), + ); Self { thread, message_editor, send_task: None, + list_state: list_state, _subscription: subscription, } } @@ -134,7 +156,7 @@ impl Focusable for AcpThreadView { } impl Render for AcpThreadView { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let text = self.message_editor.read(cx).text(cx); let is_editor_empty = text.is_empty(); let focus_handle = self.message_editor.focus_handle(cx); @@ -143,14 +165,12 @@ impl Render for AcpThreadView { .key_context("MessageEditor") .on_action(cx.listener(Self::chat)) .child( - // todo! use gpui::list - v_flex().p_2().h_full().gap_1().children( - self.thread - .read(cx) - .entries() - .iter() - .map(|entry| self.render_entry(entry, window, cx)), - ), + div() + .child( + list(self.list_state.clone()) + .with_sizing_behavior(gpui::ListSizingBehavior::Infer), + ) + .p_2(), ) .when(self.send_task.is_some(), |this| { this.child( From 17b2dd9a93e99c5d914ee3412880f9efdee268e7 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 1 Jul 2025 16:13:16 -0300 Subject: [PATCH 32/54] Update list incrementally Co-authored-by: Conrad Irwin --- crates/acp/src/acp.rs | 38 +++++++++++++++++++++-------------- crates/acp/src/thread_view.rs | 17 +++++++++++----- 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/crates/acp/src/acp.rs b/crates/acp/src/acp.rs index f6b58fea3a..64b23b2541 100644 --- a/crates/acp/src/acp.rs +++ b/crates/acp/src/acp.rs @@ -108,6 +108,13 @@ pub struct AcpThread { project: Entity, } +enum AcpThreadEvent { + NewEntry, + LastEntryUpdated, +} + +impl EventEmitter for AcpThread {} + impl AcpThread { pub fn new( server: Arc, @@ -146,33 +153,34 @@ impl AcpThread { id: self.next_entry_id.post_inc(), content: entry, }); - cx.notify(); + cx.emit(AcpThreadEvent::NewEntry) } pub fn push_assistant_chunk(&mut self, chunk: MessageChunk, cx: &mut Context) { - if let Some(last_entry) = self.entries.last_mut() { - if let AgentThreadEntryContent::Message(Message { + if let Some(last_entry) = self.entries.last_mut() + && let AgentThreadEntryContent::Message(Message { ref mut chunks, role: Role::Assistant, }) = last_entry.content - { - if let ( - Some(MessageChunk::Text { chunk: old_chunk }), - MessageChunk::Text { chunk: new_chunk }, - ) = (chunks.last_mut(), &chunk) - { - old_chunk.push_str(&new_chunk); - return cx.notify(); - } + { + cx.emit(AcpThreadEvent::LastEntryUpdated); + if let ( + Some(MessageChunk::Text { chunk: old_chunk }), + MessageChunk::Text { chunk: new_chunk }, + ) = (chunks.last_mut(), &chunk) + { + old_chunk.push_str(&new_chunk); + } else { chunks.push(chunk); return cx.notify(); } + + return; } - self.entries.push(ThreadEntry { - id: self.next_entry_id.post_inc(), - content: AgentThreadEntryContent::Message(Message { + self.push_entry( + AgentThreadEntryContent::Message(Message { role: Role::Assistant, chunks: vec![chunk], }), diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs index 6686c63fe6..59af84f39b 100644 --- a/crates/acp/src/thread_view.rs +++ b/crates/acp/src/thread_view.rs @@ -10,7 +10,9 @@ use ui::Tooltip; use ui::prelude::*; use zed_actions::agent::Chat; -use crate::{AcpThread, AgentThreadEntryContent, Message, MessageChunk, Role, ThreadEntry}; +use crate::{ + AcpThread, AcpThreadEvent, AgentThreadEntryContent, Message, MessageChunk, Role, ThreadEntry, +}; pub struct AcpThreadView { thread: Entity, @@ -42,11 +44,16 @@ impl AcpThreadView { editor }); - let subscription = cx.observe(&thread, |this, thread, cx| { + let subscription = cx.subscribe(&thread, |this, _, event, cx| { let count = this.list_state.item_count(); - // TODO: Incremental updates - this.list_state - .splice(0..count, thread.read(cx).entries.len()); + match event { + AcpThreadEvent::NewEntry => { + this.list_state.splice(count..count, 1); + } + AcpThreadEvent::LastEntryUpdated => { + this.list_state.splice(count - 1..count, 1); + } + } cx.notify(); }); From 4d803fa62822b4879327d449702397d49e0aac80 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 1 Jul 2025 16:57:22 -0300 Subject: [PATCH 33/54] message markdown Co-authored-by: Conrad Irwin --- Cargo.lock | 2 + crates/acp/Cargo.toml | 3 + crates/acp/src/acp.rs | 101 +++++++++++++++------ crates/acp/src/server.rs | 42 +++------ crates/acp/src/thread_view.rs | 160 ++++++++++++++++++++++++++++++---- 5 files changed, 230 insertions(+), 78 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 42c2e2559e..e076350c08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,11 +17,13 @@ dependencies = [ "futures 0.3.31", "gpui", "language", + "markdown", "parking_lot", "project", "serde_json", "settings", "smol", + "theme", "ui", "util", "uuid", diff --git a/crates/acp/Cargo.toml b/crates/acp/Cargo.toml index cd8be590b4..3d85c3bd42 100644 --- a/crates/acp/Cargo.toml +++ b/crates/acp/Cargo.toml @@ -26,9 +26,12 @@ editor.workspace = true futures.workspace = true gpui.workspace = true language.workspace = true +markdown.workspace = true parking_lot.workspace = true project.workspace = true +settings.workspace = true smol.workspace = true +theme.workspace = true ui.workspace = true util.workspace = true uuid.workspace = true diff --git a/crates/acp/src/acp.rs b/crates/acp/src/acp.rs index 64b23b2541..77f4097a72 100644 --- a/crates/acp/src/acp.rs +++ b/crates/acp/src/acp.rs @@ -1,11 +1,15 @@ mod server; mod thread_view; +use agentic_coding_protocol::{self as acp, Role}; use anyhow::Result; use chrono::{DateTime, Utc}; -use gpui::{Context, Entity, SharedString, Task}; +use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Task}; +use language::LanguageRegistry; +use markdown::Markdown; use project::Project; use std::{ops::Range, path::PathBuf, sync::Arc}; +use ui::App; pub use server::AcpServer; pub use thread_view::AcpThreadView; @@ -30,23 +34,29 @@ pub struct FileContent { pub content: SharedString, } -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub enum Role { - User, - Assistant, -} - #[derive(Clone, Debug, Eq, PartialEq)] pub struct Message { - pub role: Role, + pub role: acp::Role, pub chunks: Vec, } +impl Message { + fn into_acp(self, cx: &App) -> acp::Message { + acp::Message { + role: self.role, + chunks: self + .chunks + .into_iter() + .map(|chunk| chunk.into_acp(cx)) + .collect(), + } + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum MessageChunk { Text { - // todo! should it be shared string? what about streaming? - chunk: String, + chunk: Entity, }, File { content: FileContent, @@ -68,10 +78,36 @@ pub enum MessageChunk { }, } -impl From<&str> for MessageChunk { - fn from(chunk: &str) -> Self { +impl MessageChunk { + pub fn from_acp( + chunk: acp::MessageChunk, + language_registry: Arc, + cx: &mut App, + ) -> Self { + match chunk { + acp::MessageChunk::Text { chunk } => MessageChunk::Text { + chunk: cx.new(|cx| Markdown::new(chunk.into(), Some(language_registry), None, cx)), + }, + } + } + + pub fn into_acp(self, cx: &App) -> acp::MessageChunk { + match self { + MessageChunk::Text { chunk } => acp::MessageChunk::Text { + chunk: chunk.read(cx).source().to_string(), + }, + MessageChunk::File { .. } => todo!(), + MessageChunk::Directory { .. } => todo!(), + MessageChunk::Symbol { .. } => todo!(), + MessageChunk::Fetch { .. } => todo!(), + } + } + + pub fn from_str(chunk: &str, language_registry: Arc, cx: &mut App) -> Self { MessageChunk::Text { - chunk: chunk.to_string().into(), + chunk: cx.new(|cx| { + Markdown::new(chunk.to_owned().into(), Some(language_registry), None, cx) + }), } } } @@ -156,7 +192,7 @@ impl AcpThread { cx.emit(AcpThreadEvent::NewEntry) } - pub fn push_assistant_chunk(&mut self, chunk: MessageChunk, cx: &mut Context) { + pub fn push_assistant_chunk(&mut self, chunk: acp::MessageChunk, cx: &mut Context) { if let Some(last_entry) = self.entries.last_mut() && let AgentThreadEntryContent::Message(Message { ref mut chunks, @@ -167,33 +203,46 @@ impl AcpThread { if let ( Some(MessageChunk::Text { chunk: old_chunk }), - MessageChunk::Text { chunk: new_chunk }, + acp::MessageChunk::Text { chunk: new_chunk }, ) = (chunks.last_mut(), &chunk) { - old_chunk.push_str(&new_chunk); + old_chunk.update(cx, |old_chunk, cx| { + old_chunk.append(&new_chunk, cx); + }); } else { - chunks.push(chunk); - return cx.notify(); + chunks.push(MessageChunk::from_acp( + chunk, + self.project.read(cx).languages().clone(), + cx, + )); } return; } + let chunk = MessageChunk::from_acp(chunk, self.project.read(cx).languages().clone(), cx); + self.push_entry( AgentThreadEntryContent::Message(Message { role: Role::Assistant, chunks: vec![chunk], }), - }); - cx.notify(); + cx, + ); } - pub fn send(&mut self, message: Message, cx: &mut Context) -> Task> { + pub fn send(&mut self, message: &str, cx: &mut Context) -> Task> { let agent = self.server.clone(); let id = self.id.clone(); + let chunk = MessageChunk::from_str(message, self.project.read(cx).languages().clone(), cx); + let message = Message { + role: Role::User, + chunks: vec![chunk], + }; self.push_entry(AgentThreadEntryContent::Message(message.clone()), cx); + let acp_message = message.into_acp(cx); cx.spawn(async move |_, cx| { - agent.send_message(id, message, cx).await?; + agent.send_message(id, acp_message, cx).await?; Ok(()) }) } @@ -237,13 +286,7 @@ mod tests { thread .update(cx, |thread, cx| { thread.send( - Message { - role: Role::User, - chunks: vec![ - "Read the '/private/tmp/foo' file and output all of its contents." - .into(), - ], - }, + "Read the '/private/tmp/foo' file and output all of its contents.", cx, ) }) diff --git a/crates/acp/src/server.rs b/crates/acp/src/server.rs index 57f700c312..323f6bf2d0 100644 --- a/crates/acp/src/server.rs +++ b/crates/acp/src/server.rs @@ -1,4 +1,4 @@ -use crate::{AcpThread, AgentThreadEntryContent, MessageChunk, Role, ThreadEntryId, ThreadId}; +use crate::{AcpThread, AgentThreadEntryContent, ThreadEntryId, ThreadId}; use agentic_coding_protocol as acp; use anyhow::{Context as _, Result}; use async_trait::async_trait; @@ -42,7 +42,7 @@ impl AcpClientDelegate { &self, thread_id: &ThreadId, cx: &mut App, - callback: impl FnMut(&mut AcpThread, &mut Context) -> R, + callback: impl FnOnce(&mut AcpThread, &mut Context) -> R, ) -> Option { let thread = self.threads.lock().get(&thread_id)?.clone(); let Some(thread) = thread.upgrade() else { @@ -80,18 +80,11 @@ impl acp::Client for AcpClientDelegate { &self, params: acp::StreamMessageChunkParams, ) -> Result { - dbg!(); let cx = &mut self.cx.clone(); cx.update(|cx| { self.update_thread(¶ms.thread_id.into(), cx, |thread, cx| { - let acp::MessageChunk::Text { chunk } = ¶ms.chunk; - thread.push_assistant_chunk( - MessageChunk::Text { - chunk: chunk.into(), - }, - cx, - ) + thread.push_assistant_chunk(params.chunk, cx) }); })?; @@ -186,7 +179,10 @@ impl acp::Client for AcpClientDelegate { }) } - async fn glob_search(&self, request: acp::GlobSearchParams) -> Result { + async fn glob_search( + &self, + _request: acp::GlobSearchParams, + ) -> Result { todo!() } } @@ -238,31 +234,13 @@ impl AcpServer { pub async fn send_message( &self, thread_id: ThreadId, - message: crate::Message, - cx: &mut AsyncApp, + message: acp::Message, + _cx: &mut AsyncApp, ) -> Result<()> { self.connection .request(acp::SendMessageParams { thread_id: thread_id.clone().into(), - message: acp::Message { - role: match message.role { - Role::User => acp::Role::User, - Role::Assistant => acp::Role::Assistant, - }, - chunks: message - .chunks - .into_iter() - .map(|chunk| match chunk { - MessageChunk::Text { chunk } => acp::MessageChunk::Text { - chunk: chunk.into(), - }, - MessageChunk::File { .. } => todo!(), - MessageChunk::Directory { .. } => todo!(), - MessageChunk::Symbol { .. } => todo!(), - MessageChunk::Fetch { .. } => todo!(), - }) - .collect(), - }, + message, }) .await?; Ok(()) diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs index 59af84f39b..314bcecdbb 100644 --- a/crates/acp/src/thread_view.rs +++ b/crates/acp/src/thread_view.rs @@ -1,18 +1,21 @@ +use std::rc::Rc; + use anyhow::Result; use editor::{Editor, MultiBuffer}; use gpui::{ - App, Empty, Entity, Focusable, ListState, SharedString, Subscription, Window, div, list, - prelude::*, + App, EdgesRefinement, Empty, Entity, Focusable, ListState, SharedString, StyleRefinement, + Subscription, TextStyleRefinement, UnderlineStyle, Window, div, list, prelude::*, }; use gpui::{FocusHandle, Task}; use language::Buffer; +use markdown::{HeadingLevelStyles, MarkdownElement, MarkdownStyle}; +use settings::Settings as _; +use theme::ThemeSettings; use ui::Tooltip; use ui::prelude::*; use zed_actions::agent::Chat; -use crate::{ - AcpThread, AcpThreadEvent, AgentThreadEntryContent, Message, MessageChunk, Role, ThreadEntry, -}; +use crate::{AcpThread, AcpThreadEvent, AgentThreadEntryContent, MessageChunk, Role, ThreadEntry}; pub struct AcpThreadView { thread: Entity, @@ -93,13 +96,7 @@ impl AcpThreadView { return; } - let task = self.thread.update(cx, |thread, cx| { - let message = Message { - role: Role::User, - chunks: vec![MessageChunk::Text { chunk: text.into() }], - }; - thread.send(message, cx) - }); + let task = self.thread.update(cx, |thread, cx| thread.send(&text, cx)); self.send_task = Some(cx.spawn(async move |this, cx| { task.await?; @@ -117,16 +114,21 @@ impl AcpThreadView { fn render_entry( &self, entry: &ThreadEntry, - _window: &mut Window, + window: &mut Window, cx: &Context, ) -> AnyElement { match &entry.content { AgentThreadEntryContent::Message(message) => { + let style = if message.role == Role::User { + user_message_markdown_style(window, cx) + } else { + default_markdown_style(window, cx) + }; let message_body = div() .children(message.chunks.iter().map(|chunk| match chunk { MessageChunk::Text { chunk } => { - // todo! markdown - Label::new(chunk.clone()) + // todo!() open link + MarkdownElement::new(chunk.clone(), style.clone()) } _ => todo!(), })) @@ -134,7 +136,8 @@ impl AcpThreadView { match message.role { Role::User => div() - .my_1() + .text_xs() + .m_1() .p_2() .bg(cx.theme().colors().editor_background) .rounded_lg() @@ -143,7 +146,12 @@ impl AcpThreadView { .border_color(cx.theme().colors().border) .child(message_body) .into_any(), - Role::Assistant => message_body, + Role::Assistant => div() + .text_ui(cx) + .px_2() + .py_4() + .child(message_body) + .into_any(), } } AgentThreadEntryContent::ReadFile { path, content: _ } => { @@ -237,3 +245,121 @@ impl Render for AcpThreadView { ) } } + +fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { + let mut style = default_markdown_style(window, cx); + let mut text_style = window.text_style(); + let theme_settings = ThemeSettings::get_global(cx); + + let buffer_font = theme_settings.buffer_font.family.clone(); + let buffer_font_size = TextSize::Small.rems(cx); + + text_style.refine(&TextStyleRefinement { + font_family: Some(buffer_font), + font_size: Some(buffer_font_size.into()), + ..Default::default() + }); + + style.base_text_style = text_style; + style +} + +fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { + let theme_settings = ThemeSettings::get_global(cx); + let colors = cx.theme().colors(); + let ui_font_size = TextSize::Default.rems(cx); + let buffer_font_size = TextSize::Small.rems(cx); + let mut text_style = window.text_style(); + let line_height = buffer_font_size * 1.75; + + text_style.refine(&TextStyleRefinement { + font_family: Some(theme_settings.ui_font.family.clone()), + font_fallbacks: theme_settings.ui_font.fallbacks.clone(), + font_features: Some(theme_settings.ui_font.features.clone()), + font_size: Some(ui_font_size.into()), + line_height: Some(line_height.into()), + color: Some(cx.theme().colors().text), + ..Default::default() + }); + + MarkdownStyle { + base_text_style: text_style.clone(), + syntax: cx.theme().syntax().clone(), + selection_background_color: cx.theme().colors().element_selection_background, + code_block_overflow_x_scroll: true, + table_overflow_x_scroll: true, + heading_level_styles: Some(HeadingLevelStyles { + h1: Some(TextStyleRefinement { + font_size: Some(rems(1.15).into()), + ..Default::default() + }), + h2: Some(TextStyleRefinement { + font_size: Some(rems(1.1).into()), + ..Default::default() + }), + h3: Some(TextStyleRefinement { + font_size: Some(rems(1.05).into()), + ..Default::default() + }), + h4: Some(TextStyleRefinement { + font_size: Some(rems(1.).into()), + ..Default::default() + }), + h5: Some(TextStyleRefinement { + font_size: Some(rems(0.95).into()), + ..Default::default() + }), + h6: Some(TextStyleRefinement { + font_size: Some(rems(0.875).into()), + ..Default::default() + }), + }), + code_block: StyleRefinement { + padding: EdgesRefinement { + top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))), + left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))), + right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))), + bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))), + }, + background: Some(colors.editor_background.into()), + text: Some(TextStyleRefinement { + font_family: Some(theme_settings.buffer_font.family.clone()), + font_fallbacks: theme_settings.buffer_font.fallbacks.clone(), + font_features: Some(theme_settings.buffer_font.features.clone()), + font_size: Some(buffer_font_size.into()), + ..Default::default() + }), + ..Default::default() + }, + inline_code: TextStyleRefinement { + font_family: Some(theme_settings.buffer_font.family.clone()), + font_fallbacks: theme_settings.buffer_font.fallbacks.clone(), + font_features: Some(theme_settings.buffer_font.features.clone()), + font_size: Some(buffer_font_size.into()), + background_color: Some(colors.editor_foreground.opacity(0.08)), + ..Default::default() + }, + link: TextStyleRefinement { + background_color: Some(colors.editor_foreground.opacity(0.025)), + underline: Some(UnderlineStyle { + color: Some(colors.text_accent.opacity(0.5)), + thickness: px(1.), + ..Default::default() + }), + ..Default::default() + }, + link_callback: Some(Rc::new(move |_url, _cx| { + // todo!() + // if MentionLink::is_valid(url) { + // let colors = cx.theme().colors(); + // Some(TextStyleRefinement { + // background_color: Some(colors.element_background), + // ..Default::default() + // }) + // } else { + None + // } + })), + ..Default::default() + } +} From 28ac84ed019d9f22f8387abfe76d0ca2f0673ac7 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 1 Jul 2025 17:15:20 -0300 Subject: [PATCH 34/54] Jump to gemini thread view immediately Co-authored-by: Conrad Irwin --- crates/acp/src/thread_view.rs | 128 +++++++++++++++++++++++------ crates/agent_ui/src/agent_panel.rs | 30 +------ 2 files changed, 106 insertions(+), 52 deletions(-) diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs index 314bcecdbb..79cdfdd6dd 100644 --- a/crates/acp/src/thread_view.rs +++ b/crates/acp/src/thread_view.rs @@ -1,3 +1,4 @@ +use std::path::Path; use std::rc::Rc; use anyhow::Result; @@ -9,25 +10,63 @@ use gpui::{ use gpui::{FocusHandle, Task}; use language::Buffer; use markdown::{HeadingLevelStyles, MarkdownElement, MarkdownStyle}; +use project::Project; use settings::Settings as _; use theme::ThemeSettings; use ui::Tooltip; use ui::prelude::*; +use util::ResultExt; use zed_actions::agent::Chat; -use crate::{AcpThread, AcpThreadEvent, AgentThreadEntryContent, MessageChunk, Role, ThreadEntry}; +use crate::{ + AcpServer, AcpThread, AcpThreadEvent, AgentThreadEntryContent, MessageChunk, Role, ThreadEntry, +}; pub struct AcpThreadView { - thread: Entity, + thread_state: ThreadState, // todo! use full message editor from agent2 message_editor: Entity, list_state: ListState, send_task: Option>>, - _subscription: Subscription, +} + +enum ThreadState { + Loading { + _task: Task<()>, + }, + Ready { + thread: Entity, + _subscription: Subscription, + }, + LoadError(SharedString), } impl AcpThreadView { - pub fn new(thread: Entity, window: &mut Window, cx: &mut Context) -> Self { + pub fn new(project: Entity, window: &mut Window, cx: &mut Context) -> Self { + let Some(root_dir) = project + .read(cx) + .visible_worktrees(cx) + .next() + .map(|worktree| worktree.read(cx).abs_path()) + else { + todo!(); + }; + + let cli_path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../gemini-cli/packages/cli"); + + let child = util::command::new_smol_command("node") + .arg(cli_path) + .arg("--acp") + .args(["--model", "gemini-2.5-flash"]) + .current_dir(root_dir) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let message_editor = cx.new(|cx| { let buffer = cx.new(|cx| Buffer::local("", cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); @@ -47,43 +86,79 @@ impl AcpThreadView { editor }); - 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::LastEntryUpdated => { - this.list_state.splice(count - 1..count, 1); - } - } - cx.notify(); + let project = project.clone(); + let load_task = cx.spawn_in(window, async move |this, cx| { + let agent = AcpServer::stdio(child, project, cx); + let result = agent.create_thread(cx).await; + + this.update(cx, |this, 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::LastEntryUpdated => { + this.list_state.splice(count - 1..count, 1); + } + } + cx.notify(); + }); + this.list_state + .splice(0..0, thread.read(cx).entries().len()); + + this.thread_state = ThreadState::Ready { + thread, + _subscription: subscription, + }; + } + Err(e) => this.thread_state = ThreadState::LoadError(e.to_string().into()), + }; + cx.notify(); + }) + .log_err(); }); let list_state = ListState::new( - thread.read(cx).entries.len(), + 0, gpui::ListAlignment::Top, px(1000.0), cx.processor({ move |this: &mut Self, item: usize, window, cx| { - let Some(entry) = this.thread.read(cx).entries.get(item) else { + let Some(entry) = this + .thread() + .and_then(|thread| thread.read(cx).entries.get(item)) + else { return Empty.into_any(); }; this.render_entry(entry, window, cx) } }), ); + Self { - thread, + thread_state: ThreadState::Loading { _task: load_task }, message_editor, send_task: None, list_state: list_state, - _subscription: subscription, + } + } + + fn thread(&self) -> Option<&Entity> { + match &self.thread_state { + ThreadState::Ready { thread, .. } => Some(thread), + _ => None, } } pub fn title(&self, cx: &App) -> SharedString { - self.thread.read(cx).title() + match &self.thread_state { + ThreadState::Ready { thread, .. } => thread.read(cx).title(), + ThreadState::Loading { .. } => "Loading...".into(), + ThreadState::LoadError(_) => "Failed to load".into(), + } } pub fn cancel(&mut self) { @@ -95,8 +170,9 @@ impl AcpThreadView { if text.is_empty() { return; } + let Some(thread) = self.thread() else { return }; - let task = self.thread.update(cx, |thread, cx| thread.send(&text, cx)); + let task = thread.update(cx, |thread, cx| thread.send(&text, cx)); self.send_task = Some(cx.spawn(async move |this, cx| { task.await?; @@ -179,14 +255,18 @@ impl Render for AcpThreadView { v_flex() .key_context("MessageEditor") .on_action(cx.listener(Self::chat)) - .child( - div() + .child(match &self.thread_state { + ThreadState::Loading { .. } => div().p_2().child(Label::new("Loading...")), + ThreadState::LoadError(e) => div() + .p_2() + .child(Label::new(format!("Failed to load {e}")).into_any_element()), + ThreadState::Ready { .. } => div() .child( list(self.list_state.clone()) .with_sizing_behavior(gpui::ListSizingBehavior::Infer), ) .p_2(), - ) + }) .when(self.send_task.is_some(), |this| { this.child( div().p_2().child( diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 149fcb2259..ce38ff023b 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -4,7 +4,6 @@ use std::rc::Rc; use std::sync::Arc; use std::time::Duration; -use acp::AcpServer; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use serde::{Deserialize, Serialize}; @@ -890,36 +889,11 @@ impl AgentPanel { } fn new_gemini_thread(&mut self, window: &mut Window, cx: &mut Context) { - let Some(root_dir) = self - .project - .read(cx) - .visible_worktrees(cx) - .next() - .map(|worktree| worktree.read(cx).abs_path()) - else { - todo!(); - }; - - let cli_path = - Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../gemini-cli/packages/cli"); - let child = util::command::new_smol_command("node") - .arg(cli_path) - .arg("--acp") - .args(["--model", "gemini-2.5-flash"]) - .current_dir(root_dir) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::inherit()) - .kill_on_drop(true) - .spawn() - .unwrap(); - let project = self.project.clone(); + cx.spawn_in(window, async move |this, cx| { - let agent = AcpServer::stdio(child, project, cx); - let thread = agent.create_thread(cx).await?; let thread_view = - cx.new_window_entity(|window, cx| acp::AcpThreadView::new(thread, window, cx))?; + cx.new_window_entity(|window, cx| acp::AcpThreadView::new(project, window, cx))?; this.update_in(cx, |this, window, cx| { this.set_active_view(ActiveView::AcpThread { thread_view }, window, cx); }) From 6f768aefa2bb0300faf96e4025611bd36866b70b Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 1 Jul 2025 17:15:57 -0300 Subject: [PATCH 35/54] Copy Co-authored-by: Conrad Irwin --- crates/acp/src/thread_view.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs index 79cdfdd6dd..7ad76caabb 100644 --- a/crates/acp/src/thread_view.rs +++ b/crates/acp/src/thread_view.rs @@ -256,7 +256,9 @@ impl Render for AcpThreadView { .key_context("MessageEditor") .on_action(cx.listener(Self::chat)) .child(match &self.thread_state { - ThreadState::Loading { .. } => div().p_2().child(Label::new("Loading...")), + ThreadState::Loading { .. } => { + div().p_2().child(Label::new("Connecting to Gemini...")) + } ThreadState::LoadError(e) => div() .p_2() .child(Label::new(format!("Failed to load {e}")).into_any_element()), From 3ceeefe46056daab32f8f2ae308c6a4bc718f27a Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 1 Jul 2025 20:32:21 -0300 Subject: [PATCH 36/54] Tool authorization --- Cargo.lock | 1 + crates/acp/Cargo.toml | 1 + crates/acp/src/acp.rs | 124 +++++++++++++++++++++++++---- crates/acp/src/server.rs | 40 +++++++++- crates/acp/src/thread_view.rs | 59 +++++++++++++- crates/ui/src/traits/styled_ext.rs | 2 +- 6 files changed, 206 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e076350c08..d4e7d08dee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,7 @@ dependencies = [ "futures 0.3.31", "gpui", "language", + "log", "markdown", "parking_lot", "project", diff --git a/crates/acp/Cargo.toml b/crates/acp/Cargo.toml index 3d85c3bd42..2b81dc03e9 100644 --- a/crates/acp/Cargo.toml +++ b/crates/acp/Cargo.toml @@ -26,6 +26,7 @@ editor.workspace = true futures.workspace = true gpui.workspace = true language.workspace = true +log.workspace = true markdown.workspace = true parking_lot.workspace = true project.workspace = true diff --git a/crates/acp/src/acp.rs b/crates/acp/src/acp.rs index 77f4097a72..b28e204c89 100644 --- a/crates/acp/src/acp.rs +++ b/crates/acp/src/acp.rs @@ -4,12 +4,14 @@ mod thread_view; use agentic_coding_protocol::{self as acp, Role}; use anyhow::Result; use chrono::{DateTime, Utc}; +use futures::channel::oneshot; use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Task}; use language::LanguageRegistry; use markdown::Markdown; use project::Project; -use std::{ops::Range, path::PathBuf, sync::Arc}; +use std::{mem, ops::Range, path::PathBuf, sync::Arc}; use ui::App; +use util::{ResultExt, debug_panic}; pub use server::AcpServer; pub use thread_view::AcpThreadView; @@ -112,14 +114,32 @@ impl MessageChunk { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Debug)] pub enum AgentThreadEntryContent { Message(Message), ReadFile { path: PathBuf, content: String }, + ToolCall(ToolCall), } +#[derive(Debug)] +pub enum ToolCall { + WaitingForConfirmation { + id: ToolCallId, + tool_name: Entity, + description: Entity, + respond_tx: oneshot::Sender, + }, + // todo! Running? + Allowed, + Rejected, +} + +/// A `ThreadEntryId` that is known to be a ToolCall #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct ThreadEntryId(usize); +pub struct ToolCallId(ThreadEntryId); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ThreadEntryId(pub u64); impl ThreadEntryId { pub fn post_inc(&mut self) -> Self { @@ -146,7 +166,7 @@ pub struct AcpThread { enum AcpThreadEvent { NewEntry, - LastEntryUpdated, + EntryUpdated(usize), } impl EventEmitter for AcpThread {} @@ -184,22 +204,26 @@ impl AcpThread { &self.entries } - pub fn push_entry(&mut self, entry: AgentThreadEntryContent, cx: &mut Context) { - self.entries.push(ThreadEntry { - id: self.next_entry_id.post_inc(), - content: entry, - }); - cx.emit(AcpThreadEvent::NewEntry) + pub fn push_entry( + &mut self, + entry: AgentThreadEntryContent, + cx: &mut Context, + ) -> ThreadEntryId { + let id = self.next_entry_id.post_inc(); + self.entries.push(ThreadEntry { id, content: entry }); + cx.emit(AcpThreadEvent::NewEntry); + id } pub fn push_assistant_chunk(&mut self, chunk: acp::MessageChunk, cx: &mut Context) { + let entries_len = self.entries.len(); if let Some(last_entry) = self.entries.last_mut() && let AgentThreadEntryContent::Message(Message { ref mut chunks, role: Role::Assistant, }) = last_entry.content { - cx.emit(AcpThreadEvent::LastEntryUpdated); + cx.emit(AcpThreadEvent::EntryUpdated(entries_len - 1)); if let ( Some(MessageChunk::Text { chunk: old_chunk }), @@ -231,6 +255,74 @@ impl AcpThread { ); } + pub fn push_tool_call( + &mut self, + title: String, + description: String, + respond_tx: oneshot::Sender, + cx: &mut Context, + ) -> ToolCallId { + let language_registry = self.project.read(cx).languages().clone(); + + let entry_id = self.push_entry( + AgentThreadEntryContent::ToolCall(ToolCall::WaitingForConfirmation { + // todo! clean up id creation + id: ToolCallId(ThreadEntryId(self.entries.len() as u64)), + tool_name: cx.new(|cx| { + Markdown::new(title.into(), Some(language_registry.clone()), None, cx) + }), + description: cx.new(|cx| { + Markdown::new( + description.into(), + Some(language_registry.clone()), + None, + cx, + ) + }), + respond_tx, + }), + cx, + ); + + ToolCallId(entry_id) + } + + pub fn authorize_tool_call(&mut self, id: ToolCallId, allowed: bool, cx: &mut Context) { + let Some(entry) = self.entry_mut(id.0) else { + return; + }; + + let AgentThreadEntryContent::ToolCall(call) = &mut entry.content else { + debug_panic!("expected ToolCall"); + return; + }; + + let new_state = if allowed { + ToolCall::Allowed + } else { + ToolCall::Rejected + }; + + let call = mem::replace(call, new_state); + + if let ToolCall::WaitingForConfirmation { respond_tx, .. } = call { + respond_tx.send(allowed).log_err(); + } else { + debug_panic!("tried to authorize an already authorized tool call"); + } + + cx.emit(AcpThreadEvent::EntryUpdated(id.0.0 as usize)); + } + + fn entry_mut(&mut self, id: ThreadEntryId) -> Option<&mut ThreadEntry> { + let entry = self.entries.get_mut(id.0 as usize); + debug_assert!( + entry.is_some(), + "We shouldn't give out ids to entries that don't exist" + ); + entry + } + pub fn send(&mut self, message: &str, cx: &mut Context) -> Task> { let agent = self.server.clone(); let id = self.id.clone(); @@ -303,11 +395,13 @@ mod tests { )); assert!( thread.entries().iter().any(|entry| { - entry.content - == AgentThreadEntryContent::ReadFile { - path: "/private/tmp/foo".into(), - content: "Lorem ipsum dolor".into(), + match &entry.content { + AgentThreadEntryContent::ReadFile { path, content } => { + path.to_string_lossy().to_string() == "/private/tmp/foo" + && content == "Lorem ipsum dolor" } + _ => false, + } }), "Thread does not contain entry. Actual: {:?}", thread.entries() diff --git a/crates/acp/src/server.rs b/crates/acp/src/server.rs index 323f6bf2d0..44b5acc3e6 100644 --- a/crates/acp/src/server.rs +++ b/crates/acp/src/server.rs @@ -1,8 +1,9 @@ -use crate::{AcpThread, AgentThreadEntryContent, ThreadEntryId, ThreadId}; +use crate::{AcpThread, AgentThreadEntryContent, ThreadEntryId, ThreadId, ToolCallId}; use agentic_coding_protocol as acp; use anyhow::{Context as _, Result}; use async_trait::async_trait; use collections::HashMap; +use futures::channel::oneshot; use gpui::{App, AppContext, AsyncApp, Context, Entity, Task, WeakEntity}; use parking_lot::Mutex; use project::Project; @@ -185,6 +186,31 @@ impl acp::Client for AcpClientDelegate { ) -> Result { todo!() } + + async fn request_tool_call( + &self, + request: acp::RequestToolCallParams, + ) -> Result { + let (tx, rx) = oneshot::channel(); + + let cx = &mut self.cx.clone(); + let entry_id = cx + .update(|cx| { + self.update_thread(&request.thread_id.into(), cx, |thread, cx| { + // todo! tools that don't require confirmation + thread.push_tool_call(request.tool_name, request.description, tx, cx) + }) + })? + .context("Failed to update thread")?; + + if dbg!(rx.await)? { + Ok(acp::RequestToolCallResponse::Allowed { + id: entry_id.into(), + }) + } else { + Ok(acp::RequestToolCallResponse::Rejected) + } + } } impl AcpServer { @@ -258,3 +284,15 @@ impl From for acp::ThreadId { acp::ThreadId(thread_id.0.to_string()) } } + +impl From for ToolCallId { + fn from(tool_call_id: acp::ToolCallId) -> Self { + Self(ThreadEntryId(tool_call_id.0.into())) + } +} + +impl From for acp::ToolCallId { + fn from(tool_call_id: ToolCallId) -> Self { + acp::ToolCallId(tool_call_id.0.0) + } +} diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs index 7ad76caabb..cddefeb964 100644 --- a/crates/acp/src/thread_view.rs +++ b/crates/acp/src/thread_view.rs @@ -13,13 +13,14 @@ use markdown::{HeadingLevelStyles, MarkdownElement, MarkdownStyle}; use project::Project; use settings::Settings as _; use theme::ThemeSettings; -use ui::Tooltip; use ui::prelude::*; +use ui::{Button, Tooltip}; use util::ResultExt; use zed_actions::agent::Chat; use crate::{ AcpServer, AcpThread, AcpThreadEvent, AgentThreadEntryContent, MessageChunk, Role, ThreadEntry, + ToolCall, ToolCallId, }; pub struct AcpThreadView { @@ -100,8 +101,8 @@ impl AcpThreadView { AcpThreadEvent::NewEntry => { this.list_state.splice(count..count, 1); } - AcpThreadEvent::LastEntryUpdated => { - this.list_state.splice(count - 1..count, 1); + AcpThreadEvent::EntryUpdated(index) => { + this.list_state.splice(*index..*index + 1, 1); } } cx.notify(); @@ -149,7 +150,7 @@ impl AcpThreadView { fn thread(&self) -> Option<&Entity> { match &self.thread_state { ThreadState::Ready { thread, .. } => Some(thread), - _ => None, + ThreadState::Loading { .. } | ThreadState::LoadError(..) => None, } } @@ -187,6 +188,16 @@ impl AcpThreadView { }); } + fn authorize_tool_call(&mut self, id: ToolCallId, allowed: bool, cx: &mut Context) { + let Some(thread) = self.thread() else { + return; + }; + thread.update(cx, |thread, cx| { + thread.authorize_tool_call(id, allowed, cx); + }); + cx.notify(); + } + fn render_entry( &self, entry: &ThreadEntry, @@ -236,6 +247,46 @@ impl AcpThreadView { .child(format!("", path.display())) .into_any() } + AgentThreadEntryContent::ToolCall(tool_call) => match tool_call { + ToolCall::WaitingForConfirmation { + id, + tool_name, + description, + .. + } => { + let id = *id; + v_flex() + .elevation_1(cx) + .child(MarkdownElement::new( + tool_name.clone(), + default_markdown_style(window, cx), + )) + .child(MarkdownElement::new( + description.clone(), + default_markdown_style(window, cx), + )) + .child( + h_flex() + .child(Button::new(("allow", id.0.0), "Allow").on_click( + cx.listener({ + move |this, _, _, cx| { + this.authorize_tool_call(id, true, cx); + } + }), + )) + .child(Button::new(("reject", id.0.0), "Reject").on_click( + cx.listener({ + move |this, _, _, cx| { + this.authorize_tool_call(id, false, cx); + } + }), + )), + ) + .into_any() + } + ToolCall::Allowed => div().child("Allowed!").into_any(), + ToolCall::Rejected => div().child("Rejected!").into_any(), + }, } } } diff --git a/crates/ui/src/traits/styled_ext.rs b/crates/ui/src/traits/styled_ext.rs index 63926070c8..cf452a2826 100644 --- a/crates/ui/src/traits/styled_ext.rs +++ b/crates/ui/src/traits/styled_ext.rs @@ -39,7 +39,7 @@ pub trait StyledExt: Styled + Sized { /// Sets `bg()`, `rounded_lg()`, `border()`, `border_color()`, `shadow()` /// /// Example Elements: Title Bar, Panel, Tab Bar, Editor - fn elevation_1(self, cx: &mut App) -> Self { + fn elevation_1(self, cx: &App) -> Self { elevated(self, cx, ElevationIndex::Surface) } From 8137b3318f1201b6f1a5d7898cd692fd8afae231 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 1 Jul 2025 21:37:31 -0300 Subject: [PATCH 37/54] Remove ReadFile entry and test tool call --- crates/acp/src/acp.rs | 146 ++++++++++++++++++++++++++++------ crates/acp/src/server.rs | 15 +--- crates/acp/src/thread_view.rs | 6 -- 3 files changed, 123 insertions(+), 44 deletions(-) diff --git a/crates/acp/src/acp.rs b/crates/acp/src/acp.rs index b28e204c89..8804d0cfe2 100644 --- a/crates/acp/src/acp.rs +++ b/crates/acp/src/acp.rs @@ -117,7 +117,6 @@ impl MessageChunk { #[derive(Debug)] pub enum AgentThreadEntryContent { Message(Message), - ReadFile { path: PathBuf, content: String }, ToolCall(ToolCall), } @@ -343,15 +342,17 @@ impl AcpThread { #[cfg(test)] mod tests { use super::*; + use futures::{FutureExt as _, channel::mpsc, select}; use gpui::{AsyncApp, TestAppContext}; use project::FakeFs; use serde_json::json; use settings::SettingsStore; - use std::{env, path::Path, process::Stdio}; + use smol::stream::StreamExt; + use std::{env, path::Path, process::Stdio, time::Duration}; use util::path; fn init_test(cx: &mut TestAppContext) { - env_logger::init(); + env_logger::try_init().ok(); cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); @@ -361,7 +362,41 @@ mod tests { } #[gpui::test] - async fn test_gemini(cx: &mut TestAppContext) { + async fn test_gemini_basic(cx: &mut TestAppContext) { + init_test(cx); + + cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let server = gemini_acp_server(project.clone(), cx.to_async()).unwrap(); + let thread = server.create_thread(&mut cx.to_async()).await.unwrap(); + thread + .update(cx, |thread, cx| thread.send("Hello from Zed!", cx)) + .await + .unwrap(); + + thread.read_with(cx, |thread, _| { + assert_eq!(thread.entries.len(), 2); + assert!(matches!( + thread.entries[0].content, + AgentThreadEntryContent::Message(Message { + role: Role::User, + .. + }) + )); + assert!(matches!( + thread.entries[1].content, + AgentThreadEntryContent::Message(Message { + role: Role::Assistant, + .. + }) + )); + }); + } + + #[gpui::test] + async fn test_gemini_tool_call(cx: &mut TestAppContext) { init_test(cx); cx.executor().allow_parking(); @@ -375,17 +410,52 @@ mod tests { let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await; let server = gemini_acp_server(project.clone(), cx.to_async()).unwrap(); let thread = server.create_thread(&mut cx.to_async()).await.unwrap(); - thread - .update(cx, |thread, cx| { - thread.send( - "Read the '/private/tmp/foo' file and output all of its contents.", - cx, - ) - }) - .await - .unwrap(); + let full_turn = thread.update(cx, |thread, cx| { + thread.send( + "Read the '/private/tmp/foo' file and tell me what you see.", + cx, + ) + }); + + run_until_tool_call(&thread, cx).await; + + let tool_call_id = thread.read_with(cx, |thread, cx| { + let AgentThreadEntryContent::ToolCall(ToolCall::WaitingForConfirmation { + id, + tool_name, + description, + .. + }) = &thread.entries().last().unwrap().content + else { + panic!(); + }; + + tool_name.read_with(cx, |md, _cx| { + assert_eq!(md.source(), "read_file"); + }); + + description.read_with(cx, |md, _cx| { + assert!( + md.source().contains("foo"), + "Expected description to contain 'foo', but got {}", + md.source() + ); + }); + *id + }); + + thread.update(cx, |thread, cx| { + thread.authorize_tool_call(tool_call_id, true, cx); + assert!(matches!( + thread.entries().last().unwrap().content, + AgentThreadEntryContent::ToolCall(ToolCall::Allowed) + )); + }); + + full_turn.await.unwrap(); thread.read_with(cx, |thread, _| { + assert!(thread.entries.len() >= 3, "{:?}", &thread.entries); assert!(matches!( thread.entries[0].content, AgentThreadEntryContent::Message(Message { @@ -393,22 +463,46 @@ mod tests { .. }) )); - assert!( - thread.entries().iter().any(|entry| { - match &entry.content { - AgentThreadEntryContent::ReadFile { path, content } => { - path.to_string_lossy().to_string() == "/private/tmp/foo" - && content == "Lorem ipsum dolor" - } - _ => false, - } - }), - "Thread does not contain entry. Actual: {:?}", - thread.entries() - ); + assert!(matches!( + thread.entries[1].content, + AgentThreadEntryContent::ToolCall(ToolCall::Allowed) + )); + assert!(matches!( + thread.entries[2].content, + AgentThreadEntryContent::Message(Message { + role: Role::Assistant, + .. + }) + )); }); } + async fn run_until_tool_call(thread: &Entity, cx: &mut TestAppContext) { + let (mut tx, mut rx) = mpsc::channel(1); + + let subscription = cx.update(|cx| { + cx.subscribe(thread, move |thread, _, cx| { + if thread + .read(cx) + .entries + .iter() + .any(|e| matches!(e.content, AgentThreadEntryContent::ToolCall(_))) + { + tx.try_send(()).unwrap(); + } + }) + }); + + select! { + _ = cx.executor().timer(Duration::from_secs(5)).fuse() => { + panic!("Timeout waiting for tool call") + } + _ = rx.next().fuse() => { + drop(subscription); + } + } + } + pub fn gemini_acp_server(project: Entity, mut cx: AsyncApp) -> Result> { let cli_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../gemini-cli/packages/cli"); diff --git a/crates/acp/src/server.rs b/crates/acp/src/server.rs index 44b5acc3e6..6bb198b87a 100644 --- a/crates/acp/src/server.rs +++ b/crates/acp/src/server.rs @@ -1,4 +1,4 @@ -use crate::{AcpThread, AgentThreadEntryContent, ThreadEntryId, ThreadId, ToolCallId}; +use crate::{AcpThread, ThreadEntryId, ThreadId, ToolCallId}; use agentic_coding_protocol as acp; use anyhow::{Context as _, Result}; use async_trait::async_trait; @@ -107,7 +107,7 @@ impl acp::Client for AcpClientDelegate { })?? .await?; - buffer.update(cx, |buffer, cx| { + buffer.update(cx, |buffer, _cx| { let start = language::Point::new(request.line_offset.unwrap_or(0), 0); let end = match request.line_limit { None => buffer.max_point(), @@ -115,15 +115,6 @@ impl acp::Client for AcpClientDelegate { }; let content: String = buffer.text_for_range(start..end).collect(); - self.update_thread(&request.thread_id.into(), cx, |thread, cx| { - thread.push_entry( - AgentThreadEntryContent::ReadFile { - path: request.path.clone(), - content: content.clone(), - }, - cx, - ); - }); acp::ReadTextFileResponse { content, @@ -203,7 +194,7 @@ impl acp::Client for AcpClientDelegate { })? .context("Failed to update thread")?; - if dbg!(rx.await)? { + if rx.await? { Ok(acp::RequestToolCallResponse::Allowed { id: entry_id.into(), }) diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs index cddefeb964..e07b2bbc7a 100644 --- a/crates/acp/src/thread_view.rs +++ b/crates/acp/src/thread_view.rs @@ -241,12 +241,6 @@ impl AcpThreadView { .into_any(), } } - AgentThreadEntryContent::ReadFile { path, content: _ } => { - // todo! - div() - .child(format!("", path.display())) - .into_any() - } AgentThreadEntryContent::ToolCall(tool_call) => match tool_call { ToolCall::WaitingForConfirmation { id, From d9fd8d5eeef025693a3eb84dfc396a05e3a92aeb Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 1 Jul 2025 21:50:14 -0300 Subject: [PATCH 38/54] Improve spacing --- crates/acp/src/acp.rs | 6 ++++++ crates/acp/src/server.rs | 2 +- crates/acp/src/thread_view.rs | 38 ++++++++++++++++++----------------- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/crates/acp/src/acp.rs b/crates/acp/src/acp.rs index 8804d0cfe2..ac9c1954bb 100644 --- a/crates/acp/src/acp.rs +++ b/crates/acp/src/acp.rs @@ -137,6 +137,12 @@ pub enum ToolCall { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct ToolCallId(ThreadEntryId); +impl ToolCallId { + pub fn as_u64(&self) -> u64 { + self.0.0 + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct ThreadEntryId(pub u64); diff --git a/crates/acp/src/server.rs b/crates/acp/src/server.rs index 6bb198b87a..b485e6a346 100644 --- a/crates/acp/src/server.rs +++ b/crates/acp/src/server.rs @@ -284,6 +284,6 @@ impl From for ToolCallId { impl From for acp::ToolCallId { fn from(tool_call_id: ToolCallId) -> Self { - acp::ToolCallId(tool_call_id.0.0) + acp::ToolCallId(tool_call_id.as_u64()) } } diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs index e07b2bbc7a..32aa14e45e 100644 --- a/crates/acp/src/thread_view.rs +++ b/crates/acp/src/thread_view.rs @@ -223,20 +223,24 @@ impl AcpThreadView { match message.role { Role::User => div() - .text_xs() - .m_1() .p_2() - .bg(cx.theme().colors().editor_background) - .rounded_lg() - .shadow_md() - .border_1() - .border_color(cx.theme().colors().border) - .child(message_body) + .pt_4() + .child( + div() + .text_xs() + .p_2() + .bg(cx.theme().colors().editor_background) + .rounded_lg() + .shadow_md() + .border_1() + .border_color(cx.theme().colors().border) + .child(message_body), + ) .into_any(), Role::Assistant => div() .text_ui(cx) - .px_2() - .py_4() + .p_4() + .pt_2() .child(message_body) .into_any(), } @@ -261,14 +265,14 @@ impl AcpThreadView { )) .child( h_flex() - .child(Button::new(("allow", id.0.0), "Allow").on_click( + .child(Button::new(("allow", id.as_u64()), "Allow").on_click( cx.listener({ move |this, _, _, cx| { this.authorize_tool_call(id, true, cx); } }), )) - .child(Button::new(("reject", id.0.0), "Reject").on_click( + .child(Button::new(("reject", id.as_u64()), "Reject").on_click( cx.listener({ move |this, _, _, cx| { this.authorize_tool_call(id, false, cx); @@ -307,12 +311,10 @@ impl Render for AcpThreadView { ThreadState::LoadError(e) => div() .p_2() .child(Label::new(format!("Failed to load {e}")).into_any_element()), - ThreadState::Ready { .. } => div() - .child( - list(self.list_state.clone()) - .with_sizing_behavior(gpui::ListSizingBehavior::Infer), - ) - .p_2(), + ThreadState::Ready { .. } => div().h_full().child( + list(self.list_state.clone()) + .with_sizing_behavior(gpui::ListSizingBehavior::Infer), + ), }) .when(self.send_task.is_some(), |this| { this.child( From f2f32fb3bd3bc7c1de6f8b513ed9dc0cdfd4c3c3 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 1 Jul 2025 23:13:56 -0300 Subject: [PATCH 39/54] Proper allow/reject UI --- crates/acp/src/acp.rs | 60 +++++++++------ crates/acp/src/thread_view.rs | 136 +++++++++++++++++++++++----------- 2 files changed, 130 insertions(+), 66 deletions(-) diff --git a/crates/acp/src/acp.rs b/crates/acp/src/acp.rs index ac9c1954bb..cf0f81ddd2 100644 --- a/crates/acp/src/acp.rs +++ b/crates/acp/src/acp.rs @@ -121,10 +121,15 @@ pub enum AgentThreadEntryContent { } #[derive(Debug)] -pub enum ToolCall { +pub struct ToolCall { + id: ToolCallId, + tool_name: Entity, + status: ToolCallStatus, +} + +#[derive(Debug)] +pub enum ToolCallStatus { WaitingForConfirmation { - id: ToolCallId, - tool_name: Entity, description: Entity, respond_tx: oneshot::Sender, }, @@ -270,21 +275,23 @@ impl AcpThread { let language_registry = self.project.read(cx).languages().clone(); let entry_id = self.push_entry( - AgentThreadEntryContent::ToolCall(ToolCall::WaitingForConfirmation { + AgentThreadEntryContent::ToolCall(ToolCall { // todo! clean up id creation id: ToolCallId(ThreadEntryId(self.entries.len() as u64)), tool_name: cx.new(|cx| { Markdown::new(title.into(), Some(language_registry.clone()), None, cx) }), - description: cx.new(|cx| { - Markdown::new( - description.into(), - Some(language_registry.clone()), - None, - cx, - ) - }), - respond_tx, + status: ToolCallStatus::WaitingForConfirmation { + description: cx.new(|cx| { + Markdown::new( + description.into(), + Some(language_registry.clone()), + None, + cx, + ) + }), + respond_tx, + }, }), cx, ); @@ -302,21 +309,21 @@ impl AcpThread { return; }; - let new_state = if allowed { - ToolCall::Allowed + let new_status = if allowed { + ToolCallStatus::Allowed } else { - ToolCall::Rejected + ToolCallStatus::Rejected }; - let call = mem::replace(call, new_state); + let curr_status = mem::replace(&mut call.status, new_status); - if let ToolCall::WaitingForConfirmation { respond_tx, .. } = call { + if let ToolCallStatus::WaitingForConfirmation { respond_tx, .. } = curr_status { respond_tx.send(allowed).log_err(); } else { debug_panic!("tried to authorize an already authorized tool call"); } - cx.emit(AcpThreadEvent::EntryUpdated(id.0.0 as usize)); + cx.emit(AcpThreadEvent::EntryUpdated(id.as_u64() as usize)); } fn entry_mut(&mut self, id: ThreadEntryId) -> Option<&mut ThreadEntry> { @@ -426,11 +433,10 @@ mod tests { run_until_tool_call(&thread, cx).await; let tool_call_id = thread.read_with(cx, |thread, cx| { - let AgentThreadEntryContent::ToolCall(ToolCall::WaitingForConfirmation { + let AgentThreadEntryContent::ToolCall(ToolCall { id, tool_name, - description, - .. + status: ToolCallStatus::WaitingForConfirmation { description, .. }, }) = &thread.entries().last().unwrap().content else { panic!(); @@ -454,7 +460,10 @@ mod tests { thread.authorize_tool_call(tool_call_id, true, cx); assert!(matches!( thread.entries().last().unwrap().content, - AgentThreadEntryContent::ToolCall(ToolCall::Allowed) + AgentThreadEntryContent::ToolCall(ToolCall { + status: ToolCallStatus::Allowed, + .. + }) )); }); @@ -471,7 +480,10 @@ mod tests { )); assert!(matches!( thread.entries[1].content, - AgentThreadEntryContent::ToolCall(ToolCall::Allowed) + AgentThreadEntryContent::ToolCall(ToolCall { + status: ToolCallStatus::Allowed, + .. + }) )); assert!(matches!( thread.entries[2].content, diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs index 32aa14e45e..70458acd3a 100644 --- a/crates/acp/src/thread_view.rs +++ b/crates/acp/src/thread_view.rs @@ -20,7 +20,7 @@ use zed_actions::agent::Chat; use crate::{ AcpServer, AcpThread, AcpThreadEvent, AgentThreadEntryContent, MessageChunk, Role, ThreadEntry, - ToolCall, ToolCallId, + ToolCall, ToolCallId, ToolCallStatus, }; pub struct AcpThreadView { @@ -224,7 +224,7 @@ impl AcpThreadView { match message.role { Role::User => div() .p_2() - .pt_4() + .pt_5() .child( div() .text_xs() @@ -245,48 +245,100 @@ impl AcpThreadView { .into_any(), } } - AgentThreadEntryContent::ToolCall(tool_call) => match tool_call { - ToolCall::WaitingForConfirmation { - id, - tool_name, - description, - .. - } => { - let id = *id; - v_flex() - .elevation_1(cx) - .child(MarkdownElement::new( - tool_name.clone(), - default_markdown_style(window, cx), - )) - .child(MarkdownElement::new( - description.clone(), - default_markdown_style(window, cx), - )) - .child( - h_flex() - .child(Button::new(("allow", id.as_u64()), "Allow").on_click( - cx.listener({ - move |this, _, _, cx| { - this.authorize_tool_call(id, true, cx); - } - }), - )) - .child(Button::new(("reject", id.as_u64()), "Reject").on_click( - cx.listener({ - move |this, _, _, cx| { - this.authorize_tool_call(id, false, cx); - } - }), - )), - ) - .into_any() - } - ToolCall::Allowed => div().child("Allowed!").into_any(), - ToolCall::Rejected => div().child("Rejected!").into_any(), - }, + AgentThreadEntryContent::ToolCall(tool_call) => div() + .px_2() + .py_4() + .child(self.render_tool_call(tool_call, window, cx)) + .into_any(), } } + + fn render_tool_call(&self, tool_call: &ToolCall, window: &Window, cx: &Context) -> Div { + let status_icon = match &tool_call.status { + ToolCallStatus::WaitingForConfirmation { .. } => Empty.into_element().into_any(), + ToolCallStatus::Allowed => Icon::new(IconName::Check) + .color(Color::Success) + .size(IconSize::Small) + .into_any_element(), + ToolCallStatus::Rejected => Icon::new(IconName::X) + .color(Color::Error) + .size(IconSize::Small) + .into_any_element(), + }; + + let content = match &tool_call.status { + ToolCallStatus::WaitingForConfirmation { description, .. } => v_flex() + .border_color(cx.theme().colors().border) + .border_t_1() + .px_2() + .py_1p5() + .child(MarkdownElement::new( + description.clone(), + default_markdown_style(window, cx), + )) + .child( + h_flex() + .justify_end() + .gap_1() + .child( + Button::new(("allow", tool_call.id.as_u64()), "Allow") + .icon(IconName::Check) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Success) + .on_click(cx.listener({ + let id = tool_call.id; + move |this, _, _, cx| { + this.authorize_tool_call(id, true, cx); + } + })), + ) + .child( + Button::new(("reject", tool_call.id.as_u64()), "Reject") + .icon(IconName::X) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Error) + .on_click(cx.listener({ + let id = tool_call.id; + move |this, _, _, cx| { + this.authorize_tool_call(id, false, cx); + } + })), + ), + ) + .into_any() + .into(), + ToolCallStatus::Allowed => None, + ToolCallStatus::Rejected => None, + }; + + v_flex() + .text_xs() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().editor_background) + .child( + h_flex() + .px_2() + .py_1p5() + .w_full() + .gap_1p5() + .child( + Icon::new(IconName::Cog) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(MarkdownElement::new( + tool_call.tool_name.clone(), + default_markdown_style(window, cx), + )) + .child(div().w_full()) + .child(status_icon), + ) + .children(content) + } } impl Focusable for AcpThreadView { From 825aecfd2876c93b14148c9e4b0e05697253c789 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 1 Jul 2025 23:27:12 -0300 Subject: [PATCH 40/54] Fix spacing and list scrolling --- crates/acp/src/thread_view.rs | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs index 70458acd3a..a31b72de03 100644 --- a/crates/acp/src/thread_view.rs +++ b/crates/acp/src/thread_view.rs @@ -124,8 +124,8 @@ impl AcpThreadView { let list_state = ListState::new( 0, - gpui::ListAlignment::Top, - px(1000.0), + gpui::ListAlignment::Bottom, + px(2048.0), cx.processor({ move |this: &mut Self, item: usize, window, cx| { let Some(entry) = this @@ -228,7 +228,7 @@ impl AcpThreadView { .child( div() .text_xs() - .p_2() + .p_3() .bg(cx.theme().colors().editor_background) .rounded_lg() .shadow_md() @@ -239,7 +239,7 @@ impl AcpThreadView { .into_any(), Role::Assistant => div() .text_ui(cx) - .p_4() + .p_5() .pt_2() .child(message_body) .into_any(), @@ -356,16 +356,22 @@ impl Render for AcpThreadView { v_flex() .key_context("MessageEditor") .on_action(cx.listener(Self::chat)) + .h_full() .child(match &self.thread_state { - ThreadState::Loading { .. } => { - div().p_2().child(Label::new("Connecting to Gemini...")) - } + ThreadState::Loading { .. } => v_flex() + .p_2() + .flex_1() + .justify_end() + .child(Label::new("Connecting to Gemini...")), ThreadState::LoadError(e) => div() .p_2() + .flex_1() + .justify_end() .child(Label::new(format!("Failed to load {e}")).into_any_element()), - ThreadState::Ready { .. } => div().h_full().child( + ThreadState::Ready { .. } => v_flex().flex_1().pb_4().child( list(self.list_state.clone()) - .with_sizing_behavior(gpui::ListSizingBehavior::Infer), + .with_sizing_behavior(gpui::ListSizingBehavior::Auto) + .flex_grow(), ), }) .when(self.send_task.is_some(), |this| { From 7c992adfe1c97bad93c28bf64c8f3a96a60150e8 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 1 Jul 2025 23:35:29 -0300 Subject: [PATCH 41/54] Improve spacing even more --- crates/acp/src/thread_view.rs | 47 ++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs index a31b72de03..2cd971d91a 100644 --- a/crates/acp/src/thread_view.rs +++ b/crates/acp/src/thread_view.rs @@ -74,7 +74,7 @@ impl AcpThreadView { let mut editor = Editor::new( editor::EditorMode::AutoHeight { - min_lines: 5, + min_lines: 4, max_lines: None, }, buffer, @@ -368,34 +368,35 @@ impl Render for AcpThreadView { .flex_1() .justify_end() .child(Label::new(format!("Failed to load {e}")).into_any_element()), - ThreadState::Ready { .. } => v_flex().flex_1().pb_4().child( - list(self.list_state.clone()) - .with_sizing_behavior(gpui::ListSizingBehavior::Auto) - .flex_grow(), - ), - }) - .when(self.send_task.is_some(), |this| { - this.child( - div().p_2().child( - Label::new("Generating...") - .color(Color::Muted) - .size(LabelSize::Small), + ThreadState::Ready { .. } => v_flex() + .flex_1() + .gap_2() + .pb_2() + .child( + list(self.list_state.clone()) + .with_sizing_behavior(gpui::ListSizingBehavior::Auto) + .flex_grow(), + ) + .child( + div() + .px_3() + .child( + Label::new("Generating...") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .when(self.send_task.is_none(), |this| this.invisible()), ), - ) }) .child( - div() + v_flex() .bg(cx.theme().colors().editor_background) .border_t_1() .border_color(cx.theme().colors().border) .p_2() - .child(self.message_editor.clone()), - ) - .child( - h_flex() - .p_2() - .justify_end() - .child(if self.send_task.is_some() { + .gap_2() + .child(self.message_editor.clone()) + .child(h_flex().justify_end().child(if self.send_task.is_some() { IconButton::new("stop-generation", IconName::StopFilled) .icon_color(Color::Error) .style(ButtonStyle::Tinted(ui::TintColor::Error)) @@ -428,7 +429,7 @@ impl Render for AcpThreadView { .when(is_editor_empty, |button| { button.tooltip(Tooltip::text("Type a message to submit")) }) - }), + })), ) } } From 780db30e0b7923e1205474ff59df9ac4bdf7b9af Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 1 Jul 2025 23:48:09 -0300 Subject: [PATCH 42/54] Handle waiting for tool confirmation in UI --- crates/acp/src/acp.rs | 17 +++++++++++++++++ crates/acp/src/thread_view.rs | 24 +++++++++++++----------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/crates/acp/src/acp.rs b/crates/acp/src/acp.rs index cf0f81ddd2..1fc225ee7c 100644 --- a/crates/acp/src/acp.rs +++ b/crates/acp/src/acp.rs @@ -335,6 +335,23 @@ impl AcpThread { entry } + /// Returns true if the last turn is awaiting tool authorization + pub fn waiting_for_tool_confirmation(&self) -> bool { + for entry in self.entries.iter().rev() { + match &entry.content { + AgentThreadEntryContent::ToolCall(call) => match call.status { + ToolCallStatus::WaitingForConfirmation { .. } => return true, + ToolCallStatus::Allowed | ToolCallStatus::Rejected => continue, + }, + AgentThreadEntryContent::Message(_) => { + // Reached the beginning of the turn + return false; + } + } + } + false + } + pub fn send(&mut self, message: &str, cx: &mut Context) -> Task> { let agent = self.server.clone(); let id = self.id.clone(); diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs index 2cd971d91a..a6390d62ac 100644 --- a/crates/acp/src/thread_view.rs +++ b/crates/acp/src/thread_view.rs @@ -368,7 +368,7 @@ impl Render for AcpThreadView { .flex_1() .justify_end() .child(Label::new(format!("Failed to load {e}")).into_any_element()), - ThreadState::Ready { .. } => v_flex() + ThreadState::Ready { thread, .. } => v_flex() .flex_1() .gap_2() .pb_2() @@ -377,16 +377,18 @@ impl Render for AcpThreadView { .with_sizing_behavior(gpui::ListSizingBehavior::Auto) .flex_grow(), ) - .child( - div() - .px_3() - .child( - Label::new("Generating...") - .color(Color::Muted) - .size(LabelSize::Small), - ) - .when(self.send_task.is_none(), |this| this.invisible()), - ), + .child(div().px_3().children(if self.send_task.is_none() { + None + } else { + Label::new(if thread.read(cx).waiting_for_tool_confirmation() { + "Waiting for tool confirmation" + } else { + "Generating..." + }) + .color(Color::Muted) + .size(LabelSize::Small) + .into() + })), }) .child( v_flex() From fde15a5a682634ef2e6f445e57972e83a124bb5d Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 2 Jul 2025 00:47:28 -0300 Subject: [PATCH 43/54] Update tool calls via ACP --- crates/acp/src/acp.rs | 56 +++++++++++++++++++++++++++++++---- crates/acp/src/server.rs | 21 +++++++++++++ crates/acp/src/thread_view.rs | 43 +++++++++++++++++++++++---- 3 files changed, 109 insertions(+), 11 deletions(-) diff --git a/crates/acp/src/acp.rs b/crates/acp/src/acp.rs index 1fc225ee7c..403915ec3c 100644 --- a/crates/acp/src/acp.rs +++ b/crates/acp/src/acp.rs @@ -2,7 +2,7 @@ mod server; mod thread_view; use agentic_coding_protocol::{self as acp, Role}; -use anyhow::Result; +use anyhow::{Context as _, Result}; use chrono::{DateTime, Utc}; use futures::channel::oneshot; use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Task}; @@ -134,7 +134,11 @@ pub enum ToolCallStatus { respond_tx: oneshot::Sender, }, // todo! Running? - Allowed, + Allowed { + // todo! should this be variants in crate::ToolCallStatus instead? + status: acp::ToolCallStatus, + content: Option>, + }, Rejected, } @@ -310,7 +314,10 @@ impl AcpThread { }; let new_status = if allowed { - ToolCallStatus::Allowed + ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Running, + content: None, + } } else { ToolCallStatus::Rejected }; @@ -326,6 +333,43 @@ impl AcpThread { cx.emit(AcpThreadEvent::EntryUpdated(id.as_u64() as usize)); } + pub fn update_tool_call( + &mut self, + id: ToolCallId, + new_status: acp::ToolCallStatus, + new_content: Option, + cx: &mut Context, + ) -> Result<()> { + let language_registry = self.project.read(cx).languages().clone(); + let entry = self.entry_mut(id.0).context("Entry not found")?; + + 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) + }) + }); + + *status = new_status; + } + ToolCallStatus::WaitingForConfirmation { .. } => { + anyhow::bail!("Tool call hasn't been authorized yet") + } + ToolCallStatus::Rejected => { + anyhow::bail!("Tool call was rejected and therefore can't be updated") + } + }, + _ => anyhow::bail!("Entry is not a tool call"), + } + + cx.emit(AcpThreadEvent::EntryUpdated(id.as_u64() as usize)); + Ok(()) + } + fn entry_mut(&mut self, id: ThreadEntryId) -> Option<&mut ThreadEntry> { let entry = self.entries.get_mut(id.0 as usize); debug_assert!( @@ -341,7 +385,7 @@ impl AcpThread { match &entry.content { AgentThreadEntryContent::ToolCall(call) => match call.status { ToolCallStatus::WaitingForConfirmation { .. } => return true, - ToolCallStatus::Allowed | ToolCallStatus::Rejected => continue, + ToolCallStatus::Allowed { .. } | ToolCallStatus::Rejected => continue, }, AgentThreadEntryContent::Message(_) => { // Reached the beginning of the turn @@ -478,7 +522,7 @@ mod tests { assert!(matches!( thread.entries().last().unwrap().content, AgentThreadEntryContent::ToolCall(ToolCall { - status: ToolCallStatus::Allowed, + status: ToolCallStatus::Allowed { .. }, .. }) )); @@ -498,7 +542,7 @@ mod tests { assert!(matches!( thread.entries[1].content, AgentThreadEntryContent::ToolCall(ToolCall { - status: ToolCallStatus::Allowed, + status: ToolCallStatus::Allowed { .. }, .. }) )); diff --git a/crates/acp/src/server.rs b/crates/acp/src/server.rs index b485e6a346..f64cde1cfd 100644 --- a/crates/acp/src/server.rs +++ b/crates/acp/src/server.rs @@ -202,6 +202,27 @@ impl acp::Client for AcpClientDelegate { Ok(acp::RequestToolCallResponse::Rejected) } } + + async fn update_tool_call( + &self, + request: acp::UpdateToolCallParams, + ) -> Result { + let cx = &mut self.cx.clone(); + + cx.update(|cx| { + self.update_thread(&request.thread_id.into(), cx, |thread, cx| { + thread.update_tool_call( + request.tool_call_id.into(), + request.status, + request.content, + cx, + ) + }) + })? + .context("Failed to update thread")??; + + Ok(acp::UpdateToolCallResponse) + } } impl AcpServer { diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs index a6390d62ac..f32218a3fe 100644 --- a/crates/acp/src/thread_view.rs +++ b/crates/acp/src/thread_view.rs @@ -1,11 +1,14 @@ use std::path::Path; use std::rc::Rc; +use std::time::Duration; +use agentic_coding_protocol::{self as acp}; use anyhow::Result; use editor::{Editor, MultiBuffer}; use gpui::{ - App, EdgesRefinement, Empty, Entity, Focusable, ListState, SharedString, StyleRefinement, - Subscription, TextStyleRefinement, UnderlineStyle, Window, div, list, prelude::*, + Animation, AnimationExt, App, EdgesRefinement, Empty, Entity, Focusable, ListState, + SharedString, StyleRefinement, Subscription, TextStyleRefinement, Transformation, + UnderlineStyle, Window, div, list, percentage, prelude::*, }; use gpui::{FocusHandle, Task}; use language::Buffer; @@ -256,11 +259,30 @@ impl AcpThreadView { fn render_tool_call(&self, tool_call: &ToolCall, window: &Window, cx: &Context) -> Div { let status_icon = match &tool_call.status { ToolCallStatus::WaitingForConfirmation { .. } => Empty.into_element().into_any(), - ToolCallStatus::Allowed => Icon::new(IconName::Check) + ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Running, + .. + } => Icon::new(IconName::ArrowCircle) + .color(Color::Success) + .size(IconSize::Small) + .with_animation( + "running", + Animation::new(Duration::from_secs(2)).repeat(), + |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), + ) + .into_any_element(), + ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Finished, + .. + } => Icon::new(IconName::Check) .color(Color::Success) .size(IconSize::Small) .into_any_element(), - ToolCallStatus::Rejected => Icon::new(IconName::X) + ToolCallStatus::Rejected + | ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Error, + .. + } => Icon::new(IconName::X) .color(Color::Error) .size(IconSize::Small) .into_any_element(), @@ -309,7 +331,18 @@ impl AcpThreadView { ) .into_any() .into(), - ToolCallStatus::Allowed => None, + ToolCallStatus::Allowed { content, .. } => content.clone().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), + )) + .into_any_element() + }), ToolCallStatus::Rejected => None, }; From 28d992487d08133bab326d56a32eab54467e5002 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 2 Jul 2025 00:58:05 -0300 Subject: [PATCH 44/54] Better temporary title --- crates/acp/src/server.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/acp/src/server.rs b/crates/acp/src/server.rs index f64cde1cfd..4e1214b635 100644 --- a/crates/acp/src/server.rs +++ b/crates/acp/src/server.rs @@ -258,7 +258,8 @@ impl AcpServer { let thread_id: ThreadId = response.thread_id.into(); let server = self.clone(); let thread = cx.new(|_| AcpThread { - title: "The agent2 thread".into(), + // todo! + title: "ACP Thread".into(), id: thread_id.clone(), next_entry_id: ThreadEntryId(0), entries: Vec::default(), From ab0b16939df7bcd61040271e0e4f6ce2974c7f74 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 2 Jul 2025 11:32:03 +0200 Subject: [PATCH 45/54] Update tool call confirmation --- crates/acp/src/server.rs | 51 +++++++++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/crates/acp/src/server.rs b/crates/acp/src/server.rs index 4e1214b635..d8b4fa115e 100644 --- a/crates/acp/src/server.rs +++ b/crates/acp/src/server.rs @@ -178,29 +178,58 @@ impl acp::Client for AcpClientDelegate { todo!() } - async fn request_tool_call( + async fn request_tool_call_confirmation( &self, - request: acp::RequestToolCallParams, - ) -> Result { + request: acp::RequestToolCallConfirmationParams, + ) -> Result { let (tx, rx) = oneshot::channel(); let cx = &mut self.cx.clone(); let entry_id = cx .update(|cx| { self.update_thread(&request.thread_id.into(), cx, |thread, cx| { + // todo! Should we pass through richer data than a description? + let description = match request.confirmation { + acp::ToolCallConfirmation::Edit { + file_name, + file_diff, + } => { + // todo! Nicer syntax/presentation based on file extension? Better way to communicate diff? + format!("Edit file `{file_name}` with diff:\n```\n{file_diff}\n```") + } + acp::ToolCallConfirmation::Execute { + command, + root_command: _, + } => { + format!("Execute command `{command}`") + } + acp::ToolCallConfirmation::Mcp { + server_name, + tool_name: _, + tool_display_name, + } => { + format!("MCP: {server_name} - {tool_display_name}") + } + acp::ToolCallConfirmation::Info { prompt, urls } => { + format!("Info: {prompt}\n{urls:?}") + } + }; // todo! tools that don't require confirmation - thread.push_tool_call(request.tool_name, request.description, tx, cx) + thread.push_tool_call(request.title, description, tx, cx) }) })? .context("Failed to update thread")?; - if rx.await? { - Ok(acp::RequestToolCallResponse::Allowed { - id: entry_id.into(), - }) + let outcome = if rx.await? { + // todo! Handle other outcomes + acp::ToolCallConfirmationOutcome::Allow } else { - Ok(acp::RequestToolCallResponse::Rejected) - } + acp::ToolCallConfirmationOutcome::Reject + }; + Ok(acp::RequestToolCallConfirmationResponse { + id: entry_id.into(), + outcome, + }) } async fn update_tool_call( @@ -300,7 +329,7 @@ impl From for acp::ThreadId { impl From for ToolCallId { fn from(tool_call_id: acp::ToolCallId) -> Self { - Self(ThreadEntryId(tool_call_id.0.into())) + Self(ThreadEntryId(tool_call_id.0)) } } From 7a3105b0c6b780659d2fd916f83bb62e198d55d0 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 2 Jul 2025 12:03:35 +0200 Subject: [PATCH 46/54] Wire up push_tool_call Co-authored-by: Antonio Scandurra --- crates/acp/src/acp.rs | 31 ++++++++++++++++++++----------- crates/acp/src/server.rs | 21 +++++++++++++++++++-- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/crates/acp/src/acp.rs b/crates/acp/src/acp.rs index 403915ec3c..652016eade 100644 --- a/crates/acp/src/acp.rs +++ b/crates/acp/src/acp.rs @@ -273,11 +273,20 @@ impl AcpThread { &mut self, title: String, description: String, - respond_tx: oneshot::Sender, + confirmation_tx: Option>, cx: &mut Context, ) -> ToolCallId { let language_registry = self.project.read(cx).languages().clone(); + let description = cx.new(|cx| { + Markdown::new( + description.into(), + Some(language_registry.clone()), + None, + cx, + ) + }); + let entry_id = self.push_entry( AgentThreadEntryContent::ToolCall(ToolCall { // todo! clean up id creation @@ -285,16 +294,16 @@ impl AcpThread { tool_name: cx.new(|cx| { Markdown::new(title.into(), Some(language_registry.clone()), None, cx) }), - status: ToolCallStatus::WaitingForConfirmation { - description: cx.new(|cx| { - Markdown::new( - description.into(), - Some(language_registry.clone()), - None, - cx, - ) - }), - respond_tx, + status: if let Some(respond_tx) = confirmation_tx { + ToolCallStatus::WaitingForConfirmation { + description, + respond_tx, + } + } else { + ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Running, + content: Some(description), + } }, }), cx, diff --git a/crates/acp/src/server.rs b/crates/acp/src/server.rs index d8b4fa115e..213588fc53 100644 --- a/crates/acp/src/server.rs +++ b/crates/acp/src/server.rs @@ -214,8 +214,7 @@ impl acp::Client for AcpClientDelegate { format!("Info: {prompt}\n{urls:?}") } }; - // todo! tools that don't require confirmation - thread.push_tool_call(request.title, description, tx, cx) + thread.push_tool_call(request.title, description, Some(tx), cx) }) })? .context("Failed to update thread")?; @@ -232,6 +231,24 @@ impl acp::Client for AcpClientDelegate { }) } + async fn push_tool_call( + &self, + request: acp::PushToolCallParams, + ) -> Result { + let cx = &mut self.cx.clone(); + let entry_id = cx + .update(|cx| { + self.update_thread(&request.thread_id.into(), cx, |thread, cx| { + thread.push_tool_call(request.title, request.description, None, cx) + }) + })? + .context("Failed to update thread")?; + + Ok(acp::PushToolCallResponse { + id: entry_id.into(), + }) + } + async fn update_tool_call( &self, request: acp::UpdateToolCallParams, From 5f9afdf7ba861925a343e33f1abfc00911139a34 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 2 Jul 2025 12:56:03 +0200 Subject: [PATCH 47/54] Add buttons for more outcomes and handle tools that don't need authorization Co-authored-by: Antonio Scandurra --- crates/acp/src/acp.rs | 95 +++++---- crates/acp/src/server.rs | 47 +---- crates/acp/src/thread_view.rs | 359 +++++++++++++++++++++++++++++----- 3 files changed, 376 insertions(+), 125 deletions(-) diff --git a/crates/acp/src/acp.rs b/crates/acp/src/acp.rs index 652016eade..b755db232a 100644 --- a/crates/acp/src/acp.rs +++ b/crates/acp/src/acp.rs @@ -130,8 +130,8 @@ pub struct ToolCall { #[derive(Debug)] pub enum ToolCallStatus { WaitingForConfirmation { - description: Entity, - respond_tx: oneshot::Sender, + confirmation: acp::ToolCallConfirmation, + respond_tx: oneshot::Sender, }, // todo! Running? Allowed { @@ -269,24 +269,40 @@ impl AcpThread { ); } - pub fn push_tool_call( + pub fn request_tool_call( &mut self, title: String, - description: String, - confirmation_tx: Option>, + confirmation: acp::ToolCallConfirmation, + cx: &mut Context, + ) -> ToolCallRequest { + let (tx, rx) = oneshot::channel(); + + let status = ToolCallStatus::WaitingForConfirmation { + confirmation, + respond_tx: tx, + }; + + let id = self.insert_tool_call(title, status, cx); + ToolCallRequest { id, outcome: rx } + } + + pub fn push_tool_call(&mut self, title: String, cx: &mut Context) -> ToolCallId { + let status = ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Running, + content: None, + }; + + self.insert_tool_call(title, status, cx) + } + + fn insert_tool_call( + &mut self, + title: String, + status: ToolCallStatus, cx: &mut Context, ) -> ToolCallId { let language_registry = self.project.read(cx).languages().clone(); - let description = cx.new(|cx| { - Markdown::new( - description.into(), - Some(language_registry.clone()), - None, - cx, - ) - }); - let entry_id = self.push_entry( AgentThreadEntryContent::ToolCall(ToolCall { // todo! clean up id creation @@ -294,17 +310,7 @@ impl AcpThread { tool_name: cx.new(|cx| { Markdown::new(title.into(), Some(language_registry.clone()), None, cx) }), - status: if let Some(respond_tx) = confirmation_tx { - ToolCallStatus::WaitingForConfirmation { - description, - respond_tx, - } - } else { - ToolCallStatus::Allowed { - status: acp::ToolCallStatus::Running, - content: Some(description), - } - }, + status, }), cx, ); @@ -312,7 +318,12 @@ impl AcpThread { ToolCallId(entry_id) } - pub fn authorize_tool_call(&mut self, id: ToolCallId, allowed: bool, cx: &mut Context) { + pub fn authorize_tool_call( + &mut self, + id: ToolCallId, + outcome: acp::ToolCallConfirmationOutcome, + cx: &mut Context, + ) { let Some(entry) = self.entry_mut(id.0) else { return; }; @@ -322,19 +333,19 @@ impl AcpThread { return; }; - let new_status = if allowed { + let new_status = if outcome == acp::ToolCallConfirmationOutcome::Reject { + ToolCallStatus::Rejected + } else { ToolCallStatus::Allowed { status: acp::ToolCallStatus::Running, content: None, } - } else { - ToolCallStatus::Rejected }; let curr_status = mem::replace(&mut call.status, new_status); if let ToolCallStatus::WaitingForConfirmation { respond_tx, .. } = curr_status { - respond_tx.send(allowed).log_err(); + respond_tx.send(outcome).log_err(); } else { debug_panic!("tried to authorize an already authorized tool call"); } @@ -422,6 +433,11 @@ impl AcpThread { } } +pub struct ToolCallRequest { + pub id: ToolCallId, + pub outcome: oneshot::Receiver, +} + #[cfg(test)] mod tests { use super::*; @@ -506,7 +522,7 @@ mod tests { let AgentThreadEntryContent::ToolCall(ToolCall { id, tool_name, - status: ToolCallStatus::WaitingForConfirmation { description, .. }, + status: ToolCallStatus::Allowed { .. }, }) = &thread.entries().last().unwrap().content else { panic!(); @@ -516,18 +532,19 @@ mod tests { assert_eq!(md.source(), "read_file"); }); - description.read_with(cx, |md, _cx| { - assert!( - md.source().contains("foo"), - "Expected description to contain 'foo', but got {}", - md.source() - ); - }); + // todo! + // description.read_with(cx, |md, _cx| { + // assert!( + // md.source().contains("foo"), + // "Expected description to contain 'foo', but got {}", + // md.source() + // ); + // }); *id }); thread.update(cx, |thread, cx| { - thread.authorize_tool_call(tool_call_id, true, cx); + thread.authorize_tool_call(tool_call_id, acp::ToolCallConfirmationOutcome::Allow, cx); assert!(matches!( thread.entries().last().unwrap().content, AgentThreadEntryContent::ToolCall(ToolCall { diff --git a/crates/acp/src/server.rs b/crates/acp/src/server.rs index 213588fc53..ed4032777f 100644 --- a/crates/acp/src/server.rs +++ b/crates/acp/src/server.rs @@ -1,9 +1,8 @@ -use crate::{AcpThread, ThreadEntryId, ThreadId, ToolCallId}; +use crate::{AcpThread, ThreadEntryId, ThreadId, ToolCallId, ToolCallRequest}; use agentic_coding_protocol as acp; use anyhow::{Context as _, Result}; use async_trait::async_trait; use collections::HashMap; -use futures::channel::oneshot; use gpui::{App, AppContext, AsyncApp, Context, Entity, Task, WeakEntity}; use parking_lot::Mutex; use project::Project; @@ -182,52 +181,18 @@ impl acp::Client for AcpClientDelegate { &self, request: acp::RequestToolCallConfirmationParams, ) -> Result { - let (tx, rx) = oneshot::channel(); - let cx = &mut self.cx.clone(); - let entry_id = cx + let ToolCallRequest { id, outcome } = cx .update(|cx| { self.update_thread(&request.thread_id.into(), cx, |thread, cx| { - // todo! Should we pass through richer data than a description? - let description = match request.confirmation { - acp::ToolCallConfirmation::Edit { - file_name, - file_diff, - } => { - // todo! Nicer syntax/presentation based on file extension? Better way to communicate diff? - format!("Edit file `{file_name}` with diff:\n```\n{file_diff}\n```") - } - acp::ToolCallConfirmation::Execute { - command, - root_command: _, - } => { - format!("Execute command `{command}`") - } - acp::ToolCallConfirmation::Mcp { - server_name, - tool_name: _, - tool_display_name, - } => { - format!("MCP: {server_name} - {tool_display_name}") - } - acp::ToolCallConfirmation::Info { prompt, urls } => { - format!("Info: {prompt}\n{urls:?}") - } - }; - thread.push_tool_call(request.title, description, Some(tx), cx) + thread.request_tool_call(request.title, request.confirmation, cx) }) })? .context("Failed to update thread")?; - let outcome = if rx.await? { - // todo! Handle other outcomes - acp::ToolCallConfirmationOutcome::Allow - } else { - acp::ToolCallConfirmationOutcome::Reject - }; Ok(acp::RequestToolCallConfirmationResponse { - id: entry_id.into(), - outcome, + id: id.into(), + outcome: outcome.await?, }) } @@ -239,7 +204,7 @@ impl acp::Client for AcpClientDelegate { let entry_id = cx .update(|cx| { self.update_thread(&request.thread_id.into(), cx, |thread, cx| { - thread.push_tool_call(request.title, request.description, None, cx) + thread.push_tool_call(request.title, cx) }) })? .context("Failed to update thread")?; diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs index f32218a3fe..1ca02dab32 100644 --- a/crates/acp/src/thread_view.rs +++ b/crates/acp/src/thread_view.rs @@ -2,7 +2,7 @@ use std::path::Path; use std::rc::Rc; use std::time::Duration; -use agentic_coding_protocol::{self as acp}; +use agentic_coding_protocol::{self as acp, ToolCallConfirmation}; use anyhow::Result; use editor::{Editor, MultiBuffer}; use gpui::{ @@ -191,12 +191,17 @@ impl AcpThreadView { }); } - fn authorize_tool_call(&mut self, id: ToolCallId, allowed: bool, cx: &mut Context) { + fn authorize_tool_call( + &mut self, + id: ToolCallId, + outcome: acp::ToolCallConfirmationOutcome, + cx: &mut Context, + ) { let Some(thread) = self.thread() else { return; }; thread.update(cx, |thread, cx| { - thread.authorize_tool_call(id, allowed, cx); + thread.authorize_tool_call(id, outcome, cx); }); cx.notify(); } @@ -289,48 +294,9 @@ impl AcpThreadView { }; let content = match &tool_call.status { - ToolCallStatus::WaitingForConfirmation { description, .. } => v_flex() - .border_color(cx.theme().colors().border) - .border_t_1() - .px_2() - .py_1p5() - .child(MarkdownElement::new( - description.clone(), - default_markdown_style(window, cx), - )) - .child( - h_flex() - .justify_end() - .gap_1() - .child( - Button::new(("allow", tool_call.id.as_u64()), "Allow") - .icon(IconName::Check) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Success) - .on_click(cx.listener({ - let id = tool_call.id; - move |this, _, _, cx| { - this.authorize_tool_call(id, true, cx); - } - })), - ) - .child( - Button::new(("reject", tool_call.id.as_u64()), "Reject") - .icon(IconName::X) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Error) - .on_click(cx.listener({ - let id = tool_call.id; - move |this, _, _, cx| { - this.authorize_tool_call(id, false, cx); - } - })), - ), - ) - .into_any() - .into(), + ToolCallStatus::WaitingForConfirmation { confirmation, .. } => { + Some(self.render_tool_call_confirmation(tool_call.id, confirmation, cx)) + } ToolCallStatus::Allowed { content, .. } => content.clone().map(|content| { div() .border_color(cx.theme().colors().border) @@ -372,6 +338,309 @@ impl AcpThreadView { ) .children(content) } + + fn render_tool_call_confirmation( + &self, + tool_call_id: ToolCallId, + confirmation: &ToolCallConfirmation, + cx: &Context, + ) -> AnyElement { + match confirmation { + ToolCallConfirmation::Edit { + file_name, + file_diff, + } => v_flex() + .border_color(cx.theme().colors().border) + .border_t_1() + .px_2() + .py_1p5() + // todo! nicer rendering + .child(file_name.clone()) + .child(file_diff.clone()) + .child( + h_flex() + .justify_end() + .gap_1() + .child( + Button::new(("allow", tool_call_id.as_u64()), "Always Allow Edits") + .icon(IconName::CheckDouble) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Success) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::AlwaysAllow, + cx, + ); + } + })), + ) + .child( + Button::new(("allow", tool_call_id.as_u64()), "Allow") + .icon(IconName::Check) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Success) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::Allow, + cx, + ); + } + })), + ) + .child( + Button::new(("reject", tool_call_id.as_u64()), "Reject") + .icon(IconName::X) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Error) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::Reject, + cx, + ); + } + })), + ), + ) + .into_any(), + ToolCallConfirmation::Execute { + command, + root_command, + } => v_flex() + .border_color(cx.theme().colors().border) + .border_t_1() + .px_2() + .py_1p5() + // todo! nicer rendering + .child(command.clone()) + .child( + h_flex() + .justify_end() + .gap_1() + .child( + Button::new( + ("allow", tool_call_id.as_u64()), + format!("Always Allow {root_command}"), + ) + .icon(IconName::CheckDouble) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Success) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::AlwaysAllow, + cx, + ); + } + })), + ) + .child( + Button::new(("allow", tool_call_id.as_u64()), "Allow") + .icon(IconName::Check) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Success) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::Allow, + cx, + ); + } + })), + ) + .child( + Button::new(("reject", tool_call_id.as_u64()), "Reject") + .icon(IconName::X) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Error) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::Reject, + cx, + ); + } + })), + ), + ) + .into_any(), + ToolCallConfirmation::Mcp { + server_name, + tool_name: _, + tool_display_name, + } => v_flex() + .border_color(cx.theme().colors().border) + .border_t_1() + .px_2() + .py_1p5() + // todo! nicer rendering + .child(format!("{server_name} - {tool_display_name}")) + .child( + h_flex() + .justify_end() + .gap_1() + .child( + Button::new( + ("allow", tool_call_id.as_u64()), + format!("Always Allow {server_name}"), + ) + .icon(IconName::CheckDouble) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Success) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::AlwaysAllowMcpServer, + cx, + ); + } + })), + ) + .child( + Button::new( + ("allow", tool_call_id.as_u64()), + format!("Always Allow {tool_display_name}"), + ) + .icon(IconName::CheckDouble) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Success) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::AlwaysAllowTool, + cx, + ); + } + })), + ) + .child( + Button::new(("allow", tool_call_id.as_u64()), "Allow") + .icon(IconName::Check) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Success) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::Allow, + cx, + ); + } + })), + ) + .child( + Button::new(("reject", tool_call_id.as_u64()), "Reject") + .icon(IconName::X) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Error) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::Reject, + cx, + ); + } + })), + ), + ) + .into_any(), + ToolCallConfirmation::Info { prompt, urls: _ } => v_flex() + .border_color(cx.theme().colors().border) + .border_t_1() + .px_2() + .py_1p5() + // todo! nicer rendering + .child(prompt.clone()) + .child( + h_flex() + .justify_end() + .gap_1() + .child( + Button::new(("allow", tool_call_id.as_u64()), "Always Allow") + .icon(IconName::CheckDouble) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Success) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::AlwaysAllow, + cx, + ); + } + })), + ) + .child( + Button::new(("allow", tool_call_id.as_u64()), "Allow") + .icon(IconName::Check) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Success) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::Allow, + cx, + ); + } + })), + ) + .child( + Button::new(("reject", tool_call_id.as_u64()), "Reject") + .icon(IconName::X) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Error) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::Reject, + cx, + ); + } + })), + ), + ) + .into_any(), + } + } } impl Focusable for AcpThreadView { From 7d2f7cb70e68a7c71093873a921888059162078f Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 2 Jul 2025 14:40:16 +0200 Subject: [PATCH 48/54] Replace title with display_name for tool calls Co-authored-by: Ben Brandt --- crates/acp/src/acp.rs | 89 +++++++++++------------------------ crates/acp/src/server.rs | 4 +- crates/acp/src/thread_view.rs | 2 +- 3 files changed, 31 insertions(+), 64 deletions(-) diff --git a/crates/acp/src/acp.rs b/crates/acp/src/acp.rs index b755db232a..0d2a9cec4f 100644 --- a/crates/acp/src/acp.rs +++ b/crates/acp/src/acp.rs @@ -123,7 +123,7 @@ pub enum AgentThreadEntryContent { #[derive(Debug)] pub struct ToolCall { id: ToolCallId, - tool_name: Entity, + display_name: Entity, status: ToolCallStatus, } @@ -271,7 +271,7 @@ impl AcpThread { pub fn request_tool_call( &mut self, - title: String, + display_name: String, confirmation: acp::ToolCallConfirmation, cx: &mut Context, ) -> ToolCallRequest { @@ -282,22 +282,22 @@ impl AcpThread { respond_tx: tx, }; - let id = self.insert_tool_call(title, status, cx); + let id = self.insert_tool_call(display_name, status, cx); ToolCallRequest { id, outcome: rx } } - pub fn push_tool_call(&mut self, title: String, cx: &mut Context) -> ToolCallId { + pub fn push_tool_call(&mut self, display_name: String, cx: &mut Context) -> ToolCallId { let status = ToolCallStatus::Allowed { status: acp::ToolCallStatus::Running, content: None, }; - self.insert_tool_call(title, status, cx) + self.insert_tool_call(display_name, status, cx) } fn insert_tool_call( &mut self, - title: String, + display_name: String, status: ToolCallStatus, cx: &mut Context, ) -> ToolCallId { @@ -307,8 +307,13 @@ impl AcpThread { AgentThreadEntryContent::ToolCall(ToolCall { // todo! clean up id creation id: ToolCallId(ThreadEntryId(self.entries.len() as u64)), - tool_name: cx.new(|cx| { - Markdown::new(title.into(), Some(language_registry.clone()), None, cx) + display_name: cx.new(|cx| { + Markdown::new( + display_name.into(), + Some(language_registry.clone()), + None, + cx, + ) }), status, }), @@ -509,27 +514,27 @@ mod tests { let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await; let server = gemini_acp_server(project.clone(), cx.to_async()).unwrap(); let thread = server.create_thread(&mut cx.to_async()).await.unwrap(); - let full_turn = thread.update(cx, |thread, cx| { - thread.send( - "Read the '/private/tmp/foo' file and tell me what you see.", - cx, - ) - }); - - run_until_tool_call(&thread, cx).await; - - let tool_call_id = thread.read_with(cx, |thread, cx| { + thread + .update(cx, |thread, cx| { + thread.send( + "Read the '/private/tmp/foo' file and tell me what you see.", + cx, + ) + }) + .await + .unwrap(); + thread.read_with(cx, |thread, cx| { let AgentThreadEntryContent::ToolCall(ToolCall { id, - tool_name, - status: ToolCallStatus::Allowed { .. }, - }) = &thread.entries().last().unwrap().content + display_name, + status: ToolCallStatus::Allowed { content, .. }, + }) = &thread.entries()[1].content else { panic!(); }; - tool_name.read_with(cx, |md, _cx| { - assert_eq!(md.source(), "read_file"); + display_name.read_with(cx, |md, _cx| { + assert_eq!(md.source(), "ReadFile"); }); // todo! @@ -542,44 +547,6 @@ mod tests { // }); *id }); - - thread.update(cx, |thread, cx| { - thread.authorize_tool_call(tool_call_id, acp::ToolCallConfirmationOutcome::Allow, cx); - assert!(matches!( - thread.entries().last().unwrap().content, - AgentThreadEntryContent::ToolCall(ToolCall { - status: ToolCallStatus::Allowed { .. }, - .. - }) - )); - }); - - full_turn.await.unwrap(); - - thread.read_with(cx, |thread, _| { - assert!(thread.entries.len() >= 3, "{:?}", &thread.entries); - assert!(matches!( - thread.entries[0].content, - AgentThreadEntryContent::Message(Message { - role: Role::User, - .. - }) - )); - assert!(matches!( - thread.entries[1].content, - AgentThreadEntryContent::ToolCall(ToolCall { - status: ToolCallStatus::Allowed { .. }, - .. - }) - )); - assert!(matches!( - thread.entries[2].content, - AgentThreadEntryContent::Message(Message { - role: Role::Assistant, - .. - }) - )); - }); } async fn run_until_tool_call(thread: &Entity, cx: &mut TestAppContext) { diff --git a/crates/acp/src/server.rs b/crates/acp/src/server.rs index ed4032777f..98bb3c35e5 100644 --- a/crates/acp/src/server.rs +++ b/crates/acp/src/server.rs @@ -185,7 +185,7 @@ impl acp::Client for AcpClientDelegate { let ToolCallRequest { id, outcome } = cx .update(|cx| { self.update_thread(&request.thread_id.into(), cx, |thread, cx| { - thread.request_tool_call(request.title, request.confirmation, cx) + thread.request_tool_call(request.display_name, request.confirmation, cx) }) })? .context("Failed to update thread")?; @@ -204,7 +204,7 @@ impl acp::Client for AcpClientDelegate { let entry_id = cx .update(|cx| { self.update_thread(&request.thread_id.into(), cx, |thread, cx| { - thread.push_tool_call(request.title, cx) + thread.push_tool_call(request.display_name, cx) }) })? .context("Failed to update thread")?; diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs index 1ca02dab32..183ff22253 100644 --- a/crates/acp/src/thread_view.rs +++ b/crates/acp/src/thread_view.rs @@ -330,7 +330,7 @@ impl AcpThreadView { .color(Color::Muted), ) .child(MarkdownElement::new( - tool_call.tool_name.clone(), + tool_call.display_name.clone(), default_markdown_style(window, cx), )) .child(div().w_full()) From 975a7e6f7f55dc096cd1c5fe25579b6ae060c6dc Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 2 Jul 2025 14:54:24 +0200 Subject: [PATCH 49/54] Fix clicking on tool confirmation buttons Co-authored-by: Ben Brandt --- crates/acp/src/acp.rs | 30 +------------------------ crates/acp/src/thread_view.rs | 42 ++++++++++++++++++----------------- 2 files changed, 23 insertions(+), 49 deletions(-) diff --git a/crates/acp/src/acp.rs b/crates/acp/src/acp.rs index 0d2a9cec4f..33fa793e84 100644 --- a/crates/acp/src/acp.rs +++ b/crates/acp/src/acp.rs @@ -446,13 +446,11 @@ pub struct ToolCallRequest { #[cfg(test)] mod tests { use super::*; - use futures::{FutureExt as _, channel::mpsc, select}; use gpui::{AsyncApp, TestAppContext}; use project::FakeFs; use serde_json::json; use settings::SettingsStore; - use smol::stream::StreamExt; - use std::{env, path::Path, process::Stdio, time::Duration}; + use std::{env, path::Path, process::Stdio}; use util::path; fn init_test(cx: &mut TestAppContext) { @@ -549,32 +547,6 @@ mod tests { }); } - async fn run_until_tool_call(thread: &Entity, cx: &mut TestAppContext) { - let (mut tx, mut rx) = mpsc::channel(1); - - let subscription = cx.update(|cx| { - cx.subscribe(thread, move |thread, _, cx| { - if thread - .read(cx) - .entries - .iter() - .any(|e| matches!(e.content, AgentThreadEntryContent::ToolCall(_))) - { - tx.try_send(()).unwrap(); - } - }) - }); - - select! { - _ = cx.executor().timer(Duration::from_secs(5)).fuse() => { - panic!("Timeout waiting for tool call") - } - _ = rx.next().fuse() => { - drop(subscription); - } - } - } - pub fn gemini_acp_server(project: Entity, mut cx: AsyncApp) -> Result> { let cli_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../gemini-cli/packages/cli"); diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs index 183ff22253..b311110c94 100644 --- a/crates/acp/src/thread_view.rs +++ b/crates/acp/src/thread_view.rs @@ -62,7 +62,6 @@ impl AcpThreadView { let child = util::command::new_smol_command("node") .arg(cli_path) .arg("--acp") - .args(["--model", "gemini-2.5-flash"]) .current_dir(root_dir) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) @@ -362,21 +361,24 @@ impl AcpThreadView { .justify_end() .gap_1() .child( - Button::new(("allow", tool_call_id.as_u64()), "Always Allow Edits") - .icon(IconName::CheckDouble) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Success) - .on_click(cx.listener({ - let id = tool_call_id; - move |this, _, _, cx| { - this.authorize_tool_call( - id, - acp::ToolCallConfirmationOutcome::AlwaysAllow, - cx, - ); - } - })), + Button::new( + ("always_allow", tool_call_id.as_u64()), + "Always Allow Edits", + ) + .icon(IconName::CheckDouble) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Success) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::AlwaysAllow, + cx, + ); + } + })), ) .child( Button::new(("allow", tool_call_id.as_u64()), "Allow") @@ -430,7 +432,7 @@ impl AcpThreadView { .gap_1() .child( Button::new( - ("allow", tool_call_id.as_u64()), + ("always_allow", tool_call_id.as_u64()), format!("Always Allow {root_command}"), ) .icon(IconName::CheckDouble) @@ -501,7 +503,7 @@ impl AcpThreadView { .gap_1() .child( Button::new( - ("allow", tool_call_id.as_u64()), + ("always_allow_server", tool_call_id.as_u64()), format!("Always Allow {server_name}"), ) .icon(IconName::CheckDouble) @@ -521,7 +523,7 @@ impl AcpThreadView { ) .child( Button::new( - ("allow", tool_call_id.as_u64()), + ("always_allow_tool", tool_call_id.as_u64()), format!("Always Allow {tool_display_name}"), ) .icon(IconName::CheckDouble) @@ -587,7 +589,7 @@ impl AcpThreadView { .justify_end() .gap_1() .child( - Button::new(("allow", tool_call_id.as_u64()), "Always Allow") + Button::new(("always_allow", tool_call_id.as_u64()), "Always Allow") .icon(IconName::CheckDouble) .icon_position(IconPosition::Start) .icon_size(IconSize::Small) From d16c595d574ddd914e571bd8ac320539003b2e75 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 2 Jul 2025 11:31:51 -0300 Subject: [PATCH 50/54] Fix always allow, and update acp confirmation types --- crates/acp/src/server.rs | 4 +- crates/acp/src/thread_view.rs | 117 +++++++++++++++++++++++++++------- 2 files changed, 97 insertions(+), 24 deletions(-) diff --git a/crates/acp/src/server.rs b/crates/acp/src/server.rs index ed4032777f..98bb3c35e5 100644 --- a/crates/acp/src/server.rs +++ b/crates/acp/src/server.rs @@ -185,7 +185,7 @@ impl acp::Client for AcpClientDelegate { let ToolCallRequest { id, outcome } = cx .update(|cx| { self.update_thread(&request.thread_id.into(), cx, |thread, cx| { - thread.request_tool_call(request.title, request.confirmation, cx) + thread.request_tool_call(request.display_name, request.confirmation, cx) }) })? .context("Failed to update thread")?; @@ -204,7 +204,7 @@ impl acp::Client for AcpClientDelegate { let entry_id = cx .update(|cx| { self.update_thread(&request.thread_id.into(), cx, |thread, cx| { - thread.push_tool_call(request.title, cx) + thread.push_tool_call(request.display_name, cx) }) })? .context("Failed to update thread")?; diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs index 1ca02dab32..7c8336961f 100644 --- a/crates/acp/src/thread_view.rs +++ b/crates/acp/src/thread_view.rs @@ -62,7 +62,6 @@ impl AcpThreadView { let child = util::command::new_smol_command("node") .arg(cli_path) .arg("--acp") - .args(["--model", "gemini-2.5-flash"]) .current_dir(root_dir) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) @@ -349,6 +348,7 @@ impl AcpThreadView { ToolCallConfirmation::Edit { file_name, file_diff, + description, } => v_flex() .border_color(cx.theme().colors().border) .border_t_1() @@ -357,26 +357,30 @@ impl AcpThreadView { // todo! nicer rendering .child(file_name.clone()) .child(file_diff.clone()) + .children(description.clone()) .child( h_flex() .justify_end() .gap_1() .child( - Button::new(("allow", tool_call_id.as_u64()), "Always Allow Edits") - .icon(IconName::CheckDouble) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Success) - .on_click(cx.listener({ - let id = tool_call_id; - move |this, _, _, cx| { - this.authorize_tool_call( - id, - acp::ToolCallConfirmationOutcome::AlwaysAllow, - cx, - ); - } - })), + Button::new( + ("always_allow", tool_call_id.as_u64()), + "Always Allow Edits", + ) + .icon(IconName::CheckDouble) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Success) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::AlwaysAllow, + cx, + ); + } + })), ) .child( Button::new(("allow", tool_call_id.as_u64()), "Allow") @@ -417,6 +421,7 @@ impl AcpThreadView { ToolCallConfirmation::Execute { command, root_command, + description, } => v_flex() .border_color(cx.theme().colors().border) .border_t_1() @@ -424,13 +429,14 @@ impl AcpThreadView { .py_1p5() // todo! nicer rendering .child(command.clone()) + .children(description.clone()) .child( h_flex() .justify_end() .gap_1() .child( Button::new( - ("allow", tool_call_id.as_u64()), + ("always_allow", tool_call_id.as_u64()), format!("Always Allow {root_command}"), ) .icon(IconName::CheckDouble) @@ -488,6 +494,7 @@ impl AcpThreadView { server_name, tool_name: _, tool_display_name, + description, } => v_flex() .border_color(cx.theme().colors().border) .border_t_1() @@ -495,13 +502,14 @@ impl AcpThreadView { .py_1p5() // todo! nicer rendering .child(format!("{server_name} - {tool_display_name}")) + .children(description.clone()) .child( h_flex() .justify_end() .gap_1() .child( Button::new( - ("allow", tool_call_id.as_u64()), + ("always_allow_server", tool_call_id.as_u64()), format!("Always Allow {server_name}"), ) .icon(IconName::CheckDouble) @@ -521,7 +529,7 @@ impl AcpThreadView { ) .child( Button::new( - ("allow", tool_call_id.as_u64()), + ("always_allow_tool", tool_call_id.as_u64()), format!("Always Allow {tool_display_name}"), ) .icon(IconName::CheckDouble) @@ -575,19 +583,84 @@ impl AcpThreadView { ), ) .into_any(), - ToolCallConfirmation::Info { prompt, urls: _ } => v_flex() + ToolCallConfirmation::Fetch { description, urls } => v_flex() .border_color(cx.theme().colors().border) .border_t_1() .px_2() .py_1p5() // todo! nicer rendering - .child(prompt.clone()) + .children(urls.clone()) + .children(description.clone()) .child( h_flex() .justify_end() .gap_1() .child( - Button::new(("allow", tool_call_id.as_u64()), "Always Allow") + Button::new(("always_allow", tool_call_id.as_u64()), "Always Allow") + .icon(IconName::CheckDouble) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Success) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::AlwaysAllow, + cx, + ); + } + })), + ) + .child( + Button::new(("allow", tool_call_id.as_u64()), "Allow") + .icon(IconName::Check) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Success) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::Allow, + cx, + ); + } + })), + ) + .child( + Button::new(("reject", tool_call_id.as_u64()), "Reject") + .icon(IconName::X) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Error) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::Reject, + cx, + ); + } + })), + ), + ) + .into_any(), + ToolCallConfirmation::Other { description } => v_flex() + .border_color(cx.theme().colors().border) + .border_t_1() + .px_2() + .py_1p5() + // todo! nicer rendering + .child(description.clone()) + .child( + h_flex() + .justify_end() + .gap_1() + .child( + Button::new(("always_allow", tool_call_id.as_u64()), "Always Allow") .icon(IconName::CheckDouble) .icon_position(IconPosition::Start) .icon_size(IconSize::Small) From 450604b4a1859e3bd2eb5f6761e136e534826401 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 2 Jul 2025 12:13:20 -0300 Subject: [PATCH 51/54] Add tool call with confirmation test --- crates/acp/src/acp.rs | 121 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 109 insertions(+), 12 deletions(-) diff --git a/crates/acp/src/acp.rs b/crates/acp/src/acp.rs index 33fa793e84..06faf048ec 100644 --- a/crates/acp/src/acp.rs +++ b/crates/acp/src/acp.rs @@ -446,11 +446,13 @@ pub struct ToolCallRequest { #[cfg(test)] mod tests { use super::*; + use futures::{FutureExt as _, channel::mpsc, select}; use gpui::{AsyncApp, TestAppContext}; use project::FakeFs; use serde_json::json; use settings::SettingsStore; - use std::{env, path::Path, process::Stdio}; + use smol::stream::StreamExt as _; + use std::{env, path::Path, process::Stdio, time::Duration}; use util::path; fn init_test(cx: &mut TestAppContext) { @@ -523,9 +525,9 @@ mod tests { .unwrap(); thread.read_with(cx, |thread, cx| { let AgentThreadEntryContent::ToolCall(ToolCall { - id, display_name, - status: ToolCallStatus::Allowed { content, .. }, + status: ToolCallStatus::Allowed { .. }, + .. }) = &thread.entries()[1].content else { panic!(); @@ -535,16 +537,112 @@ mod tests { assert_eq!(md.source(), "ReadFile"); }); - // todo! - // description.read_with(cx, |md, _cx| { - // assert!( - // md.source().contains("foo"), - // "Expected description to contain 'foo', but got {}", - // md.source() - // ); - // }); + assert!(matches!( + thread.entries[2].content, + AgentThreadEntryContent::Message(Message { + role: Role::Assistant, + .. + }) + )); + }); + } + + #[gpui::test] + async fn test_gemini_tool_call_with_confirmation(cx: &mut TestAppContext) { + init_test(cx); + + cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await; + let server = gemini_acp_server(project.clone(), cx.to_async()).unwrap(); + let thread = server.create_thread(&mut cx.to_async()).await.unwrap(); + let full_turn = thread.update(cx, |thread, cx| { + thread.send(r#"Run `echo "Hello, world!"`"#, cx) + }); + + run_until_tool_call(&thread, cx).await; + + let tool_call_id = thread.read_with(cx, |thread, cx| { + let AgentThreadEntryContent::ToolCall(ToolCall { + id, + display_name, + status: + ToolCallStatus::WaitingForConfirmation { + confirmation: acp::ToolCallConfirmation::Execute { root_command, .. }, + .. + }, + }) = &thread.entries()[1].content + else { + panic!(); + }; + + assert_eq!(root_command, "echo"); + + display_name.read_with(cx, |md, _cx| { + assert_eq!(md.source(), "Shell"); + }); + *id }); + + thread.update(cx, |thread, cx| { + thread.authorize_tool_call(tool_call_id, acp::ToolCallConfirmationOutcome::Allow, cx); + + assert!(matches!( + &thread.entries()[1].content, + AgentThreadEntryContent::ToolCall(ToolCall { + status: ToolCallStatus::Allowed { .. }, + .. + }) + )); + }); + + full_turn.await.unwrap(); + + thread.read_with(cx, |thread, cx| { + let AgentThreadEntryContent::ToolCall(ToolCall { + status: ToolCallStatus::Allowed { content, .. }, + .. + }) = &thread.entries()[1].content + else { + panic!(); + }; + + content.as_ref().unwrap().read_with(cx, |md, _cx| { + assert!( + md.source().contains("Hello, world!"), + r#"Expected '{}' to contain "Hello, world!""#, + md.source() + ); + }); + }); + } + + async fn run_until_tool_call(thread: &Entity, cx: &mut TestAppContext) { + let (mut tx, mut rx) = mpsc::channel::<()>(1); + + let subscription = cx.update(|cx| { + cx.subscribe(thread, move |thread, _, cx| { + if thread + .read(cx) + .entries + .iter() + .any(|e| matches!(e.content, AgentThreadEntryContent::ToolCall(_))) + { + tx.try_send(()).unwrap(); + } + }) + }); + + select! { + _ = futures::FutureExt::fuse(smol::Timer::after(Duration::from_secs(10))) => { + panic!("Timeout waiting for tool call") + } + _ = rx.next().fuse() => { + drop(subscription); + } + } } pub fn gemini_acp_server(project: Entity, mut cx: AsyncApp) -> Result> { @@ -554,7 +652,6 @@ mod tests { command .arg(cli_path) .arg("--acp") - .args(["--model", "gemini-2.5-flash"]) .current_dir("/private/tmp") .stdin(Stdio::piped()) .stdout(Stdio::piped()) From 135143d51bc9cc4429d040b84651b59b4756ffda Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 2 Jul 2025 13:16:30 -0300 Subject: [PATCH 52/54] Rename display_name to label Co-authored-by: Antonio Scandurra Co-authored-by: Nathan Sobo Co-authored-by: Conrad Irwin --- crates/acp/src/acp.rs | 29 ++++++++++++----------------- crates/acp/src/server.rs | 4 ++-- crates/acp/src/thread_view.rs | 3 ++- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/crates/acp/src/acp.rs b/crates/acp/src/acp.rs index 06faf048ec..9fbfc95f22 100644 --- a/crates/acp/src/acp.rs +++ b/crates/acp/src/acp.rs @@ -123,7 +123,7 @@ pub enum AgentThreadEntryContent { #[derive(Debug)] pub struct ToolCall { id: ToolCallId, - display_name: Entity, + label: Entity, status: ToolCallStatus, } @@ -271,7 +271,7 @@ impl AcpThread { pub fn request_tool_call( &mut self, - display_name: String, + label: String, confirmation: acp::ToolCallConfirmation, cx: &mut Context, ) -> ToolCallRequest { @@ -282,22 +282,22 @@ impl AcpThread { respond_tx: tx, }; - let id = self.insert_tool_call(display_name, status, cx); + let id = self.insert_tool_call(label, status, cx); ToolCallRequest { id, outcome: rx } } - pub fn push_tool_call(&mut self, display_name: String, cx: &mut Context) -> ToolCallId { + pub fn push_tool_call(&mut self, label: String, cx: &mut Context) -> ToolCallId { let status = ToolCallStatus::Allowed { status: acp::ToolCallStatus::Running, content: None, }; - self.insert_tool_call(display_name, status, cx) + self.insert_tool_call(label, status, cx) } fn insert_tool_call( &mut self, - display_name: String, + label: String, status: ToolCallStatus, cx: &mut Context, ) -> ToolCallId { @@ -307,13 +307,8 @@ impl AcpThread { AgentThreadEntryContent::ToolCall(ToolCall { // todo! clean up id creation id: ToolCallId(ThreadEntryId(self.entries.len() as u64)), - display_name: cx.new(|cx| { - Markdown::new( - display_name.into(), - Some(language_registry.clone()), - None, - cx, - ) + label: cx.new(|cx| { + Markdown::new(label.into(), Some(language_registry.clone()), None, cx) }), status, }), @@ -525,7 +520,7 @@ mod tests { .unwrap(); thread.read_with(cx, |thread, cx| { let AgentThreadEntryContent::ToolCall(ToolCall { - display_name, + label, status: ToolCallStatus::Allowed { .. }, .. }) = &thread.entries()[1].content @@ -533,7 +528,7 @@ mod tests { panic!(); }; - display_name.read_with(cx, |md, _cx| { + label.read_with(cx, |md, _cx| { assert_eq!(md.source(), "ReadFile"); }); @@ -566,7 +561,7 @@ mod tests { let tool_call_id = thread.read_with(cx, |thread, cx| { let AgentThreadEntryContent::ToolCall(ToolCall { id, - display_name, + label, status: ToolCallStatus::WaitingForConfirmation { confirmation: acp::ToolCallConfirmation::Execute { root_command, .. }, @@ -579,7 +574,7 @@ mod tests { assert_eq!(root_command, "echo"); - display_name.read_with(cx, |md, _cx| { + label.read_with(cx, |md, _cx| { assert_eq!(md.source(), "Shell"); }); diff --git a/crates/acp/src/server.rs b/crates/acp/src/server.rs index 98bb3c35e5..93ee4d1c13 100644 --- a/crates/acp/src/server.rs +++ b/crates/acp/src/server.rs @@ -185,7 +185,7 @@ impl acp::Client for AcpClientDelegate { let ToolCallRequest { id, outcome } = cx .update(|cx| { self.update_thread(&request.thread_id.into(), cx, |thread, cx| { - thread.request_tool_call(request.display_name, request.confirmation, cx) + thread.request_tool_call(request.label, request.confirmation, cx) }) })? .context("Failed to update thread")?; @@ -204,7 +204,7 @@ impl acp::Client for AcpClientDelegate { let entry_id = cx .update(|cx| { self.update_thread(&request.thread_id.into(), cx, |thread, cx| { - thread.push_tool_call(request.display_name, cx) + thread.push_tool_call(request.label, cx) }) })? .context("Failed to update thread")?; diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs index 58f0fca6a6..e3a9073e82 100644 --- a/crates/acp/src/thread_view.rs +++ b/crates/acp/src/thread_view.rs @@ -328,8 +328,9 @@ impl AcpThreadView { .size(IconSize::Small) .color(Color::Muted), ) + // todo! danilo please help .child(MarkdownElement::new( - tool_call.display_name.clone(), + tool_call.label.clone(), default_markdown_style(window, cx), )) .child(div().w_full()) From 4755d6fa9d8d5eb40f94ef6f99930a5eeb313892 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 2 Jul 2025 13:48:57 -0300 Subject: [PATCH 53/54] Display tool icons Co-authored-by: Mikayla Maki Co-authored-by: Antonio Scandurra Co-authored-by: Nathan Sobo --- crates/acp/src/acp.rs | 59 +++++++++++++++++++++-------------- crates/acp/src/server.rs | 4 +-- crates/acp/src/thread_view.rs | 2 +- 3 files changed, 39 insertions(+), 26 deletions(-) diff --git a/crates/acp/src/acp.rs b/crates/acp/src/acp.rs index 9fbfc95f22..31d9f66dfa 100644 --- a/crates/acp/src/acp.rs +++ b/crates/acp/src/acp.rs @@ -10,7 +10,7 @@ use language::LanguageRegistry; use markdown::Markdown; use project::Project; use std::{mem, ops::Range, path::PathBuf, sync::Arc}; -use ui::App; +use ui::{App, IconName}; use util::{ResultExt, debug_panic}; pub use server::AcpServer; @@ -124,6 +124,7 @@ pub enum AgentThreadEntryContent { pub struct ToolCall { id: ToolCallId, label: Entity, + icon: IconName, status: ToolCallStatus, } @@ -272,6 +273,7 @@ impl AcpThread { pub fn request_tool_call( &mut self, label: String, + icon: acp::Icon, confirmation: acp::ToolCallConfirmation, cx: &mut Context, ) -> ToolCallRequest { @@ -282,23 +284,29 @@ impl AcpThread { respond_tx: tx, }; - let id = self.insert_tool_call(label, status, cx); + let id = self.insert_tool_call(label, status, icon, cx); ToolCallRequest { id, outcome: rx } } - pub fn push_tool_call(&mut self, label: String, cx: &mut Context) -> ToolCallId { + pub fn push_tool_call( + &mut self, + label: String, + icon: acp::Icon, + cx: &mut Context, + ) -> ToolCallId { let status = ToolCallStatus::Allowed { status: acp::ToolCallStatus::Running, content: None, }; - self.insert_tool_call(label, status, cx) + self.insert_tool_call(label, status, icon, cx) } fn insert_tool_call( &mut self, label: String, status: ToolCallStatus, + icon: acp::Icon, cx: &mut Context, ) -> ToolCallId { let language_registry = self.project.read(cx).languages().clone(); @@ -310,6 +318,7 @@ impl AcpThread { label: cx.new(|cx| { Markdown::new(label.into(), Some(language_registry.clone()), None, cx) }), + icon: acp_icon_to_ui_icon(icon), status, }), cx, @@ -433,6 +442,19 @@ impl AcpThread { } } +fn acp_icon_to_ui_icon(icon: acp::Icon) -> IconName { + match icon { + acp::Icon::FileSearch => IconName::FileSearch, + acp::Icon::Folder => IconName::Folder, + acp::Icon::Globe => IconName::Globe, + acp::Icon::Hammer => IconName::Hammer, + acp::Icon::LightBulb => IconName::LightBulb, + acp::Icon::Pencil => IconName::Pencil, + acp::Icon::Regex => IconName::Regex, + acp::Icon::Terminal => IconName::Terminal, + } +} + pub struct ToolCallRequest { pub id: ToolCallId, pub outcome: oneshot::Receiver, @@ -518,19 +540,14 @@ mod tests { }) .await .unwrap(); - thread.read_with(cx, |thread, cx| { - let AgentThreadEntryContent::ToolCall(ToolCall { - label, - status: ToolCallStatus::Allowed { .. }, - .. - }) = &thread.entries()[1].content - else { - panic!(); - }; - - label.read_with(cx, |md, _cx| { - assert_eq!(md.source(), "ReadFile"); - }); + thread.read_with(cx, |thread, _cx| { + assert!(matches!( + &thread.entries()[1].content, + AgentThreadEntryContent::ToolCall(ToolCall { + status: ToolCallStatus::Allowed { .. }, + .. + }) + )); assert!(matches!( thread.entries[2].content, @@ -558,15 +575,15 @@ mod tests { run_until_tool_call(&thread, cx).await; - let tool_call_id = thread.read_with(cx, |thread, cx| { + let tool_call_id = thread.read_with(cx, |thread, _cx| { let AgentThreadEntryContent::ToolCall(ToolCall { id, - label, status: ToolCallStatus::WaitingForConfirmation { confirmation: acp::ToolCallConfirmation::Execute { root_command, .. }, .. }, + .. }) = &thread.entries()[1].content else { panic!(); @@ -574,10 +591,6 @@ mod tests { assert_eq!(root_command, "echo"); - label.read_with(cx, |md, _cx| { - assert_eq!(md.source(), "Shell"); - }); - *id }); diff --git a/crates/acp/src/server.rs b/crates/acp/src/server.rs index 93ee4d1c13..e1cdbbae40 100644 --- a/crates/acp/src/server.rs +++ b/crates/acp/src/server.rs @@ -185,7 +185,7 @@ impl acp::Client for AcpClientDelegate { let ToolCallRequest { id, outcome } = cx .update(|cx| { self.update_thread(&request.thread_id.into(), cx, |thread, cx| { - thread.request_tool_call(request.label, request.confirmation, cx) + thread.request_tool_call(request.label, request.icon, request.confirmation, cx) }) })? .context("Failed to update thread")?; @@ -204,7 +204,7 @@ impl acp::Client for AcpClientDelegate { let entry_id = cx .update(|cx| { self.update_thread(&request.thread_id.into(), cx, |thread, cx| { - thread.push_tool_call(request.label, cx) + thread.push_tool_call(request.label, request.icon, cx) }) })? .context("Failed to update thread")?; diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs index e3a9073e82..9853fd9523 100644 --- a/crates/acp/src/thread_view.rs +++ b/crates/acp/src/thread_view.rs @@ -324,7 +324,7 @@ impl AcpThreadView { .w_full() .gap_1p5() .child( - Icon::new(IconName::Cog) + Icon::new(tool_call.icon.into()) .size(IconSize::Small) .color(Color::Muted), ) From 34c890e23e09875dee0a1f677e9a766fc4ff0928 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Wed, 2 Jul 2025 15:51:38 -0700 Subject: [PATCH 54/54] WIP co-authored-by: Nathan --- crates/acp/src/thread_view.rs | 8 ++- crates/markdown/src/markdown.rs | 95 +++++++++++++++++++++++++++++++-- crates/project/src/direnv.rs | 2 +- 3 files changed, 97 insertions(+), 8 deletions(-) diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs index 9853fd9523..86a09d0383 100644 --- a/crates/acp/src/thread_view.rs +++ b/crates/acp/src/thread_view.rs @@ -1,5 +1,6 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use std::rc::Rc; +use std::sync::Arc; use std::time::Duration; use agentic_coding_protocol::{self as acp, ToolCallConfirmation}; @@ -32,6 +33,7 @@ pub struct AcpThreadView { message_editor: Entity, list_state: ListState, send_task: Option>>, + root: Arc, } enum ThreadState { @@ -47,6 +49,7 @@ enum ThreadState { impl AcpThreadView { pub fn new(project: Entity, window: &mut Window, cx: &mut Context) -> Self { + // todo!(): This should probably be contextual, like the terminal let Some(root_dir) = project .read(cx) .visible_worktrees(cx) @@ -62,7 +65,7 @@ impl AcpThreadView { let child = util::command::new_smol_command("node") .arg(cli_path) .arg("--acp") - .current_dir(root_dir) + .current_dir(&root_dir) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::inherit()) @@ -146,6 +149,7 @@ impl AcpThreadView { message_editor, send_task: None, list_state: list_state, + root: root_dir, } } diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 9c057baec9..220c47008e 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -199,6 +199,9 @@ impl Markdown { self.pending_parse.is_some() } + // Chunks: `Mark|down.md` You need to reparse every back tick, everytime + // `[foo.rs](foo.rs)` [`foo.rs`](foo.rs) `ba|r.rs` + pub fn source(&self) -> &str { &self.source } @@ -439,11 +442,25 @@ impl ParsedMarkdown { } } +// pub trait TextClickHandler { +// fn pattern(&self) -> +// fn hovered(&mut self, text: &str) -> bool; +// fn clicked(&mut self, text: &str); +// } +// const WORD_REGEX: &str = +// r#"[\$\+\w.\[\]:/\\@\-~()]+(?:\((?:\d+|\d+,\d+)\))|[\$\+\w.\[\]:/\\@\-~()]+"#; + +pub struct UrlHandler { + pub on_hover: Box bool>, + pub on_click: Box, +} + pub struct MarkdownElement { markdown: Entity, style: MarkdownStyle, code_block_renderer: CodeBlockRenderer, - on_url_click: Option>, + on_link_click: Option>, + url_handler: Option, } impl MarkdownElement { @@ -456,7 +473,8 @@ impl MarkdownElement { copy_button_on_hover: false, border: false, }, - on_url_click: None, + on_link_click: None, + url_handler: None, } } @@ -490,7 +508,12 @@ impl MarkdownElement { mut self, handler: impl Fn(SharedString, &mut Window, &mut App) + 'static, ) -> Self { - self.on_url_click = Some(Box::new(handler)); + self.on_link_click = Some(Box::new(handler)); + self + } + + pub fn handle_urls(mut self, handler: UrlHandler) -> Self { + self.url_handler = Some(handler); self } @@ -580,7 +603,7 @@ impl MarkdownElement { window.set_cursor_style(CursorStyle::IBeam, hitbox); } - let on_open_url = self.on_url_click.take(); + let on_open_url = self.on_link_click.take(); self.on_mouse_event(window, cx, { let rendered_text = rendered_text.clone(); @@ -591,6 +614,8 @@ impl MarkdownElement { if let Some(link) = rendered_text.link_for_position(event.position) { markdown.pressed_link = Some(link.clone()); } else { + // if + let source_index = match rendered_text.source_index_for_position(event.position) { Ok(ix) | Err(ix) => ix, @@ -1798,8 +1823,10 @@ impl RenderedText { #[cfg(test)] mod tests { + use std::cell::RefCell; + use super::*; - use gpui::{TestAppContext, size}; + use gpui::{Modifiers, MouseButton, TestAppContext, size}; #[gpui::test] fn test_mappings(cx: &mut TestAppContext) { @@ -1881,6 +1908,64 @@ mod tests { ); } + #[gpui::test] + fn test_url_handling(cx: &mut TestAppContext) { + let markdown = r#"hello `world` + Check out `https://zed.dev` for a great editor! + Also available locally: crates/ README.md, + "#; + + struct TestWindow; + + impl Render for TestWindow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + let (_, cx) = cx.add_window_view(|_, _| TestWindow); + let markdown = cx.new(|cx| Markdown::new(markdown.to_string().into(), None, None, cx)); + cx.run_until_parked(); + + let paths_hovered = Rc::new(RefCell::new(Vec::new())); + let paths_clicked = Rc::new(RefCell::new(Vec::new())); + + let handler = { + let paths_hovered = paths_hovered.clone(); + let paths_clicked = paths_clicked.clone(); + + UrlHandler { + on_hover: Box::new(move |path, _window, _app| { + paths_hovered.borrow_mut().push(path.to_string()); + true + }), + on_click: Box::new(move |path, _window, _app| { + paths_clicked.borrow_mut().push(path.to_string()); + }), + } + }; + + let (rendered, _) = cx.draw( + Default::default(), + size(px(600.0), px(600.0)), + |_window, _cx| { + MarkdownElement::new(markdown, MarkdownStyle::default()).handle_urls(handler) + }, + ); + + cx.simulate_mouse_move( + point(px(0.0), px(0.0)), + MouseButton::Left, + Modifiers::default(), + ); + + assert_eq!(paths_hovered.borrow().len(), 1) + } + + // To have a markdown document with paths and links in it + // We want to run a function + // and we want to get those paths and links out? + #[track_caller] fn assert_mappings(rendered: &RenderedText, expected: Vec>) { assert_eq!(rendered.lines.len(), expected.len(), "line count mismatch"); diff --git a/crates/project/src/direnv.rs b/crates/project/src/direnv.rs index 9ba0ad10e3..32f4963fd1 100644 --- a/crates/project/src/direnv.rs +++ b/crates/project/src/direnv.rs @@ -65,7 +65,7 @@ pub async fn load_direnv_environment( let output = String::from_utf8_lossy(&direnv_output.stdout); if output.is_empty() { // direnv outputs nothing when it has no changes to apply to environment variables - return Ok(HashMap::new()); + return Ok(HashMap::default()); } match serde_json::from_str(&output) {