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(), )), } }