Detailed changes
@@ -308,22 +308,18 @@ dependencies = [
"libc",
"log",
"nix 0.29.0",
- "node_runtime",
- "paths",
"project",
"reqwest_client",
- "schemars",
- "semver",
"serde",
"serde_json",
"settings",
"smol",
+ "task",
"tempfile",
"thiserror 2.0.12",
"ui",
"util",
"watch",
- "which 6.0.3",
"workspace-hack",
]
@@ -12605,6 +12601,7 @@ dependencies = [
"remote",
"rpc",
"schemars",
+ "semver",
"serde",
"serde_json",
"settings",
@@ -12624,6 +12621,7 @@ dependencies = [
"unindent",
"url",
"util",
+ "watch",
"which 6.0.3",
"workspace-hack",
"worktree",
@@ -20367,7 +20365,6 @@ dependencies = [
"acp_tools",
"activity_indicator",
"agent",
- "agent_servers",
"agent_settings",
"agent_ui",
"anyhow",
@@ -2758,7 +2758,7 @@ mod tests {
}));
let thread = cx
- .update(|cx| connection.new_thread(project, Path::new("/test"), cx))
+ .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
.await
.unwrap();
@@ -35,10 +35,15 @@ impl AgentServer for NativeAgentServer {
fn connect(
&self,
- _root_dir: &Path,
+ _root_dir: Option<&Path>,
delegate: AgentServerDelegate,
cx: &mut App,
- ) -> Task<Result<Rc<dyn acp_thread::AgentConnection>>> {
+ ) -> Task<
+ Result<(
+ Rc<dyn acp_thread::AgentConnection>,
+ Option<task::SpawnInTerminal>,
+ )>,
+ > {
log::debug!(
"NativeAgentServer::connect called for path: {:?}",
_root_dir
@@ -60,7 +65,10 @@ impl AgentServer for NativeAgentServer {
let connection = NativeAgentConnection(agent);
log::debug!("NativeAgentServer connection established successfully");
- Ok(Rc::new(connection) as Rc<dyn acp_thread::AgentConnection>)
+ Ok((
+ Rc::new(connection) as Rc<dyn acp_thread::AgentConnection>,
+ None,
+ ))
})
}
@@ -35,22 +35,18 @@ language.workspace = true
language_model.workspace = true
language_models.workspace = true
log.workspace = true
-node_runtime.workspace = true
-paths.workspace = true
project.workspace = true
reqwest_client = { workspace = true, optional = true }
-schemars.workspace = true
-semver.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
smol.workspace = true
+task.workspace = true
tempfile.workspace = true
thiserror.workspace = true
ui.workspace = true
util.workspace = true
watch.workspace = true
-which.workspace = true
workspace-hack.workspace = true
[target.'cfg(unix)'.dependencies]
@@ -1,4 +1,3 @@
-use crate::AgentServerCommand;
use acp_thread::AgentConnection;
use acp_tools::AcpConnectionRegistry;
use action_log::ActionLog;
@@ -8,8 +7,10 @@ use collections::HashMap;
use futures::AsyncBufReadExt as _;
use futures::io::BufReader;
use project::Project;
+use project::agent_server_store::AgentServerCommand;
use serde::Deserialize;
+use std::path::PathBuf;
use std::{any::Any, cell::RefCell};
use std::{path::Path, rc::Rc};
use thiserror::Error;
@@ -29,6 +30,7 @@ pub struct AcpConnection {
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
auth_methods: Vec<acp::AuthMethod>,
agent_capabilities: acp::AgentCapabilities,
+ root_dir: PathBuf,
_io_task: Task<Result<()>>,
_wait_task: Task<Result<()>>,
_stderr_task: Task<Result<()>>,
@@ -43,9 +45,10 @@ pub async fn connect(
server_name: SharedString,
command: AgentServerCommand,
root_dir: &Path,
+ is_remote: bool,
cx: &mut AsyncApp,
) -> Result<Rc<dyn AgentConnection>> {
- let conn = AcpConnection::stdio(server_name, command.clone(), root_dir, cx).await?;
+ let conn = AcpConnection::stdio(server_name, command.clone(), root_dir, is_remote, cx).await?;
Ok(Rc::new(conn) as _)
}
@@ -56,17 +59,21 @@ impl AcpConnection {
server_name: SharedString,
command: AgentServerCommand,
root_dir: &Path,
+ is_remote: bool,
cx: &mut AsyncApp,
) -> Result<Self> {
- let mut child = util::command::new_smol_command(command.path)
+ let mut child = util::command::new_smol_command(command.path);
+ child
.args(command.args.iter().map(|arg| arg.as_str()))
.envs(command.env.iter().flatten())
- .current_dir(root_dir)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
- .kill_on_drop(true)
- .spawn()?;
+ .kill_on_drop(true);
+ if !is_remote {
+ child.current_dir(root_dir);
+ }
+ let mut child = child.spawn()?;
let stdout = child.stdout.take().context("Failed to take stdout")?;
let stdin = child.stdin.take().context("Failed to take stdin")?;
@@ -145,6 +152,7 @@ impl AcpConnection {
Ok(Self {
auth_methods: response.auth_methods,
+ root_dir: root_dir.to_owned(),
connection,
server_name,
sessions,
@@ -158,6 +166,10 @@ impl AcpConnection {
pub fn prompt_capabilities(&self) -> &acp::PromptCapabilities {
&self.agent_capabilities.prompt_capabilities
}
+
+ pub fn root_dir(&self) -> &Path {
+ &self.root_dir
+ }
}
impl AgentConnection for AcpConnection {
@@ -171,29 +183,36 @@ impl AgentConnection for AcpConnection {
let sessions = self.sessions.clone();
let cwd = cwd.to_path_buf();
let context_server_store = project.read(cx).context_server_store().read(cx);
- let mcp_servers = context_server_store
- .configured_server_ids()
- .iter()
- .filter_map(|id| {
- let configuration = context_server_store.configuration_for_server(id)?;
- let command = configuration.command();
- Some(acp::McpServer {
- name: id.0.to_string(),
- command: command.path.clone(),
- args: command.args.clone(),
- env: if let Some(env) = command.env.as_ref() {
- env.iter()
- .map(|(name, value)| acp::EnvVariable {
- name: name.clone(),
- value: value.clone(),
- })
- .collect()
- } else {
- vec![]
- },
+ let mcp_servers = if project.read(cx).is_local() {
+ context_server_store
+ .configured_server_ids()
+ .iter()
+ .filter_map(|id| {
+ let configuration = context_server_store.configuration_for_server(id)?;
+ let command = configuration.command();
+ Some(acp::McpServer {
+ name: id.0.to_string(),
+ command: command.path.clone(),
+ args: command.args.clone(),
+ env: if let Some(env) = command.env.as_ref() {
+ env.iter()
+ .map(|(name, value)| acp::EnvVariable {
+ name: name.clone(),
+ value: value.clone(),
+ })
+ .collect()
+ } else {
+ vec![]
+ },
+ })
})
- })
- .collect();
+ .collect()
+ } else {
+ // In SSH projects, the external agent is running on the remote
+ // machine, and currently we only run MCP servers on the local
+ // machine. So don't pass any MCP servers to the agent in that case.
+ Vec::new()
+ };
cx.spawn(async move |cx| {
let response = conn
@@ -2,47 +2,25 @@ mod acp;
mod claude;
mod custom;
mod gemini;
-mod settings;
#[cfg(any(test, feature = "test-support"))]
pub mod e2e_tests;
-use anyhow::Context as _;
pub use claude::*;
pub use custom::*;
-use fs::Fs;
-use fs::RemoveOptions;
-use fs::RenameOptions;
-use futures::StreamExt as _;
pub use gemini::*;
-use gpui::AppContext;
-use node_runtime::NodeRuntime;
-pub use settings::*;
+use project::agent_server_store::AgentServerStore;
use acp_thread::AgentConnection;
-use acp_thread::LoadError;
use anyhow::Result;
-use anyhow::anyhow;
-use collections::HashMap;
-use gpui::{App, AsyncApp, Entity, SharedString, Task};
+use gpui::{App, Entity, SharedString, Task};
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},
- rc::Rc,
- sync::Arc,
-};
-use util::ResultExt as _;
+use std::{any::Any, path::Path, rc::Rc};
-pub fn init(cx: &mut App) {
- settings::init(cx);
-}
+pub use acp::AcpConnection;
pub struct AgentServerDelegate {
+ store: Entity<AgentServerStore>,
project: Entity<Project>,
status_tx: Option<watch::Sender<SharedString>>,
new_version_available: Option<watch::Sender<Option<String>>>,
@@ -50,11 +28,13 @@ pub struct AgentServerDelegate {
impl AgentServerDelegate {
pub fn new(
+ store: Entity<AgentServerStore>,
project: Entity<Project>,
status_tx: Option<watch::Sender<SharedString>>,
new_version_tx: Option<watch::Sender<Option<String>>>,
) -> Self {
Self {
+ store,
project,
status_tx,
new_version_available: new_version_tx,
@@ -64,188 +44,6 @@ impl AgentServerDelegate {
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,
- ignore_system_version: bool,
- minimum_version: Option<Version>,
- cx: &mut App,
- ) -> Task<Result<AgentServerCommand>> {
- 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!(
- "External agents are not yet available in remote projects."
- )));
- };
- let status_tx = self.status_tx;
- let new_version_available = self.new_version_available;
-
- cx.spawn(async move |cx| {
- if !ignore_system_version {
- 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.spawn(async move |cx| {
- 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 mut stream = fs.read_dir(&dir).await?;
- let mut versions = Vec::new();
- let mut to_delete = Vec::new();
- while let Some(entry) = stream.next().await {
- let Ok(entry) = entry else { continue };
- let Some(file_name) = entry.file_name() else {
- continue;
- };
-
- if let Some(name) = file_name.to_str()
- && let Some(version) = semver::Version::from_str(name).ok()
- && fs
- .is_file(&dir.join(file_name).join(&entrypoint_path))
- .await
- {
- versions.push((version, file_name.to_owned()));
- } else {
- to_delete.push(file_name.to_owned())
- }
- }
-
- versions.sort();
- let newest_version = if let Some((version, file_name)) = versions.last().cloned()
- && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
- {
- versions.pop();
- Some(file_name)
- } else {
- None
- };
- log::debug!("existing version of {package_name}: {newest_version:?}");
- to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
-
- cx.background_spawn({
- let fs = fs.clone();
- let dir = dir.clone();
- async move {
- for file_name in to_delete {
- fs.remove_dir(
- &dir.join(file_name),
- RemoveOptions {
- recursive: true,
- ignore_if_not_exists: false,
- },
- )
- .await
- .ok();
- }
- }
- })
- .detach();
-
- let version = if let Some(file_name) = newest_version {
- cx.background_spawn({
- let file_name = file_name.clone();
- let dir = dir.clone();
- let fs = fs.clone();
- async move {
- let latest_version =
- node_runtime.npm_package_latest_version(&package_name).await;
- if let Ok(latest_version) = latest_version
- && &latest_version != &file_name.to_string_lossy()
- {
- Self::download_latest_version(
- fs,
- dir.clone(),
- node_runtime,
- package_name,
- )
- .await
- .log_err();
- if let Some(mut new_version_available) = new_version_available {
- new_version_available.send(Some(latest_version)).ok();
- }
- }
- }
- })
- .detach();
- file_name
- } else {
- if let Some(mut status_tx) = status_tx {
- status_tx.send("Installingβ¦".into()).ok();
- }
- let dir = dir.clone();
- cx.background_spawn(Self::download_latest_version(
- fs.clone(),
- dir.clone(),
- node_runtime,
- package_name,
- ))
- .await?
- .into()
- };
-
- let agent_server_path = dir.join(version).join(entrypoint_path);
- let agent_server_path_exists = fs.is_file(&agent_server_path).await;
- anyhow::ensure!(
- agent_server_path_exists,
- "Missing entrypoint path {} after installation",
- agent_server_path.to_string_lossy()
- );
-
- anyhow::Ok(AgentServerCommand {
- path: node_path,
- args: vec![agent_server_path.to_string_lossy().to_string()],
- env: Default::default(),
- })
- })
- .await
- .map_err(|e| LoadError::FailedToInstall(e.to_string().into()).into())
- })
- }
-
- async fn download_latest_version(
- fs: Arc<dyn Fs>,
- dir: PathBuf,
- node_runtime: NodeRuntime,
- package_name: SharedString,
- ) -> Result<String> {
- log::debug!("downloading latest version of {package_name}");
-
- let tmp_dir = tempfile::tempdir_in(&dir)?;
-
- node_runtime
- .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
- .await?;
-
- let version = node_runtime
- .npm_package_installed_version(tmp_dir.path(), &package_name)
- .await?
- .context("expected package to be installed")?;
-
- fs.rename(
- &tmp_dir.keep(),
- &dir.join(&version),
- RenameOptions {
- ignore_if_exists: true,
- overwrite: false,
- },
- )
- .await?;
-
- anyhow::Ok(version)
- }
}
pub trait AgentServer: Send {
@@ -255,10 +53,10 @@ pub trait AgentServer: Send {
fn connect(
&self,
- root_dir: &Path,
+ root_dir: Option<&Path>,
delegate: AgentServerDelegate,
cx: &mut App,
- ) -> Task<Result<Rc<dyn AgentConnection>>>;
+ ) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>>;
fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
}
@@ -268,120 +66,3 @@ impl dyn AgentServer {
self.into_any().downcast().ok()
}
}
-
-impl std::fmt::Debug for AgentServerCommand {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- let filtered_env = self.env.as_ref().map(|env| {
- env.iter()
- .map(|(k, v)| {
- (
- k,
- if util::redact::should_redact(k) {
- "[REDACTED]"
- } else {
- v
- },
- )
- })
- .collect::<Vec<_>>()
- });
-
- f.debug_struct("AgentServerCommand")
- .field("path", &self.path)
- .field("args", &self.args)
- .field("env", &filtered_env)
- .finish()
- }
-}
-
-#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
-pub struct AgentServerCommand {
- #[serde(rename = "command")]
- pub path: PathBuf,
- #[serde(default)]
- pub args: Vec<String>,
- pub env: Option<HashMap<String, String>>,
-}
-
-impl AgentServerCommand {
- pub async fn resolve(
- path_bin_name: &'static str,
- extra_args: &[&'static str],
- fallback_path: Option<&Path>,
- settings: Option<BuiltinAgentServerSettings>,
- project: &Entity<Project>,
- cx: &mut AsyncApp,
- ) -> Option<Self> {
- if let Some(settings) = settings
- && let Some(command) = settings.custom_command()
- {
- Some(command)
- } else {
- 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(),
- env: None,
- }),
- None => fallback_path.and_then(|path| {
- if path.exists() {
- Some(Self {
- path: path.to_path_buf(),
- args: extra_args.iter().map(|arg| arg.to_string()).collect(),
- env: None,
- })
- } else {
- None
- }
- }),
- }
- }
- }
-}
-
-async fn find_bin_in_path(
- bin_name: SharedString,
- project: &Entity<Project>,
- cx: &mut AsyncApp,
-) -> Option<PathBuf> {
- let (env_task, root_dir) = project
- .update(cx, |project, cx| {
- let worktree = project.visible_worktrees(cx).next();
- match worktree {
- Some(worktree) => {
- let env_task = project.environment().update(cx, |env, cx| {
- env.get_worktree_environment(worktree.clone(), cx)
- });
-
- let path = worktree.read(cx).abs_path();
- (env_task, path)
- }
- None => {
- let path: Arc<Path> = paths::home_dir().as_path().into();
- let env_task = project.environment().update(cx, |env, cx| {
- env.get_directory_environment(path.clone(), cx)
- });
- (env_task, path)
- }
- }
- })
- .log_err()?;
-
- cx.background_executor()
- .spawn(async move {
- let which_result = if cfg!(windows) {
- which::which(bin_name.as_str())
- } else {
- let env = env_task.await.unwrap_or_default();
- let shell_path = env.get("PATH").cloned();
- which::which_in(bin_name.as_str(), shell_path.as_ref(), root_dir.as_ref())
- };
-
- if let Err(which::Error::CannotFindBinaryPath) = which_result {
- return None;
- }
-
- which_result.log_err()
- })
- .await
-}
@@ -1,60 +1,22 @@
-use settings::SettingsStore;
use std::path::Path;
use std::rc::Rc;
use std::{any::Any, path::PathBuf};
-use anyhow::Result;
-use gpui::{App, AppContext as _, SharedString, Task};
+use anyhow::{Context as _, Result};
+use gpui::{App, SharedString, Task};
+use project::agent_server_store::CLAUDE_CODE_NAME;
-use crate::{AgentServer, AgentServerDelegate, AllAgentServersSettings};
+use crate::{AgentServer, AgentServerDelegate};
use acp_thread::AgentConnection;
#[derive(Clone)]
pub struct ClaudeCode;
-pub struct ClaudeCodeLoginCommand {
+pub struct AgentServerLoginCommand {
pub path: PathBuf,
pub arguments: Vec<String>,
}
-impl ClaudeCode {
- const BINARY_NAME: &'static str = "claude-code-acp";
- const PACKAGE_NAME: &'static str = "@zed-industries/claude-code-acp";
-
- pub fn login_command(
- delegate: AgentServerDelegate,
- cx: &mut App,
- ) -> Task<Result<ClaudeCodeLoginCommand>> {
- let settings = cx.read_global(|settings: &SettingsStore, _| {
- settings.get::<AllAgentServersSettings>(None).claude.clone()
- });
-
- cx.spawn(async move |cx| {
- let mut command = if let Some(settings) = settings {
- settings.command
- } else {
- cx.update(|cx| {
- delegate.get_or_npm_install_builtin_agent(
- Self::BINARY_NAME.into(),
- Self::PACKAGE_NAME.into(),
- "node_modules/@anthropic-ai/claude-code/cli.js".into(),
- true,
- Some("0.2.5".parse().unwrap()),
- cx,
- )
- })?
- .await?
- };
- command.args.push("/login".into());
-
- Ok(ClaudeCodeLoginCommand {
- path: command.path,
- arguments: command.args,
- })
- })
- }
-}
-
impl AgentServer for ClaudeCode {
fn telemetry_id(&self) -> &'static str {
"claude-code"
@@ -70,56 +32,33 @@ impl AgentServer for ClaudeCode {
fn connect(
&self,
- root_dir: &Path,
+ root_dir: Option<&Path>,
delegate: AgentServerDelegate,
cx: &mut App,
- ) -> Task<Result<Rc<dyn AgentConnection>>> {
- let root_dir = root_dir.to_path_buf();
- let fs = delegate.project().read(cx).fs().clone();
- let server_name = self.name();
- let settings = cx.read_global(|settings: &SettingsStore, _| {
- settings.get::<AllAgentServersSettings>(None).claude.clone()
- });
- let project = delegate.project().clone();
+ ) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
+ let name = self.name();
+ let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().to_string());
+ let is_remote = delegate.project.read(cx).is_via_remote_server();
+ let store = delegate.store.downgrade();
cx.spawn(async move |cx| {
- let mut project_env = project
- .update(cx, |project, cx| {
- project.directory_environment(root_dir.as_path().into(), cx)
- })?
- .await
- .unwrap_or_default();
- let mut command = if let Some(settings) = settings {
- settings.command
- } else {
- 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(),
- true,
- None,
- cx,
- )
- })?
- .await?
- };
- project_env.extend(command.env.take().unwrap_or_default());
- command.env = Some(project_env);
-
- command
- .env
- .get_or_insert_default()
- .insert("ANTHROPIC_API_KEY".to_owned(), "".to_owned());
-
- let root_dir_exists = fs.is_dir(&root_dir).await;
- anyhow::ensure!(
- root_dir_exists,
- "Session root {} does not exist or is not a directory",
- root_dir.to_string_lossy()
- );
-
- crate::acp::connect(server_name, command.clone(), &root_dir, cx).await
+ let (command, root_dir, login) = store
+ .update(cx, |store, cx| {
+ let agent = store
+ .get_external_agent(&CLAUDE_CODE_NAME.into())
+ .context("Claude Code is not registered")?;
+ anyhow::Ok(agent.get_command(
+ root_dir.as_deref(),
+ Default::default(),
+ delegate.status_tx,
+ delegate.new_version_available,
+ &mut cx.to_async(),
+ ))
+ })??
+ .await?;
+ let connection =
+ crate::acp::connect(name, command, root_dir.as_ref(), is_remote, cx).await?;
+ Ok((connection, login))
})
}
@@ -1,19 +1,19 @@
-use crate::{AgentServerCommand, AgentServerDelegate};
+use crate::AgentServerDelegate;
use acp_thread::AgentConnection;
-use anyhow::Result;
+use anyhow::{Context as _, Result};
use gpui::{App, SharedString, Task};
+use project::agent_server_store::ExternalAgentServerName;
use std::{path::Path, rc::Rc};
use ui::IconName;
/// A generic agent server implementation for custom user-defined agents
pub struct CustomAgentServer {
name: SharedString,
- command: AgentServerCommand,
}
impl CustomAgentServer {
- pub fn new(name: SharedString, command: AgentServerCommand) -> Self {
- Self { name, command }
+ pub fn new(name: SharedString) -> Self {
+ Self { name }
}
}
@@ -32,14 +32,36 @@ impl crate::AgentServer for CustomAgentServer {
fn connect(
&self,
- root_dir: &Path,
- _delegate: AgentServerDelegate,
+ root_dir: Option<&Path>,
+ delegate: AgentServerDelegate,
cx: &mut App,
- ) -> Task<Result<Rc<dyn AgentConnection>>> {
- let server_name = self.name();
- let command = self.command.clone();
- let root_dir = root_dir.to_path_buf();
- cx.spawn(async move |cx| crate::acp::connect(server_name, command, &root_dir, cx).await)
+ ) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
+ let name = self.name();
+ let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().to_string());
+ let is_remote = delegate.project.read(cx).is_via_remote_server();
+ let store = delegate.store.downgrade();
+
+ cx.spawn(async move |cx| {
+ let (command, root_dir, login) = store
+ .update(cx, |store, cx| {
+ let agent = store
+ .get_external_agent(&ExternalAgentServerName(name.clone()))
+ .with_context(|| {
+ format!("Custom agent server `{}` is not registered", name)
+ })?;
+ anyhow::Ok(agent.get_command(
+ root_dir.as_deref(),
+ Default::default(),
+ delegate.status_tx,
+ delegate.new_version_available,
+ &mut cx.to_async(),
+ ))
+ })??
+ .await?;
+ let connection =
+ crate::acp::connect(name, command, root_dir.as_ref(), is_remote, cx).await?;
+ Ok((connection, login))
+ })
}
fn into_any(self: Rc<Self>) -> Rc<dyn std::any::Any> {
@@ -1,12 +1,12 @@
use crate::{AgentServer, AgentServerDelegate};
-#[cfg(test)]
-use crate::{AgentServerCommand, CustomAgentServerSettings};
use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus};
use agent_client_protocol as acp;
use futures::{FutureExt, StreamExt, channel::mpsc, select};
use gpui::{AppContext, Entity, TestAppContext};
use indoc::indoc;
-use project::{FakeFs, Project};
+#[cfg(test)]
+use project::agent_server_store::{AgentServerCommand, CustomAgentServerSettings};
+use project::{FakeFs, Project, agent_server_store::AllAgentServersSettings};
use std::{
path::{Path, PathBuf},
sync::Arc,
@@ -449,7 +449,6 @@ pub use common_e2e_tests;
// Helpers
pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
- #[cfg(test)]
use settings::Settings;
env_logger::try_init().ok();
@@ -468,11 +467,11 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
language_model::init(client.clone(), cx);
language_models::init(user_store, client, cx);
agent_settings::init(cx);
- crate::settings::init(cx);
+ AllAgentServersSettings::register(cx);
#[cfg(test)]
- crate::AllAgentServersSettings::override_global(
- crate::AllAgentServersSettings {
+ AllAgentServersSettings::override_global(
+ AllAgentServersSettings {
claude: Some(CustomAgentServerSettings {
command: AgentServerCommand {
path: "claude-code-acp".into(),
@@ -498,10 +497,11 @@ pub async fn new_test_thread(
current_dir: impl AsRef<Path>,
cx: &mut TestAppContext,
) -> Entity<AcpThread> {
- let delegate = AgentServerDelegate::new(project.clone(), None, None);
+ let store = project.read_with(cx, |project, _| project.agent_server_store().clone());
+ let delegate = AgentServerDelegate::new(store, project.clone(), None, None);
- let connection = cx
- .update(|cx| server.connect(current_dir.as_ref(), delegate, cx))
+ let (connection, _) = cx
+ .update(|cx| server.connect(Some(current_dir.as_ref()), delegate, cx))
.await
.unwrap();
@@ -1,21 +1,17 @@
use std::rc::Rc;
use std::{any::Any, path::Path};
-use crate::acp::AcpConnection;
use crate::{AgentServer, AgentServerDelegate};
-use acp_thread::{AgentConnection, LoadError};
-use anyhow::Result;
-use gpui::{App, AppContext as _, SharedString, Task};
+use acp_thread::AgentConnection;
+use anyhow::{Context as _, Result};
+use collections::HashMap;
+use gpui::{App, SharedString, Task};
use language_models::provider::google::GoogleLanguageModelProvider;
-use settings::SettingsStore;
-
-use crate::AllAgentServersSettings;
+use project::agent_server_store::GEMINI_NAME;
#[derive(Clone)]
pub struct Gemini;
-const ACP_ARG: &str = "--experimental-acp";
-
impl AgentServer for Gemini {
fn telemetry_id(&self) -> &'static str {
"gemini-cli"
@@ -31,126 +27,37 @@ impl AgentServer for Gemini {
fn connect(
&self,
- root_dir: &Path,
+ root_dir: Option<&Path>,
delegate: AgentServerDelegate,
cx: &mut App,
- ) -> Task<Result<Rc<dyn AgentConnection>>> {
- let root_dir = root_dir.to_path_buf();
- let fs = delegate.project().read(cx).fs().clone();
- let server_name = self.name();
- let settings = cx.read_global(|settings: &SettingsStore, _| {
- settings.get::<AllAgentServersSettings>(None).gemini.clone()
- });
- let project = delegate.project().clone();
+ ) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
+ let name = self.name();
+ let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().to_string());
+ let is_remote = delegate.project.read(cx).is_via_remote_server();
+ let store = delegate.store.downgrade();
cx.spawn(async move |cx| {
- let ignore_system_version = settings
- .as_ref()
- .and_then(|settings| settings.ignore_system_version)
- .unwrap_or(true);
- let mut project_env = project
- .update(cx, |project, cx| {
- project.directory_environment(root_dir.as_path().into(), cx)
- })?
- .await
- .unwrap_or_default();
- let mut command = if let Some(settings) = settings
- && let Some(command) = settings.custom_command()
- {
- command
- } else {
- 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(),
- ignore_system_version,
- Some(Self::MINIMUM_VERSION.parse().unwrap()),
- cx,
- )
- })?
- .await?
- };
- if !command.args.contains(&ACP_ARG.into()) {
- command.args.push(ACP_ARG.into());
- }
+ let mut extra_env = HashMap::default();
if let Some(api_key) = cx.update(GoogleLanguageModelProvider::api_key)?.await.ok() {
- project_env
- .insert("GEMINI_API_KEY".to_owned(), api_key.key);
- }
- project_env.extend(command.env.take().unwrap_or_default());
- command.env = Some(project_env);
-
- let root_dir_exists = fs.is_dir(&root_dir).await;
- anyhow::ensure!(
- root_dir_exists,
- "Session root {} does not exist or is not a directory",
- root_dir.to_string_lossy()
- );
-
- let result = crate::acp::connect(server_name, command.clone(), &root_dir, cx).await;
- match &result {
- Ok(connection) => {
- if let Some(connection) = connection.clone().downcast::<AcpConnection>()
- && !connection.prompt_capabilities().image
- {
- let version_output = util::command::new_smol_command(&command.path)
- .args(command.args.iter())
- .arg("--version")
- .kill_on_drop(true)
- .output()
- .await;
- let current_version =
- String::from_utf8(version_output?.stdout)?.trim().to_owned();
-
- log::error!("connected to gemini, but missing prompt_capabilities.image (version is {current_version})");
- return Err(LoadError::Unsupported {
- current_version: current_version.into(),
- command: (command.path.to_string_lossy().to_string() + " " + &command.args.join(" ")).into(),
- minimum_version: Self::MINIMUM_VERSION.into(),
- }
- .into());
- }
- }
- Err(e) => {
- let version_fut = util::command::new_smol_command(&command.path)
- .args(command.args.iter())
- .arg("--version")
- .kill_on_drop(true)
- .output();
-
- let help_fut = util::command::new_smol_command(&command.path)
- .args(command.args.iter())
- .arg("--help")
- .kill_on_drop(true)
- .output();
-
- let (version_output, help_output) =
- futures::future::join(version_fut, help_fut).await;
- let Some(version_output) = version_output.ok().and_then(|output| String::from_utf8(output.stdout).ok()) else {
- return result;
- };
- let Some((help_stdout, help_stderr)) = help_output.ok().and_then(|output| String::from_utf8(output.stdout).ok().zip(String::from_utf8(output.stderr).ok())) else {
- return result;
- };
-
- let current_version = version_output.trim().to_string();
- let supported = help_stdout.contains(ACP_ARG) || current_version.parse::<semver::Version>().is_ok_and(|version| version >= Self::MINIMUM_VERSION.parse::<semver::Version>().unwrap());
-
- log::error!("failed to create ACP connection to gemini (version is {current_version}, supported: {supported}): {e}");
- log::debug!("gemini --help stdout: {help_stdout:?}");
- log::debug!("gemini --help stderr: {help_stderr:?}");
- if !supported {
- return Err(LoadError::Unsupported {
- current_version: current_version.into(),
- command: (command.path.to_string_lossy().to_string() + " " + &command.args.join(" ")).into(),
- minimum_version: Self::MINIMUM_VERSION.into(),
- }
- .into());
- }
- }
+ extra_env.insert("GEMINI_API_KEY".into(), api_key.key);
}
- result
+ let (command, root_dir, login) = store
+ .update(cx, |store, cx| {
+ let agent = store
+ .get_external_agent(&GEMINI_NAME.into())
+ .context("Gemini CLI is not registered")?;
+ anyhow::Ok(agent.get_command(
+ root_dir.as_deref(),
+ extra_env,
+ delegate.status_tx,
+ delegate.new_version_available,
+ &mut cx.to_async(),
+ ))
+ })??
+ .await?;
+ let connection =
+ crate::acp::connect(name, command, root_dir.as_ref(), is_remote, cx).await?;
+ Ok((connection, login))
})
}
@@ -159,18 +66,11 @@ impl AgentServer for Gemini {
}
}
-impl Gemini {
- const PACKAGE_NAME: &str = "@google/gemini-cli";
-
- const MINIMUM_VERSION: &str = "0.2.1";
-
- const BINARY_NAME: &str = "gemini";
-}
-
#[cfg(test)]
pub(crate) mod tests {
+ use project::agent_server_store::AgentServerCommand;
+
use super::*;
- use crate::AgentServerCommand;
use std::path::Path;
crate::common_e2e_tests!(async |_, _, _| Gemini, allow_option_id = "proceed_once");
@@ -1,110 +0,0 @@
-use std::path::PathBuf;
-
-use crate::AgentServerCommand;
-use anyhow::Result;
-use collections::HashMap;
-use gpui::{App, SharedString};
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsKey, SettingsSources, SettingsUi};
-
-pub fn init(cx: &mut App) {
- AllAgentServersSettings::register(cx);
-}
-
-#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug, SettingsUi, SettingsKey)]
-#[settings_key(key = "agent_servers")]
-pub struct AllAgentServersSettings {
- pub gemini: Option<BuiltinAgentServerSettings>,
- pub claude: Option<CustomAgentServerSettings>,
-
- /// Custom agent servers configured by the user
- #[serde(flatten)]
- 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 CustomAgentServerSettings {
- #[serde(flatten)]
- pub command: AgentServerCommand,
-}
-
-impl settings::Settings for AllAgentServersSettings {
- type FileContent = Self;
-
- fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
- let mut settings = AllAgentServersSettings::default();
-
- for AllAgentServersSettings {
- gemini,
- claude,
- custom,
- } in sources.defaults_and_customizations()
- {
- if gemini.is_some() {
- settings.gemini = gemini.clone();
- }
- if claude.is_some() {
- settings.claude = claude.clone();
- }
-
- // Merge custom agents
- for (name, config) in custom {
- // Skip built-in agent names to avoid conflicts
- if name != "gemini" && name != "claude" {
- settings.custom.insert(name.clone(), config.clone());
- }
- }
- }
-
- Ok(settings)
- }
-
- fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
-}
@@ -699,10 +699,15 @@ impl MessageEditor {
self.project.read(cx).fs().clone(),
self.history_store.clone(),
));
- let delegate = AgentServerDelegate::new(self.project.clone(), None, None);
- let connection = server.connect(Path::new(""), delegate, cx);
+ let delegate = AgentServerDelegate::new(
+ self.project.read(cx).agent_server_store().clone(),
+ self.project.clone(),
+ None,
+ None,
+ );
+ let connection = server.connect(None, delegate, cx);
cx.spawn(async move |_, cx| {
- let agent = connection.await?;
+ let (agent, _) = connection.await?;
let agent = agent.downcast::<agent2::NativeAgentConnection>().unwrap();
let summary = agent
.0
@@ -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, AgentServerDelegate, ClaudeCode};
+use agent_servers::{AgentServer, AgentServerDelegate};
use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting};
use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore, NativeAgentServer};
use anyhow::{Context as _, Result, anyhow, bail};
@@ -40,7 +40,6 @@ use std::path::Path;
use std::sync::Arc;
use std::time::Instant;
use std::{collections::BTreeMap, rc::Rc, time::Duration};
-use task::SpawnInTerminal;
use terminal_view::terminal_panel::TerminalPanel;
use text::Anchor;
use theme::{AgentFontSize, ThemeSettings};
@@ -263,6 +262,7 @@ pub struct AcpThreadView {
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
thread_state: ThreadState,
+ login: Option<task::SpawnInTerminal>,
history_store: Entity<HistoryStore>,
hovered_recent_history_item: Option<usize>,
entry_view_state: Entity<EntryViewState>,
@@ -392,6 +392,7 @@ impl AcpThreadView {
project: project.clone(),
entry_view_state,
thread_state: Self::initial_state(agent, resume_thread, workspace, project, window, cx),
+ login: None,
message_editor,
model_selector: None,
profile_selector: None,
@@ -444,9 +445,11 @@ impl AcpThreadView {
window: &mut Window,
cx: &mut Context<Self>,
) -> ThreadState {
- if !project.read(cx).is_local() && agent.clone().downcast::<NativeAgentServer>().is_none() {
+ if project.read(cx).is_via_collab()
+ && agent.clone().downcast::<NativeAgentServer>().is_none()
+ {
return ThreadState::LoadError(LoadError::Other(
- "External agents are not yet supported for remote projects.".into(),
+ "External agents are not yet supported in shared projects.".into(),
));
}
let mut worktrees = project.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
@@ -466,20 +469,23 @@ impl AcpThreadView {
Some(worktree.read(cx).abs_path())
}
})
- .next()
- .unwrap_or_else(|| paths::home_dir().as_path().into());
+ .next();
let (status_tx, mut status_rx) = watch::channel("Loadingβ¦".into());
let (new_version_available_tx, mut new_version_available_rx) = watch::channel(None);
let delegate = AgentServerDelegate::new(
+ project.read(cx).agent_server_store().clone(),
project.clone(),
Some(status_tx),
Some(new_version_available_tx),
);
- let connect_task = agent.connect(&root_dir, delegate, cx);
+ let connect_task = agent.connect(root_dir.as_deref(), delegate, cx);
let load_task = cx.spawn_in(window, async move |this, cx| {
let connection = match connect_task.await {
- Ok(connection) => connection,
+ Ok((connection, login)) => {
+ this.update(cx, |this, _| this.login = login).ok();
+ connection
+ }
Err(err) => {
this.update_in(cx, |this, window, cx| {
if err.downcast_ref::<LoadError>().is_some() {
@@ -506,6 +512,14 @@ impl AcpThreadView {
})
.log_err()
} else {
+ let root_dir = if let Some(acp_agent) = connection
+ .clone()
+ .downcast::<agent_servers::AcpConnection>()
+ {
+ acp_agent.root_dir().into()
+ } else {
+ root_dir.unwrap_or(paths::home_dir().as_path().into())
+ };
cx.update(|_, cx| {
connection
.clone()
@@ -1462,9 +1476,12 @@ impl AcpThreadView {
self.thread_error.take();
configuration_view.take();
pending_auth_method.replace(method.clone());
- let authenticate = if method.0.as_ref() == "claude-login" {
+ let authenticate = if (method.0.as_ref() == "claude-login"
+ || method.0.as_ref() == "spawn-gemini-cli")
+ && let Some(login) = self.login.clone()
+ {
if let Some(workspace) = self.workspace.upgrade() {
- Self::spawn_claude_login(&workspace, window, cx)
+ Self::spawn_external_agent_login(login, workspace, false, window, cx)
} else {
Task::ready(Ok(()))
}
@@ -1511,31 +1528,28 @@ impl AcpThreadView {
}));
}
- fn spawn_claude_login(
- workspace: &Entity<Workspace>,
+ fn spawn_external_agent_login(
+ login: task::SpawnInTerminal,
+ workspace: Entity<Workspace>,
+ previous_attempt: bool,
window: &mut Window,
cx: &mut App,
) -> Task<Result<()>> {
let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
return Task::ready(Ok(()));
};
- let project_entity = workspace.read(cx).project();
- let project = project_entity.read(cx);
- let cwd = project.first_project_directory(cx);
- let shell = project.terminal_settings(&cwd, cx).shell.clone();
-
- let delegate = AgentServerDelegate::new(project_entity.clone(), None, None);
- let command = ClaudeCode::login_command(delegate, cx);
+ let project = workspace.read(cx).project().clone();
+ let cwd = project.read(cx).first_project_directory(cx);
+ let shell = project.read(cx).terminal_settings(&cwd, cx).shell.clone();
window.spawn(cx, async move |cx| {
- let login_command = command.await?;
- let command = login_command
- .path
- .to_str()
- .with_context(|| format!("invalid login command: {:?}", login_command.path))?;
- let command = shlex::try_quote(command)?;
- let args = login_command
- .arguments
+ let mut task = login.clone();
+ task.command = task
+ .command
+ .map(|command| anyhow::Ok(shlex::try_quote(&command)?.to_string()))
+ .transpose()?;
+ task.args = task
+ .args
.iter()
.map(|arg| {
Ok(shlex::try_quote(arg)
@@ -1543,26 +1557,16 @@ impl AcpThreadView {
.to_string())
})
.collect::<Result<Vec<_>>>()?;
+ task.full_label = task.label.clone();
+ task.id = task::TaskId(format!("external-agent-{}-login", task.label));
+ task.command_label = task.label.clone();
+ task.use_new_terminal = true;
+ task.allow_concurrent_runs = true;
+ task.hide = task::HideStrategy::Always;
+ task.shell = shell;
let terminal = terminal_panel.update_in(cx, |terminal_panel, window, cx| {
- terminal_panel.spawn_task(
- &SpawnInTerminal {
- id: task::TaskId("claude-login".into()),
- full_label: "claude /login".to_owned(),
- label: "claude /login".to_owned(),
- command: Some(command.into()),
- args,
- command_label: "claude /login".to_owned(),
- cwd,
- use_new_terminal: true,
- allow_concurrent_runs: true,
- hide: task::HideStrategy::Always,
- shell,
- ..Default::default()
- },
- window,
- cx,
- )
+ terminal_panel.spawn_task(&login, window, cx)
})?;
let terminal = terminal.await?;
@@ -1578,7 +1582,9 @@ impl AcpThreadView {
cx.background_executor().timer(Duration::from_secs(1)).await;
let content =
terminal.update(cx, |terminal, _cx| terminal.get_content())?;
- if content.contains("Login successful") {
+ if content.contains("Login successful")
+ || content.contains("Type your message")
+ {
return anyhow::Ok(());
}
}
@@ -1594,6 +1600,9 @@ impl AcpThreadView {
}
}
_ = exit_status => {
+ if !previous_attempt && project.read_with(cx, |project, _| project.is_via_remote_server())? && login.label.contains("gemini") {
+ return cx.update(|window, cx| Self::spawn_external_agent_login(login, workspace, true, window, cx))?.await
+ }
return Err(anyhow!("exited before logging in"));
}
}
@@ -3088,26 +3097,38 @@ impl AcpThreadView {
})
.children(connection.auth_methods().iter().enumerate().rev().map(
|(ix, method)| {
- Button::new(
- SharedString::from(method.id.0.clone()),
- method.name.clone(),
- )
- .when(ix == 0, |el| {
- el.style(ButtonStyle::Tinted(ui::TintColor::Warning))
- })
- .label_size(LabelSize::Small)
- .on_click({
- let method_id = method.id.clone();
- cx.listener(move |this, _, window, cx| {
- telemetry::event!(
- "Authenticate Agent Started",
- agent = this.agent.telemetry_id(),
- method = method_id
- );
+ let (method_id, name) = if self
+ .project
+ .read(cx)
+ .is_via_remote_server()
+ && method.id.0.as_ref() == "oauth-personal"
+ && method.name == "Log in with Google"
+ {
+ ("spawn-gemini-cli".into(), "Log in with Gemini CLI".into())
+ } else {
+ (method.id.0.clone(), method.name.clone())
+ };
- this.authenticate(method_id.clone(), window, cx)
+ Button::new(SharedString::from(method_id.clone()), name)
+ .when(ix == 0, |el| {
+ el.style(ButtonStyle::Tinted(ui::TintColor::Warning))
+ })
+ .label_size(LabelSize::Small)
+ .on_click({
+ cx.listener(move |this, _, window, cx| {
+ telemetry::event!(
+ "Authenticate Agent Started",
+ agent = this.agent.telemetry_id(),
+ method = method_id
+ );
+
+ this.authenticate(
+ acp::AuthMethodId(method_id.clone()),
+ window,
+ cx,
+ )
+ })
})
- })
},
)),
)
@@ -5710,11 +5731,11 @@ pub(crate) mod tests {
fn connect(
&self,
- _root_dir: &Path,
+ _root_dir: Option<&Path>,
_delegate: AgentServerDelegate,
_cx: &mut App,
- ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
- Task::ready(Ok(Rc::new(self.connection.clone())))
+ ) -> Task<gpui::Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
+ Task::ready(Ok((Rc::new(self.connection.clone()), None)))
}
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
@@ -5,7 +5,6 @@ mod tool_picker;
use std::{ops::Range, sync::Arc};
-use agent_servers::{AgentServerCommand, AllAgentServersSettings, CustomAgentServerSettings};
use agent_settings::AgentSettings;
use anyhow::Result;
use assistant_tool::{ToolSource, ToolWorkingSet};
@@ -26,6 +25,10 @@ use language_model::{
};
use notifications::status_toast::{StatusToast, ToastIcon};
use project::{
+ agent_server_store::{
+ AgentServerCommand, AgentServerStore, AllAgentServersSettings, CLAUDE_CODE_NAME,
+ CustomAgentServerSettings, GEMINI_NAME,
+ },
context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
project_settings::{ContextServerSettings, ProjectSettings},
};
@@ -45,11 +48,13 @@ pub(crate) use manage_profiles_modal::ManageProfilesModal;
use crate::{
AddContextServer, ExternalAgent, NewExternalAgentThread,
agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider},
+ placeholder_command,
};
pub struct AgentConfiguration {
fs: Arc<dyn Fs>,
language_registry: Arc<LanguageRegistry>,
+ agent_server_store: Entity<AgentServerStore>,
workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
configuration_views_by_provider: HashMap<LanguageModelProviderId, AnyView>,
@@ -66,6 +71,7 @@ pub struct AgentConfiguration {
impl AgentConfiguration {
pub fn new(
fs: Arc<dyn Fs>,
+ agent_server_store: Entity<AgentServerStore>,
context_server_store: Entity<ContextServerStore>,
tools: Entity<ToolWorkingSet>,
language_registry: Arc<LanguageRegistry>,
@@ -104,6 +110,7 @@ impl AgentConfiguration {
workspace,
focus_handle,
configuration_views_by_provider: HashMap::default(),
+ agent_server_store,
context_server_store,
expanded_context_server_tools: HashMap::default(),
expanded_provider_configurations: HashMap::default(),
@@ -991,17 +998,30 @@ impl AgentConfiguration {
}
fn render_agent_servers_section(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
- let settings = AllAgentServersSettings::get_global(cx).clone();
- let user_defined_agents = settings
+ let custom_settings = cx
+ .global::<SettingsStore>()
+ .get::<AllAgentServersSettings>(None)
.custom
- .iter()
- .map(|(name, settings)| {
+ .clone();
+ let user_defined_agents = self
+ .agent_server_store
+ .read(cx)
+ .external_agents()
+ .filter(|name| name.0 != GEMINI_NAME && name.0 != CLAUDE_CODE_NAME)
+ .cloned()
+ .collect::<Vec<_>>();
+ let user_defined_agents = user_defined_agents
+ .into_iter()
+ .map(|name| {
self.render_agent_server(
IconName::Ai,
name.clone(),
ExternalAgent::Custom {
- name: name.clone(),
- command: settings.command.clone(),
+ name: name.clone().into(),
+ command: custom_settings
+ .get(&name.0)
+ .map(|settings| settings.command.clone())
+ .unwrap_or(placeholder_command()),
},
cx,
)
@@ -5,9 +5,11 @@ use std::sync::Arc;
use std::time::Duration;
use acp_thread::AcpThread;
-use agent_servers::AgentServerCommand;
use agent2::{DbThreadMetadata, HistoryEntry};
use db::kvp::{Dismissable, KEY_VALUE_STORE};
+use project::agent_server_store::{
+ AgentServerCommand, AllAgentServersSettings, CLAUDE_CODE_NAME, GEMINI_NAME,
+};
use serde::{Deserialize, Serialize};
use zed_actions::OpenBrowser;
use zed_actions::agent::{OpenClaudeCodeOnboardingModal, ReauthenticateAgent};
@@ -33,7 +35,9 @@ use crate::{
thread_history::{HistoryEntryElement, ThreadHistory},
ui::{AgentOnboardingModal, EndTrialUpsell},
};
-use crate::{ExternalAgent, NewExternalAgentThread, NewNativeAgentThreadFromSummary};
+use crate::{
+ ExternalAgent, NewExternalAgentThread, NewNativeAgentThreadFromSummary, placeholder_command,
+};
use agent::{
Thread, ThreadError, ThreadEvent, ThreadId, ThreadSummary, TokenUsageRatio,
context_store::ContextStore,
@@ -62,7 +66,7 @@ use project::{DisableAiSettings, Project, ProjectPath, Worktree};
use prompt_store::{PromptBuilder, PromptStore, UserPromptId};
use rules_library::{RulesLibrary, open_rules_library};
use search::{BufferSearchBar, buffer_search};
-use settings::{Settings, update_settings_file};
+use settings::{Settings, SettingsStore, update_settings_file};
use theme::ThemeSettings;
use time::UtcOffset;
use ui::utils::WithRemSize;
@@ -1094,7 +1098,7 @@ impl AgentPanel {
let workspace = self.workspace.clone();
let project = self.project.clone();
let fs = self.fs.clone();
- let is_not_local = !self.project.read(cx).is_local();
+ let is_via_collab = self.project.read(cx).is_via_collab();
const LAST_USED_EXTERNAL_AGENT_KEY: &str = "agent_panel__last_used_external_agent";
@@ -1126,7 +1130,7 @@ impl AgentPanel {
agent
}
None => {
- if is_not_local {
+ if is_via_collab {
ExternalAgent::NativeAgent
} else {
cx.background_spawn(async move {
@@ -1503,6 +1507,7 @@ impl AgentPanel {
}
pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ let agent_server_store = self.project.read(cx).agent_server_store().clone();
let context_server_store = self.project.read(cx).context_server_store();
let tools = self.thread_store.read(cx).tools();
let fs = self.fs.clone();
@@ -1511,6 +1516,7 @@ impl AgentPanel {
self.configuration = Some(cx.new(|cx| {
AgentConfiguration::new(
fs,
+ agent_server_store,
context_server_store,
tools,
self.language_registry.clone(),
@@ -2503,6 +2509,7 @@ impl AgentPanel {
}
fn render_toolbar_new(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let agent_server_store = self.project.read(cx).agent_server_store().clone();
let focus_handle = self.focus_handle(cx);
let active_thread = match &self.active_view {
@@ -2535,8 +2542,10 @@ impl AgentPanel {
.with_handle(self.new_thread_menu_handle.clone())
.menu({
let workspace = self.workspace.clone();
- let is_not_local = workspace
- .update(cx, |workspace, cx| !workspace.project().read(cx).is_local())
+ let is_via_collab = workspace
+ .update(cx, |workspace, cx| {
+ workspace.project().read(cx).is_via_collab()
+ })
.unwrap_or_default();
move |window, cx| {
@@ -2628,7 +2637,7 @@ impl AgentPanel {
ContextMenuEntry::new("New Gemini CLI Thread")
.icon(IconName::AiGemini)
.icon_color(Color::Muted)
- .disabled(is_not_local)
+ .disabled(is_via_collab)
.handler({
let workspace = workspace.clone();
move |window, cx| {
@@ -2655,7 +2664,7 @@ impl AgentPanel {
menu.item(
ContextMenuEntry::new("New Claude Code Thread")
.icon(IconName::AiClaude)
- .disabled(is_not_local)
+ .disabled(is_via_collab)
.icon_color(Color::Muted)
.handler({
let workspace = workspace.clone();
@@ -2680,19 +2689,25 @@ impl AgentPanel {
)
})
.when(cx.has_flag::<GeminiAndNativeFeatureFlag>(), |mut menu| {
- // Add custom agents from settings
- let settings =
- agent_servers::AllAgentServersSettings::get_global(cx);
- for (agent_name, agent_settings) in &settings.custom {
+ let agent_names = agent_server_store
+ .read(cx)
+ .external_agents()
+ .filter(|name| {
+ name.0 != GEMINI_NAME && name.0 != CLAUDE_CODE_NAME
+ })
+ .cloned()
+ .collect::<Vec<_>>();
+ let custom_settings = cx.global::<SettingsStore>().get::<AllAgentServersSettings>(None).custom.clone();
+ for agent_name in agent_names {
menu = menu.item(
ContextMenuEntry::new(format!("New {} Thread", agent_name))
.icon(IconName::Terminal)
.icon_color(Color::Muted)
- .disabled(is_not_local)
+ .disabled(is_via_collab)
.handler({
let workspace = workspace.clone();
let agent_name = agent_name.clone();
- let agent_settings = agent_settings.clone();
+ let custom_settings = custom_settings.clone();
move |window, cx| {
if let Some(workspace) = workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
@@ -2703,10 +2718,9 @@ impl AgentPanel {
panel.new_agent_thread(
AgentType::Custom {
name: agent_name
- .clone(),
- command: agent_settings
- .command
- .clone(),
+ .clone()
+ .into(),
+ command: custom_settings.get(&agent_name.0).map(|settings| settings.command.clone()).unwrap_or(placeholder_command())
},
window,
cx,
@@ -28,7 +28,6 @@ use std::rc::Rc;
use std::sync::Arc;
use agent::{Thread, ThreadId};
-use agent_servers::AgentServerCommand;
use agent_settings::{AgentProfileId, AgentSettings, LanguageModelSelection};
use assistant_slash_command::SlashCommandRegistry;
use client::Client;
@@ -41,6 +40,7 @@ use language_model::{
ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry,
};
use project::DisableAiSettings;
+use project::agent_server_store::AgentServerCommand;
use prompt_store::PromptBuilder;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -174,6 +174,14 @@ enum ExternalAgent {
},
}
+fn placeholder_command() -> AgentServerCommand {
+ AgentServerCommand {
+ path: "/placeholder".into(),
+ args: vec![],
+ env: None,
+ }
+}
+
impl ExternalAgent {
fn name(&self) -> &'static str {
match self {
@@ -193,10 +201,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, command } => Rc::new(agent_servers::CustomAgentServer::new(
- name.clone(),
- command.clone(),
- )),
+ Self::Custom { name, command: _ } => {
+ Rc::new(agent_servers::CustomAgentServer::new(name.clone()))
+ }
}
}
}
@@ -67,6 +67,7 @@ regex.workspace = true
remote.workspace = true
rpc.workspace = true
schemars.workspace = true
+semver.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
@@ -85,6 +86,7 @@ text.workspace = true
toml.workspace = true
url.workspace = true
util.workspace = true
+watch.workspace = true
which.workspace = true
worktree.workspace = true
zlog.workspace = true
@@ -0,0 +1,1091 @@
+use std::{
+ any::Any,
+ borrow::Borrow,
+ path::{Path, PathBuf},
+ str::FromStr as _,
+ sync::Arc,
+ time::Duration,
+};
+
+use anyhow::{Context as _, Result, bail};
+use collections::HashMap;
+use fs::{Fs, RemoveOptions, RenameOptions};
+use futures::StreamExt as _;
+use gpui::{
+ App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task,
+};
+use node_runtime::NodeRuntime;
+use remote::RemoteClient;
+use rpc::{
+ AnyProtoClient, TypedEnvelope,
+ proto::{self, ToProto},
+};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::{SettingsKey, SettingsSources, SettingsStore, SettingsUi};
+use util::{ResultExt as _, debug_panic};
+
+use crate::ProjectEnvironment;
+
+#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
+pub struct AgentServerCommand {
+ #[serde(rename = "command")]
+ pub path: PathBuf,
+ #[serde(default)]
+ pub args: Vec<String>,
+ pub env: Option<HashMap<String, String>>,
+}
+
+impl std::fmt::Debug for AgentServerCommand {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let filtered_env = self.env.as_ref().map(|env| {
+ env.iter()
+ .map(|(k, v)| {
+ (
+ k,
+ if util::redact::should_redact(k) {
+ "[REDACTED]"
+ } else {
+ v
+ },
+ )
+ })
+ .collect::<Vec<_>>()
+ });
+
+ f.debug_struct("AgentServerCommand")
+ .field("path", &self.path)
+ .field("args", &self.args)
+ .field("env", &filtered_env)
+ .finish()
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+pub struct ExternalAgentServerName(pub SharedString);
+
+impl std::fmt::Display for ExternalAgentServerName {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+impl From<&'static str> for ExternalAgentServerName {
+ fn from(value: &'static str) -> Self {
+ ExternalAgentServerName(value.into())
+ }
+}
+
+impl From<ExternalAgentServerName> for SharedString {
+ fn from(value: ExternalAgentServerName) -> Self {
+ value.0
+ }
+}
+
+impl Borrow<str> for ExternalAgentServerName {
+ fn borrow(&self) -> &str {
+ &self.0
+ }
+}
+
+pub trait ExternalAgentServer {
+ fn get_command(
+ &mut self,
+ root_dir: Option<&str>,
+ extra_env: HashMap<String, String>,
+ status_tx: Option<watch::Sender<SharedString>>,
+ new_version_available_tx: Option<watch::Sender<Option<String>>>,
+ cx: &mut AsyncApp,
+ ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>>;
+
+ fn as_any_mut(&mut self) -> &mut dyn Any;
+}
+
+impl dyn ExternalAgentServer {
+ fn downcast_mut<T: ExternalAgentServer + 'static>(&mut self) -> Option<&mut T> {
+ self.as_any_mut().downcast_mut()
+ }
+}
+
+enum AgentServerStoreState {
+ Local {
+ node_runtime: NodeRuntime,
+ fs: Arc<dyn Fs>,
+ project_environment: Entity<ProjectEnvironment>,
+ downstream_client: Option<(u64, AnyProtoClient)>,
+ settings: Option<AllAgentServersSettings>,
+ _subscriptions: [Subscription; 1],
+ },
+ Remote {
+ project_id: u64,
+ upstream_client: Entity<RemoteClient>,
+ },
+ Collab,
+}
+
+pub struct AgentServerStore {
+ state: AgentServerStoreState,
+ external_agents: HashMap<ExternalAgentServerName, Box<dyn ExternalAgentServer>>,
+}
+
+pub struct AgentServersUpdated;
+
+impl EventEmitter<AgentServersUpdated> for AgentServerStore {}
+
+impl AgentServerStore {
+ pub fn init_remote(session: &AnyProtoClient) {
+ session.add_entity_message_handler(Self::handle_external_agents_updated);
+ session.add_entity_message_handler(Self::handle_loading_status_updated);
+ session.add_entity_message_handler(Self::handle_new_version_available);
+ }
+
+ pub fn init_headless(session: &AnyProtoClient) {
+ session.add_entity_request_handler(Self::handle_get_agent_server_command);
+ }
+
+ fn agent_servers_settings_changed(&mut self, cx: &mut Context<Self>) {
+ let AgentServerStoreState::Local {
+ node_runtime,
+ fs,
+ project_environment,
+ downstream_client,
+ settings: old_settings,
+ ..
+ } = &mut self.state
+ else {
+ debug_panic!(
+ "should not be subscribed to agent server settings changes in non-local project"
+ );
+ return;
+ };
+
+ let new_settings = cx
+ .global::<SettingsStore>()
+ .get::<AllAgentServersSettings>(None)
+ .clone();
+ if Some(&new_settings) == old_settings.as_ref() {
+ return;
+ }
+
+ self.external_agents.clear();
+ self.external_agents.insert(
+ GEMINI_NAME.into(),
+ Box::new(LocalGemini {
+ fs: fs.clone(),
+ node_runtime: node_runtime.clone(),
+ project_environment: project_environment.clone(),
+ custom_command: new_settings
+ .gemini
+ .clone()
+ .and_then(|settings| settings.custom_command()),
+ ignore_system_version: new_settings
+ .gemini
+ .as_ref()
+ .and_then(|settings| settings.ignore_system_version)
+ .unwrap_or(true),
+ }),
+ );
+ self.external_agents.insert(
+ CLAUDE_CODE_NAME.into(),
+ Box::new(LocalClaudeCode {
+ fs: fs.clone(),
+ node_runtime: node_runtime.clone(),
+ project_environment: project_environment.clone(),
+ custom_command: new_settings.claude.clone().map(|settings| settings.command),
+ }),
+ );
+ self.external_agents
+ .extend(new_settings.custom.iter().map(|(name, settings)| {
+ (
+ ExternalAgentServerName(name.clone()),
+ Box::new(LocalCustomAgent {
+ command: settings.command.clone(),
+ project_environment: project_environment.clone(),
+ }) as Box<dyn ExternalAgentServer>,
+ )
+ }));
+
+ *old_settings = Some(new_settings.clone());
+
+ if let Some((project_id, downstream_client)) = downstream_client {
+ downstream_client
+ .send(proto::ExternalAgentsUpdated {
+ project_id: *project_id,
+ names: self
+ .external_agents
+ .keys()
+ .map(|name| name.to_string())
+ .collect(),
+ })
+ .log_err();
+ }
+ cx.emit(AgentServersUpdated);
+ }
+
+ pub fn local(
+ node_runtime: NodeRuntime,
+ fs: Arc<dyn Fs>,
+ project_environment: Entity<ProjectEnvironment>,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ let subscription = cx.observe_global::<SettingsStore>(|this, cx| {
+ this.agent_servers_settings_changed(cx);
+ });
+ let this = Self {
+ state: AgentServerStoreState::Local {
+ node_runtime,
+ fs,
+ project_environment,
+ downstream_client: None,
+ settings: None,
+ _subscriptions: [subscription],
+ },
+ external_agents: Default::default(),
+ };
+ cx.spawn(async move |this, cx| {
+ cx.background_executor().timer(Duration::from_secs(1)).await;
+ this.update(cx, |this, cx| {
+ this.agent_servers_settings_changed(cx);
+ })
+ .ok();
+ })
+ .detach();
+ this
+ }
+
+ pub(crate) fn remote(
+ project_id: u64,
+ upstream_client: Entity<RemoteClient>,
+ _cx: &mut Context<Self>,
+ ) -> Self {
+ // Set up the builtin agents here so they're immediately available in
+ // remote projects--we know that the HeadlessProject on the other end
+ // will have them.
+ let external_agents = [
+ (
+ GEMINI_NAME.into(),
+ Box::new(RemoteExternalAgentServer {
+ project_id,
+ upstream_client: upstream_client.clone(),
+ name: GEMINI_NAME.into(),
+ status_tx: None,
+ new_version_available_tx: None,
+ }) as Box<dyn ExternalAgentServer>,
+ ),
+ (
+ CLAUDE_CODE_NAME.into(),
+ Box::new(RemoteExternalAgentServer {
+ project_id,
+ upstream_client: upstream_client.clone(),
+ name: CLAUDE_CODE_NAME.into(),
+ status_tx: None,
+ new_version_available_tx: None,
+ }) as Box<dyn ExternalAgentServer>,
+ ),
+ ]
+ .into_iter()
+ .collect();
+
+ Self {
+ state: AgentServerStoreState::Remote {
+ project_id,
+ upstream_client,
+ },
+ external_agents,
+ }
+ }
+
+ pub(crate) fn collab(_cx: &mut Context<Self>) -> Self {
+ Self {
+ state: AgentServerStoreState::Collab,
+ external_agents: Default::default(),
+ }
+ }
+
+ pub fn shared(&mut self, project_id: u64, client: AnyProtoClient) {
+ match &mut self.state {
+ AgentServerStoreState::Local {
+ downstream_client, ..
+ } => {
+ client
+ .send(proto::ExternalAgentsUpdated {
+ project_id,
+ names: self
+ .external_agents
+ .keys()
+ .map(|name| name.to_string())
+ .collect(),
+ })
+ .log_err();
+ *downstream_client = Some((project_id, client));
+ }
+ AgentServerStoreState::Remote { .. } => {
+ debug_panic!(
+ "external agents over collab not implemented, remote project should not be shared"
+ );
+ }
+ AgentServerStoreState::Collab => {
+ debug_panic!("external agents over collab not implemented, should not be shared");
+ }
+ }
+ }
+
+ pub fn get_external_agent(
+ &mut self,
+ name: &ExternalAgentServerName,
+ ) -> Option<&mut (dyn ExternalAgentServer + 'static)> {
+ self.external_agents
+ .get_mut(name)
+ .map(|agent| agent.as_mut())
+ }
+
+ pub fn external_agents(&self) -> impl Iterator<Item = &ExternalAgentServerName> {
+ self.external_agents.keys()
+ }
+
+ async fn handle_get_agent_server_command(
+ this: Entity<Self>,
+ envelope: TypedEnvelope<proto::GetAgentServerCommand>,
+ mut cx: AsyncApp,
+ ) -> Result<proto::AgentServerCommand> {
+ let (command, root_dir, login) = this
+ .update(&mut cx, |this, cx| {
+ let AgentServerStoreState::Local {
+ downstream_client, ..
+ } = &this.state
+ else {
+ debug_panic!("should not receive GetAgentServerCommand in a non-local project");
+ bail!("unexpected GetAgentServerCommand request in a non-local project");
+ };
+ let agent = this
+ .external_agents
+ .get_mut(&*envelope.payload.name)
+ .with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
+ let (status_tx, new_version_available_tx) = downstream_client
+ .clone()
+ .map(|(project_id, downstream_client)| {
+ let (status_tx, mut status_rx) = watch::channel(SharedString::from(""));
+ let (new_version_available_tx, mut new_version_available_rx) =
+ watch::channel(None);
+ cx.spawn({
+ let downstream_client = downstream_client.clone();
+ let name = envelope.payload.name.clone();
+ async move |_, _| {
+ while let Some(status) = status_rx.recv().await.ok() {
+ downstream_client.send(
+ proto::ExternalAgentLoadingStatusUpdated {
+ project_id,
+ name: name.clone(),
+ status: status.to_string(),
+ },
+ )?;
+ }
+ anyhow::Ok(())
+ }
+ })
+ .detach_and_log_err(cx);
+ cx.spawn({
+ let name = envelope.payload.name.clone();
+ async move |_, _| {
+ if let Some(version) =
+ new_version_available_rx.recv().await.ok().flatten()
+ {
+ downstream_client.send(
+ proto::NewExternalAgentVersionAvailable {
+ project_id,
+ name: name.clone(),
+ version,
+ },
+ )?;
+ }
+ anyhow::Ok(())
+ }
+ })
+ .detach_and_log_err(cx);
+ (status_tx, new_version_available_tx)
+ })
+ .unzip();
+ anyhow::Ok(agent.get_command(
+ envelope.payload.root_dir.as_deref(),
+ HashMap::default(),
+ status_tx,
+ new_version_available_tx,
+ &mut cx.to_async(),
+ ))
+ })??
+ .await?;
+ Ok(proto::AgentServerCommand {
+ path: command.path.to_string_lossy().to_string(),
+ args: command.args,
+ env: command
+ .env
+ .map(|env| env.into_iter().collect())
+ .unwrap_or_default(),
+ root_dir: root_dir,
+ login: login.map(|login| login.to_proto()),
+ })
+ }
+
+ async fn handle_external_agents_updated(
+ this: Entity<Self>,
+ envelope: TypedEnvelope<proto::ExternalAgentsUpdated>,
+ mut cx: AsyncApp,
+ ) -> Result<()> {
+ this.update(&mut cx, |this, cx| {
+ let AgentServerStoreState::Remote {
+ project_id,
+ upstream_client,
+ } = &this.state
+ else {
+ debug_panic!(
+ "handle_external_agents_updated should not be called for a non-remote project"
+ );
+ bail!("unexpected ExternalAgentsUpdated message")
+ };
+
+ let mut status_txs = this
+ .external_agents
+ .iter_mut()
+ .filter_map(|(name, agent)| {
+ Some((
+ name.clone(),
+ agent
+ .downcast_mut::<RemoteExternalAgentServer>()?
+ .status_tx
+ .take(),
+ ))
+ })
+ .collect::<HashMap<_, _>>();
+ let mut new_version_available_txs = this
+ .external_agents
+ .iter_mut()
+ .filter_map(|(name, agent)| {
+ Some((
+ name.clone(),
+ agent
+ .downcast_mut::<RemoteExternalAgentServer>()?
+ .new_version_available_tx
+ .take(),
+ ))
+ })
+ .collect::<HashMap<_, _>>();
+
+ this.external_agents = envelope
+ .payload
+ .names
+ .into_iter()
+ .map(|name| {
+ let agent = RemoteExternalAgentServer {
+ project_id: *project_id,
+ upstream_client: upstream_client.clone(),
+ name: ExternalAgentServerName(name.clone().into()),
+ status_tx: status_txs.remove(&*name).flatten(),
+ new_version_available_tx: new_version_available_txs
+ .remove(&*name)
+ .flatten(),
+ };
+ (
+ ExternalAgentServerName(name.into()),
+ Box::new(agent) as Box<dyn ExternalAgentServer>,
+ )
+ })
+ .collect();
+ cx.emit(AgentServersUpdated);
+ Ok(())
+ })?
+ }
+
+ async fn handle_loading_status_updated(
+ this: Entity<Self>,
+ envelope: TypedEnvelope<proto::ExternalAgentLoadingStatusUpdated>,
+ mut cx: AsyncApp,
+ ) -> Result<()> {
+ this.update(&mut cx, |this, _| {
+ if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
+ && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
+ && let Some(status_tx) = &mut agent.status_tx
+ {
+ status_tx.send(envelope.payload.status.into()).ok();
+ }
+ })
+ }
+
+ async fn handle_new_version_available(
+ this: Entity<Self>,
+ envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
+ mut cx: AsyncApp,
+ ) -> Result<()> {
+ this.update(&mut cx, |this, _| {
+ if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
+ && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
+ && let Some(new_version_available_tx) = &mut agent.new_version_available_tx
+ {
+ new_version_available_tx
+ .send(Some(envelope.payload.version))
+ .ok();
+ }
+ })
+ }
+}
+
+fn get_or_npm_install_builtin_agent(
+ binary_name: SharedString,
+ package_name: SharedString,
+ entrypoint_path: PathBuf,
+ minimum_version: Option<semver::Version>,
+ status_tx: Option<watch::Sender<SharedString>>,
+ new_version_available: Option<watch::Sender<Option<String>>>,
+ fs: Arc<dyn Fs>,
+ node_runtime: NodeRuntime,
+ cx: &mut AsyncApp,
+) -> Task<std::result::Result<AgentServerCommand, anyhow::Error>> {
+ cx.spawn(async move |cx| {
+ 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 mut stream = fs.read_dir(&dir).await?;
+ let mut versions = Vec::new();
+ let mut to_delete = Vec::new();
+ while let Some(entry) = stream.next().await {
+ let Ok(entry) = entry else { continue };
+ let Some(file_name) = entry.file_name() else {
+ continue;
+ };
+
+ if let Some(name) = file_name.to_str()
+ && let Some(version) = semver::Version::from_str(name).ok()
+ && fs
+ .is_file(&dir.join(file_name).join(&entrypoint_path))
+ .await
+ {
+ versions.push((version, file_name.to_owned()));
+ } else {
+ to_delete.push(file_name.to_owned())
+ }
+ }
+
+ versions.sort();
+ let newest_version = if let Some((version, file_name)) = versions.last().cloned()
+ && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
+ {
+ versions.pop();
+ Some(file_name)
+ } else {
+ None
+ };
+ log::debug!("existing version of {package_name}: {newest_version:?}");
+ to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
+
+ cx.background_spawn({
+ let fs = fs.clone();
+ let dir = dir.clone();
+ async move {
+ for file_name in to_delete {
+ fs.remove_dir(
+ &dir.join(file_name),
+ RemoveOptions {
+ recursive: true,
+ ignore_if_not_exists: false,
+ },
+ )
+ .await
+ .ok();
+ }
+ }
+ })
+ .detach();
+
+ let version = if let Some(file_name) = newest_version {
+ cx.background_spawn({
+ let file_name = file_name.clone();
+ let dir = dir.clone();
+ let fs = fs.clone();
+ async move {
+ let latest_version =
+ node_runtime.npm_package_latest_version(&package_name).await;
+ if let Ok(latest_version) = latest_version
+ && &latest_version != &file_name.to_string_lossy()
+ {
+ download_latest_version(
+ fs,
+ dir.clone(),
+ node_runtime,
+ package_name.clone(),
+ )
+ .await
+ .log_err();
+ if let Some(mut new_version_available) = new_version_available {
+ new_version_available.send(Some(latest_version)).ok();
+ }
+ }
+ }
+ })
+ .detach();
+ file_name
+ } else {
+ if let Some(mut status_tx) = status_tx {
+ status_tx.send("Installingβ¦".into()).ok();
+ }
+ let dir = dir.clone();
+ cx.background_spawn(download_latest_version(
+ fs.clone(),
+ dir.clone(),
+ node_runtime,
+ package_name.clone(),
+ ))
+ .await?
+ .into()
+ };
+
+ let agent_server_path = dir.join(version).join(entrypoint_path);
+ let agent_server_path_exists = fs.is_file(&agent_server_path).await;
+ anyhow::ensure!(
+ agent_server_path_exists,
+ "Missing entrypoint path {} after installation",
+ agent_server_path.to_string_lossy()
+ );
+
+ anyhow::Ok(AgentServerCommand {
+ path: node_path,
+ args: vec![agent_server_path.to_string_lossy().to_string()],
+ env: None,
+ })
+ })
+}
+
+fn find_bin_in_path(
+ bin_name: SharedString,
+ root_dir: PathBuf,
+ env: HashMap<String, String>,
+ cx: &mut AsyncApp,
+) -> Task<Option<PathBuf>> {
+ cx.background_executor().spawn(async move {
+ let which_result = if cfg!(windows) {
+ which::which(bin_name.as_str())
+ } else {
+ let shell_path = env.get("PATH").cloned();
+ which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
+ };
+
+ if let Err(which::Error::CannotFindBinaryPath) = which_result {
+ return None;
+ }
+
+ which_result.log_err()
+ })
+}
+
+async fn download_latest_version(
+ fs: Arc<dyn Fs>,
+ dir: PathBuf,
+ node_runtime: NodeRuntime,
+ package_name: SharedString,
+) -> Result<String> {
+ log::debug!("downloading latest version of {package_name}");
+
+ let tmp_dir = tempfile::tempdir_in(&dir)?;
+
+ node_runtime
+ .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
+ .await?;
+
+ let version = node_runtime
+ .npm_package_installed_version(tmp_dir.path(), &package_name)
+ .await?
+ .context("expected package to be installed")?;
+
+ fs.rename(
+ &tmp_dir.keep(),
+ &dir.join(&version),
+ RenameOptions {
+ ignore_if_exists: true,
+ overwrite: false,
+ },
+ )
+ .await?;
+
+ anyhow::Ok(version)
+}
+
+struct RemoteExternalAgentServer {
+ project_id: u64,
+ upstream_client: Entity<RemoteClient>,
+ name: ExternalAgentServerName,
+ status_tx: Option<watch::Sender<SharedString>>,
+ new_version_available_tx: Option<watch::Sender<Option<String>>>,
+}
+
+// new method: status_updated
+// does nothing in the all-local case
+// for RemoteExternalAgentServer, sends on the stored tx
+// etc.
+
+impl ExternalAgentServer for RemoteExternalAgentServer {
+ fn get_command(
+ &mut self,
+ root_dir: Option<&str>,
+ extra_env: HashMap<String, String>,
+ status_tx: Option<watch::Sender<SharedString>>,
+ new_version_available_tx: Option<watch::Sender<Option<String>>>,
+ cx: &mut AsyncApp,
+ ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
+ let project_id = self.project_id;
+ let name = self.name.to_string();
+ let upstream_client = self.upstream_client.downgrade();
+ let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
+ self.status_tx = status_tx;
+ self.new_version_available_tx = new_version_available_tx;
+ cx.spawn(async move |cx| {
+ let mut response = upstream_client
+ .update(cx, |upstream_client, _| {
+ upstream_client
+ .proto_client()
+ .request(proto::GetAgentServerCommand {
+ project_id,
+ name,
+ root_dir: root_dir.clone(),
+ })
+ })?
+ .await?;
+ let root_dir = response.root_dir;
+ response.env.extend(extra_env);
+ let command = upstream_client.update(cx, |client, _| {
+ client.build_command(
+ Some(response.path),
+ &response.args,
+ &response.env.into_iter().collect(),
+ Some(root_dir.clone()),
+ None,
+ )
+ })??;
+ Ok((
+ AgentServerCommand {
+ path: command.program.into(),
+ args: command.args,
+ env: Some(command.env),
+ },
+ root_dir,
+ response
+ .login
+ .map(|login| task::SpawnInTerminal::from_proto(login)),
+ ))
+ })
+ }
+
+ fn as_any_mut(&mut self) -> &mut dyn Any {
+ self
+ }
+}
+
+struct LocalGemini {
+ fs: Arc<dyn Fs>,
+ node_runtime: NodeRuntime,
+ project_environment: Entity<ProjectEnvironment>,
+ custom_command: Option<AgentServerCommand>,
+ ignore_system_version: bool,
+}
+
+impl ExternalAgentServer for LocalGemini {
+ fn get_command(
+ &mut self,
+ root_dir: Option<&str>,
+ extra_env: HashMap<String, String>,
+ status_tx: Option<watch::Sender<SharedString>>,
+ new_version_available_tx: Option<watch::Sender<Option<String>>>,
+ cx: &mut AsyncApp,
+ ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
+ let fs = self.fs.clone();
+ let node_runtime = self.node_runtime.clone();
+ let project_environment = self.project_environment.downgrade();
+ let custom_command = self.custom_command.clone();
+ let ignore_system_version = self.ignore_system_version;
+ let root_dir: Arc<Path> = root_dir
+ .map(|root_dir| Path::new(root_dir))
+ .unwrap_or(paths::home_dir())
+ .into();
+
+ cx.spawn(async move |cx| {
+ let mut env = project_environment
+ .update(cx, |project_environment, cx| {
+ project_environment.get_directory_environment(root_dir.clone(), cx)
+ })?
+ .await
+ .unwrap_or_default();
+
+ let mut command = if let Some(mut custom_command) = custom_command {
+ env.extend(custom_command.env.unwrap_or_default());
+ custom_command.env = Some(env);
+ custom_command
+ } else if !ignore_system_version
+ && let Some(bin) =
+ find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
+ {
+ AgentServerCommand {
+ path: bin,
+ args: Vec::new(),
+ env: Some(env),
+ }
+ } else {
+ let mut command = get_or_npm_install_builtin_agent(
+ GEMINI_NAME.into(),
+ "@google/gemini-cli".into(),
+ "node_modules/@google/gemini-cli/dist/index.js".into(),
+ Some("0.2.1".parse().unwrap()),
+ status_tx,
+ new_version_available_tx,
+ fs,
+ node_runtime,
+ cx,
+ )
+ .await?;
+ command.env = Some(env);
+ command
+ };
+
+ // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
+ let login = task::SpawnInTerminal {
+ command: Some(command.path.clone().to_proto()),
+ args: command.args.clone(),
+ env: command.env.clone().unwrap_or_default(),
+ label: "gemini /auth".into(),
+ ..Default::default()
+ };
+
+ command.env.get_or_insert_default().extend(extra_env);
+ command.args.push("--experimental-acp".into());
+ Ok((command, root_dir.to_proto(), Some(login)))
+ })
+ }
+
+ fn as_any_mut(&mut self) -> &mut dyn Any {
+ self
+ }
+}
+
+struct LocalClaudeCode {
+ fs: Arc<dyn Fs>,
+ node_runtime: NodeRuntime,
+ project_environment: Entity<ProjectEnvironment>,
+ custom_command: Option<AgentServerCommand>,
+}
+
+impl ExternalAgentServer for LocalClaudeCode {
+ fn get_command(
+ &mut self,
+ root_dir: Option<&str>,
+ extra_env: HashMap<String, String>,
+ status_tx: Option<watch::Sender<SharedString>>,
+ new_version_available_tx: Option<watch::Sender<Option<String>>>,
+ cx: &mut AsyncApp,
+ ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
+ let fs = self.fs.clone();
+ let node_runtime = self.node_runtime.clone();
+ let project_environment = self.project_environment.downgrade();
+ let custom_command = self.custom_command.clone();
+ let root_dir: Arc<Path> = root_dir
+ .map(|root_dir| Path::new(root_dir))
+ .unwrap_or(paths::home_dir())
+ .into();
+
+ cx.spawn(async move |cx| {
+ let mut env = project_environment
+ .update(cx, |project_environment, cx| {
+ project_environment.get_directory_environment(root_dir.clone(), cx)
+ })?
+ .await
+ .unwrap_or_default();
+ env.insert("ANTHROPIC_API_KEY".into(), "".into());
+
+ let (mut command, login) = if let Some(mut custom_command) = custom_command {
+ env.extend(custom_command.env.unwrap_or_default());
+ custom_command.env = Some(env);
+ (custom_command, None)
+ } else {
+ let mut command = get_or_npm_install_builtin_agent(
+ "claude-code-acp".into(),
+ "@zed-industries/claude-code-acp".into(),
+ "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
+ Some("0.2.5".parse().unwrap()),
+ status_tx,
+ new_version_available_tx,
+ fs,
+ node_runtime,
+ cx,
+ )
+ .await?;
+ command.env = Some(env);
+ let login = command
+ .args
+ .first()
+ .and_then(|path| {
+ path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
+ })
+ .map(|path_prefix| task::SpawnInTerminal {
+ command: Some(command.path.clone().to_proto()),
+ args: vec![
+ Path::new(path_prefix)
+ .join("@anthropic-ai/claude-code/cli.js")
+ .to_string_lossy()
+ .to_string(),
+ "/login".into(),
+ ],
+ env: command.env.clone().unwrap_or_default(),
+ label: "claude /login".into(),
+ ..Default::default()
+ });
+ (command, login)
+ };
+
+ command.env.get_or_insert_default().extend(extra_env);
+ Ok((command, root_dir.to_proto(), login))
+ })
+ }
+
+ fn as_any_mut(&mut self) -> &mut dyn Any {
+ self
+ }
+}
+
+struct LocalCustomAgent {
+ project_environment: Entity<ProjectEnvironment>,
+ command: AgentServerCommand,
+}
+
+impl ExternalAgentServer for LocalCustomAgent {
+ fn get_command(
+ &mut self,
+ root_dir: Option<&str>,
+ extra_env: HashMap<String, String>,
+ _status_tx: Option<watch::Sender<SharedString>>,
+ _new_version_available_tx: Option<watch::Sender<Option<String>>>,
+ cx: &mut AsyncApp,
+ ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
+ let mut command = self.command.clone();
+ let root_dir: Arc<Path> = root_dir
+ .map(|root_dir| Path::new(root_dir))
+ .unwrap_or(paths::home_dir())
+ .into();
+ let project_environment = self.project_environment.downgrade();
+ cx.spawn(async move |cx| {
+ let mut env = project_environment
+ .update(cx, |project_environment, cx| {
+ project_environment.get_directory_environment(root_dir.clone(), cx)
+ })?
+ .await
+ .unwrap_or_default();
+ env.extend(command.env.unwrap_or_default());
+ env.extend(extra_env);
+ command.env = Some(env);
+ Ok((command, root_dir.to_proto(), None))
+ })
+ }
+
+ fn as_any_mut(&mut self) -> &mut dyn Any {
+ self
+ }
+}
+
+pub const GEMINI_NAME: &'static str = "gemini";
+pub const CLAUDE_CODE_NAME: &'static str = "claude";
+
+#[derive(
+ Default, Deserialize, Serialize, Clone, JsonSchema, Debug, SettingsUi, SettingsKey, PartialEq,
+)]
+#[settings_key(key = "agent_servers")]
+pub struct AllAgentServersSettings {
+ pub gemini: Option<BuiltinAgentServerSettings>,
+ pub claude: Option<CustomAgentServerSettings>,
+
+ /// Custom agent servers configured by the user
+ #[serde(flatten)]
+ 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 CustomAgentServerSettings {
+ #[serde(flatten)]
+ pub command: AgentServerCommand,
+}
+
+impl settings::Settings for AllAgentServersSettings {
+ type FileContent = Self;
+
+ fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
+ let mut settings = AllAgentServersSettings::default();
+
+ for AllAgentServersSettings {
+ gemini,
+ claude,
+ custom,
+ } in sources.defaults_and_customizations()
+ {
+ if gemini.is_some() {
+ settings.gemini = gemini.clone();
+ }
+ if claude.is_some() {
+ settings.claude = claude.clone();
+ }
+
+ // Merge custom agents
+ for (name, config) in custom {
+ // Skip built-in agent names to avoid conflicts
+ if name != GEMINI_NAME && name != CLAUDE_CODE_NAME {
+ settings.custom.insert(name.clone(), config.clone());
+ }
+ }
+ }
+
+ Ok(settings)
+ }
+
+ fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
+}
@@ -1,3 +1,4 @@
+pub mod agent_server_store;
pub mod buffer_store;
mod color_extractor;
pub mod connection_manager;
@@ -34,7 +35,11 @@ mod yarn;
use dap::inline_value::{InlineValueLocation, VariableLookupKind, VariableScope};
-use crate::{git_store::GitStore, lsp_store::log_store::LogKind};
+use crate::{
+ agent_server_store::{AgentServerStore, AllAgentServersSettings},
+ git_store::GitStore,
+ lsp_store::log_store::LogKind,
+};
pub use git_store::{
ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate,
git_traversal::{ChildEntriesGitIter, GitEntry, GitEntryRef, GitTraversal},
@@ -179,6 +184,7 @@ pub struct Project {
buffer_ordered_messages_tx: mpsc::UnboundedSender<BufferOrderedMessage>,
languages: Arc<LanguageRegistry>,
dap_store: Entity<DapStore>,
+ agent_server_store: Entity<AgentServerStore>,
breakpoint_store: Entity<BreakpointStore>,
collab_client: Arc<client::Client>,
@@ -1019,6 +1025,7 @@ impl Project {
WorktreeSettings::register(cx);
ProjectSettings::register(cx);
DisableAiSettings::register(cx);
+ AllAgentServersSettings::register(cx);
}
pub fn init(client: &Arc<Client>, cx: &mut App) {
@@ -1174,6 +1181,10 @@ impl Project {
)
});
+ let agent_server_store = cx.new(|cx| {
+ AgentServerStore::local(node.clone(), fs.clone(), environment.clone(), cx)
+ });
+
cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach();
Self {
@@ -1200,6 +1211,7 @@ impl Project {
remote_client: None,
breakpoint_store,
dap_store,
+ agent_server_store,
buffers_needing_diff: Default::default(),
git_diff_debouncer: DebouncedDelay::new(),
@@ -1338,6 +1350,9 @@ impl Project {
)
});
+ let agent_server_store =
+ cx.new(|cx| AgentServerStore::remote(REMOTE_SERVER_PROJECT_ID, remote.clone(), cx));
+
cx.subscribe(&remote, Self::on_remote_client_event).detach();
let this = Self {
@@ -1353,6 +1368,7 @@ impl Project {
join_project_response_message_id: 0,
client_state: ProjectClientState::Local,
git_store,
+ agent_server_store,
client_subscriptions: Vec::new(),
_subscriptions: vec![
cx.on_release(Self::release),
@@ -1407,6 +1423,7 @@ impl Project {
remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.dap_store);
remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.settings_observer);
remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.git_store);
+ remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.agent_server_store);
remote_proto.add_entity_message_handler(Self::handle_create_buffer_for_peer);
remote_proto.add_entity_message_handler(Self::handle_update_worktree);
@@ -1422,6 +1439,7 @@ impl Project {
ToolchainStore::init(&remote_proto);
DapStore::init(&remote_proto, cx);
GitStore::init(&remote_proto);
+ AgentServerStore::init_remote(&remote_proto);
this
})
@@ -1564,6 +1582,8 @@ impl Project {
)
})?;
+ let agent_server_store = cx.new(|cx| AgentServerStore::collab(cx))?;
+
let project = cx.new(|cx| {
let replica_id = response.payload.replica_id as ReplicaId;
@@ -1624,6 +1644,7 @@ impl Project {
breakpoint_store,
dap_store: dap_store.clone(),
git_store: git_store.clone(),
+ agent_server_store,
buffers_needing_diff: Default::default(),
git_diff_debouncer: DebouncedDelay::new(),
terminals: Terminals {
@@ -5199,6 +5220,10 @@ impl Project {
&self.git_store
}
+ pub fn agent_server_store(&self) -> &Entity<AgentServerStore> {
+ &self.agent_server_store
+ }
+
#[cfg(test)]
fn git_scans_complete(&self, cx: &Context<Self>) -> Task<()> {
cx.spawn(async move |this, cx| {
@@ -2,6 +2,7 @@ syntax = "proto3";
package zed.messages;
import "buffer.proto";
+import "task.proto";
message Context {
repeated ContextOperation operations = 1;
@@ -164,3 +165,35 @@ enum LanguageModelRole {
LanguageModelSystem = 2;
reserved 3;
}
+
+message GetAgentServerCommand {
+ uint64 project_id = 1;
+ string name = 2;
+ optional string root_dir = 3;
+}
+
+message AgentServerCommand {
+ string path = 1;
+ repeated string args = 2;
+ map<string, string> env = 3;
+ string root_dir = 4;
+
+ optional SpawnInTerminal login = 5;
+}
+
+message ExternalAgentsUpdated {
+ uint64 project_id = 1;
+ repeated string names = 2;
+}
+
+message ExternalAgentLoadingStatusUpdated {
+ uint64 project_id = 1;
+ string name = 2;
+ string status = 3;
+}
+
+message NewExternalAgentVersionAvailable {
+ uint64 project_id = 1;
+ string name = 2;
+ string version = 3;
+}
@@ -3,6 +3,7 @@ package zed.messages;
import "core.proto";
import "buffer.proto";
+import "task.proto";
enum BreakpointState {
Enabled = 0;
@@ -533,14 +534,6 @@ message DebugScenario {
optional string configuration = 7;
}
-message SpawnInTerminal {
- string label = 1;
- optional string command = 2;
- repeated string args = 3;
- map<string, string> env = 4;
- optional string cwd = 5;
-}
-
message LogToDebugConsole {
uint64 project_id = 1;
uint64 session_id = 2;
@@ -40,3 +40,11 @@ enum HideStrategy {
HideNever = 1;
HideOnSuccess = 2;
}
+
+message SpawnInTerminal {
+ string label = 1;
+ optional string command = 2;
+ repeated string args = 3;
+ map<string, string> env = 4;
+ optional string cwd = 5;
+}
@@ -405,7 +405,15 @@ message Envelope {
GetProcessesResponse get_processes_response = 370;
ResolveToolchain resolve_toolchain = 371;
- ResolveToolchainResponse resolve_toolchain_response = 372; // current max
+ ResolveToolchainResponse resolve_toolchain_response = 372;
+
+ GetAgentServerCommand get_agent_server_command = 373;
+ AgentServerCommand agent_server_command = 374;
+
+ ExternalAgentsUpdated external_agents_updated = 375;
+
+ ExternalAgentLoadingStatusUpdated external_agent_loading_status_updated = 376;
+ NewExternalAgentVersionAvailable new_external_agent_version_available = 377; // current max
}
reserved 87 to 88;
@@ -319,6 +319,11 @@ messages!(
(GitClone, Background),
(GitCloneResponse, Background),
(ToggleLspLogs, Background),
+ (GetAgentServerCommand, Background),
+ (AgentServerCommand, Background),
+ (ExternalAgentsUpdated, Background),
+ (ExternalAgentLoadingStatusUpdated, Background),
+ (NewExternalAgentVersionAvailable, Background),
);
request_messages!(
@@ -491,6 +496,7 @@ request_messages!(
(GitClone, GitCloneResponse),
(ToggleLspLogs, Ack),
(GetProcesses, GetProcessesResponse),
+ (GetAgentServerCommand, AgentServerCommand)
);
lsp_messages!(
@@ -644,7 +650,11 @@ entity_messages!(
GetDocumentDiagnostics,
PullWorkspaceDiagnostics,
GetDefaultBranch,
- GitClone
+ GitClone,
+ GetAgentServerCommand,
+ ExternalAgentsUpdated,
+ ExternalAgentLoadingStatusUpdated,
+ NewExternalAgentVersionAvailable,
);
entity_messages!(
@@ -12,6 +12,7 @@ use node_runtime::NodeRuntime;
use project::{
LspStore, LspStoreEvent, ManifestTree, PrettierStore, ProjectEnvironment, ProjectPath,
ToolchainStore, WorktreeId,
+ agent_server_store::AgentServerStore,
buffer_store::{BufferStore, BufferStoreEvent},
debugger::{breakpoint_store::BreakpointStore, dap_store::DapStore},
git_store::GitStore,
@@ -44,6 +45,7 @@ pub struct HeadlessProject {
pub lsp_store: Entity<LspStore>,
pub task_store: Entity<TaskStore>,
pub dap_store: Entity<DapStore>,
+ pub agent_server_store: Entity<AgentServerStore>,
pub settings_observer: Entity<SettingsObserver>,
pub next_entry_id: Arc<AtomicUsize>,
pub languages: Arc<LanguageRegistry>,
@@ -182,7 +184,7 @@ impl HeadlessProject {
.as_local_store()
.expect("Toolchain store to be local")
.clone(),
- environment,
+ environment.clone(),
manifest_tree,
languages.clone(),
http_client.clone(),
@@ -193,6 +195,13 @@ impl HeadlessProject {
lsp_store
});
+ let agent_server_store = cx.new(|cx| {
+ let mut agent_server_store =
+ AgentServerStore::local(node_runtime.clone(), fs.clone(), environment, cx);
+ agent_server_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone());
+ agent_server_store
+ });
+
cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach();
language_extension::init(
language_extension::LspAccess::ViaLspStore(lsp_store.clone()),
@@ -226,6 +235,7 @@ impl HeadlessProject {
session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &dap_store);
session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &settings_observer);
session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &git_store);
+ session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &agent_server_store);
session.add_request_handler(cx.weak_entity(), Self::handle_list_remote_directory);
session.add_request_handler(cx.weak_entity(), Self::handle_get_path_metadata);
@@ -264,6 +274,7 @@ impl HeadlessProject {
// todo(debugger): Re init breakpoint store when we set it up for collab
// BreakpointStore::init(&client);
GitStore::init(&session);
+ AgentServerStore::init_headless(&session);
HeadlessProject {
next_entry_id: Default::default(),
@@ -275,6 +286,7 @@ impl HeadlessProject {
lsp_store,
task_store,
dap_store,
+ agent_server_store,
languages,
extensions,
git_store,
@@ -24,7 +24,6 @@ acp_tools.workspace = true
agent.workspace = true
agent_ui.workspace = true
agent_settings.workspace = true
-agent_servers.workspace = true
anyhow.workspace = true
askpass.workspace = true
assets.workspace = true
@@ -567,7 +567,6 @@ pub fn main() {
language_model::init(app_state.client.clone(), cx);
language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx);
agent_settings::init(cx);
- agent_servers::init(cx);
acp_tools::init(cx);
web_search::init(cx);
web_search_providers::init(app_state.client.clone(), cx);