acp: Automatically install gemini under Zed's data dir (#37054)

Cole Miller created

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
    }
  }
}
```

Change summary

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

Detailed changes

Cargo.lock ๐Ÿ”—

@@ -306,6 +306,7 @@ dependencies = [
  "libc",
  "log",
  "nix 0.29.0",
+ "node_runtime",
  "paths",
  "project",
  "rand 0.8.5",

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

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<Project>,
+        delegate: AgentServerDelegate,
         cx: &mut App,
     ) -> Task<Result<Rc<dyn acp_thread::AgentConnection>>> {
         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);

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

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<Project>,
+    status_tx: watch::Sender<SharedString>,
+}
+
+impl AgentServerDelegate {
+    pub fn new(project: Entity<Project>, status_tx: watch::Sender<SharedString>) -> Self {
+        Self { project, status_tx }
+    }
+
+    pub fn project(&self) -> &Entity<Project> {
+        &self.project
+    }
+
+    fn get_or_npm_install_builtin_agent(
+        self,
+        binary_name: SharedString,
+        package_name: SharedString,
+        entrypoint_path: PathBuf,
+        settings: Option<BuiltinAgentServerSettings>,
+        minimum_version: Option<Version>,
+        cx: &mut App,
+    ) -> Task<Result<AgentServerCommand>> {
+        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<Project>,
+        delegate: AgentServerDelegate,
         cx: &mut App,
     ) -> Task<Result<Rc<dyn AgentConnection>>>;
 
     fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
-
-    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<AgentServerSettings>,
+        settings: Option<BuiltinAgentServerSettings>,
         project: &Entity<Project>,
         cx: &mut AsyncApp,
     ) -> Option<Self> {
-        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<Project>,
     cx: &mut AsyncApp,
 ) -> Option<PathBuf> {
@@ -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 {

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<Project>,
+        _delegate: AgentServerDelegate,
         _cx: &mut App,
     ) -> Task<Result<Rc<dyn AgentConnection>>> {
         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 }

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<Project>,
+        _delegate: AgentServerDelegate,
         cx: &mut App,
     ) -> Task<Result<Rc<dyn AgentConnection>>> {
         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<Self>) -> Rc<dyn std::any::Any> {
         self
     }

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<FakeFs> {
         #[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<Path>,
     cx: &mut TestAppContext,
 ) -> Entity<AcpThread> {
+    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();
 

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<Project>,
+        delegate: AgentServerDelegate,
         cx: &mut App,
     ) -> Task<Result<Rc<dyn AgentConnection>>> {
-        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::<AllAgentServersSettings>(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)]

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<AgentServerSettings>,
-    pub claude: Option<AgentServerSettings>,
+    pub gemini: Option<BuiltinAgentServerSettings>,
+    pub claude: Option<BuiltinAgentServerSettings>,
 
     /// Custom agent servers configured by the user
     #[serde(flatten)]
-    pub custom: HashMap<SharedString, AgentServerSettings>,
+    pub custom: HashMap<SharedString, CustomAgentServerSettings>,
+}
+
+#[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<PathBuf>,
+    /// If a binary is specified in `command`, it will be passed these arguments.
+    pub args: Option<Vec<String>>,
+    /// If a binary is specified in `command`, it will be passed these environment variables.
+    pub env: Option<HashMap<String, String>>,
+    /// 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<bool>,
+}
+
+impl BuiltinAgentServerSettings {
+    pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
+        self.path.map(|path| AgentServerCommand {
+            path,
+            args: self.args.unwrap_or_default(),
+            env: self.env,
+        })
+    }
+}
+
+impl From<AgentServerCommand> 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,
 }

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::<agent2::NativeAgentConnection>().unwrap();

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<usize>,
     prompt_capabilities: Rc<Cell<PromptCapabilities>>,
     is_loading_contents: bool,
-    install_command_markdown: Entity<Markdown>,
     _cancel_task: Option<Task<()>>,
     _subscriptions: [Subscription; 3],
 }
 
 enum ThreadState {
-    Loading {
-        _task: Task<()>,
-    },
+    Loading(Entity<LoadingView>),
     Ready {
         thread: Entity<AcpThread>,
         title_editor: Option<Entity<Editor>>,
@@ -309,6 +306,12 @@ enum ThreadState {
     },
 }
 
+struct LoadingView {
+    title: SharedString,
+    _load_task: Task<()>,
+    _update_title_task: Task<anyhow::Result<()>>,
+}
+
 impl AcpThreadView {
     pub fn new(
         agent: Rc<dyn AgentServer>,
@@ -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<Self>,
     ) -> 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<Self>) {
-        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<Self>,
     ) -> 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>) {
-        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<Self>) {
         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<Project>,
+            _delegate: AgentServerDelegate,
             _cx: &mut App,
         ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
             Task::ready(Ok(Rc::new(self.connection.clone())))

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<dyn Fs>,
     language_registry: Arc<LanguageRegistry>,
     workspace: WeakEntity<Workspace>,
-    project: WeakEntity<Project>,
     focus_handle: FocusHandle,
     configuration_views_by_provider: HashMap<LanguageModelProviderId, AnyView>,
     context_server_store: Entity<ContextServerStore>,
@@ -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<ToolWorkingSet>,
         language_registry: Arc<LanguageRegistry>,
         workspace: WeakEntity<Workspace>,
-        project: WeakEntity<Project>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
@@ -98,11 +94,6 @@ impl AgentConfiguration {
 
         cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify())
             .detach();
-        cx.observe_global_in::<SettingsStore>(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<Self>) {
-        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<SharedString>,
         agent: ExternalAgent,
-        install_command: Option<SharedString>,
         cx: &mut Context<Self>,
     ) -> 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![],

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,

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