diff --git a/Cargo.lock b/Cargo.lock index 36d08ec201..d4e7d08dee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,36 @@ # 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", + "log", + "markdown", + "parking_lot", + "project", + "serde_json", + "settings", + "smol", + "theme", + "ui", + "util", + "uuid", + "workspace-hack", + "zed_actions", +] + [[package]] name = "activity_indicator" version = "0.1.0" @@ -130,6 +160,7 @@ dependencies = [ name = "agent_ui" version = "0.1.0" dependencies = [ + "acp", "agent", "agent_settings", "anyhow", @@ -212,6 +243,21 @@ dependencies = [ "zed_llm_client", ] +[[package]] +name = "agentic-coding-protocol" +version = "0.0.1" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "futures 0.3.31", + "log", + "parking_lot", + "schemars", + "serde", + "serde_json", +] + [[package]] name = "ahash" version = "0.7.8" @@ -14056,6 +14102,7 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe8c9d1c68d67dd9f97ecbc6f932b60eb289c5dbddd8aa1405484a8fd2fcd984" dependencies = [ + "chrono", "dyn-clone", "indexmap", "ref-cast", diff --git a/Cargo.toml b/Cargo.toml index e7926f025f..52ec99e969 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "crates/activity_indicator", + "crates/acp", "crates/agent_ui", "crates/agent", "crates/agent_settings", @@ -215,8 +216,9 @@ edition = "2024" # Workspace member crates # -activity_indicator = { path = "crates/activity_indicator" } +acp = { path = "crates/acp" } agent = { path = "crates/agent" } +activity_indicator = { path = "crates/activity_indicator" } agent_ui = { path = "crates/agent_ui" } agent_settings = { path = "crates/agent_settings" } ai = { path = "crates/ai" } @@ -398,6 +400,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" @@ -480,7 +483,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" @@ -491,7 +494,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" @@ -531,7 +534,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/acp/Cargo.toml b/crates/acp/Cargo.toml new file mode 100644 index 0000000000..2b81dc03e9 --- /dev/null +++ b/crates/acp/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "acp" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/acp.rs" +doctest = false + +[features] +test-support = ["gpui/test-support", "project/test-support"] + +[dependencies] +agentic-coding-protocol = { path = "../../../agentic-coding-protocol" } +anyhow.workspace = true +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 +log.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 +workspace-hack.workspace = true +zed_actions.workspace = true + +[dev-dependencies] +env_logger.workspace = true +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/acp/LICENSE-GPL b/crates/acp/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/acp/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/acp/src/acp.rs b/crates/acp/src/acp.rs new file mode 100644 index 0000000000..31d9f66dfa --- /dev/null +++ b/crates/acp/src/acp.rs @@ -0,0 +1,677 @@ +mod server; +mod thread_view; + +use agentic_coding_protocol::{self as acp, Role}; +use anyhow::{Context as _, 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::{mem, ops::Range, path::PathBuf, sync::Arc}; +use ui::{App, IconName}; +use util::{ResultExt, debug_panic}; + +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, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FileContent { + pub path: PathBuf, + pub version: FileVersion, + pub content: SharedString, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Message { + 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 { + chunk: Entity, + }, + 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 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: cx.new(|cx| { + Markdown::new(chunk.to_owned().into(), Some(language_registry), None, cx) + }), + } + } +} + +#[derive(Debug)] +pub enum AgentThreadEntryContent { + Message(Message), + ToolCall(ToolCall), +} + +#[derive(Debug)] +pub struct ToolCall { + id: ToolCallId, + label: Entity, + icon: IconName, + status: ToolCallStatus, +} + +#[derive(Debug)] +pub enum ToolCallStatus { + WaitingForConfirmation { + confirmation: acp::ToolCallConfirmation, + respond_tx: oneshot::Sender, + }, + // todo! Running? + Allowed { + // todo! should this be variants in crate::ToolCallStatus instead? + status: acp::ToolCallStatus, + content: Option>, + }, + Rejected, +} + +/// A `ThreadEntryId` that is known to be a 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); + +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, +} + +enum AcpThreadEvent { + NewEntry, + EntryUpdated(usize), +} + +impl EventEmitter for AcpThread {} + +impl AcpThread { + pub fn new( + server: 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(), + server, + 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, + ) -> 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::EntryUpdated(entries_len - 1)); + + if let ( + Some(MessageChunk::Text { chunk: old_chunk }), + acp::MessageChunk::Text { chunk: new_chunk }, + ) = (chunks.last_mut(), &chunk) + { + old_chunk.update(cx, |old_chunk, cx| { + old_chunk.append(&new_chunk, cx); + }); + } else { + 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, + ); + } + + pub fn request_tool_call( + &mut self, + label: String, + icon: acp::Icon, + 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(label, status, icon, cx); + ToolCallRequest { id, outcome: rx } + } + + 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, 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(); + + let entry_id = self.push_entry( + AgentThreadEntryContent::ToolCall(ToolCall { + // todo! clean up id creation + id: ToolCallId(ThreadEntryId(self.entries.len() as u64)), + label: cx.new(|cx| { + Markdown::new(label.into(), Some(language_registry.clone()), None, cx) + }), + icon: acp_icon_to_ui_icon(icon), + status, + }), + cx, + ); + + ToolCallId(entry_id) + } + + 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; + }; + + let AgentThreadEntryContent::ToolCall(call) = &mut entry.content else { + debug_panic!("expected ToolCall"); + return; + }; + + let new_status = if outcome == acp::ToolCallConfirmationOutcome::Reject { + ToolCallStatus::Rejected + } else { + ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Running, + content: None, + } + }; + + let curr_status = mem::replace(&mut call.status, new_status); + + if let ToolCallStatus::WaitingForConfirmation { respond_tx, .. } = curr_status { + respond_tx.send(outcome).log_err(); + } else { + debug_panic!("tried to authorize an already authorized tool call"); + } + + 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!( + entry.is_some(), + "We shouldn't give out ids to entries that don't exist" + ); + 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(); + 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, acp_message, cx).await?; + Ok(()) + }) + } +} + +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, +} + +#[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 as _; + use std::{env, path::Path, process::Stdio, time::Duration}; + use util::path; + + fn init_test(cx: &mut TestAppContext) { + env_logger::try_init().ok(); + 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_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(); + + 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( + "Read the '/private/tmp/foo' file and tell me what you see.", + cx, + ) + }) + .await + .unwrap(); + thread.read_with(cx, |thread, _cx| { + assert!(matches!( + &thread.entries()[1].content, + AgentThreadEntryContent::ToolCall(ToolCall { + status: ToolCallStatus::Allowed { .. }, + .. + }) + )); + + 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, + status: + ToolCallStatus::WaitingForConfirmation { + confirmation: acp::ToolCallConfirmation::Execute { root_command, .. }, + .. + }, + .. + }) = &thread.entries()[1].content + else { + panic!(); + }; + + assert_eq!(root_command, "echo"); + + *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> { + 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") + .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(AcpServer::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..e1cdbbae40 --- /dev/null +++ b/crates/acp/src/server.rs @@ -0,0 +1,322 @@ +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 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 FnOnce(&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 { + let cx = &mut self.cx.clone(); + + cx.update(|cx| { + self.update_thread(¶ms.thread_id.into(), cx, |thread, cx| { + thread.push_assistant_chunk(params.chunk, 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(); + + 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!() + } + + async fn request_tool_call_confirmation( + &self, + request: acp::RequestToolCallConfirmationParams, + ) -> Result { + let cx = &mut self.cx.clone(); + let ToolCallRequest { id, outcome } = cx + .update(|cx| { + self.update_thread(&request.thread_id.into(), cx, |thread, cx| { + thread.request_tool_call(request.label, request.icon, request.confirmation, cx) + }) + })? + .context("Failed to update thread")?; + + Ok(acp::RequestToolCallConfirmationResponse { + id: id.into(), + outcome: outcome.await?, + }) + } + + 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.label, request.icon, cx) + }) + })? + .context("Failed to update thread")?; + + Ok(acp::PushToolCallResponse { + id: entry_id.into(), + }) + } + + 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 { + 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 { + // todo! + title: "ACP 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: acp::Message, + _cx: &mut AsyncApp, + ) -> Result<()> { + self.connection + .request(acp::SendMessageParams { + thread_id: thread_id.clone().into(), + message, + }) + .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()) + } +} + +impl From for ToolCallId { + fn from(tool_call_id: acp::ToolCallId) -> Self { + Self(ThreadEntryId(tool_call_id.0)) + } +} + +impl From for acp::ToolCallId { + fn from(tool_call_id: ToolCallId) -> Self { + acp::ToolCallId(tool_call_id.as_u64()) + } +} diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs new file mode 100644 index 0000000000..86a09d0383 --- /dev/null +++ b/crates/acp/src/thread_view.rs @@ -0,0 +1,935 @@ +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}; +use anyhow::Result; +use editor::{Editor, MultiBuffer}; +use gpui::{ + 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; +use markdown::{HeadingLevelStyles, MarkdownElement, MarkdownStyle}; +use project::Project; +use settings::Settings as _; +use theme::ThemeSettings; +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, ToolCallStatus, +}; + +pub struct AcpThreadView { + thread_state: ThreadState, + // todo! use full message editor from agent2 + message_editor: Entity, + list_state: ListState, + send_task: Option>>, + root: Arc, +} + +enum ThreadState { + Loading { + _task: Task<()>, + }, + Ready { + thread: Entity, + _subscription: Subscription, + }, + LoadError(SharedString), +} + +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) + .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") + .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)); + + let mut editor = Editor::new( + editor::EditorMode::AutoHeight { + min_lines: 4, + max_lines: None, + }, + buffer, + None, + window, + cx, + ); + editor.set_placeholder_text("Send a message", cx); + editor.set_soft_wrap(); + editor + }); + + 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::EntryUpdated(index) => { + this.list_state.splice(*index..*index + 1, 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( + 0, + gpui::ListAlignment::Bottom, + px(2048.0), + cx.processor({ + move |this: &mut Self, item: usize, window, cx| { + 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_state: ThreadState::Loading { _task: load_task }, + message_editor, + send_task: None, + list_state: list_state, + root: root_dir, + } + } + + fn thread(&self) -> Option<&Entity> { + match &self.thread_state { + ThreadState::Ready { thread, .. } => Some(thread), + ThreadState::Loading { .. } | ThreadState::LoadError(..) => None, + } + } + + pub fn title(&self, cx: &App) -> SharedString { + 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) { + 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; + } + let Some(thread) = self.thread() else { return }; + + let task = thread.update(cx, |thread, cx| thread.send(&text, 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 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, outcome, cx); + }); + cx.notify(); + } + + fn render_entry( + &self, + entry: &ThreadEntry, + 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!() open link + MarkdownElement::new(chunk.clone(), style.clone()) + } + _ => todo!(), + })) + .into_any(); + + match message.role { + Role::User => div() + .p_2() + .pt_5() + .child( + div() + .text_xs() + .p_3() + .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) + .p_5() + .pt_2() + .child(message_body) + .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 { + 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 + | ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Error, + .. + } => Icon::new(IconName::X) + .color(Color::Error) + .size(IconSize::Small) + .into_any_element(), + }; + + let content = match &tool_call.status { + 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) + .border_t_1() + .px_2() + .py_1p5() + .child(MarkdownElement::new( + content, + default_markdown_style(window, cx), + )) + .into_any_element() + }), + 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(tool_call.icon.into()) + .size(IconSize::Small) + .color(Color::Muted), + ) + // todo! danilo please help + .child(MarkdownElement::new( + tool_call.label.clone(), + default_markdown_style(window, cx), + )) + .child(div().w_full()) + .child(status_icon), + ) + .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, + description, + } => 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()) + .children(description.clone()) + .child( + h_flex() + .justify_end() + .gap_1() + .child( + 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") + .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, + description, + } => v_flex() + .border_color(cx.theme().colors().border) + .border_t_1() + .px_2() + .py_1p5() + // todo! nicer rendering + .child(command.clone()) + .children(description.clone()) + .child( + h_flex() + .justify_end() + .gap_1() + .child( + Button::new( + ("always_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, + description, + } => 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}")) + .children(description.clone()) + .child( + h_flex() + .justify_end() + .gap_1() + .child( + Button::new( + ("always_allow_server", 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( + ("always_allow_tool", 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::Fetch { description, urls } => v_flex() + .border_color(cx.theme().colors().border) + .border_t_1() + .px_2() + .py_1p5() + // todo! nicer rendering + .children(urls.clone()) + .children(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) + .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) + .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 { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.message_editor.focus_handle(cx) + } +} + +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(); + let focus_handle = self.message_editor.focus_handle(cx); + + v_flex() + .key_context("MessageEditor") + .on_action(cx.listener(Self::chat)) + .h_full() + .child(match &self.thread_state { + 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 { thread, .. } => 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().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() + .bg(cx.theme().colors().editor_background) + .border_t_1() + .border_color(cx.theme().colors().border) + .p_2() + .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)) + .tooltip(move |window, cx| { + Tooltip::for_action( + "Stop Generation", + &editor::actions::Cancel, + window, + cx, + ) + }) + .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")) + }) + })), + ) + } +} + +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() + } +} diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 070e8eb585..75c68b1644 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -13,12 +13,10 @@ 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 agent_settings.workspace = true anyhow.workspace = true diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 5f58e0bd8d..ce38ff023b 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -7,6 +7,7 @@ use std::time::Duration; 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 +110,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); @@ -125,6 +132,7 @@ pub fn init(cx: &mut App) { let thread = thread.read(cx).thread().clone(); AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx); } + ActiveView::AcpThread { .. } => todo!(), ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} @@ -188,6 +196,9 @@ enum ActiveView { message_editor: Entity, _subscriptions: Vec, }, + AcpThread { + thread_view: Entity, + }, TextThread { context_editor: Entity, title_editor: Entity, @@ -207,7 +218,9 @@ enum WhichFontSize { impl ActiveView { pub fn which_font_size_used(&self) -> WhichFontSize { match self { - ActiveView::Thread { .. } | ActiveView::History => WhichFontSize::AgentFont, + ActiveView::Thread { .. } | ActiveView::AcpThread { .. } | ActiveView::History => { + WhichFontSize::AgentFont + } ActiveView::TextThread { .. } => WhichFontSize::BufferFont, ActiveView::Configuration => WhichFontSize::None, } @@ -238,6 +251,9 @@ impl ActiveView { thread.scroll_to_bottom(cx); }); } + ActiveView::AcpThread { .. } => { + // todo! + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} @@ -653,6 +669,9 @@ impl AgentPanel { .clone() .update(cx, |thread, cx| thread.get_or_init_configured_model(cx)); } + ActiveView::AcpThread { .. } => { + // todo! + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} @@ -733,6 +752,9 @@ impl AgentPanel { ActiveView::Thread { thread, .. } => { thread.update(cx, |thread, cx| thread.cancel_last_completion(window, cx)); } + ActiveView::AcpThread { thread_view, .. } => { + thread_view.update(cx, |thread_element, _cx| thread_element.cancel()); + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} } } @@ -740,6 +762,10 @@ impl AgentPanel { fn active_message_editor(&self) -> Option<&Entity> { match &self.active_view { ActiveView::Thread { message_editor, .. } => Some(message_editor), + ActiveView::AcpThread { .. } => { + // todo! + None + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None, } } @@ -862,6 +888,19 @@ impl AgentPanel { context_editor.focus_handle(cx).focus(window); } + fn new_gemini_thread(&mut self, window: &mut Window, cx: &mut Context) { + let project = self.project.clone(); + + cx.spawn_in(window, async move |this, cx| { + let thread_view = + 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); + }) + }) + .detach(); + } + fn deploy_rules_library( &mut self, action: &OpenRulesLibrary, @@ -994,6 +1033,7 @@ impl AgentPanel { cx, ) }); + let message_editor = cx.new(|cx| { MessageEditor::new( self.fs.clone(), @@ -1018,6 +1058,7 @@ impl AgentPanel { 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; @@ -1025,6 +1066,9 @@ impl AgentPanel { ActiveView::Thread { message_editor, .. } => { message_editor.focus_handle(cx).focus(window); } + ActiveView::AcpThread { .. } => { + todo!() + } ActiveView::TextThread { context_editor, .. } => { context_editor.focus_handle(cx).focus(window); } @@ -1144,6 +1188,7 @@ impl AgentPanel { }) .log_err(); } + ActiveView::AcpThread { .. } => todo!(), ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} } } @@ -1197,6 +1242,9 @@ impl AgentPanel { ) .detach_and_log_err(cx); } + ActiveView::AcpThread { .. } => { + todo!() + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} } } @@ -1231,6 +1279,10 @@ 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::AcpThread { .. } => { + // todo! + None + } _ => None, } } @@ -1336,6 +1388,9 @@ impl AgentPanel { }); } } + ActiveView::AcpThread { .. } => { + // todo! + } _ => {} } @@ -1351,6 +1406,9 @@ impl AgentPanel { } }) } + ActiveView::AcpThread { .. } => { + // todo! push history entry + } _ => {} } @@ -1437,6 +1495,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_view, .. } => thread_view.focus_handle(cx), ActiveView::History => self.history.focus_handle(cx), ActiveView::TextThread { context_editor, .. } => context_editor.focus_handle(cx), ActiveView::Configuration => { @@ -1593,6 +1652,9 @@ impl AgentPanel { .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, @@ -1727,6 +1789,10 @@ impl AgentPanel { let active_thread = match &self.active_view { ActiveView::Thread { thread, .. } => Some(thread.read(cx).thread().clone()), + ActiveView::AcpThread { .. } => { + // todo! + None + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None, }; @@ -1755,6 +1821,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_some(active_thread, |this, active_thread| { let thread = active_thread.read(cx); if !thread.is_empty() { @@ -1893,6 +1960,10 @@ impl AgentPanel { message_editor, .. } => (thread.read(cx), message_editor.read(cx)), + ActiveView::AcpThread { .. } => { + // todo! + return None; + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { return None; } @@ -2031,6 +2102,10 @@ impl AgentPanel { return false; } } + ActiveView::AcpThread { .. } => { + // todo! + return false; + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { return false; } @@ -2615,6 +2690,10 @@ impl AgentPanel { ) -> Option { let active_thread = match &self.active_view { ActiveView::Thread { thread, .. } => thread, + ActiveView::AcpThread { .. } => { + // todo! + return None; + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { return None; } @@ -2961,6 +3040,9 @@ impl AgentPanel { .detach(); }); } + ActiveView::AcpThread { .. } => { + unimplemented!() + } ActiveView::TextThread { context_editor, .. } => { context_editor.update(cx, |context_editor, cx| { TextThreadEditor::insert_dragged_files( @@ -3034,6 +3116,9 @@ impl Render for AgentPanel { }); this.continue_conversation(window, cx); } + ActiveView::AcpThread { .. } => { + todo!() + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} @@ -3075,6 +3160,12 @@ impl Render for AgentPanel { }) .child(h_flex().child(message_editor.clone())) .child(self.render_drag_target(cx)), + ActiveView::AcpThread { thread_view, .. } => parent + .relative() + .child(thread_view.clone()) + // todo! + // .child(h_flex().child(self.message_editor.clone())) + .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 b5ab5a147e..55a1cba94f 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, @@ -65,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 70d2b6e066..d8d97853dc 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/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) { 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) } diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index fa852084d6..f039679f1f 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -205,7 +205,12 @@ pub mod agent { actions!( agent, - [OpenConfiguration, OpenOnboardingModal, ResetOnboarding] + [ + OpenConfiguration, + OpenOnboardingModal, + ResetOnboarding, + Chat + ] ); }