From 8697b91ea09e612b5d5c088b5fe548fa8c1084c0 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Thu, 28 Aug 2025 15:33:00 -0400 Subject: [PATCH] acp: Automatically install gemini under Zed's data dir (#37054) Closes: https://github.com/zed-industries/zed/issues/37089 Instead of looking for the gemini command on `$PATH`, by default we'll install our own copy on demand under our data dir, as we already do for language servers and debug adapters. This also means we can handle keeping the binary up to date instead of prompting the user to upgrade. Notes: - The download is only triggered if you open a new Gemini thread - Custom commands from `agent_servers.gemini` in settings are respected as before - A new `agent_servers.gemini.ignore_system_version` setting is added, similar to the existing settings for language servers. It's `true` by default, and setting it to `false` disables the automatic download and makes Zed search `$PATH` as before. - If `agent_servers.gemini.ignore_system_version` is `false` and no binary is found on `$PATH`, we'll fall back to automatic installation. If it's `false` and a binary is found, but the version is older than v0.2.1, we'll show an error. Release Notes: - acp: By default, Zed will now download and use a private copy of the Gemini CLI binary, instead of searching your `$PATH`. To make Zed search your `$PATH` for Gemini CLI before attempting to download it, use the following setting: ``` { "agent_servers": { "gemini": { "ignore_system_version": false } } } ``` --- Cargo.lock | 1 + crates/acp_thread/src/acp_thread.rs | 13 +- crates/agent2/src/native_agent_server.rs | 19 +- crates/agent_servers/Cargo.toml | 5 +- crates/agent_servers/src/agent_servers.rs | 146 +++++++++++--- crates/agent_servers/src/claude.rs | 19 +- crates/agent_servers/src/custom.rs | 26 +-- crates/agent_servers/src/e2e_tests.rs | 14 +- crates/agent_servers/src/gemini.rs | 64 +++--- crates/agent_servers/src/settings.rs | 56 +++++- crates/agent_ui/src/acp/message_editor.rs | 5 +- crates/agent_ui/src/acp/thread_view.rs | 217 +++++++-------------- crates/agent_ui/src/agent_configuration.rs | 151 +++----------- crates/agent_ui/src/agent_panel.rs | 17 +- crates/agent_ui/src/agent_ui.rs | 8 +- 15 files changed, 331 insertions(+), 430 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4ecd8b42c79dee10549a73edfafd302e981ff488..0e3bfd18c2991328de18149be6688fcfc303eb61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -306,6 +306,7 @@ dependencies = [ "libc", "log", "nix 0.29.0", + "node_runtime", "paths", "project", "rand 0.8.5", diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 0da4b43394b76125b2ea9a310ae5bfe9bf0fac9a..04ff032ad40c600c80fed7cff9f48139b2307931 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -789,11 +789,12 @@ pub enum ThreadStatus { #[derive(Debug, Clone)] pub enum LoadError { - NotInstalled, Unsupported { command: SharedString, current_version: SharedString, + minimum_version: SharedString, }, + FailedToInstall(SharedString), Exited { status: ExitStatus, }, @@ -803,15 +804,19 @@ pub enum LoadError { impl Display for LoadError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { - LoadError::NotInstalled => write!(f, "not installed"), LoadError::Unsupported { command: path, current_version, + minimum_version, } => { - write!(f, "version {current_version} from {path} is not supported") + write!( + f, + "version {current_version} from {path} is not supported (need at least {minimum_version})" + ) } + LoadError::FailedToInstall(msg) => write!(f, "Failed to install: {msg}"), LoadError::Exited { status } => write!(f, "Server exited with status {status}"), - LoadError::Other(msg) => write!(f, "{}", msg), + LoadError::Other(msg) => write!(f, "{msg}"), } } } diff --git a/crates/agent2/src/native_agent_server.rs b/crates/agent2/src/native_agent_server.rs index 0079dcc5724a77e02cd68be3deaee0735fcf56fa..030d2cce746970bd9c8a0c7f0f5e1516eb68fcaf 100644 --- a/crates/agent2/src/native_agent_server.rs +++ b/crates/agent2/src/native_agent_server.rs @@ -1,10 +1,9 @@ use std::{any::Any, path::Path, rc::Rc, sync::Arc}; -use agent_servers::AgentServer; +use agent_servers::{AgentServer, AgentServerDelegate}; use anyhow::Result; use fs::Fs; use gpui::{App, Entity, SharedString, Task}; -use project::Project; use prompt_store::PromptStore; use crate::{HistoryStore, NativeAgent, NativeAgentConnection, templates::Templates}; @@ -30,33 +29,21 @@ impl AgentServer for NativeAgentServer { "Zed Agent".into() } - fn empty_state_headline(&self) -> SharedString { - self.name() - } - - fn empty_state_message(&self) -> SharedString { - "".into() - } - fn logo(&self) -> ui::IconName { ui::IconName::ZedAgent } - fn install_command(&self) -> Option<&'static str> { - None - } - fn connect( &self, _root_dir: &Path, - project: &Entity, + delegate: AgentServerDelegate, cx: &mut App, ) -> Task>> { log::debug!( "NativeAgentServer::connect called for path: {:?}", _root_dir ); - let project = project.clone(); + let project = delegate.project().clone(); let fs = self.fs.clone(); let history = self.history.clone(); let prompt_store = PromptStore::global(cx); diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml index 9f90f3a78aed825c372cc8bffc67d194b7ec2027..3e6bae104ce339f371b2ea69afebecbc6c1cec27 100644 --- a/crates/agent_servers/Cargo.toml +++ b/crates/agent_servers/Cargo.toml @@ -6,7 +6,7 @@ publish.workspace = true license = "GPL-3.0-or-later" [features] -test-support = ["acp_thread/test-support", "gpui/test-support", "project/test-support", "dep:env_logger", "fs", "client/test-support", "dep:gpui_tokio", "reqwest_client/test-support"] +test-support = ["acp_thread/test-support", "gpui/test-support", "project/test-support", "dep:env_logger", "client/test-support", "dep:gpui_tokio", "reqwest_client/test-support"] e2e = [] [lints] @@ -27,7 +27,7 @@ client = { workspace = true, optional = true } collections.workspace = true context_server.workspace = true env_logger = { workspace = true, optional = true } -fs = { workspace = true, optional = true } +fs.workspace = true futures.workspace = true gpui.workspace = true gpui_tokio = { workspace = true, optional = true } @@ -37,6 +37,7 @@ language.workspace = true language_model.workspace = true language_models.workspace = true log.workspace = true +node_runtime.workspace = true paths.workspace = true project.workspace = true rand.workspace = true diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index dc7d75c52d72928cfc3673bc1eb476f08206669f..e5d954b071a86f39a44e7c370dbc841c2f58d706 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -13,12 +13,19 @@ pub use gemini::*; pub use settings::*; use acp_thread::AgentConnection; +use acp_thread::LoadError; use anyhow::Result; +use anyhow::anyhow; +use anyhow::bail; use collections::HashMap; +use gpui::AppContext as _; use gpui::{App, AsyncApp, Entity, SharedString, Task}; +use node_runtime::VersionStrategy; use project::Project; use schemars::JsonSchema; +use semver::Version; use serde::{Deserialize, Serialize}; +use std::str::FromStr as _; use std::{ any::Any, path::{Path, PathBuf}, @@ -31,23 +38,118 @@ pub fn init(cx: &mut App) { settings::init(cx); } +pub struct AgentServerDelegate { + project: Entity, + status_tx: watch::Sender, +} + +impl AgentServerDelegate { + pub fn new(project: Entity, status_tx: watch::Sender) -> Self { + Self { project, status_tx } + } + + pub fn project(&self) -> &Entity { + &self.project + } + + fn get_or_npm_install_builtin_agent( + self, + binary_name: SharedString, + package_name: SharedString, + entrypoint_path: PathBuf, + settings: Option, + minimum_version: Option, + cx: &mut App, + ) -> Task> { + if let Some(settings) = &settings + && let Some(command) = settings.clone().custom_command() + { + return Task::ready(Ok(command)); + } + + let project = self.project; + let fs = project.read(cx).fs().clone(); + let Some(node_runtime) = project.read(cx).node_runtime().cloned() else { + return Task::ready(Err(anyhow!("Missing node runtime"))); + }; + let mut status_tx = self.status_tx; + + cx.spawn(async move |cx| { + if let Some(settings) = settings && !settings.ignore_system_version.unwrap_or(true) { + if let Some(bin) = find_bin_in_path(binary_name.clone(), &project, cx).await { + return Ok(AgentServerCommand { path: bin, args: Vec::new(), env: Default::default() }) + } + } + + cx.background_spawn(async move { + let node_path = node_runtime.binary_path().await?; + let dir = paths::data_dir().join("external_agents").join(binary_name.as_str()); + fs.create_dir(&dir).await?; + let local_executable_path = dir.join(entrypoint_path); + let command = AgentServerCommand { + path: node_path, + args: vec![local_executable_path.to_string_lossy().to_string()], + env: Default::default(), + }; + + let installed_version = node_runtime + .npm_package_installed_version(&dir, &package_name) + .await? + .filter(|version| { + Version::from_str(&version) + .is_ok_and(|version| Some(version) >= minimum_version) + }); + + status_tx.send("Checking for latest version…".into())?; + let latest_version = match node_runtime.npm_package_latest_version(&package_name).await + { + Ok(latest_version) => latest_version, + Err(e) => { + if let Some(installed_version) = installed_version { + log::error!("{e}"); + log::warn!("failed to fetch latest version of {package_name}, falling back to cached version {installed_version}"); + return Ok(command); + } else { + bail!(e); + } + } + }; + + let should_install = node_runtime + .should_install_npm_package( + &package_name, + &local_executable_path, + &dir, + VersionStrategy::Latest(&latest_version), + ) + .await; + + if should_install { + status_tx.send("Installing latest version…".into())?; + node_runtime + .npm_install_packages(&dir, &[(&package_name, &latest_version)]) + .await?; + } + + Ok(command) + }).await.map_err(|e| LoadError::FailedToInstall(e.to_string().into()).into()) + }) + } +} + pub trait AgentServer: Send { fn logo(&self) -> ui::IconName; fn name(&self) -> SharedString; - fn empty_state_headline(&self) -> SharedString; - fn empty_state_message(&self) -> SharedString; fn telemetry_id(&self) -> &'static str; fn connect( &self, root_dir: &Path, - project: &Entity, + delegate: AgentServerDelegate, cx: &mut App, ) -> Task>>; fn into_any(self: Rc) -> Rc; - - fn install_command(&self) -> Option<&'static str>; } impl dyn AgentServer { @@ -81,15 +183,6 @@ impl std::fmt::Debug for AgentServerCommand { } } -pub enum AgentServerVersion { - Supported, - Unsupported { - error_message: SharedString, - upgrade_message: SharedString, - upgrade_command: String, - }, -} - #[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)] pub struct AgentServerCommand { #[serde(rename = "command")] @@ -104,23 +197,16 @@ impl AgentServerCommand { path_bin_name: &'static str, extra_args: &[&'static str], fallback_path: Option<&Path>, - settings: Option, + settings: Option, project: &Entity, cx: &mut AsyncApp, ) -> Option { - if let Some(agent_settings) = settings { - Some(Self { - path: agent_settings.command.path, - args: agent_settings - .command - .args - .into_iter() - .chain(extra_args.iter().map(|arg| arg.to_string())) - .collect(), - env: agent_settings.command.env, - }) + if let Some(settings) = settings + && let Some(command) = settings.custom_command() + { + Some(command) } else { - match find_bin_in_path(path_bin_name, project, cx).await { + match find_bin_in_path(path_bin_name.into(), project, cx).await { Some(path) => Some(Self { path, args: extra_args.iter().map(|arg| arg.to_string()).collect(), @@ -143,7 +229,7 @@ impl AgentServerCommand { } async fn find_bin_in_path( - bin_name: &'static str, + bin_name: SharedString, project: &Entity, cx: &mut AsyncApp, ) -> Option { @@ -173,11 +259,11 @@ async fn find_bin_in_path( cx.background_executor() .spawn(async move { let which_result = if cfg!(windows) { - which::which(bin_name) + which::which(bin_name.as_str()) } 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()) + which::which_in(bin_name.as_str(), shell_path.as_ref(), root_dir.as_ref()) }; if let Err(which::Error::CannotFindBinaryPath) = which_result { diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index 3a16b0601a0a85a2a66d170455af6b4cb9f4ae8f..b1832191480f112c7788e4e908c2e1594f08c0ad 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -36,7 +36,7 @@ use util::{ResultExt, debug_panic}; use crate::claude::mcp_server::{ClaudeZedMcpServer, McpConfig}; use crate::claude::tools::ClaudeTool; -use crate::{AgentServer, AgentServerCommand, AllAgentServersSettings}; +use crate::{AgentServer, AgentServerCommand, AgentServerDelegate, AllAgentServersSettings}; use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError, MentionUri}; #[derive(Clone)] @@ -51,26 +51,14 @@ impl AgentServer for ClaudeCode { "Claude Code".into() } - fn empty_state_headline(&self) -> SharedString { - self.name() - } - - fn empty_state_message(&self) -> SharedString { - "How can I help you today?".into() - } - fn logo(&self) -> ui::IconName { ui::IconName::AiClaude } - fn install_command(&self) -> Option<&'static str> { - Some("npm install -g @anthropic-ai/claude-code@latest") - } - fn connect( &self, _root_dir: &Path, - _project: &Entity, + _delegate: AgentServerDelegate, _cx: &mut App, ) -> Task>> { let connection = ClaudeAgentConnection { @@ -112,7 +100,7 @@ impl AgentConnection for ClaudeAgentConnection { ) .await else { - return Err(LoadError::NotInstalled.into()); + return Err(anyhow!("Failed to find Claude Code binary")); }; let api_key = @@ -232,6 +220,7 @@ impl AgentConnection for ClaudeAgentConnection { LoadError::Unsupported { command: command.path.to_string_lossy().to_string().into(), current_version: version.to_string().into(), + minimum_version: "1.0.0".into(), } } else { LoadError::Exited { status } diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index 0669e0a68ef019975fdbdcbe155fa3dc6aeb0b96..a481a850ff70018ff2e6b72446ca24e78732137a 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -1,9 +1,8 @@ -use crate::{AgentServerCommand, AgentServerSettings}; +use crate::{AgentServerCommand, AgentServerDelegate}; use acp_thread::AgentConnection; use anyhow::Result; -use gpui::{App, Entity, SharedString, Task}; +use gpui::{App, SharedString, Task}; use language_models::provider::anthropic::AnthropicLanguageModelProvider; -use project::Project; use std::{path::Path, rc::Rc}; use ui::IconName; @@ -14,11 +13,8 @@ pub struct CustomAgentServer { } impl CustomAgentServer { - pub fn new(name: SharedString, settings: &AgentServerSettings) -> Self { - Self { - name, - command: settings.command.clone(), - } + pub fn new(name: SharedString, command: AgentServerCommand) -> Self { + Self { name, command } } } @@ -35,18 +31,10 @@ impl crate::AgentServer for CustomAgentServer { IconName::Terminal } - fn empty_state_headline(&self) -> SharedString { - "No conversations yet".into() - } - - fn empty_state_message(&self) -> SharedString { - format!("Start a conversation with {}", self.name).into() - } - fn connect( &self, root_dir: &Path, - _project: &Entity, + _delegate: AgentServerDelegate, cx: &mut App, ) -> Task>> { let server_name = self.name(); @@ -70,10 +58,6 @@ impl crate::AgentServer for CustomAgentServer { }) } - fn install_command(&self) -> Option<&'static str> { - None - } - fn into_any(self: Rc) -> Rc { self } diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index 42264b4b4f747e11cab11a21f7cde2ad0c43fee3..d310870c23c957900e3cdcdb8a88084f26520208 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -1,4 +1,4 @@ -use crate::AgentServer; +use crate::{AgentServer, AgentServerDelegate}; use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus}; use agent_client_protocol as acp; use futures::{FutureExt, StreamExt, channel::mpsc, select}; @@ -471,12 +471,8 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc { #[cfg(test)] crate::AllAgentServersSettings::override_global( crate::AllAgentServersSettings { - claude: Some(crate::AgentServerSettings { - command: crate::claude::tests::local_command(), - }), - gemini: Some(crate::AgentServerSettings { - command: crate::gemini::tests::local_command(), - }), + claude: Some(crate::claude::tests::local_command().into()), + gemini: Some(crate::gemini::tests::local_command().into()), custom: collections::HashMap::default(), }, cx, @@ -494,8 +490,10 @@ pub async fn new_test_thread( current_dir: impl AsRef, cx: &mut TestAppContext, ) -> Entity { + let delegate = AgentServerDelegate::new(project.clone(), watch::channel("".into()).0); + let connection = cx - .update(|cx| server.connect(current_dir.as_ref(), &project, cx)) + .update(|cx| server.connect(current_dir.as_ref(), delegate, cx)) .await .unwrap(); diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index 6d17cc0512d382f9b1a550dcb978957318de0d74..84dc6750b1a8e74b509e467d811ec790cdc0dea9 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -2,12 +2,11 @@ use std::rc::Rc; use std::{any::Any, path::Path}; use crate::acp::AcpConnection; -use crate::{AgentServer, AgentServerCommand}; +use crate::{AgentServer, AgentServerDelegate}; use acp_thread::{AgentConnection, LoadError}; use anyhow::Result; -use gpui::{App, Entity, SharedString, Task}; +use gpui::{App, SharedString, Task}; use language_models::provider::google::GoogleLanguageModelProvider; -use project::Project; use settings::SettingsStore; use crate::AllAgentServersSettings; @@ -26,29 +25,16 @@ impl AgentServer for Gemini { "Gemini CLI".into() } - fn empty_state_headline(&self) -> SharedString { - self.name() - } - - fn empty_state_message(&self) -> SharedString { - "Ask questions, edit files, run commands".into() - } - fn logo(&self) -> ui::IconName { ui::IconName::AiGemini } - fn install_command(&self) -> Option<&'static str> { - Some("npm install --engine-strict -g @google/gemini-cli@latest") - } - fn connect( &self, root_dir: &Path, - project: &Entity, + delegate: AgentServerDelegate, cx: &mut App, ) -> Task>> { - let project = project.clone(); let root_dir = root_dir.to_path_buf(); let server_name = self.name(); cx.spawn(async move |cx| { @@ -56,12 +42,19 @@ impl AgentServer for Gemini { settings.get::(None).gemini.clone() })?; - let Some(mut command) = - AgentServerCommand::resolve("gemini", &[ACP_ARG], None, settings, &project, cx) - .await - else { - return Err(LoadError::NotInstalled.into()); - }; + let mut command = cx + .update(|cx| { + delegate.get_or_npm_install_builtin_agent( + Self::BINARY_NAME.into(), + Self::PACKAGE_NAME.into(), + format!("node_modules/{}/dist/index.js", Self::PACKAGE_NAME).into(), + settings, + Some("0.2.1".parse().unwrap()), + cx, + ) + })? + .await?; + command.args.push("--experimental-acp".into()); if let Some(api_key) = cx.update(GoogleLanguageModelProvider::api_key)?.await.ok() { command @@ -87,12 +80,8 @@ impl AgentServer for Gemini { if !connection.prompt_capabilities().image { return Err(LoadError::Unsupported { current_version: current_version.into(), - command: format!( - "{} {}", - command.path.to_string_lossy(), - command.args.join(" ") - ) - .into(), + command: command.path.to_string_lossy().to_string().into(), + minimum_version: Self::MINIMUM_VERSION.into(), } .into()); } @@ -114,13 +103,16 @@ impl AgentServer for Gemini { let (version_output, help_output) = futures::future::join(version_fut, help_fut).await; - let current_version = String::from_utf8(version_output?.stdout)?; + let current_version = std::str::from_utf8(&version_output?.stdout)? + .trim() + .to_string(); let supported = String::from_utf8(help_output?.stdout)?.contains(ACP_ARG); if !supported { return Err(LoadError::Unsupported { current_version: current_version.into(), command: command.path.to_string_lossy().to_string().into(), + minimum_version: Self::MINIMUM_VERSION.into(), } .into()); } @@ -136,17 +128,11 @@ impl AgentServer for Gemini { } impl Gemini { - pub fn binary_name() -> &'static str { - "gemini" - } + const PACKAGE_NAME: &str = "@google/gemini-cli"; - pub fn install_command() -> &'static str { - "npm install --engine-strict -g @google/gemini-cli@latest" - } + const MINIMUM_VERSION: &str = "0.2.1"; - pub fn upgrade_command() -> &'static str { - "npm install -g @google/gemini-cli@latest" - } + const BINARY_NAME: &str = "gemini"; } #[cfg(test)] diff --git a/crates/agent_servers/src/settings.rs b/crates/agent_servers/src/settings.rs index 96ac6e3cbe7dcd8a03aef5c6ec79c884bf99ae67..59f3b4b54089a5598bc77d0ba127f2c54e9ec986 100644 --- a/crates/agent_servers/src/settings.rs +++ b/crates/agent_servers/src/settings.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use crate::AgentServerCommand; use anyhow::Result; use collections::HashMap; @@ -12,16 +14,62 @@ pub fn init(cx: &mut App) { #[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug)] pub struct AllAgentServersSettings { - pub gemini: Option, - pub claude: Option, + pub gemini: Option, + pub claude: Option, /// Custom agent servers configured by the user #[serde(flatten)] - pub custom: HashMap, + pub custom: HashMap, +} + +#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)] +pub struct BuiltinAgentServerSettings { + /// Absolute path to a binary to be used when launching this agent. + /// + /// This can be used to run a specific binary without automatic downloads or searching `$PATH`. + #[serde(rename = "command")] + pub path: Option, + /// If a binary is specified in `command`, it will be passed these arguments. + pub args: Option>, + /// If a binary is specified in `command`, it will be passed these environment variables. + pub env: Option>, + /// Whether to skip searching `$PATH` for an agent server binary when + /// launching this agent. + /// + /// This has no effect if a `command` is specified. Otherwise, when this is + /// `false`, Zed will search `$PATH` for an agent server binary and, if one + /// is found, use it for threads with this agent. If no agent binary is + /// found on `$PATH`, Zed will automatically install and use its own binary. + /// When this is `true`, Zed will not search `$PATH`, and will always use + /// its own binary. + /// + /// Default: true + pub ignore_system_version: Option, +} + +impl BuiltinAgentServerSettings { + pub(crate) fn custom_command(self) -> Option { + self.path.map(|path| AgentServerCommand { + path, + args: self.args.unwrap_or_default(), + env: self.env, + }) + } +} + +impl From for BuiltinAgentServerSettings { + fn from(value: AgentServerCommand) -> Self { + BuiltinAgentServerSettings { + path: Some(value.path), + args: Some(value.args), + env: value.env, + ..Default::default() + } + } } #[derive(Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)] -pub struct AgentServerSettings { +pub struct CustomAgentServerSettings { #[serde(flatten)] pub command: AgentServerCommand, } diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 12ae893c317b9a5518b78fdc4c4d7ab7c315eba7..f4ce2652d60c76848827967f8a34a23376e7406f 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -4,7 +4,7 @@ use crate::{ }; use acp_thread::{MentionUri, selection_name}; use agent_client_protocol as acp; -use agent_servers::AgentServer; +use agent_servers::{AgentServer, AgentServerDelegate}; use agent2::HistoryStore; use anyhow::{Result, anyhow}; use assistant_slash_commands::codeblock_fence_for_path; @@ -645,7 +645,8 @@ impl MessageEditor { self.project.read(cx).fs().clone(), self.history_store.clone(), )); - let connection = server.connect(Path::new(""), &self.project, cx); + let delegate = AgentServerDelegate::new(self.project.clone(), watch::channel("".into()).0); + let connection = server.connect(Path::new(""), delegate, cx); cx.spawn(async move |_, cx| { let agent = connection.await?; let agent = agent.downcast::().unwrap(); diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 2b18ebcd1d72e721e57d91469c835cb70e7812f4..8069812729265c20c7758487cef87479b01dea02 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -6,7 +6,7 @@ use acp_thread::{ use acp_thread::{AgentConnection, Plan}; use action_log::ActionLog; use agent_client_protocol::{self as acp, PromptCapabilities}; -use agent_servers::{AgentServer, ClaudeCode}; +use agent_servers::{AgentServer, AgentServerDelegate, ClaudeCode}; use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting}; use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore}; use anyhow::{Result, anyhow, bail}; @@ -46,7 +46,7 @@ use text::Anchor; use theme::ThemeSettings; use ui::{ Callout, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle, - Scrollbar, ScrollbarState, SpinnerLabel, TintColor, Tooltip, prelude::*, + Scrollbar, ScrollbarState, SpinnerLabel, Tooltip, prelude::*, }; use util::{ResultExt, size::format_file_size, time::duration_alt_display}; use workspace::{CollaboratorId, Workspace}; @@ -285,15 +285,12 @@ pub struct AcpThreadView { editing_message: Option, prompt_capabilities: Rc>, is_loading_contents: bool, - install_command_markdown: Entity, _cancel_task: Option>, _subscriptions: [Subscription; 3], } enum ThreadState { - Loading { - _task: Task<()>, - }, + Loading(Entity), Ready { thread: Entity, title_editor: Option>, @@ -309,6 +306,12 @@ enum ThreadState { }, } +struct LoadingView { + title: SharedString, + _load_task: Task<()>, + _update_title_task: Task>, +} + impl AcpThreadView { pub fn new( agent: Rc, @@ -399,7 +402,6 @@ impl AcpThreadView { hovered_recent_history_item: None, prompt_capabilities, is_loading_contents: false, - install_command_markdown: cx.new(|cx| Markdown::new("".into(), None, None, cx)), _subscriptions: subscriptions, _cancel_task: None, focus_handle: cx.focus_handle(), @@ -420,8 +422,10 @@ impl AcpThreadView { .next() .map(|worktree| worktree.read(cx).abs_path()) .unwrap_or_else(|| paths::home_dir().as_path().into()); + let (tx, mut rx) = watch::channel("Loading…".into()); + let delegate = AgentServerDelegate::new(project.clone(), tx); - let connect_task = agent.connect(&root_dir, &project, cx); + let connect_task = agent.connect(&root_dir, delegate, cx); let load_task = cx.spawn_in(window, async move |this, cx| { let connection = match connect_task.await { Ok(connection) => connection, @@ -574,7 +578,25 @@ impl AcpThreadView { .log_err(); }); - ThreadState::Loading { _task: load_task } + let loading_view = cx.new(|cx| { + let update_title_task = cx.spawn(async move |this, cx| { + loop { + let status = rx.recv().await?; + this.update(cx, |this: &mut LoadingView, cx| { + this.title = status; + cx.notify(); + })?; + } + }); + + LoadingView { + title: "Loading…".into(), + _load_task: load_task, + _update_title_task: update_title_task, + } + }); + + ThreadState::Loading(loading_view) } fn handle_auth_required( @@ -674,13 +696,15 @@ impl AcpThreadView { } } - pub fn title(&self) -> SharedString { + pub fn title(&self, cx: &App) -> SharedString { match &self.thread_state { ThreadState::Ready { .. } | ThreadState::Unauthenticated { .. } => "New Thread".into(), - ThreadState::Loading { .. } => "Loading…".into(), + ThreadState::Loading(loading_view) => loading_view.read(cx).title.clone(), ThreadState::LoadError(error) => match error { - LoadError::NotInstalled { .. } => format!("Install {}", self.agent.name()).into(), LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(), + LoadError::FailedToInstall(_) => { + format!("Failed to Install {}", self.agent.name()).into() + } LoadError::Exited { .. } => format!("{} Exited", self.agent.name()).into(), LoadError::Other(_) => format!("Error Loading {}", self.agent.name()).into(), }, @@ -2950,18 +2974,26 @@ impl AcpThreadView { window: &mut Window, cx: &mut Context, ) -> AnyElement { - let (message, action_slot): (SharedString, _) = match e { - LoadError::NotInstalled => { - return self.render_not_installed(None, window, cx); - } + let (title, message, action_slot): (_, SharedString, _) = match e { LoadError::Unsupported { command: path, current_version, + minimum_version, } => { - return self.render_not_installed(Some((path, current_version)), window, cx); + return self.render_unsupported(path, current_version, minimum_version, window, cx); } - LoadError::Exited { .. } => ("Server exited with status {status}".into(), None), + LoadError::FailedToInstall(msg) => ( + "Failed to Install", + msg.into(), + Some(self.create_copy_button(msg.to_string()).into_any_element()), + ), + LoadError::Exited { status } => ( + "Failed to Launch", + format!("Server exited with status {status}").into(), + None, + ), LoadError::Other(msg) => ( + "Failed to Launch", msg.into(), Some(self.create_copy_button(msg.to_string()).into_any_element()), ), @@ -2970,95 +3002,34 @@ impl AcpThreadView { Callout::new() .severity(Severity::Error) .icon(IconName::XCircleFilled) - .title("Failed to Launch") + .title(title) .description(message) .actions_slot(div().children(action_slot)) .into_any_element() } - fn install_agent(&self, window: &mut Window, cx: &mut Context) { - telemetry::event!("Agent Install CLI", agent = self.agent.telemetry_id()); - let Some(install_command) = self.agent.install_command().map(|s| s.to_owned()) else { - return; - }; - let task = self - .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 spawn_in_terminal = task::SpawnInTerminal { - id: task::TaskId(install_command.clone()), - full_label: install_command.clone(), - label: install_command.clone(), - command: Some(install_command.clone()), - args: Vec::new(), - command_label: install_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) - }) - .ok(); - let Some(task) = task else { return }; - cx.spawn_in(window, async move |this, cx| { - if let Some(Ok(_)) = task.await { - this.update_in(cx, |this, window, cx| { - this.reset(window, cx); - }) - .ok(); - } - }) - .detach() - } - - fn render_not_installed( + fn render_unsupported( &self, - existing_version: Option<(&SharedString, &SharedString)>, - window: &mut Window, + path: &SharedString, + version: &SharedString, + minimum_version: &SharedString, + _window: &mut Window, cx: &mut Context, ) -> AnyElement { - let install_command = self.agent.install_command().unwrap_or_default(); - - self.install_command_markdown.update(cx, |markdown, cx| { - if !markdown.source().contains(&install_command) { - markdown.replace(format!("```\n{}\n```", install_command), cx); - } - }); - - let (heading_label, description_label, button_label) = - if let Some((path, version)) = existing_version { - ( - format!("Upgrade {} to work with Zed", self.agent.name()), - if version.is_empty() { - format!( - "Currently using {}, which does not report a valid --version", - path, - ) - } else { - format!( - "Currently using {}, which is only version {}", - path, version - ) - }, - format!("Upgrade {}", self.agent.name()), + let (heading_label, description_label) = ( + format!("Upgrade {} to work with Zed", self.agent.name()), + if version.is_empty() { + format!( + "Currently using {}, which does not report a valid --version", + path, ) } else { - ( - format!("Get Started with {} in Zed", self.agent.name()), - "Use Google's new coding agent directly in Zed.".to_string(), - format!("Install {}", self.agent.name()), + format!( + "Currently using {}, which is only version {} (need at least {minimum_version})", + path, version ) - }; + }, + ); v_flex() .w_full() @@ -3078,34 +3049,6 @@ impl AcpThreadView { .color(Color::Muted), ), ) - .child( - Button::new("install_gemini", button_label) - .full_width() - .size(ButtonSize::Medium) - .style(ButtonStyle::Tinted(TintColor::Accent)) - .label_size(LabelSize::Small) - .icon(IconName::TerminalGhost) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .on_click(cx.listener(|this, _, window, cx| this.install_agent(window, cx))), - ) - .child( - Label::new("Or, run the following command in your terminal:") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child(MarkdownElement::new( - self.install_command_markdown.clone(), - default_markdown_style(false, false, window, cx), - )) - .when_some(existing_version, |el, (path, _)| { - el.child( - Label::new(format!("If this does not work you will need to upgrade manually, or uninstall your existing version from {}", path)) - .size(LabelSize::Small) - .color(Color::Muted), - ) - }) .into_any_element() } @@ -4994,18 +4937,6 @@ impl AcpThreadView { })) } - fn reset(&mut self, window: &mut Window, cx: &mut Context) { - self.thread_state = Self::initial_state( - self.agent.clone(), - None, - self.workspace.clone(), - self.project.clone(), - window, - cx, - ); - cx.notify(); - } - pub fn delete_history_entry(&mut self, entry: HistoryEntry, cx: &mut Context) { let task = match entry { HistoryEntry::AcpThread(thread) => self.history_store.update(cx, |history, cx| { @@ -5534,22 +5465,10 @@ pub(crate) mod tests { "Test".into() } - fn empty_state_headline(&self) -> SharedString { - "Test".into() - } - - fn empty_state_message(&self) -> SharedString { - "Test".into() - } - - fn install_command(&self) -> Option<&'static str> { - None - } - fn connect( &self, _root_dir: &Path, - _project: &Entity, + _delegate: AgentServerDelegate, _cx: &mut App, ) -> Task>> { Task::ready(Ok(Rc::new(self.connection.clone()))) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 224f49cc3e11f71a208a3f8f5b9f777b14478d23..23b6e69a56886ca2e5d7c4bdbd27ee8fb1307629 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -5,7 +5,7 @@ mod tool_picker; use std::{ops::Range, sync::Arc, time::Duration}; -use agent_servers::{AgentServerCommand, AgentServerSettings, AllAgentServersSettings, Gemini}; +use agent_servers::{AgentServerCommand, AllAgentServersSettings, CustomAgentServerSettings}; use agent_settings::AgentSettings; use anyhow::Result; use assistant_tool::{ToolSource, ToolWorkingSet}; @@ -27,7 +27,6 @@ use language_model::{ }; use notifications::status_toast::{StatusToast, ToastIcon}; use project::{ - Project, context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, project_settings::{ContextServerSettings, ProjectSettings}, }; @@ -52,7 +51,6 @@ pub struct AgentConfiguration { fs: Arc, language_registry: Arc, workspace: WeakEntity, - project: WeakEntity, focus_handle: FocusHandle, configuration_views_by_provider: HashMap, context_server_store: Entity, @@ -62,7 +60,6 @@ pub struct AgentConfiguration { _registry_subscription: Subscription, scroll_handle: ScrollHandle, scrollbar_state: ScrollbarState, - gemini_is_installed: bool, _check_for_gemini: Task<()>, } @@ -73,7 +70,6 @@ impl AgentConfiguration { tools: Entity, language_registry: Arc, workspace: WeakEntity, - project: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -98,11 +94,6 @@ impl AgentConfiguration { cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify()) .detach(); - cx.observe_global_in::(window, |this, _, cx| { - this.check_for_gemini(cx); - cx.notify(); - }) - .detach(); let scroll_handle = ScrollHandle::new(); let scrollbar_state = ScrollbarState::new(scroll_handle.clone()); @@ -111,7 +102,6 @@ impl AgentConfiguration { fs, language_registry, workspace, - project, focus_handle, configuration_views_by_provider: HashMap::default(), context_server_store, @@ -121,11 +111,9 @@ impl AgentConfiguration { _registry_subscription: registry_subscription, scroll_handle, scrollbar_state, - gemini_is_installed: false, _check_for_gemini: Task::ready(()), }; this.build_provider_configuration_views(window, cx); - this.check_for_gemini(cx); this } @@ -155,34 +143,6 @@ impl AgentConfiguration { self.configuration_views_by_provider .insert(provider.id(), configuration_view); } - - fn check_for_gemini(&mut self, cx: &mut Context) { - let project = self.project.clone(); - let settings = AllAgentServersSettings::get_global(cx).clone(); - self._check_for_gemini = cx.spawn({ - async move |this, cx| { - let Some(project) = project.upgrade() else { - return; - }; - let gemini_is_installed = AgentServerCommand::resolve( - Gemini::binary_name(), - &[], - // TODO expose fallback path from the Gemini/CC types so we don't have to hardcode it again here - None, - settings.gemini, - &project, - cx, - ) - .await - .is_some(); - this.update(cx, |this, cx| { - this.gemini_is_installed = gemini_is_installed; - cx.notify(); - }) - .ok(); - } - }); - } } impl Focusable for AgentConfiguration { @@ -1041,9 +1001,8 @@ impl AgentConfiguration { name.clone(), ExternalAgent::Custom { name: name.clone(), - settings: settings.clone(), + command: settings.command.clone(), }, - None, cx, ) .into_any_element() @@ -1102,7 +1061,6 @@ impl AgentConfiguration { IconName::AiGemini, "Gemini CLI", ExternalAgent::Gemini, - (!self.gemini_is_installed).then_some(Gemini::install_command().into()), cx, )) // TODO add CC @@ -1115,7 +1073,6 @@ impl AgentConfiguration { icon: IconName, name: impl Into, agent: ExternalAgent, - install_command: Option, cx: &mut Context, ) -> impl IntoElement { let name = name.into(); @@ -1135,88 +1092,28 @@ impl AgentConfiguration { .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) .child(Label::new(name.clone())), ) - .map(|this| { - if let Some(install_command) = install_command { - this.child( - Button::new( - SharedString::from(format!("install_external_agent-{name}")), - "Install Agent", - ) - .label_size(LabelSize::Small) - .icon(IconName::Plus) - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .tooltip(Tooltip::text(install_command.clone())) - .on_click(cx.listener( - move |this, _, window, cx| { - let Some(project) = this.project.upgrade() else { - return; - }; - let Some(workspace) = this.workspace.upgrade() else { - return; - }; - let cwd = project.read(cx).first_project_directory(cx); - let shell = - project.read(cx).terminal_settings(&cwd, cx).shell.clone(); - let spawn_in_terminal = task::SpawnInTerminal { - id: task::TaskId(install_command.to_string()), - full_label: install_command.to_string(), - label: install_command.to_string(), - command: Some(install_command.to_string()), - args: Vec::new(), - command_label: install_command.to_string(), - 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, - }; - let task = workspace.update(cx, |workspace, cx| { - workspace.spawn_in_terminal(spawn_in_terminal, window, cx) - }); - cx.spawn(async move |this, cx| { - task.await; - this.update(cx, |this, cx| { - this.check_for_gemini(cx); - }) - .ok(); - }) - .detach(); - }, - )), - ) - } else { - this.child( - h_flex().gap_1().child( - Button::new( - SharedString::from(format!("start_acp_thread-{name}")), - "Start New Thread", - ) - .label_size(LabelSize::Small) - .icon(IconName::Thread) - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .on_click(move |_, window, cx| { - window.dispatch_action( - NewExternalAgentThread { - agent: Some(agent.clone()), - } - .boxed_clone(), - cx, - ); - }), - ), + .child( + h_flex().gap_1().child( + Button::new( + SharedString::from(format!("start_acp_thread-{name}")), + "Start New Thread", ) - } - }) + .label_size(LabelSize::Small) + .icon(IconName::Thread) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .on_click(move |_, window, cx| { + window.dispatch_action( + NewExternalAgentThread { + agent: Some(agent.clone()), + } + .boxed_clone(), + cx, + ); + }), + ), + ) } } @@ -1393,7 +1290,7 @@ async fn open_new_agent_servers_entry_in_settings_editor( unique_server_name = Some(server_name.clone()); file.custom.insert( server_name, - AgentServerSettings { + CustomAgentServerSettings { command: AgentServerCommand { path: "path_to_executable".into(), args: vec![], diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 586a782bc35211fa77c744b229fb7bf9f1ef0057..232311c5b02cdaa9edad4c0e9053163f450378e8 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use std::time::Duration; use acp_thread::AcpThread; -use agent_servers::AgentServerSettings; +use agent_servers::AgentServerCommand; use agent2::{DbThreadMetadata, HistoryEntry}; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use serde::{Deserialize, Serialize}; @@ -259,7 +259,7 @@ pub enum AgentType { NativeAgent, Custom { name: SharedString, - settings: AgentServerSettings, + command: AgentServerCommand, }, } @@ -1479,7 +1479,6 @@ impl AgentPanel { tools, self.language_registry.clone(), self.workspace.clone(), - self.project.downgrade(), window, cx, ) @@ -1896,8 +1895,8 @@ impl AgentPanel { window, cx, ), - AgentType::Custom { name, settings } => self.external_thread( - Some(crate::ExternalAgent::Custom { name, settings }), + AgentType::Custom { name, command } => self.external_thread( + Some(crate::ExternalAgent::Custom { name, command }), None, None, window, @@ -2115,7 +2114,7 @@ impl AgentPanel { .child(title_editor) .into_any_element() } else { - Label::new(thread_view.read(cx).title()) + Label::new(thread_view.read(cx).title(cx)) .color(Color::Muted) .truncate() .into_any_element() @@ -2664,9 +2663,9 @@ impl AgentPanel { AgentType::Custom { name: agent_name .clone(), - settings: - agent_settings - .clone(), + command: agent_settings + .command + .clone(), }, window, cx, diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 110c432df3932f902b6ffcdac505a86e88550b28..93a4a8f748eefc933f809669af841f443888f7ed 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -28,7 +28,7 @@ use std::rc::Rc; use std::sync::Arc; use agent::{Thread, ThreadId}; -use agent_servers::AgentServerSettings; +use agent_servers::AgentServerCommand; use agent_settings::{AgentProfileId, AgentSettings, LanguageModelSelection}; use assistant_slash_command::SlashCommandRegistry; use client::Client; @@ -170,7 +170,7 @@ enum ExternalAgent { NativeAgent, Custom { name: SharedString, - settings: AgentServerSettings, + command: AgentServerCommand, }, } @@ -193,9 +193,9 @@ impl ExternalAgent { Self::Gemini => Rc::new(agent_servers::Gemini), Self::ClaudeCode => Rc::new(agent_servers::ClaudeCode), Self::NativeAgent => Rc::new(agent2::NativeAgentServer::new(fs, history)), - Self::Custom { name, settings } => Rc::new(agent_servers::CustomAgentServer::new( + Self::Custom { name, command } => Rc::new(agent_servers::CustomAgentServer::new( name.clone(), - settings, + command.clone(), )), } }