diff --git a/Cargo.lock b/Cargo.lock index 5c22b90526..0815155ee6 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 = [ + "agent_servers", + "agentic-coding-protocol", + "anyhow", + "async-pipe", + "buffer_diff", + "editor", + "env_logger 0.11.8", + "futures 0.3.31", + "gpui", + "indoc", + "itertools 0.14.0", + "language", + "markdown", + "project", + "serde_json", + "settings", + "smol", + "tempfile", + "ui", + "util", + "workspace-hack", +] + [[package]] name = "activity_indicator" version = "0.1.0" @@ -107,6 +134,24 @@ dependencies = [ "zstd", ] +[[package]] +name = "agent_servers" +version = "0.1.0" +dependencies = [ + "anyhow", + "collections", + "futures 0.3.31", + "gpui", + "paths", + "project", + "schemars", + "serde", + "settings", + "util", + "which 6.0.3", + "workspace-hack", +] + [[package]] name = "agent_settings" version = "0.1.0" @@ -130,8 +175,11 @@ dependencies = [ name = "agent_ui" version = "0.1.0" dependencies = [ + "acp", "agent", + "agent_servers", "agent_settings", + "agentic-coding-protocol", "anyhow", "assistant_context", "assistant_slash_command", @@ -191,6 +239,7 @@ dependencies = [ "settings", "smol", "streaming_diff", + "task", "telemetry", "telemetry_events", "terminal", @@ -212,6 +261,22 @@ dependencies = [ "zed_llm_client", ] +[[package]] +name = "agentic-coding-protocol" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b962eee17ee3924870d9b9d28cc8b6dcb5421e4d4e81cd864226374a122ceed1" +dependencies = [ + "anyhow", + "chrono", + "futures 0.3.31", + "log", + "parking_lot", + "schemars", + "serde", + "serde_json", +] + [[package]] name = "ahash" version = "0.7.8" @@ -14078,6 +14143,7 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe8c9d1c68d67dd9f97ecbc6f932b60eb289c5dbddd8aa1405484a8fd2fcd984" dependencies = [ + "chrono", "dyn-clone", "indexmap", "ref-cast", @@ -19579,6 +19645,7 @@ dependencies = [ "rustix 1.0.7", "rustls 0.23.26", "rustls-webpki 0.103.1", + "schemars", "scopeguard", "sea-orm", "sea-query-binder", @@ -19976,6 +20043,7 @@ version = "0.196.0" dependencies = [ "activity_indicator", "agent", + "agent_servers", "agent_settings", "agent_ui", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index a4d8b3cb95..aac12b7ff8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,9 +2,11 @@ resolver = "2" members = [ "crates/activity_indicator", + "crates/acp", "crates/agent_ui", "crates/agent", "crates/agent_settings", + "crates/agent_servers", "crates/anthropic", "crates/askpass", "crates/assets", @@ -216,10 +218,12 @@ 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" } +agent_servers = { path = "crates/agent_servers" } ai = { path = "crates/ai" } anthropic = { path = "crates/anthropic" } askpass = { path = "crates/askpass" } @@ -400,6 +404,7 @@ zlog_settings = { path = "crates/zlog_settings" } # External crates # +agentic-coding-protocol = "0.0.5" 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/assets/icons/ai_gemini.svg b/assets/icons/ai_gemini.svg new file mode 100644 index 0000000000..60197dc4ad --- /dev/null +++ b/assets/icons/ai_gemini.svg @@ -0,0 +1 @@ +Google Gemini diff --git a/assets/icons/tool_bulb.svg b/assets/icons/tool_bulb.svg new file mode 100644 index 0000000000..54d5ac5fd7 --- /dev/null +++ b/assets/icons/tool_bulb.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/tool_folder.svg b/assets/icons/tool_folder.svg new file mode 100644 index 0000000000..9d3ac299d2 --- /dev/null +++ b/assets/icons/tool_folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/tool_hammer.svg b/assets/icons/tool_hammer.svg new file mode 100644 index 0000000000..e66173ce70 --- /dev/null +++ b/assets/icons/tool_hammer.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/tool_pencil.svg b/assets/icons/tool_pencil.svg new file mode 100644 index 0000000000..b913015c08 --- /dev/null +++ b/assets/icons/tool_pencil.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/tool_regex.svg b/assets/icons/tool_regex.svg new file mode 100644 index 0000000000..0432cd570f --- /dev/null +++ b/assets/icons/tool_regex.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/tool_search.svg b/assets/icons/tool_search.svg new file mode 100644 index 0000000000..4f2750cfa2 --- /dev/null +++ b/assets/icons/tool_search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/tool_terminal.svg b/assets/icons/tool_terminal.svg new file mode 100644 index 0000000000..5154fa8e70 --- /dev/null +++ b/assets/icons/tool_terminal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/tool_web.svg b/assets/icons/tool_web.svg new file mode 100644 index 0000000000..49e9544b4a --- /dev/null +++ b/assets/icons/tool_web.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 6f50945828..a19bc77dcc 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -306,6 +306,15 @@ "enter": "agent::AcceptSuggestedContext" } }, + { + "context": "AcpThread > Editor", + "use_key_equivalents": true, + "bindings": { + "enter": "agent::Chat", + "up": "agent::PreviousHistoryMessage", + "down": "agent::NextHistoryMessage" + } + }, { "context": "ThreadHistory", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index cbc90c05e6..875658c5a0 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -357,6 +357,15 @@ "ctrl--": "pane::GoBack" } }, + { + "context": "AcpThread > Editor", + "use_key_equivalents": true, + "bindings": { + "enter": "agent::Chat", + "up": "agent::PreviousHistoryMessage", + "down": "agent::NextHistoryMessage" + } + }, { "context": "ThreadHistory", "bindings": { diff --git a/assets/settings/default.json b/assets/settings/default.json index 8c105b2c1e..08daa236ab 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1855,6 +1855,8 @@ "read_ssh_config": true, // Configures context servers for use by the agent. "context_servers": {}, + // Configures agent servers available in the agent panel. + "agent_servers": {}, "debugger": { "stepping_granularity": "line", "save_breakpoints": true, diff --git a/crates/acp/Cargo.toml b/crates/acp/Cargo.toml new file mode 100644 index 0000000000..dae6292e28 --- /dev/null +++ b/crates/acp/Cargo.toml @@ -0,0 +1,46 @@ +[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"] +gemini = [] + +[dependencies] +agent_servers.workspace = true +agentic-coding-protocol.workspace = true +anyhow.workspace = true +buffer_diff.workspace = true +editor.workspace = true +futures.workspace = true +gpui.workspace = true +itertools.workspace = true +language.workspace = true +markdown.workspace = true +project.workspace = true +settings.workspace = true +smol.workspace = true +ui.workspace = true +util.workspace = true +workspace-hack.workspace = true + +[dev-dependencies] +async-pipe.workspace = true +env_logger.workspace = true +gpui = { workspace = true, "features" = ["test-support"] } +indoc.workspace = true +project = { workspace = true, "features" = ["test-support"] } +serde_json.workspace = true +tempfile.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..ce83618288 --- /dev/null +++ b/crates/acp/src/acp.rs @@ -0,0 +1,1625 @@ +pub use acp::ToolCallId; +use agent_servers::AgentServer; +use agentic_coding_protocol::{self as acp, UserMessageChunk}; +use anyhow::{Context as _, Result, anyhow}; +use buffer_diff::BufferDiff; +use editor::{MultiBuffer, PathKey}; +use futures::{FutureExt, channel::oneshot, future::BoxFuture}; +use gpui::{AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity}; +use itertools::Itertools; +use language::{Anchor, Buffer, Capability, LanguageRegistry, OffsetRangeExt as _}; +use markdown::Markdown; +use project::Project; +use std::error::Error; +use std::fmt::{Formatter, Write}; +use std::{ + fmt::Display, + mem, + path::{Path, PathBuf}, + sync::Arc, +}; +use ui::{App, IconName}; +use util::ResultExt; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserMessage { + pub content: Entity, +} + +impl UserMessage { + pub fn from_acp( + message: acp::UserMessage, + language_registry: Arc, + cx: &mut App, + ) -> Self { + let mut md_source = String::new(); + + for chunk in message.chunks { + match chunk { + UserMessageChunk::Text { chunk } => md_source.push_str(&chunk), + UserMessageChunk::Path { path } => { + write!(&mut md_source, "{}", MentionPath(&path)).unwrap() + } + } + } + + Self { + content: cx + .new(|cx| Markdown::new(md_source.into(), Some(language_registry), None, cx)), + } + } + + fn to_markdown(&self, cx: &App) -> String { + format!("## User\n\n{}\n\n", self.content.read(cx).source()) + } +} + +#[derive(Debug)] +pub struct MentionPath<'a>(&'a Path); + +impl<'a> MentionPath<'a> { + const PREFIX: &'static str = "@file:"; + + pub fn new(path: &'a Path) -> Self { + MentionPath(path) + } + + pub fn try_parse(url: &'a str) -> Option { + let path = url.strip_prefix(Self::PREFIX)?; + Some(MentionPath(Path::new(path))) + } + + pub fn path(&self) -> &Path { + self.0 + } +} + +impl Display for MentionPath<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "[@{}]({}{})", + self.0.file_name().unwrap_or_default().display(), + Self::PREFIX, + self.0.display() + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AssistantMessage { + pub chunks: Vec, +} + +impl AssistantMessage { + fn to_markdown(&self, cx: &App) -> String { + format!( + "## Assistant\n\n{}\n\n", + self.chunks + .iter() + .map(|chunk| chunk.to_markdown(cx)) + .join("\n\n") + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AssistantMessageChunk { + Text { chunk: Entity }, + Thought { chunk: Entity }, +} + +impl AssistantMessageChunk { + pub fn from_acp( + chunk: acp::AssistantMessageChunk, + language_registry: Arc, + cx: &mut App, + ) -> Self { + match chunk { + acp::AssistantMessageChunk::Text { chunk } => Self::Text { + chunk: cx.new(|cx| Markdown::new(chunk.into(), Some(language_registry), None, cx)), + }, + acp::AssistantMessageChunk::Thought { chunk } => Self::Thought { + chunk: cx.new(|cx| Markdown::new(chunk.into(), Some(language_registry), None, cx)), + }, + } + } + + pub fn from_str(chunk: &str, language_registry: Arc, cx: &mut App) -> Self { + Self::Text { + chunk: cx.new(|cx| { + Markdown::new(chunk.to_owned().into(), Some(language_registry), None, cx) + }), + } + } + + fn to_markdown(&self, cx: &App) -> String { + match self { + Self::Text { chunk } => chunk.read(cx).source().to_string(), + Self::Thought { chunk } => { + format!("\n{}\n", chunk.read(cx).source()) + } + } + } +} + +#[derive(Debug)] +pub enum AgentThreadEntry { + UserMessage(UserMessage), + AssistantMessage(AssistantMessage), + ToolCall(ToolCall), +} + +impl AgentThreadEntry { + fn to_markdown(&self, cx: &App) -> String { + match self { + Self::UserMessage(message) => message.to_markdown(cx), + Self::AssistantMessage(message) => message.to_markdown(cx), + Self::ToolCall(too_call) => too_call.to_markdown(cx), + } + } +} + +#[derive(Debug)] +pub struct ToolCall { + pub id: acp::ToolCallId, + pub label: Entity, + pub icon: IconName, + pub content: Option, + pub status: ToolCallStatus, +} + +impl ToolCall { + fn to_markdown(&self, cx: &App) -> String { + let mut markdown = format!( + "**Tool Call: {}**\nStatus: {}\n\n", + self.label.read(cx).source(), + self.status + ); + if let Some(content) = &self.content { + markdown.push_str(content.to_markdown(cx).as_str()); + markdown.push_str("\n\n"); + } + markdown + } +} + +#[derive(Debug)] +pub enum ToolCallStatus { + WaitingForConfirmation { + confirmation: ToolCallConfirmation, + respond_tx: oneshot::Sender, + }, + Allowed { + status: acp::ToolCallStatus, + }, + Rejected, + Canceled, +} + +impl Display for ToolCallStatus { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + ToolCallStatus::WaitingForConfirmation { .. } => "Waiting for confirmation", + ToolCallStatus::Allowed { status } => match status { + acp::ToolCallStatus::Running => "Running", + acp::ToolCallStatus::Finished => "Finished", + acp::ToolCallStatus::Error => "Error", + }, + ToolCallStatus::Rejected => "Rejected", + ToolCallStatus::Canceled => "Canceled", + } + ) + } +} + +#[derive(Debug)] +pub enum ToolCallConfirmation { + Edit { + description: Option>, + }, + Execute { + command: String, + root_command: String, + description: Option>, + }, + Mcp { + server_name: String, + tool_name: String, + tool_display_name: String, + description: Option>, + }, + Fetch { + urls: Vec, + description: Option>, + }, + Other { + description: Entity, + }, +} + +impl ToolCallConfirmation { + pub fn from_acp( + confirmation: acp::ToolCallConfirmation, + language_registry: Arc, + cx: &mut App, + ) -> Self { + let to_md = |description: String, cx: &mut App| -> Entity { + cx.new(|cx| { + Markdown::new( + description.into(), + Some(language_registry.clone()), + None, + cx, + ) + }) + }; + + match confirmation { + acp::ToolCallConfirmation::Edit { description } => Self::Edit { + description: description.map(|description| to_md(description, cx)), + }, + acp::ToolCallConfirmation::Execute { + command, + root_command, + description, + } => Self::Execute { + command, + root_command, + description: description.map(|description| to_md(description, cx)), + }, + acp::ToolCallConfirmation::Mcp { + server_name, + tool_name, + tool_display_name, + description, + } => Self::Mcp { + server_name, + tool_name, + tool_display_name, + description: description.map(|description| to_md(description, cx)), + }, + acp::ToolCallConfirmation::Fetch { urls, description } => Self::Fetch { + urls: urls.iter().map(|url| url.into()).collect(), + description: description.map(|description| to_md(description, cx)), + }, + acp::ToolCallConfirmation::Other { description } => Self::Other { + description: to_md(description, cx), + }, + } + } +} + +#[derive(Debug)] +pub enum ToolCallContent { + Markdown { markdown: Entity }, + Diff { diff: Diff }, +} + +impl ToolCallContent { + pub fn from_acp( + content: acp::ToolCallContent, + language_registry: Arc, + cx: &mut App, + ) -> Self { + match content { + acp::ToolCallContent::Markdown { markdown } => Self::Markdown { + markdown: cx.new(|cx| Markdown::new_text(markdown.into(), cx)), + }, + acp::ToolCallContent::Diff { diff } => Self::Diff { + diff: Diff::from_acp(diff, language_registry, cx), + }, + } + } + + fn to_markdown(&self, cx: &App) -> String { + match self { + Self::Markdown { markdown } => markdown.read(cx).source().to_string(), + Self::Diff { diff } => diff.to_markdown(cx), + } + } +} + +#[derive(Debug)] +pub struct Diff { + pub multibuffer: Entity, + pub path: PathBuf, + _task: Task>, +} + +impl Diff { + pub fn from_acp( + diff: acp::Diff, + language_registry: Arc, + cx: &mut App, + ) -> Self { + let acp::Diff { + path, + old_text, + new_text, + } = diff; + + let multibuffer = cx.new(|_cx| MultiBuffer::without_headers(Capability::ReadOnly)); + + let new_buffer = cx.new(|cx| Buffer::local(new_text, cx)); + let old_buffer = cx.new(|cx| Buffer::local(old_text.unwrap_or("".into()), cx)); + let new_buffer_snapshot = new_buffer.read(cx).text_snapshot(); + let old_buffer_snapshot = old_buffer.read(cx).snapshot(); + let buffer_diff = cx.new(|cx| BufferDiff::new(&new_buffer_snapshot, cx)); + let diff_task = buffer_diff.update(cx, |diff, cx| { + diff.set_base_text( + old_buffer_snapshot, + Some(language_registry.clone()), + new_buffer_snapshot, + cx, + ) + }); + + let task = cx.spawn({ + let multibuffer = multibuffer.clone(); + let path = path.clone(); + async move |cx| { + diff_task.await?; + + multibuffer + .update(cx, |multibuffer, cx| { + let hunk_ranges = { + let buffer = new_buffer.read(cx); + let diff = buffer_diff.read(cx); + diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, cx) + .map(|diff_hunk| diff_hunk.buffer_range.to_point(&buffer)) + .collect::>() + }; + + multibuffer.set_excerpts_for_path( + PathKey::for_buffer(&new_buffer, cx), + new_buffer.clone(), + hunk_ranges, + editor::DEFAULT_MULTIBUFFER_CONTEXT, + cx, + ); + multibuffer.add_diff(buffer_diff.clone(), cx); + }) + .log_err(); + + if let Some(language) = language_registry + .language_for_file_path(&path) + .await + .log_err() + { + new_buffer.update(cx, |buffer, cx| buffer.set_language(Some(language), cx))?; + } + + anyhow::Ok(()) + } + }); + + Self { + multibuffer, + path, + _task: task, + } + } + + fn to_markdown(&self, cx: &App) -> String { + let buffer_text = self + .multibuffer + .read(cx) + .all_buffers() + .iter() + .map(|buffer| buffer.read(cx).text()) + .join("\n"); + format!("Diff: {}\n```\n{}\n```\n", self.path.display(), buffer_text) + } +} + +pub struct AcpThread { + entries: Vec, + title: SharedString, + project: Entity, + send_task: Option>, + connection: Arc, + child_status: Option>>, + _io_task: Task<()>, +} + +pub enum AcpThreadEvent { + NewEntry, + EntryUpdated(usize), +} + +impl EventEmitter for AcpThread {} + +#[derive(PartialEq, Eq)] +pub enum ThreadStatus { + Idle, + WaitingForToolConfirmation, + Generating, +} + +#[derive(Debug, Clone)] +pub enum LoadError { + Unsupported { current_version: SharedString }, + Exited(i32), + Other(SharedString), +} + +impl Display for LoadError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + LoadError::Unsupported { current_version } => { + write!( + f, + "Your installed version of Gemini {} doesn't support the Agentic Coding Protocol (ACP).", + current_version + ) + } + LoadError::Exited(status) => write!(f, "Server exited with status {}", status), + LoadError::Other(msg) => write!(f, "{}", msg), + } + } +} + +impl Error for LoadError {} + +impl AcpThread { + pub async fn spawn( + server: impl AgentServer + 'static, + root_dir: &Path, + project: Entity, + cx: &mut AsyncApp, + ) -> Result> { + let command = match server.command(&project, cx).await { + Ok(command) => command, + Err(e) => return Err(anyhow!(LoadError::Other(format!("{e}").into()))), + }; + + let mut child = util::command::new_smol_command(&command.path) + .args(command.args.iter()) + .current_dir(root_dir) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true) + .spawn()?; + + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + + cx.new(|cx| { + let foreground_executor = cx.foreground_executor().clone(); + + let (connection, io_fut) = acp::AgentConnection::connect_to_agent( + AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async()), + stdin, + stdout, + move |fut| foreground_executor.spawn(fut).detach(), + ); + + let io_task = cx.background_spawn(async move { + io_fut.await.log_err(); + }); + + let child_status = cx.background_spawn(async move { + match child.status().await { + Err(e) => Err(anyhow!(e)), + Ok(result) if result.success() => Ok(()), + Ok(result) => { + if let Some(version) = server.version(&command).await.log_err() + && !version.supported + { + Err(anyhow!(LoadError::Unsupported { + current_version: version.current_version + })) + } else { + Err(anyhow!(LoadError::Exited(result.code().unwrap_or(-127)))) + } + } + } + }); + + Self { + entries: Default::default(), + title: "ACP Thread".into(), + project, + send_task: None, + connection: Arc::new(connection), + child_status: Some(child_status), + _io_task: io_task, + } + }) + } + + #[cfg(test)] + pub fn fake( + stdin: async_pipe::PipeWriter, + stdout: async_pipe::PipeReader, + project: Entity, + cx: &mut Context, + ) -> Self { + let foreground_executor = cx.foreground_executor().clone(); + + let (connection, io_fut) = acp::AgentConnection::connect_to_agent( + AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async()), + stdin, + stdout, + move |fut| { + foreground_executor.spawn(fut).detach(); + }, + ); + + let io_task = cx.background_spawn({ + async move { + io_fut.await.log_err(); + } + }); + + Self { + entries: Default::default(), + title: "ACP Thread".into(), + project, + send_task: None, + connection: Arc::new(connection), + child_status: None, + _io_task: io_task, + } + } + + pub fn title(&self) -> SharedString { + self.title.clone() + } + + pub fn entries(&self) -> &[AgentThreadEntry] { + &self.entries + } + + pub fn status(&self) -> ThreadStatus { + if self.send_task.is_some() { + if self.waiting_for_tool_confirmation() { + ThreadStatus::WaitingForToolConfirmation + } else { + ThreadStatus::Generating + } + } else { + ThreadStatus::Idle + } + } + + pub fn push_entry(&mut self, entry: AgentThreadEntry, cx: &mut Context) { + self.entries.push(entry); + cx.emit(AcpThreadEvent::NewEntry); + } + + pub fn push_assistant_chunk( + &mut self, + chunk: acp::AssistantMessageChunk, + cx: &mut Context, + ) { + let entries_len = self.entries.len(); + if let Some(last_entry) = self.entries.last_mut() + && let AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) = last_entry + { + cx.emit(AcpThreadEvent::EntryUpdated(entries_len - 1)); + + match (chunks.last_mut(), &chunk) { + ( + Some(AssistantMessageChunk::Text { chunk: old_chunk }), + acp::AssistantMessageChunk::Text { chunk: new_chunk }, + ) + | ( + Some(AssistantMessageChunk::Thought { chunk: old_chunk }), + acp::AssistantMessageChunk::Thought { chunk: new_chunk }, + ) => { + old_chunk.update(cx, |old_chunk, cx| { + old_chunk.append(&new_chunk, cx); + }); + } + _ => { + chunks.push(AssistantMessageChunk::from_acp( + chunk, + self.project.read(cx).languages().clone(), + cx, + )); + } + } + } else { + let chunk = AssistantMessageChunk::from_acp( + chunk, + self.project.read(cx).languages().clone(), + cx, + ); + + self.push_entry( + AgentThreadEntry::AssistantMessage(AssistantMessage { + chunks: vec![chunk], + }), + cx, + ); + } + } + + pub fn request_tool_call( + &mut self, + label: String, + icon: acp::Icon, + content: Option, + confirmation: acp::ToolCallConfirmation, + cx: &mut Context, + ) -> ToolCallRequest { + let (tx, rx) = oneshot::channel(); + + let status = ToolCallStatus::WaitingForConfirmation { + confirmation: ToolCallConfirmation::from_acp( + confirmation, + self.project.read(cx).languages().clone(), + cx, + ), + respond_tx: tx, + }; + + let id = self.insert_tool_call(label, status, icon, content, cx); + ToolCallRequest { id, outcome: rx } + } + + pub fn push_tool_call( + &mut self, + label: String, + icon: acp::Icon, + content: Option, + cx: &mut Context, + ) -> acp::ToolCallId { + let status = ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Running, + }; + + self.insert_tool_call(label, status, icon, content, cx) + } + + fn insert_tool_call( + &mut self, + label: String, + status: ToolCallStatus, + icon: acp::Icon, + content: Option, + cx: &mut Context, + ) -> acp::ToolCallId { + let language_registry = self.project.read(cx).languages().clone(); + let id = acp::ToolCallId(self.entries.len() as u64); + + self.push_entry( + AgentThreadEntry::ToolCall(ToolCall { + id, + label: cx.new(|cx| { + Markdown::new(label.into(), Some(language_registry.clone()), None, cx) + }), + icon: acp_icon_to_ui_icon(icon), + content: content + .map(|content| ToolCallContent::from_acp(content, language_registry, cx)), + status, + }), + cx, + ); + + id + } + + pub fn authorize_tool_call( + &mut self, + id: acp::ToolCallId, + outcome: acp::ToolCallConfirmationOutcome, + cx: &mut Context, + ) { + let Some((ix, call)) = self.tool_call_mut(id) else { + return; + }; + + let new_status = if outcome == acp::ToolCallConfirmationOutcome::Reject { + ToolCallStatus::Rejected + } else { + ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Running, + } + }; + + 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 if cfg!(debug_assertions) { + panic!("tried to authorize an already authorized tool call"); + } + + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + } + + pub fn update_tool_call( + &mut self, + id: acp::ToolCallId, + new_status: acp::ToolCallStatus, + new_content: Option, + cx: &mut Context, + ) -> Result<()> { + let language_registry = self.project.read(cx).languages().clone(); + let (ix, call) = self.tool_call_mut(id).context("Entry not found")?; + + call.content = new_content + .map(|new_content| ToolCallContent::from_acp(new_content, language_registry, cx)); + + match &mut call.status { + ToolCallStatus::Allowed { status } => { + *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") + } + ToolCallStatus::Canceled => { + call.status = ToolCallStatus::Allowed { status: new_status }; + } + } + + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + Ok(()) + } + + fn tool_call_mut(&mut self, id: acp::ToolCallId) -> Option<(usize, &mut ToolCall)> { + 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" + ); + match entry { + Some(AgentThreadEntry::ToolCall(call)) if call.id == id => Some((id.0 as usize, call)), + _ => { + if cfg!(debug_assertions) { + panic!("entry is not a tool call"); + } + None + } + } + } + + /// 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 { + AgentThreadEntry::ToolCall(call) => match call.status { + ToolCallStatus::WaitingForConfirmation { .. } => return true, + ToolCallStatus::Allowed { .. } + | ToolCallStatus::Rejected + | ToolCallStatus::Canceled => continue, + }, + AgentThreadEntry::UserMessage(_) | AgentThreadEntry::AssistantMessage(_) => { + // Reached the beginning of the turn + return false; + } + } + } + false + } + + pub fn initialize(&self) -> impl use<> + Future> { + let connection = self.connection.clone(); + async move { Ok(connection.request(acp::InitializeParams).await?) } + } + + pub fn authenticate(&self) -> impl use<> + Future> { + let connection = self.connection.clone(); + async move { Ok(connection.request(acp::AuthenticateParams).await?) } + } + + pub fn send( + &mut self, + message: impl Into, + cx: &mut Context, + ) -> BoxFuture<'static, Result<()>> { + let agent = self.connection.clone(); + let message = message.into(); + self.push_entry( + AgentThreadEntry::UserMessage(UserMessage::from_acp( + message.clone(), + self.project.read(cx).languages().clone(), + cx, + )), + cx, + ); + + let (tx, rx) = oneshot::channel(); + let cancel = self.cancel(cx); + + self.send_task = Some(cx.spawn(async move |this, cx| { + cancel.await.log_err(); + + let result = agent.request(acp::SendUserMessageParams { message }).await; + tx.send(result).log_err(); + this.update(cx, |this, _cx| this.send_task.take()).log_err(); + })); + + async move { + match rx.await { + Ok(Err(e)) => Err(e)?, + _ => Ok(()), + } + } + .boxed() + } + + pub fn cancel(&mut self, cx: &mut Context) -> Task> { + let agent = self.connection.clone(); + + if self.send_task.take().is_some() { + cx.spawn(async move |this, cx| { + agent.request(acp::CancelSendMessageParams).await?; + + this.update(cx, |this, _cx| { + for entry in this.entries.iter_mut() { + if let AgentThreadEntry::ToolCall(call) = entry { + let cancel = matches!( + call.status, + ToolCallStatus::WaitingForConfirmation { .. } + | ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Running + } + ); + + if cancel { + let curr_status = + mem::replace(&mut call.status, ToolCallStatus::Canceled); + + if let ToolCallStatus::WaitingForConfirmation { + respond_tx, .. + } = curr_status + { + respond_tx + .send(acp::ToolCallConfirmationOutcome::Cancel) + .ok(); + } + } + } + } + }) + }) + } else { + Task::ready(Ok(())) + } + } + + pub fn child_status(&mut self) -> Option>> { + self.child_status.take() + } + + pub fn to_markdown(&self, cx: &App) -> String { + self.entries.iter().map(|e| e.to_markdown(cx)).collect() + } +} + +struct AcpClientDelegate { + thread: WeakEntity, + cx: AsyncApp, + // sent_buffer_versions: HashMap, HashMap>, +} + +impl AcpClientDelegate { + fn new(thread: WeakEntity, cx: AsyncApp) -> Self { + Self { thread, cx } + } +} + +impl acp::Client for AcpClientDelegate { + async fn stream_assistant_message_chunk( + &self, + params: acp::StreamAssistantMessageChunkParams, + ) -> Result<()> { + let cx = &mut self.cx.clone(); + + cx.update(|cx| { + self.thread + .update(cx, |thread, cx| { + thread.push_assistant_chunk(params.chunk, cx) + }) + .ok(); + })?; + + Ok(()) + } + + 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.thread.update(cx, |thread, cx| { + thread.request_tool_call( + request.label, + request.icon, + request.content, + request.confirmation, + cx, + ) + }) + })? + .context("Failed to update thread")?; + + Ok(acp::RequestToolCallConfirmationResponse { + id, + outcome: outcome.await?, + }) + } + + async fn push_tool_call( + &self, + request: acp::PushToolCallParams, + ) -> Result { + let cx = &mut self.cx.clone(); + let id = cx + .update(|cx| { + self.thread.update(cx, |thread, cx| { + thread.push_tool_call(request.label, request.icon, request.content, cx) + }) + })? + .context("Failed to update thread")?; + + Ok(acp::PushToolCallResponse { id }) + } + + async fn update_tool_call(&self, request: acp::UpdateToolCallParams) -> Result<()> { + let cx = &mut self.cx.clone(); + + cx.update(|cx| { + self.thread.update(cx, |thread, cx| { + thread.update_tool_call(request.tool_call_id, request.status, request.content, cx) + }) + })? + .context("Failed to update thread")??; + + Ok(()) + } +} + +fn acp_icon_to_ui_icon(icon: acp::Icon) -> IconName { + match icon { + acp::Icon::FileSearch => IconName::ToolSearch, + acp::Icon::Folder => IconName::ToolFolder, + acp::Icon::Globe => IconName::ToolWeb, + acp::Icon::Hammer => IconName::ToolHammer, + acp::Icon::LightBulb => IconName::ToolBulb, + acp::Icon::Pencil => IconName::ToolPencil, + acp::Icon::Regex => IconName::ToolRegex, + acp::Icon::Terminal => IconName::ToolTerminal, + } +} + +pub struct ToolCallRequest { + pub id: acp::ToolCallId, + pub outcome: oneshot::Receiver, +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_servers::{AgentServerCommand, AgentServerVersion}; + use async_pipe::{PipeReader, PipeWriter}; + use futures::{channel::mpsc, future::LocalBoxFuture, select}; + use gpui::{AsyncApp, TestAppContext}; + use indoc::indoc; + use project::FakeFs; + use serde_json::json; + use settings::SettingsStore; + use smol::{future::BoxedLocal, stream::StreamExt as _}; + use std::{cell::RefCell, env, path::Path, rc::Rc, 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_thinking_concatenation(cx: &mut TestAppContext) { + init_test(cx); + + cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (thread, fake_server) = fake_acp_thread(project, cx); + + fake_server.update(cx, |fake_server, _| { + fake_server.on_user_message(move |_, server, mut cx| async move { + server + .update(&mut cx, |server, _| { + server.send_to_zed(acp::StreamAssistantMessageChunkParams { + chunk: acp::AssistantMessageChunk::Thought { + chunk: "Thinking ".into(), + }, + }) + })? + .await + .unwrap(); + server + .update(&mut cx, |server, _| { + server.send_to_zed(acp::StreamAssistantMessageChunkParams { + chunk: acp::AssistantMessageChunk::Thought { + chunk: "hard!".into(), + }, + }) + })? + .await + .unwrap(); + + Ok(()) + }) + }); + + thread + .update(cx, |thread, cx| thread.send("Hello from Zed!", cx)) + .await + .unwrap(); + + let output = thread.read_with(cx, |thread, cx| thread.to_markdown(cx)); + assert_eq!( + output, + indoc! {r#" + ## User + + Hello from Zed! + + ## Assistant + + + Thinking hard! + + + "#} + ); + } + + #[gpui::test] + async fn test_succeeding_canceled_toolcall(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (thread, fake_server) = fake_acp_thread(project, cx); + + let (end_turn_tx, end_turn_rx) = oneshot::channel::<()>(); + + let tool_call_id = Rc::new(RefCell::new(None)); + let end_turn_rx = Rc::new(RefCell::new(Some(end_turn_rx))); + fake_server.update(cx, |fake_server, _| { + let tool_call_id = tool_call_id.clone(); + fake_server.on_user_message(move |_, server, mut cx| { + let end_turn_rx = end_turn_rx.clone(); + let tool_call_id = tool_call_id.clone(); + async move { + let tool_call_result = server + .update(&mut cx, |server, _| { + server.send_to_zed(acp::PushToolCallParams { + label: "Fetch".to_string(), + icon: acp::Icon::Globe, + content: None, + }) + })? + .await + .unwrap(); + *tool_call_id.clone().borrow_mut() = Some(tool_call_result.id); + end_turn_rx.take().unwrap().await.ok(); + + Ok(()) + } + }) + }); + + let request = thread.update(cx, |thread, cx| { + thread.send("Fetch https://example.com", cx) + }); + + run_until_first_tool_call(&thread, cx).await; + + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.entries[1], + AgentThreadEntry::ToolCall(ToolCall { + status: ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Running, + .. + }, + .. + }) + )); + }); + + cx.run_until_parked(); + + thread + .update(cx, |thread, cx| thread.cancel(cx)) + .await + .unwrap(); + + thread.read_with(cx, |thread, _| { + assert!(matches!( + &thread.entries[1], + AgentThreadEntry::ToolCall(ToolCall { + status: ToolCallStatus::Canceled, + .. + }) + )); + }); + + fake_server + .update(cx, |fake_server, _| { + fake_server.send_to_zed(acp::UpdateToolCallParams { + tool_call_id: tool_call_id.borrow().unwrap(), + status: acp::ToolCallStatus::Finished, + content: None, + }) + }) + .await + .unwrap(); + + drop(end_turn_tx); + request.await.unwrap(); + + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.entries[1], + AgentThreadEntry::ToolCall(ToolCall { + status: ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Finished, + .. + }, + .. + }) + )); + }); + } + + #[gpui::test] + #[cfg_attr(not(feature = "gemini"), ignore)] + 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 thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await; + 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], + AgentThreadEntry::UserMessage(_) + )); + assert!(matches!( + thread.entries[1], + AgentThreadEntry::AssistantMessage(_) + )); + }); + } + + #[gpui::test] + #[cfg_attr(not(feature = "gemini"), ignore)] + async fn test_gemini_path_mentions(cx: &mut TestAppContext) { + init_test(cx); + + cx.executor().allow_parking(); + let tempdir = tempfile::tempdir().unwrap(); + std::fs::write( + tempdir.path().join("foo.rs"), + indoc! {" + fn main() { + println!(\"Hello, world!\"); + } + "}, + ) + .expect("failed to write file"); + let project = Project::example([tempdir.path()], &mut cx.to_async()).await; + let thread = gemini_acp_thread(project.clone(), tempdir.path(), cx).await; + thread + .update(cx, |thread, cx| { + thread.send( + acp::UserMessage { + chunks: vec![ + "Read the file ".into(), + Path::new("foo.rs").into(), + " and tell me what the content of the println! is".into(), + ], + }, + cx, + ) + }) + .await + .unwrap(); + + thread.read_with(cx, |thread, cx| { + assert_eq!(thread.entries.len(), 3); + assert!(matches!( + thread.entries[0], + AgentThreadEntry::UserMessage(_) + )); + assert!(matches!(thread.entries[1], AgentThreadEntry::ToolCall(_))); + let AgentThreadEntry::AssistantMessage(assistant_message) = &thread.entries[2] else { + panic!("Expected AssistantMessage") + }; + assert!( + assistant_message.to_markdown(cx).contains("Hello, world!"), + "unexpected assistant message: {:?}", + assistant_message.to_markdown(cx) + ); + }); + } + + #[gpui::test] + #[cfg_attr(not(feature = "gemini"), ignore)] + 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 thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await; + 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()[2], + AgentThreadEntry::ToolCall(ToolCall { + status: ToolCallStatus::Allowed { .. }, + .. + }) + )); + + assert!(matches!( + thread.entries[3], + AgentThreadEntry::AssistantMessage(_) + )); + }); + } + + #[gpui::test] + #[cfg_attr(not(feature = "gemini"), ignore)] + 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 thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await; + let full_turn = thread.update(cx, |thread, cx| { + thread.send(r#"Run `echo "Hello, world!"`"#, cx) + }); + + run_until_first_tool_call(&thread, cx).await; + + let tool_call_id = thread.read_with(cx, |thread, _cx| { + let AgentThreadEntry::ToolCall(ToolCall { + id, + status: + ToolCallStatus::WaitingForConfirmation { + confirmation: ToolCallConfirmation::Execute { root_command, .. }, + .. + }, + .. + }) = &thread.entries()[2] + 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()[2], + AgentThreadEntry::ToolCall(ToolCall { + status: ToolCallStatus::Allowed { .. }, + .. + }) + )); + }); + + full_turn.await.unwrap(); + + thread.read_with(cx, |thread, cx| { + let AgentThreadEntry::ToolCall(ToolCall { + content: Some(ToolCallContent::Markdown { markdown }), + status: ToolCallStatus::Allowed { .. }, + .. + }) = &thread.entries()[2] + else { + panic!(); + }; + + markdown.read_with(cx, |md, _cx| { + assert!( + md.source().contains("Hello, world!"), + r#"Expected '{}' to contain "Hello, world!""#, + md.source() + ); + }); + }); + } + + #[gpui::test] + #[cfg_attr(not(feature = "gemini"), ignore)] + async fn test_gemini_cancel(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 thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await; + let full_turn = thread.update(cx, |thread, cx| { + thread.send(r#"Run `echo "Hello, world!"`"#, cx) + }); + + let first_tool_call_ix = run_until_first_tool_call(&thread, cx).await; + + thread.read_with(cx, |thread, _cx| { + let AgentThreadEntry::ToolCall(ToolCall { + id, + status: + ToolCallStatus::WaitingForConfirmation { + confirmation: ToolCallConfirmation::Execute { root_command, .. }, + .. + }, + .. + }) = &thread.entries()[first_tool_call_ix] + else { + panic!("{:?}", thread.entries()[1]); + }; + + assert_eq!(root_command, "echo"); + + *id + }); + + thread + .update(cx, |thread, cx| thread.cancel(cx)) + .await + .unwrap(); + full_turn.await.unwrap(); + thread.read_with(cx, |thread, _| { + let AgentThreadEntry::ToolCall(ToolCall { + status: ToolCallStatus::Canceled, + .. + }) = &thread.entries()[first_tool_call_ix] + else { + panic!(); + }; + }); + + thread + .update(cx, |thread, cx| { + thread.send(r#"Stop running and say goodbye to me."#, cx) + }) + .await + .unwrap(); + thread.read_with(cx, |thread, _| { + assert!(matches!( + &thread.entries().last().unwrap(), + AgentThreadEntry::AssistantMessage(..), + )) + }); + } + + async fn run_until_first_tool_call( + thread: &Entity, + cx: &mut TestAppContext, + ) -> usize { + let (mut tx, mut rx) = mpsc::channel::(1); + + let subscription = cx.update(|cx| { + cx.subscribe(thread, move |thread, _, cx| { + for (ix, entry) in thread.read(cx).entries.iter().enumerate() { + if matches!(entry, AgentThreadEntry::ToolCall(_)) { + return tx.try_send(ix).unwrap(); + } + } + }) + }); + + select! { + _ = futures::FutureExt::fuse(smol::Timer::after(Duration::from_secs(10))) => { + panic!("Timeout waiting for tool call") + } + ix = rx.next().fuse() => { + drop(subscription); + ix.unwrap() + } + } + } + + pub async fn gemini_acp_thread( + project: Entity, + current_dir: impl AsRef, + cx: &mut TestAppContext, + ) -> Entity { + struct DevGemini; + + impl agent_servers::AgentServer for DevGemini { + async fn command( + &self, + _project: &Entity, + _cx: &mut AsyncApp, + ) -> Result { + let cli_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../gemini-cli/packages/cli") + .to_string_lossy() + .to_string(); + + Ok(AgentServerCommand { + path: "node".into(), + args: vec![cli_path, "--acp".into()], + env: None, + }) + } + + async fn version( + &self, + _command: &agent_servers::AgentServerCommand, + ) -> Result { + Ok(AgentServerVersion { + current_version: "0.1.0".into(), + supported: true, + }) + } + } + + let thread = AcpThread::spawn(DevGemini, current_dir.as_ref(), project, &mut cx.to_async()) + .await + .unwrap(); + + thread + .update(cx, |thread, _| thread.initialize()) + .await + .unwrap(); + thread + } + + pub fn fake_acp_thread( + project: Entity, + cx: &mut TestAppContext, + ) -> (Entity, Entity) { + let (stdin_tx, stdin_rx) = async_pipe::pipe(); + let (stdout_tx, stdout_rx) = async_pipe::pipe(); + let thread = cx.update(|cx| cx.new(|cx| AcpThread::fake(stdin_tx, stdout_rx, project, cx))); + let agent = cx.update(|cx| cx.new(|cx| FakeAcpServer::new(stdin_rx, stdout_tx, cx))); + (thread, agent) + } + + pub struct FakeAcpServer { + connection: acp::ClientConnection, + _io_task: Task<()>, + on_user_message: Option< + Rc< + dyn Fn( + acp::SendUserMessageParams, + Entity, + AsyncApp, + ) -> LocalBoxFuture<'static, Result<()>>, + >, + >, + } + + #[derive(Clone)] + struct FakeAgent { + server: Entity, + cx: AsyncApp, + } + + impl acp::Agent for FakeAgent { + async fn initialize(&self) -> Result { + Ok(acp::InitializeResponse { + is_authenticated: true, + }) + } + + async fn authenticate(&self) -> Result<()> { + Ok(()) + } + + async fn cancel_send_message(&self) -> Result<()> { + Ok(()) + } + + async fn send_user_message(&self, request: acp::SendUserMessageParams) -> Result<()> { + let mut cx = self.cx.clone(); + let handler = self + .server + .update(&mut cx, |server, _| server.on_user_message.clone()) + .ok() + .flatten(); + if let Some(handler) = handler { + handler(request, self.server.clone(), self.cx.clone()).await + } else { + anyhow::bail!("No handler for on_user_message") + } + } + } + + impl FakeAcpServer { + fn new(stdin: PipeReader, stdout: PipeWriter, cx: &Context) -> Self { + let agent = FakeAgent { + server: cx.entity(), + cx: cx.to_async(), + }; + let foreground_executor = cx.foreground_executor().clone(); + + let (connection, io_fut) = acp::ClientConnection::connect_to_client( + agent.clone(), + stdout, + stdin, + move |fut| { + foreground_executor.spawn(fut).detach(); + }, + ); + FakeAcpServer { + connection: connection, + on_user_message: None, + _io_task: cx.background_spawn(async move { + io_fut.await.log_err(); + }), + } + } + + fn on_user_message( + &mut self, + handler: impl for<'a> Fn(acp::SendUserMessageParams, Entity, AsyncApp) -> F + + 'static, + ) where + F: Future> + 'static, + { + self.on_user_message + .replace(Rc::new(move |request, server, cx| { + handler(request, server, cx).boxed_local() + })); + } + + fn send_to_zed( + &self, + message: T, + ) -> BoxedLocal> { + self.connection + .request(message) + .map(|f| f.map_err(|err| anyhow!(err))) + .boxed_local() + } + } +} diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml new file mode 100644 index 0000000000..549162c5dd --- /dev/null +++ b/crates/agent_servers/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "agent_servers" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/agent_servers.rs" +doctest = false + +[dependencies] +anyhow.workspace = true +collections.workspace = true +futures.workspace = true +gpui.workspace = true +paths.workspace = true +project.workspace = true +schemars.workspace = true +serde.workspace = true +settings.workspace = true +util.workspace = true +which.workspace = true +workspace-hack.workspace = true diff --git a/crates/agent_servers/LICENSE-GPL b/crates/agent_servers/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/agent_servers/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs new file mode 100644 index 0000000000..5d588cd4ae --- /dev/null +++ b/crates/agent_servers/src/agent_servers.rs @@ -0,0 +1,231 @@ +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +use anyhow::{Context as _, Result}; +use collections::HashMap; +use gpui::{App, AsyncApp, Entity, SharedString}; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::{Settings, SettingsSources, SettingsStore}; +use util::{ResultExt, paths}; + +pub fn init(cx: &mut App) { + AllAgentServersSettings::register(cx); +} + +#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug)] +pub struct AllAgentServersSettings { + gemini: Option, +} + +#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug)] +pub struct AgentServerSettings { + #[serde(flatten)] + command: AgentServerCommand, +} + +#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)] +pub struct AgentServerCommand { + #[serde(rename = "command")] + pub path: PathBuf, + #[serde(default)] + pub args: Vec, + pub env: Option>, +} + +pub struct Gemini; + +pub struct AgentServerVersion { + pub current_version: SharedString, + pub supported: bool, +} + +pub trait AgentServer: Send { + fn command( + &self, + project: &Entity, + cx: &mut AsyncApp, + ) -> impl Future>; + + fn version( + &self, + command: &AgentServerCommand, + ) -> impl Future> + Send; +} + +const GEMINI_ACP_ARG: &str = "--acp"; + +impl AgentServer for Gemini { + async fn command( + &self, + project: &Entity, + cx: &mut AsyncApp, + ) -> Result { + let custom_command = cx.read_global(|settings: &SettingsStore, _| { + let settings = settings.get::(None); + settings + .gemini + .as_ref() + .map(|gemini_settings| AgentServerCommand { + path: gemini_settings.command.path.clone(), + args: gemini_settings + .command + .args + .iter() + .cloned() + .chain(std::iter::once(GEMINI_ACP_ARG.into())) + .collect(), + env: gemini_settings.command.env.clone(), + }) + })?; + + if let Some(custom_command) = custom_command { + return Ok(custom_command); + } + + if let Some(path) = find_bin_in_path("gemini", project, cx).await { + return Ok(AgentServerCommand { + path, + args: vec![GEMINI_ACP_ARG.into()], + env: None, + }); + } + + let (fs, node_runtime) = project.update(cx, |project, _| { + (project.fs().clone(), project.node_runtime().cloned()) + })?; + let node_runtime = node_runtime.context("gemini not found on path")?; + + let directory = ::paths::agent_servers_dir().join("gemini"); + fs.create_dir(&directory).await?; + node_runtime + .npm_install_packages(&directory, &[("@google/gemini-cli", "latest")]) + .await?; + let path = directory.join("node_modules/.bin/gemini"); + + Ok(AgentServerCommand { + path, + args: vec![GEMINI_ACP_ARG.into()], + env: None, + }) + } + + async fn version(&self, command: &AgentServerCommand) -> Result { + let version_fut = util::command::new_smol_command(&command.path) + .args(command.args.iter()) + .arg("--version") + .kill_on_drop(true) + .output(); + + let help_fut = util::command::new_smol_command(&command.path) + .args(command.args.iter()) + .arg("--help") + .kill_on_drop(true) + .output(); + + let (version_output, help_output) = futures::future::join(version_fut, help_fut).await; + + let current_version = String::from_utf8(version_output?.stdout)?.into(); + let supported = String::from_utf8(help_output?.stdout)?.contains(GEMINI_ACP_ARG); + + Ok(AgentServerVersion { + current_version, + supported, + }) + } +} + +async fn find_bin_in_path( + bin_name: &'static str, + project: &Entity, + cx: &mut AsyncApp, +) -> Option { + let (env_task, root_dir) = project + .update(cx, |project, cx| { + let worktree = project.visible_worktrees(cx).next(); + match worktree { + Some(worktree) => { + let env_task = project.environment().update(cx, |env, cx| { + env.get_worktree_environment(worktree.clone(), cx) + }); + + let path = worktree.read(cx).abs_path(); + (env_task, path) + } + None => { + let path: Arc = paths::home_dir().as_path().into(); + let env_task = project.environment().update(cx, |env, cx| { + env.get_directory_environment(path.clone(), cx) + }); + (env_task, path) + } + } + }) + .log_err()?; + + cx.background_executor() + .spawn(async move { + let which_result = if cfg!(windows) { + which::which(bin_name) + } else { + let env = env_task.await.unwrap_or_default(); + let shell_path = env.get("PATH").cloned(); + which::which_in(bin_name, shell_path.as_ref(), root_dir.as_ref()) + }; + + if let Err(which::Error::CannotFindBinaryPath) = which_result { + return None; + } + + which_result.log_err() + }) + .await +} + +impl std::fmt::Debug for AgentServerCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let filtered_env = self.env.as_ref().map(|env| { + env.iter() + .map(|(k, v)| { + ( + k, + if util::redact::should_redact(k) { + "[REDACTED]" + } else { + v + }, + ) + }) + .collect::>() + }); + + f.debug_struct("AgentServerCommand") + .field("path", &self.path) + .field("args", &self.args) + .field("env", &filtered_env) + .finish() + } +} + +impl settings::Settings for AllAgentServersSettings { + const KEY: Option<&'static str> = Some("agent_servers"); + + type FileContent = Self; + + fn load(sources: SettingsSources, _: &mut App) -> Result { + let mut settings = AllAgentServersSettings::default(); + + for value in sources.defaults_and_customizations() { + if value.gemini.is_some() { + settings.gemini = value.gemini.clone(); + } + } + + Ok(settings) + } + + fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {} +} diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 070e8eb585..72466fe8e7 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -13,14 +13,14 @@ 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 +agentic-coding-protocol.workspace = true agent_settings.workspace = true +agent_servers.workspace = true anyhow.workspace = true assistant_context.workspace = true assistant_slash_command.workspace = true @@ -76,6 +76,7 @@ serde_json_lenient.workspace = true settings.workspace = true smol.workspace = true streaming_diff.workspace = true +task.workspace = true telemetry.workspace = true telemetry_events.workspace = true terminal.workspace = true diff --git a/crates/agent_ui/src/acp.rs b/crates/agent_ui/src/acp.rs new file mode 100644 index 0000000000..23ada8d77a --- /dev/null +++ b/crates/agent_ui/src/acp.rs @@ -0,0 +1,5 @@ +mod completion_provider; +mod message_history; +mod thread_view; + +pub use thread_view::AcpThreadView; diff --git a/crates/agent_ui/src/acp/completion_provider.rs b/crates/agent_ui/src/acp/completion_provider.rs new file mode 100644 index 0000000000..fca4ae0300 --- /dev/null +++ b/crates/agent_ui/src/acp/completion_provider.rs @@ -0,0 +1,574 @@ +use std::ops::Range; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +use anyhow::Result; +use collections::HashMap; +use editor::display_map::CreaseId; +use editor::{CompletionProvider, Editor, ExcerptId}; +use file_icons::FileIcons; +use gpui::{App, Entity, Task, WeakEntity}; +use language::{Buffer, CodeLabel, HighlightId}; +use lsp::CompletionContext; +use parking_lot::Mutex; +use project::{Completion, CompletionIntent, CompletionResponse, ProjectPath, WorktreeId}; +use rope::Point; +use text::{Anchor, ToPoint}; +use ui::prelude::*; +use workspace::Workspace; + +use crate::context_picker::MentionLink; +use crate::context_picker::file_context_picker::{extract_file_name_and_directory, search_files}; + +#[derive(Default)] +pub struct MentionSet { + paths_by_crease_id: HashMap, +} + +impl MentionSet { + pub fn insert(&mut self, crease_id: CreaseId, path: ProjectPath) { + self.paths_by_crease_id.insert(crease_id, path); + } + + pub fn path_for_crease_id(&self, crease_id: CreaseId) -> Option { + self.paths_by_crease_id.get(&crease_id).cloned() + } + + pub fn drain(&mut self) -> impl Iterator { + self.paths_by_crease_id.drain().map(|(id, _)| id) + } +} + +pub struct ContextPickerCompletionProvider { + workspace: WeakEntity, + editor: WeakEntity, + mention_set: Arc>, +} + +impl ContextPickerCompletionProvider { + pub fn new( + mention_set: Arc>, + workspace: WeakEntity, + editor: WeakEntity, + ) -> Self { + Self { + mention_set, + workspace, + editor, + } + } + + fn completion_for_path( + project_path: ProjectPath, + path_prefix: &str, + is_recent: bool, + is_directory: bool, + excerpt_id: ExcerptId, + source_range: Range, + editor: Entity, + mention_set: Arc>, + cx: &App, + ) -> Completion { + let (file_name, directory) = + extract_file_name_and_directory(&project_path.path, path_prefix); + + let label = + build_code_label_for_full_path(&file_name, directory.as_ref().map(|s| s.as_ref()), cx); + let full_path = if let Some(directory) = directory { + format!("{}{}", directory, file_name) + } else { + file_name.to_string() + }; + + let crease_icon_path = if is_directory { + FileIcons::get_folder_icon(false, cx).unwrap_or_else(|| IconName::Folder.path().into()) + } else { + FileIcons::get_icon(Path::new(&full_path), cx) + .unwrap_or_else(|| IconName::File.path().into()) + }; + let completion_icon_path = if is_recent { + IconName::HistoryRerun.path().into() + } else { + crease_icon_path.clone() + }; + + let new_text = format!("{} ", MentionLink::for_file(&file_name, &full_path)); + let new_text_len = new_text.len(); + Completion { + replace_range: source_range.clone(), + new_text, + label, + documentation: None, + source: project::CompletionSource::Custom, + icon_path: Some(completion_icon_path), + insert_text_mode: None, + confirm: Some(confirm_completion_callback( + crease_icon_path, + file_name, + project_path, + excerpt_id, + source_range.start, + new_text_len - 1, + editor, + mention_set, + )), + } + } +} + +fn build_code_label_for_full_path(file_name: &str, directory: Option<&str>, cx: &App) -> CodeLabel { + let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId); + let mut label = CodeLabel::default(); + + label.push_str(&file_name, None); + label.push_str(" ", None); + + if let Some(directory) = directory { + label.push_str(&directory, comment_id); + } + + label.filter_range = 0..label.text().len(); + + label +} + +impl CompletionProvider for ContextPickerCompletionProvider { + fn completions( + &self, + excerpt_id: ExcerptId, + buffer: &Entity, + buffer_position: Anchor, + _trigger: CompletionContext, + _window: &mut Window, + cx: &mut Context, + ) -> Task>> { + let state = buffer.update(cx, |buffer, _cx| { + let position = buffer_position.to_point(buffer); + let line_start = Point::new(position.row, 0); + let offset_to_line = buffer.point_to_offset(line_start); + let mut lines = buffer.text_for_range(line_start..position).lines(); + let line = lines.next()?; + MentionCompletion::try_parse(line, offset_to_line) + }); + let Some(state) = state else { + return Task::ready(Ok(Vec::new())); + }; + + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(Ok(Vec::new())); + }; + + let snapshot = buffer.read(cx).snapshot(); + let source_range = snapshot.anchor_before(state.source_range.start) + ..snapshot.anchor_after(state.source_range.end); + + let editor = self.editor.clone(); + let mention_set = self.mention_set.clone(); + let MentionCompletion { argument, .. } = state; + let query = argument.unwrap_or_else(|| "".to_string()); + + let search_task = search_files(query.clone(), Arc::::default(), &workspace, cx); + + cx.spawn(async move |_, cx| { + let matches = search_task.await; + let Some(editor) = editor.upgrade() else { + return Ok(Vec::new()); + }; + + let completions = cx.update(|cx| { + matches + .into_iter() + .map(|mat| { + let path_match = &mat.mat; + let project_path = ProjectPath { + worktree_id: WorktreeId::from_usize(path_match.worktree_id), + path: path_match.path.clone(), + }; + + Self::completion_for_path( + project_path, + &path_match.path_prefix, + mat.is_recent, + path_match.is_dir, + excerpt_id, + source_range.clone(), + editor.clone(), + mention_set.clone(), + cx, + ) + }) + .collect() + })?; + + Ok(vec![CompletionResponse { + completions, + // Since this does its own filtering (see `filter_completions()` returns false), + // there is no benefit to computing whether this set of completions is incomplete. + is_incomplete: true, + }]) + }) + } + + fn is_completion_trigger( + &self, + buffer: &Entity, + position: language::Anchor, + _text: &str, + _trigger_in_words: bool, + _menu_is_open: bool, + cx: &mut Context, + ) -> bool { + let buffer = buffer.read(cx); + let position = position.to_point(buffer); + let line_start = Point::new(position.row, 0); + let offset_to_line = buffer.point_to_offset(line_start); + let mut lines = buffer.text_for_range(line_start..position).lines(); + if let Some(line) = lines.next() { + MentionCompletion::try_parse(line, offset_to_line) + .map(|completion| { + completion.source_range.start <= offset_to_line + position.column as usize + && completion.source_range.end >= offset_to_line + position.column as usize + }) + .unwrap_or(false) + } else { + false + } + } + + fn sort_completions(&self) -> bool { + false + } + + fn filter_completions(&self) -> bool { + false + } +} + +fn confirm_completion_callback( + crease_icon_path: SharedString, + crease_text: SharedString, + project_path: ProjectPath, + excerpt_id: ExcerptId, + start: Anchor, + content_len: usize, + editor: Entity, + mention_set: Arc>, +) -> Arc bool + Send + Sync> { + Arc::new(move |_, window, cx| { + let crease_text = crease_text.clone(); + let crease_icon_path = crease_icon_path.clone(); + let editor = editor.clone(); + let project_path = project_path.clone(); + let mention_set = mention_set.clone(); + window.defer(cx, move |window, cx| { + let crease_id = crate::context_picker::insert_crease_for_mention( + excerpt_id, + start, + content_len, + crease_text.clone(), + crease_icon_path, + editor.clone(), + window, + cx, + ); + if let Some(crease_id) = crease_id { + mention_set.lock().insert(crease_id, project_path); + } + }); + false + }) +} + +#[derive(Debug, Default, PartialEq)] +struct MentionCompletion { + source_range: Range, + argument: Option, +} + +impl MentionCompletion { + fn try_parse(line: &str, offset_to_line: usize) -> Option { + let last_mention_start = line.rfind('@')?; + if last_mention_start >= line.len() { + return Some(Self::default()); + } + if last_mention_start > 0 + && line + .chars() + .nth(last_mention_start - 1) + .map_or(false, |c| !c.is_whitespace()) + { + return None; + } + + let rest_of_line = &line[last_mention_start + 1..]; + let mut argument = None; + + let mut parts = rest_of_line.split_whitespace(); + let mut end = last_mention_start + 1; + if let Some(argument_text) = parts.next() { + end += argument_text.len(); + argument = Some(argument_text.to_string()); + } + + Some(Self { + source_range: last_mention_start + offset_to_line..end + offset_to_line, + argument, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext}; + use project::{Project, ProjectPath}; + use serde_json::json; + use settings::SettingsStore; + use std::{ops::Deref, rc::Rc}; + use util::path; + use workspace::{AppState, Item}; + + #[test] + fn test_mention_completion_parse() { + assert_eq!(MentionCompletion::try_parse("Lorem Ipsum", 0), None); + + assert_eq!( + MentionCompletion::try_parse("Lorem @", 0), + Some(MentionCompletion { + source_range: 6..7, + argument: None, + }) + ); + + assert_eq!( + MentionCompletion::try_parse("Lorem @main", 0), + Some(MentionCompletion { + source_range: 6..11, + argument: Some("main".to_string()), + }) + ); + + assert_eq!(MentionCompletion::try_parse("test@", 0), None); + } + + struct AtMentionEditor(Entity); + + impl Item for AtMentionEditor { + type Event = (); + + fn include_in_nav_history() -> bool { + false + } + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Test".into() + } + } + + impl EventEmitter<()> for AtMentionEditor {} + + impl Focusable for AtMentionEditor { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.0.read(cx).focus_handle(cx).clone() + } + } + + impl Render for AtMentionEditor { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + self.0.clone().into_any_element() + } + } + + #[gpui::test] + async fn test_context_completion_provider(cx: &mut TestAppContext) { + init_test(cx); + + let app_state = cx.update(AppState::test); + + cx.update(|cx| { + language::init(cx); + editor::init(cx); + workspace::init(app_state.clone(), cx); + Project::init_settings(cx); + }); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/dir"), + json!({ + "editor": "", + "a": { + "one.txt": "", + "two.txt": "", + "three.txt": "", + "four.txt": "" + }, + "b": { + "five.txt": "", + "six.txt": "", + "seven.txt": "", + "eight.txt": "", + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; + let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let workspace = window.root(cx).unwrap(); + + let worktree = project.update(cx, |project, cx| { + let mut worktrees = project.worktrees(cx).collect::>(); + assert_eq!(worktrees.len(), 1); + worktrees.pop().unwrap() + }); + let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); + + let mut cx = VisualTestContext::from_window(*window.deref(), cx); + + let paths = vec![ + path!("a/one.txt"), + path!("a/two.txt"), + path!("a/three.txt"), + path!("a/four.txt"), + path!("b/five.txt"), + path!("b/six.txt"), + path!("b/seven.txt"), + path!("b/eight.txt"), + ]; + + let mut opened_editors = Vec::new(); + for path in paths { + let buffer = workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id, + path: Path::new(path).into(), + }, + None, + false, + window, + cx, + ) + }) + .await + .unwrap(); + opened_editors.push(buffer); + } + + let editor = workspace.update_in(&mut cx, |workspace, window, cx| { + let editor = cx.new(|cx| { + Editor::new( + editor::EditorMode::full(), + multi_buffer::MultiBuffer::build_simple("", cx), + None, + window, + cx, + ) + }); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item( + Box::new(cx.new(|_| AtMentionEditor(editor.clone()))), + true, + true, + None, + window, + cx, + ); + }); + editor + }); + + let mention_set = Arc::new(Mutex::new(MentionSet::default())); + + let editor_entity = editor.downgrade(); + editor.update_in(&mut cx, |editor, window, cx| { + window.focus(&editor.focus_handle(cx)); + editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new( + mention_set.clone(), + workspace.downgrade(), + editor_entity, + )))); + }); + + cx.simulate_input("Lorem "); + + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), "Lorem "); + assert!(!editor.has_visible_completions_menu()); + }); + + cx.simulate_input("@"); + + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), "Lorem @"); + assert!(editor.has_visible_completions_menu()); + assert_eq!( + current_completion_labels(editor), + &[ + "eight.txt dir/b/", + "seven.txt dir/b/", + "six.txt dir/b/", + "five.txt dir/b/", + "four.txt dir/a/", + "three.txt dir/a/", + "two.txt dir/a/", + "one.txt dir/a/", + "dir ", + "a dir/", + "four.txt dir/a/", + "one.txt dir/a/", + "three.txt dir/a/", + "two.txt dir/a/", + "b dir/", + "eight.txt dir/b/", + "five.txt dir/b/", + "seven.txt dir/b/", + "six.txt dir/b/", + "editor dir/" + ] + ); + }); + + // Select and confirm "File" + editor.update_in(&mut cx, |editor, window, cx| { + assert!(editor.has_visible_completions_menu()); + editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); + editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); + editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); + editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + cx.run_until_parked(); + + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), "Lorem [@four.txt](@file:dir/a/four.txt) "); + }); + } + + fn current_completion_labels(editor: &Editor) -> Vec { + let completions = editor.current_completions().expect("Missing completions"); + completions + .into_iter() + .map(|completion| completion.label.text.to_string()) + .collect::>() + } + + pub(crate) fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + theme::init(theme::LoadThemes::JustBase, cx); + client::init_settings(cx); + language::init(cx); + Project::init_settings(cx); + workspace::init_settings(cx); + editor::init_settings(cx); + }); + } +} diff --git a/crates/agent_ui/src/acp/message_history.rs b/crates/agent_ui/src/acp/message_history.rs new file mode 100644 index 0000000000..6d9626627a --- /dev/null +++ b/crates/agent_ui/src/acp/message_history.rs @@ -0,0 +1,81 @@ +pub struct MessageHistory { + items: Vec, + current: Option, +} + +impl MessageHistory { + pub fn new() -> Self { + MessageHistory { + items: Vec::new(), + current: None, + } + } + + pub fn push(&mut self, message: T) { + self.current.take(); + self.items.push(message); + } + + pub fn prev(&mut self) -> Option<&T> { + if self.items.is_empty() { + return None; + } + + let new_ix = self + .current + .get_or_insert(self.items.len()) + .saturating_sub(1); + + self.current = Some(new_ix); + self.items.get(new_ix) + } + + pub fn next(&mut self) -> Option<&T> { + let current = self.current.as_mut()?; + *current += 1; + + self.items.get(*current).or_else(|| { + self.current.take(); + None + }) + } +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_prev_next() { + let mut history = MessageHistory::new(); + + // Test empty history + assert_eq!(history.prev(), None); + assert_eq!(history.next(), None); + + // Add some messages + history.push("first"); + history.push("second"); + history.push("third"); + + // Test prev navigation + assert_eq!(history.prev(), Some(&"third")); + assert_eq!(history.prev(), Some(&"second")); + assert_eq!(history.prev(), Some(&"first")); + assert_eq!(history.prev(), Some(&"first")); + + assert_eq!(history.next(), Some(&"second")); + + // Test mixed navigation + history.push("fourth"); + assert_eq!(history.prev(), Some(&"fourth")); + assert_eq!(history.prev(), Some(&"third")); + assert_eq!(history.next(), Some(&"fourth")); + assert_eq!(history.next(), None); + + // Test that push resets navigation + history.prev(); + history.prev(); + history.push("fifth"); + assert_eq!(history.prev(), Some(&"fifth")); + } +} diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs new file mode 100644 index 0000000000..f16d439da1 --- /dev/null +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -0,0 +1,1972 @@ +use std::path::Path; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use agentic_coding_protocol::{self as acp}; +use collections::{HashMap, HashSet}; +use editor::{ + AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorMode, + EditorStyle, MinimapVisibility, MultiBuffer, +}; +use file_icons::FileIcons; +use futures::channel::oneshot; +use gpui::{ + Animation, AnimationExt, App, BorderStyle, EdgesRefinement, Empty, Entity, EntityId, Focusable, + Hsla, Length, ListOffset, ListState, SharedString, StyleRefinement, Subscription, TextStyle, + TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, Window, div, list, percentage, + prelude::*, pulsating_between, +}; +use gpui::{FocusHandle, Task}; +use language::language_settings::SoftWrap; +use language::{Buffer, Language}; +use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle}; +use parking_lot::Mutex; +use project::Project; +use settings::Settings as _; +use theme::ThemeSettings; +use ui::{Disclosure, Tooltip, prelude::*}; +use util::ResultExt; +use workspace::Workspace; +use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage}; + +use ::acp::{ + AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, Diff, + LoadError, MentionPath, ThreadStatus, ToolCall, ToolCallConfirmation, ToolCallContent, + ToolCallId, ToolCallStatus, +}; + +use crate::acp::completion_provider::{ContextPickerCompletionProvider, MentionSet}; +use crate::acp::message_history::MessageHistory; + +const RESPONSE_PADDING_X: Pixels = px(19.); + +pub struct AcpThreadView { + workspace: WeakEntity, + project: Entity, + thread_state: ThreadState, + diff_editors: HashMap>, + message_editor: Entity, + mention_set: Arc>, + last_error: Option>, + list_state: ListState, + auth_task: Option>, + expanded_tool_calls: HashSet, + expanded_thinking_blocks: HashSet<(usize, usize)>, + message_history: MessageHistory, +} + +enum ThreadState { + Loading { + _task: Task<()>, + }, + Ready { + thread: Entity, + _subscription: Subscription, + }, + LoadError(LoadError), + Unauthenticated { + thread: Entity, + }, +} + +impl AcpThreadView { + pub fn new( + workspace: WeakEntity, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let language = Language::new( + language::LanguageConfig { + completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']), + ..Default::default() + }, + None, + ); + + let mention_set = Arc::new(Mutex::new(MentionSet::default())); + + let message_editor = cx.new(|cx| { + let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + + let mut editor = Editor::new( + editor::EditorMode::AutoHeight { + min_lines: 4, + max_lines: None, + }, + buffer, + None, + window, + cx, + ); + editor.set_placeholder_text("Message the agent ļ¼ @ to include files", cx); + editor.set_show_indent_guides(false, cx); + editor.set_soft_wrap(); + editor.set_use_modal_editing(true); + editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new( + mention_set.clone(), + workspace.clone(), + cx.weak_entity(), + )))); + editor.set_context_menu_options(ContextMenuOptions { + min_entries_visible: 12, + max_entries_visible: 12, + placement: Some(ContextMenuPlacement::Above), + }); + editor + }); + + let list_state = ListState::new( + 0, + gpui::ListAlignment::Bottom, + px(2048.0), + cx.processor({ + move |this: &mut Self, index: usize, window, cx| { + let Some((entry, len)) = this.thread().and_then(|thread| { + let entries = &thread.read(cx).entries(); + Some((entries.get(index)?, entries.len())) + }) else { + return Empty.into_any(); + }; + this.render_entry(index, len, entry, window, cx) + } + }), + ); + + Self { + workspace, + project: project.clone(), + thread_state: Self::initial_state(project, window, cx), + message_editor, + mention_set, + diff_editors: Default::default(), + list_state: list_state, + last_error: None, + auth_task: None, + expanded_tool_calls: HashSet::default(), + expanded_thinking_blocks: HashSet::default(), + message_history: MessageHistory::new(), + } + } + + fn initial_state( + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> ThreadState { + let root_dir = project + .read(cx) + .visible_worktrees(cx) + .next() + .map(|worktree| worktree.read(cx).abs_path()) + .unwrap_or_else(|| paths::home_dir().as_path().into()); + + let load_task = cx.spawn_in(window, async move |this, cx| { + let thread = match AcpThread::spawn(agent_servers::Gemini, &root_dir, project, cx).await + { + Ok(thread) => thread, + Err(err) => { + this.update(cx, |this, cx| { + this.handle_load_error(err, cx); + cx.notify(); + }) + .log_err(); + return; + } + }; + + let init_response = async { + let resp = thread + .read_with(cx, |thread, _cx| thread.initialize())? + .await?; + anyhow::Ok(resp) + }; + + let result = match init_response.await { + Err(e) => { + let mut cx = cx.clone(); + if e.downcast_ref::().is_some() { + let child_status = thread + .update(&mut cx, |thread, _| thread.child_status()) + .ok() + .flatten(); + if let Some(child_status) = child_status { + match child_status.await { + Ok(_) => Err(e), + Err(e) => Err(e), + } + } else { + Err(e) + } + } else { + Err(e) + } + } + Ok(response) => { + if !response.is_authenticated { + this.update(cx, |this, _| { + this.thread_state = ThreadState::Unauthenticated { thread }; + }) + .ok(); + return; + }; + Ok(()) + } + }; + + this.update_in(cx, |this, window, cx| { + match result { + Ok(()) => { + let subscription = + cx.subscribe_in(&thread, window, Self::handle_thread_event); + this.list_state + .splice(0..0, thread.read(cx).entries().len()); + + this.thread_state = ThreadState::Ready { + thread, + _subscription: subscription, + }; + cx.notify(); + } + Err(err) => { + this.handle_load_error(err, cx); + } + }; + }) + .log_err(); + }); + + ThreadState::Loading { _task: load_task } + } + + fn handle_load_error(&mut self, err: anyhow::Error, cx: &mut Context) { + if let Some(load_err) = err.downcast_ref::() { + self.thread_state = ThreadState::LoadError(load_err.clone()); + } else { + self.thread_state = ThreadState::LoadError(LoadError::Other(err.to_string().into())) + } + cx.notify(); + } + + fn thread(&self) -> Option<&Entity> { + match &self.thread_state { + ThreadState::Ready { thread, .. } | ThreadState::Unauthenticated { 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(), + ThreadState::Unauthenticated { .. } => "Not authenticated".into(), + } + } + + pub fn cancel(&mut self, cx: &mut Context) { + self.last_error.take(); + + if let Some(thread) = self.thread() { + thread.update(cx, |thread, cx| thread.cancel(cx)).detach(); + } + } + + fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context) { + self.last_error.take(); + + let mut ix = 0; + let mut chunks: Vec = Vec::new(); + + let project = self.project.clone(); + self.message_editor.update(cx, |editor, cx| { + let text = editor.text(cx); + editor.display_map.update(cx, |map, cx| { + let snapshot = map.snapshot(cx); + for (crease_id, crease) in snapshot.crease_snapshot.creases() { + if let Some(project_path) = + self.mention_set.lock().path_for_crease_id(crease_id) + { + let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot); + if crease_range.start > ix { + chunks.push(acp::UserMessageChunk::Text { + chunk: text[ix..crease_range.start].to_string(), + }); + } + if let Some(abs_path) = project.read(cx).absolute_path(&project_path, cx) { + chunks.push(acp::UserMessageChunk::Path { path: abs_path }); + } + ix = crease_range.end; + } + } + + if ix < text.len() { + let last_chunk = text[ix..].trim(); + if !last_chunk.is_empty() { + chunks.push(last_chunk.into()); + } + } + }) + }); + + if chunks.is_empty() { + return; + } + + let Some(thread) = self.thread() else { return }; + let message = acp::UserMessage { chunks }; + let task = thread.update(cx, |thread, cx| thread.send(message.clone(), cx)); + + cx.spawn(async move |this, cx| { + let result = task.await; + + this.update(cx, |this, cx| { + if let Err(err) = result { + this.last_error = + Some(cx.new(|cx| Markdown::new(err.to_string().into(), None, None, cx))) + } + }) + }) + .detach(); + + let mention_set = self.mention_set.clone(); + + self.message_editor.update(cx, |editor, cx| { + editor.clear(window, cx); + editor.remove_creases(mention_set.lock().drain(), cx) + }); + + self.message_history.push(message); + } + + fn previous_history_message( + &mut self, + _: &PreviousHistoryMessage, + window: &mut Window, + cx: &mut Context, + ) { + Self::set_draft_message( + self.message_editor.clone(), + self.mention_set.clone(), + self.project.clone(), + self.message_history.prev(), + window, + cx, + ); + } + + fn next_history_message( + &mut self, + _: &NextHistoryMessage, + window: &mut Window, + cx: &mut Context, + ) { + Self::set_draft_message( + self.message_editor.clone(), + self.mention_set.clone(), + self.project.clone(), + self.message_history.next(), + window, + cx, + ); + } + + fn set_draft_message( + message_editor: Entity, + mention_set: Arc>, + project: Entity, + message: Option<&acp::UserMessage>, + window: &mut Window, + cx: &mut Context, + ) { + cx.notify(); + + let Some(message) = message else { + message_editor.update(cx, |editor, cx| { + editor.clear(window, cx); + editor.remove_creases(mention_set.lock().drain(), cx) + }); + return; + }; + + let mut text = String::new(); + let mut mentions = Vec::new(); + + for chunk in &message.chunks { + match chunk { + acp::UserMessageChunk::Text { chunk } => { + text.push_str(&chunk); + } + acp::UserMessageChunk::Path { path } => { + let start = text.len(); + let content = MentionPath::new(path).to_string(); + text.push_str(&content); + let end = text.len(); + if let Some(project_path) = + project.read(cx).project_path_for_absolute_path(path, cx) + { + let filename: SharedString = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string() + .into(); + mentions.push((start..end, project_path, filename)); + } + } + } + } + + let snapshot = message_editor.update(cx, |editor, cx| { + editor.set_text(text, window, cx); + editor.buffer().read(cx).snapshot(cx) + }); + + for (range, project_path, filename) in mentions { + let crease_icon_path = if project_path.path.is_dir() { + FileIcons::get_folder_icon(false, cx) + .unwrap_or_else(|| IconName::Folder.path().into()) + } else { + FileIcons::get_icon(Path::new(project_path.path.as_ref()), cx) + .unwrap_or_else(|| IconName::File.path().into()) + }; + + let anchor = snapshot.anchor_before(range.start); + let crease_id = crate::context_picker::insert_crease_for_mention( + anchor.excerpt_id, + anchor.text_anchor, + range.end - range.start, + filename, + crease_icon_path, + message_editor.clone(), + window, + cx, + ); + if let Some(crease_id) = crease_id { + mention_set.lock().insert(crease_id, project_path); + } + } + } + + fn handle_thread_event( + &mut self, + thread: &Entity, + event: &AcpThreadEvent, + window: &mut Window, + cx: &mut Context, + ) { + let count = self.list_state.item_count(); + match event { + AcpThreadEvent::NewEntry => { + self.sync_thread_entry_view(thread.read(cx).entries().len() - 1, window, cx); + self.list_state.splice(count..count, 1); + } + AcpThreadEvent::EntryUpdated(index) => { + let index = *index; + self.sync_thread_entry_view(index, window, cx); + self.list_state.splice(index..index + 1, 1); + } + } + cx.notify(); + } + + fn sync_thread_entry_view( + &mut self, + entry_ix: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(multibuffer) = self.entry_diff_multibuffer(entry_ix, cx) else { + return; + }; + + if self.diff_editors.contains_key(&multibuffer.entity_id()) { + return; + } + + let editor = cx.new(|cx| { + let mut editor = Editor::new( + EditorMode::Full { + scale_ui_elements_with_buffer_font_size: false, + show_active_line_background: false, + sized_by_content: true, + }, + multibuffer.clone(), + None, + window, + cx, + ); + editor.set_show_gutter(false, cx); + editor.disable_inline_diagnostics(); + editor.disable_expand_excerpt_buttons(cx); + editor.set_show_vertical_scrollbar(false, cx); + editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx); + editor.set_soft_wrap_mode(SoftWrap::None, cx); + editor.scroll_manager.set_forbid_vertical_scroll(true); + editor.set_show_indent_guides(false, cx); + editor.set_read_only(true); + editor.set_show_breakpoints(false, cx); + editor.set_show_code_actions(false, cx); + editor.set_show_git_diff_gutter(false, cx); + editor.set_expand_all_diff_hunks(cx); + editor.set_text_style_refinement(TextStyleRefinement { + font_size: Some( + TextSize::Small + .rems(cx) + .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx)) + .into(), + ), + ..Default::default() + }); + editor + }); + let entity_id = multibuffer.entity_id(); + cx.observe_release(&multibuffer, move |this, _, _| { + this.diff_editors.remove(&entity_id); + }) + .detach(); + + self.diff_editors.insert(entity_id, editor); + } + + fn entry_diff_multibuffer(&self, entry_ix: usize, cx: &App) -> Option> { + let entry = self.thread()?.read(cx).entries().get(entry_ix)?; + if let AgentThreadEntry::ToolCall(ToolCall { + content: Some(ToolCallContent::Diff { diff }), + .. + }) = &entry + { + Some(diff.multibuffer.clone()) + } else { + None + } + } + + fn authenticate(&mut self, window: &mut Window, cx: &mut Context) { + let Some(thread) = self.thread().cloned() else { + return; + }; + + self.last_error.take(); + let authenticate = thread.read(cx).authenticate(); + self.auth_task = Some(cx.spawn_in(window, { + let project = self.project.clone(); + async move |this, cx| { + let result = authenticate.await; + + this.update_in(cx, |this, window, cx| { + if let Err(err) = result { + this.last_error = Some(cx.new(|cx| { + Markdown::new(format!("Error: {err}").into(), None, None, cx) + })) + } else { + this.thread_state = Self::initial_state(project.clone(), window, cx) + } + this.auth_task.take() + }) + .ok(); + } + })); + } + + 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, + index: usize, + total_entries: usize, + entry: &AgentThreadEntry, + window: &mut Window, + cx: &Context, + ) -> AnyElement { + match &entry { + AgentThreadEntry::UserMessage(message) => div() + .py_4() + .px_2() + .child( + v_flex() + .p_3() + .gap_1p5() + .rounded_lg() + .shadow_md() + .bg(cx.theme().colors().editor_background) + .border_1() + .border_color(cx.theme().colors().border) + .text_xs() + .child(self.render_markdown( + message.content.clone(), + user_message_markdown_style(window, cx), + )), + ) + .into_any(), + AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => { + let style = default_markdown_style(false, window, cx); + let message_body = v_flex() + .w_full() + .gap_2p5() + .children(chunks.iter().enumerate().map(|(chunk_ix, chunk)| { + match chunk { + AssistantMessageChunk::Text { chunk } => self + .render_markdown(chunk.clone(), style.clone()) + .into_any_element(), + AssistantMessageChunk::Thought { chunk } => self.render_thinking_block( + index, + chunk_ix, + chunk.clone(), + window, + cx, + ), + } + })) + .into_any(); + + v_flex() + .px_5() + .py_1() + .when(index + 1 == total_entries, |this| this.pb_4()) + .w_full() + .text_ui(cx) + .child(message_body) + .into_any() + } + AgentThreadEntry::ToolCall(tool_call) => div() + .py_1p5() + .px_5() + .child(self.render_tool_call(index, tool_call, window, cx)) + .into_any(), + } + } + + fn tool_card_header_bg(&self, cx: &Context) -> Hsla { + cx.theme() + .colors() + .element_background + .blend(cx.theme().colors().editor_foreground.opacity(0.025)) + } + + fn tool_card_border_color(&self, cx: &Context) -> Hsla { + cx.theme().colors().border.opacity(0.6) + } + + fn tool_name_font_size(&self) -> Rems { + rems_from_px(13.) + } + + fn render_thinking_block( + &self, + entry_ix: usize, + chunk_ix: usize, + chunk: Entity, + window: &Window, + cx: &Context, + ) -> AnyElement { + let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix)); + let key = (entry_ix, chunk_ix); + let is_open = self.expanded_thinking_blocks.contains(&key); + + v_flex() + .child( + h_flex() + .id(header_id) + .group("disclosure-header") + .w_full() + .justify_between() + .opacity(0.8) + .hover(|style| style.opacity(1.)) + .child( + h_flex() + .gap_1p5() + .child( + Icon::new(IconName::ToolBulb) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child( + div() + .text_size(self.tool_name_font_size()) + .child("Thinking"), + ), + ) + .child( + div().visible_on_hover("disclosure-header").child( + Disclosure::new("thinking-disclosure", is_open) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .on_click(cx.listener({ + move |this, _event, _window, cx| { + if is_open { + this.expanded_thinking_blocks.remove(&key); + } else { + this.expanded_thinking_blocks.insert(key); + } + cx.notify(); + } + })), + ), + ) + .on_click(cx.listener({ + move |this, _event, _window, cx| { + if is_open { + this.expanded_thinking_blocks.remove(&key); + } else { + this.expanded_thinking_blocks.insert(key); + } + cx.notify(); + } + })), + ) + .when(is_open, |this| { + this.child( + div() + .relative() + .mt_1p5() + .ml(px(7.)) + .pl_4() + .border_l_1() + .border_color(self.tool_card_border_color(cx)) + .text_ui_sm(cx) + .child( + self.render_markdown(chunk, default_markdown_style(false, window, cx)), + ), + ) + }) + .into_any_element() + } + + fn render_tool_call( + &self, + entry_ix: usize, + tool_call: &ToolCall, + window: &Window, + cx: &Context, + ) -> Div { + let header_id = SharedString::from(format!("tool-call-header-{}", entry_ix)); + + let status_icon = match &tool_call.status { + ToolCallStatus::WaitingForConfirmation { .. } => None, + ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Running, + .. + } => Some( + Icon::new(IconName::ArrowCircle) + .color(Color::Accent) + .size(IconSize::Small) + .with_animation( + "running", + Animation::new(Duration::from_secs(2)).repeat(), + |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), + ) + .into_any(), + ), + ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Finished, + .. + } => None, + ToolCallStatus::Rejected + | ToolCallStatus::Canceled + | ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Error, + .. + } => Some( + Icon::new(IconName::X) + .color(Color::Error) + .size(IconSize::Small) + .into_any_element(), + ), + }; + + let needs_confirmation = match &tool_call.status { + ToolCallStatus::WaitingForConfirmation { .. } => true, + _ => tool_call + .content + .iter() + .any(|content| matches!(content, ToolCallContent::Diff { .. })), + }; + + let is_collapsible = tool_call.content.is_some() && !needs_confirmation; + let is_open = !is_collapsible || self.expanded_tool_calls.contains(&tool_call.id); + + let content = if is_open { + match &tool_call.status { + ToolCallStatus::WaitingForConfirmation { confirmation, .. } => { + Some(self.render_tool_call_confirmation( + tool_call.id, + confirmation, + tool_call.content.as_ref(), + window, + cx, + )) + } + ToolCallStatus::Allowed { .. } | ToolCallStatus::Canceled => { + tool_call.content.as_ref().map(|content| { + div() + .py_1p5() + .child(self.render_tool_call_content(content, window, cx)) + .into_any_element() + }) + } + ToolCallStatus::Rejected => None, + } + } else { + None + }; + + v_flex() + .when(needs_confirmation, |this| { + this.rounded_lg() + .border_1() + .border_color(self.tool_card_border_color(cx)) + .bg(cx.theme().colors().editor_background) + .overflow_hidden() + }) + .child( + h_flex() + .id(header_id) + .w_full() + .gap_1() + .justify_between() + .map(|this| { + if needs_confirmation { + this.px_2() + .py_1() + .rounded_t_md() + .bg(self.tool_card_header_bg(cx)) + .border_b_1() + .border_color(self.tool_card_border_color(cx)) + } else { + this.opacity(0.8).hover(|style| style.opacity(1.)) + } + }) + .child( + h_flex() + .id("tool-call-header") + .overflow_x_scroll() + .map(|this| { + if needs_confirmation { + this.text_xs() + } else { + this.text_size(self.tool_name_font_size()) + } + }) + .gap_1p5() + .child( + Icon::new(tool_call.icon) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(self.render_markdown( + tool_call.label.clone(), + default_markdown_style(needs_confirmation, window, cx), + )), + ) + .child( + h_flex() + .gap_0p5() + .when(is_collapsible, |this| { + this.child( + Disclosure::new(("expand", tool_call.id.0), is_open) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .on_click(cx.listener({ + let id = tool_call.id; + move |this: &mut Self, _, _, cx: &mut Context| { + if is_open { + this.expanded_tool_calls.remove(&id); + } else { + this.expanded_tool_calls.insert(id); + } + cx.notify(); + } + })), + ) + }) + .children(status_icon), + ) + .on_click(cx.listener({ + let id = tool_call.id; + move |this: &mut Self, _, _, cx: &mut Context| { + if is_open { + this.expanded_tool_calls.remove(&id); + } else { + this.expanded_tool_calls.insert(id); + } + cx.notify(); + } + })), + ) + .when(is_open, |this| { + this.child( + div() + .text_xs() + .when(is_collapsible, |this| { + this.mt_1() + .border_1() + .border_color(self.tool_card_border_color(cx)) + .bg(cx.theme().colors().editor_background) + .rounded_lg() + }) + .children(content), + ) + }) + } + + fn render_tool_call_content( + &self, + content: &ToolCallContent, + window: &Window, + cx: &Context, + ) -> AnyElement { + match content { + ToolCallContent::Markdown { markdown } => self + .render_markdown(markdown.clone(), default_markdown_style(false, window, cx)) + .into_any_element(), + ToolCallContent::Diff { + diff: Diff { + path, multibuffer, .. + }, + .. + } => self.render_diff_editor(multibuffer, path), + } + } + + fn render_tool_call_confirmation( + &self, + tool_call_id: ToolCallId, + confirmation: &ToolCallConfirmation, + content: Option<&ToolCallContent>, + window: &Window, + cx: &Context, + ) -> AnyElement { + let confirmation_container = v_flex().mt_1().py_1p5(); + + let button_container = h_flex() + .pt_1p5() + .px_1p5() + .gap_1() + .justify_end() + .border_t_1() + .border_color(self.tool_card_border_color(cx)); + + match confirmation { + ToolCallConfirmation::Edit { description } => confirmation_container + .child( + div() + .px_2() + .children(description.clone().map(|description| { + self.render_markdown( + description, + default_markdown_style(false, window, cx), + ) + })), + ) + .children(content.map(|content| self.render_tool_call_content(content, window, cx))) + .child( + button_container + .child( + Button::new(("always_allow", tool_call_id.0), "Always Allow Edits") + .icon(IconName::CheckDouble) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .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.0), "Allow") + .icon(IconName::Check) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .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.0), "Reject") + .icon(IconName::X) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .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, + } => confirmation_container + .child(v_flex().px_2().pb_1p5().child(command.clone()).children( + description.clone().map(|description| { + self.render_markdown(description, default_markdown_style(false, window, cx)) + .on_url_click({ + let workspace = self.workspace.clone(); + move |text, window, cx| { + Self::open_link(text, &workspace, window, cx); + } + }) + }), + )) + .children(content.map(|content| self.render_tool_call_content(content, window, cx))) + .child( + button_container + .child( + Button::new( + ("always_allow", tool_call_id.0), + format!("Always Allow {root_command}"), + ) + .icon(IconName::CheckDouble) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Success) + .label_size(LabelSize::Small) + .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.0), "Allow") + .icon(IconName::Check) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Success) + .label_size(LabelSize::Small) + .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.0), "Reject") + .icon(IconName::X) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Error) + .label_size(LabelSize::Small) + .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, + } => confirmation_container + .child( + v_flex() + .px_2() + .pb_1p5() + .child(format!("{server_name} - {tool_display_name}")) + .children(description.clone().map(|description| { + self.render_markdown( + description, + default_markdown_style(false, window, cx), + ) + })), + ) + .children(content.map(|content| self.render_tool_call_content(content, window, cx))) + .child( + button_container + .child( + Button::new( + ("always_allow_server", tool_call_id.0), + format!("Always Allow {server_name}"), + ) + .icon(IconName::CheckDouble) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Success) + .label_size(LabelSize::Small) + .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.0), + format!("Always Allow {tool_display_name}"), + ) + .icon(IconName::CheckDouble) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Success) + .label_size(LabelSize::Small) + .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.0), "Allow") + .icon(IconName::Check) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Success) + .label_size(LabelSize::Small) + .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.0), "Reject") + .icon(IconName::X) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Error) + .label_size(LabelSize::Small) + .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 } => confirmation_container + .child( + v_flex() + .px_2() + .pb_1p5() + .gap_1() + .children(urls.iter().map(|url| { + h_flex().child( + Button::new(url.clone(), url) + .icon(IconName::ArrowUpRight) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + .on_click({ + let url = url.clone(); + move |_, _, cx| cx.open_url(&url) + }), + ) + })) + .children(description.clone().map(|description| { + self.render_markdown( + description, + default_markdown_style(false, window, cx), + ) + })), + ) + .children(content.map(|content| self.render_tool_call_content(content, window, cx))) + .child( + button_container + .child( + Button::new(("always_allow", tool_call_id.0), "Always Allow") + .icon(IconName::CheckDouble) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Success) + .label_size(LabelSize::Small) + .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.0), "Allow") + .icon(IconName::Check) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Success) + .label_size(LabelSize::Small) + .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.0), "Reject") + .icon(IconName::X) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Error) + .label_size(LabelSize::Small) + .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 } => confirmation_container + .child(v_flex().px_2().pb_1p5().child(self.render_markdown( + description.clone(), + default_markdown_style(false, window, cx), + ))) + .children(content.map(|content| self.render_tool_call_content(content, window, cx))) + .child( + button_container + .child( + Button::new(("always_allow", tool_call_id.0), "Always Allow") + .icon(IconName::CheckDouble) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Success) + .label_size(LabelSize::Small) + .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.0), "Allow") + .icon(IconName::Check) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Success) + .label_size(LabelSize::Small) + .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.0), "Reject") + .icon(IconName::X) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Error) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let id = tool_call_id; + move |this, _, _, cx| { + this.authorize_tool_call( + id, + acp::ToolCallConfirmationOutcome::Reject, + cx, + ); + } + })), + ), + ) + .into_any(), + } + } + + fn render_diff_editor(&self, multibuffer: &Entity, path: &Path) -> AnyElement { + v_flex() + .h_full() + .child(path.to_string_lossy().to_string()) + .child( + if let Some(editor) = self.diff_editors.get(&multibuffer.entity_id()) { + editor.clone().into_any_element() + } else { + Empty.into_any() + }, + ) + .into_any() + } + + fn render_gemini_logo(&self) -> AnyElement { + Icon::new(IconName::AiGemini) + .color(Color::Muted) + .size(IconSize::XLarge) + .into_any_element() + } + + fn render_error_gemini_logo(&self) -> AnyElement { + let logo = Icon::new(IconName::AiGemini) + .color(Color::Muted) + .size(IconSize::XLarge) + .into_any_element(); + + h_flex() + .relative() + .justify_center() + .child(div().opacity(0.3).child(logo)) + .child( + h_flex().absolute().right_1().bottom_0().child( + Icon::new(IconName::XCircle) + .color(Color::Error) + .size(IconSize::Small), + ), + ) + .into_any_element() + } + + fn render_empty_state(&self, loading: bool, cx: &App) -> AnyElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .child( + if loading { + h_flex() + .justify_center() + .child(self.render_gemini_logo()) + .with_animation( + "pulsating_icon", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 1.0)), + |icon, delta| icon.opacity(delta), + ).into_any() + } else { + self.render_gemini_logo().into_any_element() + } + ) + .child( + h_flex() + .mt_4() + .mb_1() + .justify_center() + .child(Headline::new(if loading { + "Connecting to Gemini…" + } else { + "Welcome to Gemini" + }).size(HeadlineSize::Medium)), + ) + .child( + div() + .max_w_1_2() + .text_sm() + .text_center() + .map(|this| if loading { + this.invisible() + } else { + this.text_color(cx.theme().colors().text_muted) + }) + .child("Ask questions, edit files, run commands.\nBe specific for the best results.") + ) + .into_any() + } + + fn render_pending_auth_state(&self) -> AnyElement { + v_flex() + .items_center() + .justify_center() + .child(self.render_error_gemini_logo()) + .child( + h_flex() + .mt_4() + .mb_1() + .justify_center() + .child(Headline::new("Not Authenticated").size(HeadlineSize::Medium)), + ) + .into_any() + } + + fn render_error_state(&self, e: &LoadError, cx: &Context) -> AnyElement { + let mut container = v_flex() + .items_center() + .justify_center() + .child(self.render_error_gemini_logo()) + .child( + v_flex() + .mt_4() + .mb_2() + .gap_0p5() + .text_center() + .items_center() + .child(Headline::new("Failed to launch").size(HeadlineSize::Medium)) + .child( + Label::new(e.to_string()) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ); + + if matches!(e, LoadError::Unsupported { .. }) { + container = + container.child(Button::new("upgrade", "Upgrade Gemini to Latest").on_click( + cx.listener(|this, _, window, cx| { + this.workspace + .update(cx, |workspace, cx| { + let project = workspace.project().read(cx); + let cwd = project.first_project_directory(cx); + let shell = project.terminal_settings(&cwd, cx).shell.clone(); + let command = + "npm install -g @google/gemini-cli@latest".to_string(); + let spawn_in_terminal = task::SpawnInTerminal { + id: task::TaskId("install".to_string()), + full_label: command.clone(), + label: command.clone(), + command: Some(command.clone()), + args: Vec::new(), + command_label: command.clone(), + cwd, + env: Default::default(), + use_new_terminal: true, + allow_concurrent_runs: true, + reveal: Default::default(), + reveal_target: Default::default(), + hide: Default::default(), + shell, + show_summary: true, + show_command: true, + show_rerun: false, + }; + workspace + .spawn_in_terminal(spawn_in_terminal, window, cx) + .detach(); + }) + .ok(); + }), + )); + } + + container.into_any() + } + + fn render_message_editor(&mut self, cx: &mut Context) -> AnyElement { + let settings = ThemeSettings::get_global(cx); + let font_size = TextSize::Small + .rems(cx) + .to_pixels(settings.agent_font_size(cx)); + let line_height = settings.buffer_line_height.value() * font_size; + + let text_style = TextStyle { + color: cx.theme().colors().text, + font_family: settings.buffer_font.family.clone(), + font_fallbacks: settings.buffer_font.fallbacks.clone(), + font_features: settings.buffer_font.features.clone(), + font_size: font_size.into(), + line_height: line_height.into(), + ..Default::default() + }; + + EditorElement::new( + &self.message_editor, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + syntax: cx.theme().syntax().clone(), + ..Default::default() + }, + ) + .into_any() + } + + fn render_markdown(&self, markdown: Entity, style: MarkdownStyle) -> MarkdownElement { + let workspace = self.workspace.clone(); + MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| { + Self::open_link(text, &workspace, window, cx); + }) + } + + fn open_link( + url: SharedString, + workspace: &WeakEntity, + window: &mut Window, + cx: &mut App, + ) { + let Some(workspace) = workspace.upgrade() else { + cx.open_url(&url); + return; + }; + + if let Some(mention_path) = MentionPath::try_parse(&url) { + workspace.update(cx, |workspace, cx| { + let project = workspace.project(); + let Some((path, entry)) = project.update(cx, |project, cx| { + let path = project.find_project_path(mention_path.path(), cx)?; + let entry = project.entry_for_path(&path, cx)?; + Some((path, entry)) + }) else { + return; + }; + + if entry.is_dir() { + project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel(entry.id)); + }); + } else { + workspace + .open_path(path, None, true, window, cx) + .detach_and_log_err(cx); + } + }) + } else { + cx.open_url(&url); + } + } + + pub fn open_thread_as_markdown( + &self, + workspace: Entity, + window: &mut Window, + cx: &mut App, + ) -> Task> { + let markdown_language_task = workspace + .read(cx) + .app_state() + .languages + .language_for_name("Markdown"); + + let (thread_summary, markdown) = match &self.thread_state { + ThreadState::Ready { thread, .. } | ThreadState::Unauthenticated { thread } => { + let thread = thread.read(cx); + (thread.title().to_string(), thread.to_markdown(cx)) + } + ThreadState::Loading { .. } | ThreadState::LoadError(..) => return Task::ready(Ok(())), + }; + + window.spawn(cx, async move |cx| { + let markdown_language = markdown_language_task.await?; + + workspace.update_in(cx, |workspace, window, cx| { + let project = workspace.project().clone(); + + if !project.read(cx).is_local() { + anyhow::bail!("failed to open active thread as markdown in remote project"); + } + + let buffer = project.update(cx, |project, cx| { + project.create_local_buffer(&markdown, Some(markdown_language), cx) + }); + let buffer = cx.new(|cx| { + MultiBuffer::singleton(buffer, cx).with_title(thread_summary.clone()) + }); + + workspace.add_item_to_active_pane( + Box::new(cx.new(|cx| { + let mut editor = + Editor::for_multibuffer(buffer, Some(project.clone()), window, cx); + editor.set_breadcrumb_header(thread_summary); + editor + })), + None, + true, + window, + cx, + ); + + anyhow::Ok(()) + })??; + anyhow::Ok(()) + }) + } + + fn scroll_to_top(&mut self, cx: &mut Context) { + self.list_state.scroll_to(ListOffset::default()); + cx.notify(); + } +} + +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); + + let open_as_markdown = IconButton::new("open-as-markdown", IconName::DocumentText) + .icon_size(IconSize::XSmall) + .icon_color(Color::Ignored) + .tooltip(Tooltip::text("Open Thread as Markdown")) + .on_click(cx.listener(move |this, _, window, cx| { + if let Some(workspace) = this.workspace.upgrade() { + this.open_thread_as_markdown(workspace, window, cx) + .detach_and_log_err(cx); + } + })); + + let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUpAlt) + .icon_size(IconSize::XSmall) + .icon_color(Color::Ignored) + .tooltip(Tooltip::text("Scroll To Top")) + .on_click(cx.listener(move |this, _, _, cx| { + this.scroll_to_top(cx); + })); + + let feedback_container = h_flex() + .group("feedback_container") + .mt_1() + .py_2() + .px(RESPONSE_PADDING_X) + .mr_1() + .opacity(0.4) + .hover(|style| style.opacity(1.)) + .gap_1p5() + .flex_wrap() + .justify_end() + .child(h_flex().child(open_as_markdown)) + .child(scroll_to_top) + .into_any_element(); + + let show_controls = matches!(&self.thread_state, ThreadState::Ready { thread, .. } if thread.read(cx).status() == ThreadStatus::Idle); + + v_flex() + .size_full() + .key_context("AcpThread") + .on_action(cx.listener(Self::chat)) + .on_action(cx.listener(Self::previous_history_message)) + .on_action(cx.listener(Self::next_history_message)) + .child(match &self.thread_state { + ThreadState::Unauthenticated { .. } => v_flex() + .p_2() + .flex_1() + .items_center() + .justify_center() + .child(self.render_pending_auth_state()) + .child(h_flex().mt_1p5().justify_center().child( + Button::new("sign-in", "Sign in to Gemini").on_click( + cx.listener(|this, _, window, cx| this.authenticate(window, cx)), + ), + )), + ThreadState::Loading { .. } => { + v_flex().flex_1().child(self.render_empty_state(true, cx)) + } + ThreadState::LoadError(e) => v_flex() + .p_2() + .flex_1() + .items_center() + .justify_center() + .child(self.render_error_state(e, cx)), + ThreadState::Ready { thread, .. } => v_flex().flex_1().map(|this| { + if self.list_state.item_count() > 0 { + this.child( + list(self.list_state.clone()) + .with_sizing_behavior(gpui::ListSizingBehavior::Auto) + .flex_grow() + .into_any(), + ) + .children(match thread.read(cx).status() { + ThreadStatus::Idle | ThreadStatus::WaitingForToolConfirmation => None, + ThreadStatus::Generating => div() + .px_5() + .py_2() + .child(LoadingLabel::new("").size(LabelSize::Small)) + .into(), + }) + } else { + this.child(self.render_empty_state(false, cx)) + } + }), + }) + .when(show_controls, |el| el.child(feedback_container)) + .when_some(self.last_error.clone(), |el, error| { + el.child( + div() + .p_2() + .text_xs() + .border_t_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().status().error_background) + .child( + self.render_markdown(error, default_markdown_style(false, window, cx)), + ), + ) + }) + .child( + v_flex() + .p_2() + .pt_3() + .gap_1() + .bg(cx.theme().colors().editor_background) + .border_t_1() + .border_color(cx.theme().colors().border) + .child(self.render_message_editor(cx)) + .child({ + let thread = self.thread(); + + h_flex().justify_end().child( + if thread.map_or(true, |thread| { + thread.read(cx).status() == ThreadStatus::Idle + }) { + IconButton::new("send-message", IconName::Send) + .icon_color(Color::Accent) + .style(ButtonStyle::Filled) + .disabled(thread.is_none() || 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")) + }) + } else { + 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, + ) + }) + .on_click(cx.listener(|this, _event, _, cx| this.cancel(cx))) + }, + ) + }), + ) + } +} + +fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { + let mut style = default_markdown_style(false, 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.link_callback = Some(Rc::new(move |url, cx| { + if MentionPath::try_parse(url).is_some() { + let colors = cx.theme().colors(); + Some(TextStyleRefinement { + background_color: Some(colors.element_background), + ..Default::default() + }) + } else { + None + } + })); + style +} + +fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> MarkdownStyle { + let theme_settings = ThemeSettings::get_global(cx); + let colors = cx.theme().colors(); + + let buffer_font_size = TextSize::Small.rems(cx); + + let mut text_style = window.text_style(); + let line_height = buffer_font_size * 1.75; + + let font_family = if buffer_font { + theme_settings.buffer_font.family.clone() + } else { + theme_settings.ui_font.family.clone() + }; + + let font_size = if buffer_font { + TextSize::Small.rems(cx) + } else { + TextSize::Default.rems(cx) + }; + + text_style.refine(&TextStyleRefinement { + font_family: Some(font_family), + font_fallbacks: theme_settings.ui_font.fallbacks.clone(), + font_features: Some(theme_settings.ui_font.features.clone()), + font_size: Some(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.)))), + }, + margin: EdgesRefinement { + top: Some(Length::Definite(Pixels(8.).into())), + left: Some(Length::Definite(Pixels(0.).into())), + right: Some(Length::Definite(Pixels(0.).into())), + bottom: Some(Length::Definite(Pixels(12.).into())), + }, + border_style: Some(BorderStyle::Solid), + border_widths: EdgesRefinement { + top: Some(AbsoluteLength::Pixels(Pixels(1.))), + left: Some(AbsoluteLength::Pixels(Pixels(1.))), + right: Some(AbsoluteLength::Pixels(Pixels(1.))), + bottom: Some(AbsoluteLength::Pixels(Pixels(1.))), + }, + border_color: Some(colors.border_variant), + 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() + }, + ..Default::default() + } +} diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 5f58e0bd8d..e726dd6640 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -7,12 +7,14 @@ 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, DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, ToggleBurnMode, ToggleContextPicker, ToggleNavigationMenu, ToggleOptionsMenu, + acp::AcpThreadView, active_thread::{self, ActiveThread, ActiveThreadEvent}, agent_configuration::{AgentConfiguration, AssistantConfigurationEvent}, agent_diff::AgentDiff, @@ -38,6 +40,7 @@ use assistant_slash_command::SlashCommandWorkingSet; use assistant_tool::ToolWorkingSet; use client::{UserStore, zed_urls}; use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer}; +use feature_flags::{self, FeatureFlagAppExt}; use fs::Fs; use gpui::{ Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, ClipboardItem, @@ -109,6 +112,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,7 +134,8 @@ pub fn init(cx: &mut App) { let thread = thread.read(cx).thread().clone(); AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx); } - ActiveView::TextThread { .. } + ActiveView::AcpThread { .. } + | ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} } @@ -188,6 +198,9 @@ enum ActiveView { message_editor: Entity, _subscriptions: Vec, }, + AcpThread { + thread_view: Entity, + }, TextThread { context_editor: Entity, title_editor: Entity, @@ -207,7 +220,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 +253,7 @@ impl ActiveView { thread.scroll_to_bottom(cx); }); } + ActiveView::AcpThread { .. } => {} ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} @@ -653,7 +669,8 @@ impl AgentPanel { .clone() .update(cx, |thread, cx| thread.get_or_init_configured_model(cx)); } - ActiveView::TextThread { .. } + ActiveView::AcpThread { .. } + | ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} }, @@ -733,6 +750,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(cx)); + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} } } @@ -740,7 +760,10 @@ impl AgentPanel { fn active_message_editor(&self) -> Option<&Entity> { match &self.active_view { ActiveView::Thread { message_editor, .. } => Some(message_editor), - ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None, + ActiveView::AcpThread { .. } + | ActiveView::TextThread { .. } + | ActiveView::History + | ActiveView::Configuration => None, } } @@ -862,6 +885,21 @@ impl AgentPanel { context_editor.focus_handle(cx).focus(window); } + fn new_gemini_thread(&mut self, window: &mut Window, cx: &mut Context) { + let workspace = self.workspace.clone(); + let project = self.project.clone(); + + cx.spawn_in(window, async move |this, cx| { + let thread_view = cx.new_window_entity(|window, cx| { + crate::acp::AcpThreadView::new(workspace, 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 +1032,7 @@ impl AgentPanel { cx, ) }); + let message_editor = cx.new(|cx| { MessageEditor::new( self.fs.clone(), @@ -1025,6 +1064,9 @@ impl AgentPanel { ActiveView::Thread { message_editor, .. } => { message_editor.focus_handle(cx).focus(window); } + ActiveView::AcpThread { thread_view } => { + thread_view.focus_handle(cx).focus(window); + } ActiveView::TextThread { context_editor, .. } => { context_editor.focus_handle(cx).focus(window); } @@ -1144,7 +1186,10 @@ impl AgentPanel { }) .log_err(); } - ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} + ActiveView::AcpThread { .. } + | ActiveView::TextThread { .. } + | ActiveView::History + | ActiveView::Configuration => {} } } @@ -1197,6 +1242,13 @@ impl AgentPanel { ) .detach_and_log_err(cx); } + ActiveView::AcpThread { thread_view } => { + thread_view + .update(cx, |thread_view, cx| { + thread_view.open_thread_as_markdown(workspace, window, cx) + }) + .detach_and_log_err(cx); + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} } } @@ -1351,7 +1403,8 @@ impl AgentPanel { } }) } - _ => {} + ActiveView::AcpThread { .. } => {} + ActiveView::History | ActiveView::Configuration => {} } if current_is_special && !new_is_special { @@ -1437,6 +1490,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 +1647,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,7 +1784,10 @@ impl AgentPanel { let active_thread = match &self.active_view { ActiveView::Thread { thread, .. } => Some(thread.read(cx).thread().clone()), - ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None, + ActiveView::AcpThread { .. } + | ActiveView::TextThread { .. } + | ActiveView::History + | ActiveView::Configuration => None, }; let agent_extra_menu = PopoverMenu::new("agent-options-menu") @@ -1755,6 +1815,9 @@ impl AgentPanel { menu = menu .action("New Thread", NewThread::default().boxed_clone()) .action("New Text Thread", NewTextThread.boxed_clone()) + .when(cx.has_flag::(), |this| { + this.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 +1956,9 @@ impl AgentPanel { message_editor, .. } => (thread.read(cx), message_editor.read(cx)), + ActiveView::AcpThread { .. } => { + return None; + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { return None; } @@ -2031,6 +2097,9 @@ impl AgentPanel { return false; } } + ActiveView::AcpThread { .. } => { + return false; + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { return false; } @@ -2615,6 +2684,9 @@ impl AgentPanel { ) -> Option { let active_thread = match &self.active_view { ActiveView::Thread { thread, .. } => thread, + ActiveView::AcpThread { .. } => { + return None; + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { return None; } @@ -2961,6 +3033,9 @@ impl AgentPanel { .detach(); }); } + ActiveView::AcpThread { .. } => { + unimplemented!() + } ActiveView::TextThread { context_editor, .. } => { context_editor.update(cx, |context_editor, cx| { TextThreadEditor::insert_dragged_files( @@ -3034,6 +3109,7 @@ impl Render for AgentPanel { }); this.continue_conversation(window, cx); } + ActiveView::AcpThread { .. } => {} ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} @@ -3075,6 +3151,10 @@ 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()) + .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 e488cf5a1e..10912cc055 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -1,3 +1,4 @@ +mod acp; mod active_thread; mod agent_configuration; mod agent_diff; @@ -56,6 +57,8 @@ actions!( [ /// Creates a new text-based conversation thread. NewTextThread, + /// Creates a new Gemini CLI-based conversation thread. + NewGeminiThread, /// Toggles the context picker interface for adding files, symbols, or other context. ToggleContextPicker, /// Toggles the navigation menu for switching between threads and views. @@ -76,8 +79,6 @@ actions!( AddContextServer, /// Removes the currently selected thread. RemoveSelectedThread, - /// Starts a chat conversation with the agent. - Chat, /// Starts a chat conversation with follow-up enabled. ChatWithFollow, /// Cycles to the next inline assist suggestion. diff --git a/crates/agent_ui/src/context_picker.rs b/crates/agent_ui/src/context_picker.rs index 73fc0b36ce..5cc56b014e 100644 --- a/crates/agent_ui/src/context_picker.rs +++ b/crates/agent_ui/src/context_picker.rs @@ -1,6 +1,6 @@ mod completion_provider; mod fetch_context_picker; -mod file_context_picker; +pub(crate) mod file_context_picker; mod rules_context_picker; mod symbol_context_picker; mod thread_context_picker; diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 38065b828a..d1eae02246 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/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs index 2c2bbfe30d..9252977f75 100644 --- a/crates/feature_flags/src/feature_flags.rs +++ b/crates/feature_flags/src/feature_flags.rs @@ -92,6 +92,12 @@ impl FeatureFlag for JjUiFeatureFlag { const NAME: &'static str = "jj-ui"; } +pub struct AcpFeatureFlag; + +impl FeatureFlag for AcpFeatureFlag { + const NAME: &'static str = "acp"; +} + pub struct ZedCloudFeatureFlag {} impl FeatureFlag for ZedCloudFeatureFlag { diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 332e38b038..9848cc9cee 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -13,6 +13,7 @@ pub enum IconName { AiBedrock, AiDeepSeek, AiEdit, + AiGemini, AiGoogle, AiLmStudio, AiMistral, @@ -252,6 +253,14 @@ pub enum IconName { TextSnippet, ThumbsDown, ThumbsUp, + ToolBulb, + ToolFolder, + ToolHammer, + ToolPencil, + ToolRegex, + ToolSearch, + ToolTerminal, + ToolWeb, Trash, TrashAlt, Triangle, diff --git a/crates/paths/src/paths.rs b/crates/paths/src/paths.rs index f447e474e7..2f3b188980 100644 --- a/crates/paths/src/paths.rs +++ b/crates/paths/src/paths.rs @@ -352,6 +352,14 @@ pub fn debug_adapters_dir() -> &'static PathBuf { DEBUG_ADAPTERS_DIR.get_or_init(|| data_dir().join("debug_adapters")) } +/// Returns the path to the agent servers directory +/// +/// This is where agent servers are downloaded to +pub fn agent_servers_dir() -> &'static PathBuf { + static AGENT_SERVERS_DIR: OnceLock = OnceLock::new(); + AGENT_SERVERS_DIR.get_or_init(|| data_dir().join("agent_servers")) +} + /// Returns the path to the Copilot directory. pub fn copilot_dir() -> &'static PathBuf { static COPILOT_DIR: OnceLock = OnceLock::new(); diff --git a/crates/project/src/environment.rs b/crates/project/src/environment.rs index 5b1f476f19..7379a7ef72 100644 --- a/crates/project/src/environment.rs +++ b/crates/project/src/environment.rs @@ -84,7 +84,7 @@ impl ProjectEnvironment { self.get_worktree_environment(worktree, cx) } - pub(crate) fn get_worktree_environment( + pub fn get_worktree_environment( &mut self, worktree: Entity, cx: &mut Context, @@ -118,7 +118,7 @@ impl ProjectEnvironment { /// If the project was opened from the CLI, then the inherited CLI environment is returned. /// If it wasn't opened from the CLI, and an absolute path is given, then a shell is spawned in /// that directory, to get environment variables as if the user has `cd`'d there. - pub(crate) fn get_directory_environment( + pub fn get_directory_environment( &mut self, abs_path: Arc, cx: &mut Context, 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/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index 3abec1c2eb..b8988b1d1f 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -1,13 +1,11 @@ use std::ops::{Deref, DerefMut}; use editor::test::editor_lsp_test_context::EditorLspTestContext; -use gpui::{Context, Entity, SemanticVersion, UpdateGlobal, actions}; +use gpui::{Context, Entity, SemanticVersion, UpdateGlobal}; use search::{BufferSearchBar, project_search::ProjectSearchBar}; use crate::{state::Operator, *}; -actions!(agent, [Chat]); - pub struct VimTestContext { cx: EditorLspTestContext, } diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 48591d65c1..3af1709b74 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -23,6 +23,7 @@ activity_indicator.workspace = true agent.workspace = true agent_ui.workspace = true agent_settings.workspace = true +agent_servers.workspace = true anyhow.workspace = true askpass.workspace = true assets.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index b5efea10e2..34f0ea99cf 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -520,6 +520,7 @@ pub fn main() { supermaven::init(app_state.client.clone(), cx); language_model::init(app_state.client.clone(), cx); language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx); + agent_servers::init(cx); web_search::init(cx); web_search_providers::init(app_state.client.clone(), cx); snippet_provider::init(cx); diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index ffe232ad7b..06121a9de8 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -268,7 +268,13 @@ pub mod agent { /// Opens the agent onboarding modal. OpenOnboardingModal, /// Resets the agent onboarding state. - ResetOnboarding + ResetOnboarding, + /// Starts a chat conversation with the agent. + Chat, + /// Displays the previous message in the history. + PreviousHistoryMessage, + /// Displays the next message in the history. + NextHistoryMessage ] ); } diff --git a/tooling/workspace-hack/Cargo.toml b/tooling/workspace-hack/Cargo.toml index 84ed001536..2a242329e2 100644 --- a/tooling/workspace-hack/Cargo.toml +++ b/tooling/workspace-hack/Cargo.toml @@ -107,6 +107,7 @@ rustc-hash = { version = "1" } rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", default-features = false, features = ["fs", "net", "std"] } rustls = { version = "0.23", features = ["ring"] } rustls-webpki = { version = "0.103", default-features = false, features = ["aws-lc-rs", "ring", "std"] } +schemars = { version = "1", features = ["chrono04", "indexmap2"] } sea-orm = { version = "1", features = ["runtime-tokio-rustls", "sqlx-postgres", "sqlx-sqlite"] } sea-query-binder = { version = "0.7", default-features = false, features = ["postgres-array", "sqlx-postgres", "sqlx-sqlite", "with-bigdecimal", "with-chrono", "with-json", "with-rust_decimal", "with-time", "with-uuid"] } semver = { version = "1", features = ["serde"] } @@ -239,6 +240,7 @@ rustc-hash = { version = "1" } rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", default-features = false, features = ["fs", "net", "std"] } rustls = { version = "0.23", features = ["ring"] } rustls-webpki = { version = "0.103", default-features = false, features = ["aws-lc-rs", "ring", "std"] } +schemars = { version = "1", features = ["chrono04", "indexmap2"] } sea-orm = { version = "1", features = ["runtime-tokio-rustls", "sqlx-postgres", "sqlx-sqlite"] } sea-query-binder = { version = "0.7", default-features = false, features = ["postgres-array", "sqlx-postgres", "sqlx-sqlite", "with-bigdecimal", "with-chrono", "with-json", "with-rust_decimal", "with-time", "with-uuid"] } semver = { version = "1", features = ["serde"] }