diff --git a/crates/agent/src/native_agent_server.rs b/crates/agent/src/native_agent_server.rs index cdd017f76a1840edc2742588131f5ba57d968d85..18c41670ac4b4ba3146fb207992a7020a44fbd5f 100644 --- a/crates/agent/src/native_agent_server.rs +++ b/crates/agent/src/native_agent_server.rs @@ -37,12 +37,7 @@ impl AgentServer for NativeAgentServer { &self, delegate: AgentServerDelegate, cx: &mut App, - ) -> Task< - Result<( - Rc, - Option, - )>, - > { + ) -> Task>> { log::debug!("NativeAgentServer::connect"); let project = delegate.project().clone(); let fs = self.fs.clone(); @@ -62,10 +57,7 @@ impl AgentServer for NativeAgentServer { let connection = NativeAgentConnection(agent); log::debug!("NativeAgentServer connection established successfully"); - Ok(( - Rc::new(connection) as Rc, - None, - )) + Ok(Rc::new(connection) as Rc) }) } diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 14b7616bd0405ab3eaa3b52e6c4c64f3691dc56a..c63e4fab2201671fa6448e9d58f6c925c2c91cd8 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -10,7 +10,7 @@ use collections::HashMap; use futures::AsyncBufReadExt as _; use futures::io::BufReader; use project::Project; -use project::agent_server_store::AgentServerCommand; +use project::agent_server_store::{AgentServerCommand, GEMINI_NAME}; use serde::Deserialize; use settings::Settings as _; use task::ShellBuilder; @@ -319,8 +319,27 @@ impl AcpConnection { None }; + // TODO: Remove this override once Google team releases their official auth methods + let auth_methods = if server_name == GEMINI_NAME { + let mut args = command.args.clone(); + args.retain(|a| a != "--experimental-acp"); + let value = serde_json::json!({ + "label": "gemini /auth", + "command": command.path.to_string_lossy().into_owned(), + "args": args, + "env": command.env.clone().unwrap_or_default(), + }); + let meta = acp::Meta::from_iter([("terminal-auth".to_string(), value)]); + vec![ + acp::AuthMethod::new("spawn-gemini-cli", "Login") + .description("Login with your Google or Vertex AI account") + .meta(meta), + ] + } else { + response.auth_methods + }; Ok(Self { - auth_methods: response.auth_methods, + auth_methods, connection, server_name, display_name, diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index 15ab23adcfbea5a61838e15a4002db9fc8de06d6..a07226ca25095fdb7037114d32d5033364a4999f 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -1,19 +1,13 @@ mod acp; -mod claude; -mod codex; mod custom; -mod gemini; #[cfg(any(test, feature = "test-support"))] pub mod e2e_tests; -pub use claude::*; use client::ProxySettings; -pub use codex::*; use collections::{HashMap, HashSet}; pub use custom::*; use fs::Fs; -pub use gemini::*; use http_client::read_no_proxy_from_env; use project::agent_server_store::AgentServerStore; @@ -60,7 +54,7 @@ pub trait AgentServer: Send { &self, delegate: AgentServerDelegate, cx: &mut App, - ) -> Task, Option)>>; + ) -> Task>>; fn into_any(self: Rc) -> Rc; diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs deleted file mode 100644 index 6b491d6808c8ebfc36af2495a5581f502ba3c961..0000000000000000000000000000000000000000 --- a/crates/agent_servers/src/claude.rs +++ /dev/null @@ -1,258 +0,0 @@ -use agent_client_protocol as acp; -use collections::HashSet; -use fs::Fs; -use settings::{SettingsStore, update_settings_file}; -use std::rc::Rc; -use std::sync::Arc; -use std::{any::Any, path::PathBuf}; - -use anyhow::{Context as _, Result}; -use gpui::{App, AppContext as _, SharedString, Task}; -use project::agent_server_store::{AllAgentServersSettings, CLAUDE_AGENT_NAME}; - -use crate::{AgentServer, AgentServerDelegate, load_proxy_env}; -use acp_thread::AgentConnection; - -#[derive(Clone)] -pub struct ClaudeCode; - -pub struct AgentServerLoginCommand { - pub path: PathBuf, - pub arguments: Vec, -} - -impl AgentServer for ClaudeCode { - fn name(&self) -> SharedString { - "Claude Agent".into() - } - - fn logo(&self) -> ui::IconName { - ui::IconName::AiClaude - } - - fn default_mode(&self, cx: &App) -> Option { - let settings = cx.read_global(|settings: &SettingsStore, _| { - settings.get::(None).claude.clone() - }); - - settings - .as_ref() - .and_then(|s| s.default_mode.clone().map(acp::SessionModeId::new)) - } - - fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { - update_settings_file(fs, cx, |settings, _| { - settings - .agent_servers - .get_or_insert_default() - .claude - .get_or_insert_default() - .default_mode = mode_id.map(|m| m.to_string()) - }); - } - - fn default_model(&self, cx: &App) -> Option { - let settings = cx.read_global(|settings: &SettingsStore, _| { - settings.get::(None).claude.clone() - }); - - settings - .as_ref() - .and_then(|s| s.default_model.clone().map(acp::ModelId::new)) - } - - fn set_default_model(&self, model_id: Option, fs: Arc, cx: &mut App) { - update_settings_file(fs, cx, |settings, _| { - settings - .agent_servers - .get_or_insert_default() - .claude - .get_or_insert_default() - .default_model = model_id.map(|m| m.to_string()) - }); - } - - fn favorite_model_ids(&self, cx: &mut App) -> HashSet { - let settings = cx.read_global(|settings: &SettingsStore, _| { - settings.get::(None).claude.clone() - }); - - settings - .as_ref() - .map(|s| { - s.favorite_models - .iter() - .map(|id| acp::ModelId::new(id.clone())) - .collect() - }) - .unwrap_or_default() - } - - fn toggle_favorite_model( - &self, - model_id: acp::ModelId, - should_be_favorite: bool, - fs: Arc, - cx: &App, - ) { - update_settings_file(fs, cx, move |settings, _| { - let favorite_models = &mut settings - .agent_servers - .get_or_insert_default() - .claude - .get_or_insert_default() - .favorite_models; - - let model_id_str = model_id.to_string(); - if should_be_favorite { - if !favorite_models.contains(&model_id_str) { - favorite_models.push(model_id_str); - } - } else { - favorite_models.retain(|id| id != &model_id_str); - } - }); - } - - fn default_config_option(&self, config_id: &str, cx: &App) -> Option { - let settings = cx.read_global(|settings: &SettingsStore, _| { - settings.get::(None).claude.clone() - }); - - settings - .as_ref() - .and_then(|s| s.default_config_options.get(config_id).cloned()) - } - - fn set_default_config_option( - &self, - config_id: &str, - value_id: Option<&str>, - fs: Arc, - cx: &mut App, - ) { - let config_id = config_id.to_string(); - let value_id = value_id.map(|s| s.to_string()); - update_settings_file(fs, cx, move |settings, _| { - let config_options = &mut settings - .agent_servers - .get_or_insert_default() - .claude - .get_or_insert_default() - .default_config_options; - - if let Some(value) = value_id.clone() { - config_options.insert(config_id.clone(), value); - } else { - config_options.remove(&config_id); - } - }); - } - - fn favorite_config_option_value_ids( - &self, - config_id: &acp::SessionConfigId, - cx: &mut App, - ) -> HashSet { - let settings = cx.read_global(|settings: &SettingsStore, _| { - settings.get::(None).claude.clone() - }); - - settings - .as_ref() - .and_then(|s| s.favorite_config_option_values.get(config_id.0.as_ref())) - .map(|values| { - values - .iter() - .cloned() - .map(acp::SessionConfigValueId::new) - .collect() - }) - .unwrap_or_default() - } - - fn toggle_favorite_config_option_value( - &self, - config_id: acp::SessionConfigId, - value_id: acp::SessionConfigValueId, - should_be_favorite: bool, - fs: Arc, - cx: &App, - ) { - let config_id = config_id.to_string(); - let value_id = value_id.to_string(); - - update_settings_file(fs, cx, move |settings, _| { - let favorites = &mut settings - .agent_servers - .get_or_insert_default() - .claude - .get_or_insert_default() - .favorite_config_option_values; - - let entry = favorites.entry(config_id.clone()).or_insert_with(Vec::new); - - if should_be_favorite { - if !entry.iter().any(|v| v == &value_id) { - entry.push(value_id.clone()); - } - } else { - entry.retain(|v| v != &value_id); - if entry.is_empty() { - favorites.remove(&config_id); - } - } - }); - } - - fn connect( - &self, - delegate: AgentServerDelegate, - cx: &mut App, - ) -> Task, Option)>> { - let name = self.name(); - let store = delegate.store.downgrade(); - let extra_env = load_proxy_env(cx); - let default_mode = self.default_mode(cx); - let default_model = self.default_model(cx); - let default_config_options = cx.read_global(|settings: &SettingsStore, _| { - settings - .get::(None) - .claude - .as_ref() - .map(|s| s.default_config_options.clone()) - .unwrap_or_default() - }); - - cx.spawn(async move |cx| { - let (command, login) = store - .update(cx, |store, cx| { - let agent = store - .get_external_agent(&CLAUDE_AGENT_NAME.into()) - .context("Claude Agent is not registered")?; - anyhow::Ok(agent.get_command( - extra_env, - delegate.status_tx, - delegate.new_version_available, - &mut cx.to_async(), - )) - })?? - .await?; - let connection = crate::acp::connect( - name.clone(), - name, - command, - default_mode, - default_model, - default_config_options, - cx, - ) - .await?; - Ok((connection, login)) - }) - } - - fn into_any(self: Rc) -> Rc { - self - } -} diff --git a/crates/agent_servers/src/codex.rs b/crates/agent_servers/src/codex.rs deleted file mode 100644 index 587e207a82bbb0401c7c2bfa4a8199a21afd2da4..0000000000000000000000000000000000000000 --- a/crates/agent_servers/src/codex.rs +++ /dev/null @@ -1,270 +0,0 @@ -use std::any::Any; -use std::rc::Rc; -use std::sync::Arc; - -use acp_thread::AgentConnection; -use agent_client_protocol as acp; -use anyhow::{Context as _, Result}; -use collections::HashSet; -use fs::Fs; -use gpui::{App, AppContext as _, SharedString, Task}; -use project::agent_server_store::{AllAgentServersSettings, CODEX_NAME}; -use settings::{SettingsStore, update_settings_file}; - -use crate::{AgentServer, AgentServerDelegate, load_proxy_env}; - -#[derive(Clone)] -pub struct Codex; - -const CODEX_API_KEY_VAR_NAME: &str = "CODEX_API_KEY"; -const OPEN_AI_API_KEY_VAR_NAME: &str = "OPEN_AI_API_KEY"; - -impl AgentServer for Codex { - fn name(&self) -> SharedString { - "Codex".into() - } - - fn logo(&self) -> ui::IconName { - ui::IconName::AiOpenAi - } - - fn default_mode(&self, cx: &App) -> Option { - let settings = cx.read_global(|settings: &SettingsStore, _| { - settings.get::(None).codex.clone() - }); - - settings - .as_ref() - .and_then(|s| s.default_mode.clone().map(acp::SessionModeId::new)) - } - - fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { - update_settings_file(fs, cx, |settings, _| { - settings - .agent_servers - .get_or_insert_default() - .codex - .get_or_insert_default() - .default_mode = mode_id.map(|m| m.to_string()) - }); - } - - fn default_model(&self, cx: &App) -> Option { - let settings = cx.read_global(|settings: &SettingsStore, _| { - settings.get::(None).codex.clone() - }); - - settings - .as_ref() - .and_then(|s| s.default_model.clone().map(acp::ModelId::new)) - } - - fn set_default_model(&self, model_id: Option, fs: Arc, cx: &mut App) { - update_settings_file(fs, cx, |settings, _| { - settings - .agent_servers - .get_or_insert_default() - .codex - .get_or_insert_default() - .default_model = model_id.map(|m| m.to_string()) - }); - } - - fn favorite_model_ids(&self, cx: &mut App) -> HashSet { - let settings = cx.read_global(|settings: &SettingsStore, _| { - settings.get::(None).codex.clone() - }); - - settings - .as_ref() - .map(|s| { - s.favorite_models - .iter() - .map(|id| acp::ModelId::new(id.clone())) - .collect() - }) - .unwrap_or_default() - } - - fn toggle_favorite_model( - &self, - model_id: acp::ModelId, - should_be_favorite: bool, - fs: Arc, - cx: &App, - ) { - update_settings_file(fs, cx, move |settings, _| { - let favorite_models = &mut settings - .agent_servers - .get_or_insert_default() - .codex - .get_or_insert_default() - .favorite_models; - - let model_id_str = model_id.to_string(); - if should_be_favorite { - if !favorite_models.contains(&model_id_str) { - favorite_models.push(model_id_str); - } - } else { - favorite_models.retain(|id| id != &model_id_str); - } - }); - } - - fn default_config_option(&self, config_id: &str, cx: &App) -> Option { - let settings = cx.read_global(|settings: &SettingsStore, _| { - settings.get::(None).codex.clone() - }); - - settings - .as_ref() - .and_then(|s| s.default_config_options.get(config_id).cloned()) - } - - fn set_default_config_option( - &self, - config_id: &str, - value_id: Option<&str>, - fs: Arc, - cx: &mut App, - ) { - let config_id = config_id.to_string(); - let value_id = value_id.map(|s| s.to_string()); - update_settings_file(fs, cx, move |settings, _| { - let config_options = &mut settings - .agent_servers - .get_or_insert_default() - .codex - .get_or_insert_default() - .default_config_options; - - if let Some(value) = value_id.clone() { - config_options.insert(config_id.clone(), value); - } else { - config_options.remove(&config_id); - } - }); - } - - fn favorite_config_option_value_ids( - &self, - config_id: &acp::SessionConfigId, - cx: &mut App, - ) -> HashSet { - let settings = cx.read_global(|settings: &SettingsStore, _| { - settings.get::(None).codex.clone() - }); - - settings - .as_ref() - .and_then(|s| s.favorite_config_option_values.get(config_id.0.as_ref())) - .map(|values| { - values - .iter() - .cloned() - .map(acp::SessionConfigValueId::new) - .collect() - }) - .unwrap_or_default() - } - - fn toggle_favorite_config_option_value( - &self, - config_id: acp::SessionConfigId, - value_id: acp::SessionConfigValueId, - should_be_favorite: bool, - fs: Arc, - cx: &App, - ) { - let config_id = config_id.to_string(); - let value_id = value_id.to_string(); - - update_settings_file(fs, cx, move |settings, _| { - let favorites = &mut settings - .agent_servers - .get_or_insert_default() - .codex - .get_or_insert_default() - .favorite_config_option_values; - - let entry = favorites.entry(config_id.clone()).or_insert_with(Vec::new); - - if should_be_favorite { - if !entry.iter().any(|v| v == &value_id) { - entry.push(value_id.clone()); - } - } else { - entry.retain(|v| v != &value_id); - if entry.is_empty() { - favorites.remove(&config_id); - } - } - }); - } - - fn connect( - &self, - delegate: AgentServerDelegate, - cx: &mut App, - ) -> Task, Option)>> { - let name = self.name(); - let store = delegate.store.downgrade(); - let mut extra_env = load_proxy_env(cx); - let default_mode = self.default_mode(cx); - let default_model = self.default_model(cx); - let default_config_options = cx.read_global(|settings: &SettingsStore, _| { - settings - .get::(None) - .codex - .as_ref() - .map(|s| s.default_config_options.clone()) - .unwrap_or_default() - }); - if let Ok(api_key) = std::env::var(CODEX_API_KEY_VAR_NAME) { - extra_env.insert(CODEX_API_KEY_VAR_NAME.into(), api_key); - } - if let Ok(api_key) = std::env::var(OPEN_AI_API_KEY_VAR_NAME) { - extra_env.insert(OPEN_AI_API_KEY_VAR_NAME.into(), api_key); - } - - cx.spawn(async move |cx| { - let (command, login) = store - .update(cx, |store, cx| { - let agent = store - .get_external_agent(&CODEX_NAME.into()) - .context("Codex is not registered")?; - anyhow::Ok(agent.get_command( - extra_env, - delegate.status_tx, - delegate.new_version_available, - &mut cx.to_async(), - )) - })?? - .await?; - - let connection = crate::acp::connect( - name.clone(), - name, - command, - default_mode, - default_model, - default_config_options, - cx, - ) - .await?; - Ok((connection, login)) - }) - } - - fn into_any(self: Rc) -> Rc { - self - } -} - -#[cfg(test)] -pub(crate) mod tests { - use super::*; - - crate::common_e2e_tests!(async |_, _| Codex, allow_option_id = "proceed_once"); -} diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index 0dd4df7e7b33493f9f5efff041d3076a91eb21e6..b0669d1fb69e110f0ba206a3579f16738de5e7e2 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -3,9 +3,13 @@ use acp_thread::AgentConnection; use agent_client_protocol as acp; use anyhow::{Context as _, Result}; use collections::HashSet; +use credentials_provider::CredentialsProvider; use fs::Fs; use gpui::{App, AppContext as _, SharedString, Task}; -use project::agent_server_store::{AllAgentServersSettings, ExternalAgentServerName}; +use language_model::{ApiKey, EnvVar}; +use project::agent_server_store::{ + AllAgentServersSettings, CLAUDE_AGENT_NAME, CODEX_NAME, ExternalAgentServerName, GEMINI_NAME, +}; use settings::{SettingsStore, update_settings_file}; use std::{rc::Rc, sync::Arc}; use ui::IconName; @@ -34,7 +38,6 @@ impl AgentServer for CustomAgentServer { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .custom .get(self.name().as_ref()) .cloned() }); @@ -52,7 +55,6 @@ impl AgentServer for CustomAgentServer { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .custom .get(self.name().as_ref()) .cloned() }); @@ -86,7 +88,6 @@ impl AgentServer for CustomAgentServer { let settings = settings .agent_servers .get_or_insert_default() - .custom .entry(name.to_string()) .or_insert_with(|| settings::CustomAgentServerSettings::Extension { default_model: None, @@ -135,7 +136,6 @@ impl AgentServer for CustomAgentServer { let settings = settings .agent_servers .get_or_insert_default() - .custom .entry(name.to_string()) .or_insert_with(|| settings::CustomAgentServerSettings::Extension { default_model: None, @@ -160,7 +160,6 @@ impl AgentServer for CustomAgentServer { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .custom .get(self.name().as_ref()) .cloned() }); @@ -176,7 +175,6 @@ impl AgentServer for CustomAgentServer { let settings = settings .agent_servers .get_or_insert_default() - .custom .entry(name.to_string()) .or_insert_with(|| settings::CustomAgentServerSettings::Extension { default_model: None, @@ -201,7 +199,6 @@ impl AgentServer for CustomAgentServer { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .custom .get(self.name().as_ref()) .cloned() }); @@ -229,7 +226,6 @@ impl AgentServer for CustomAgentServer { let settings = settings .agent_servers .get_or_insert_default() - .custom .entry(name.to_string()) .or_insert_with(|| settings::CustomAgentServerSettings::Extension { default_model: None, @@ -267,7 +263,6 @@ impl AgentServer for CustomAgentServer { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .custom .get(self.name().as_ref()) .cloned() }); @@ -291,7 +286,6 @@ impl AgentServer for CustomAgentServer { let settings = settings .agent_servers .get_or_insert_default() - .custom .entry(name.to_string()) .or_insert_with(|| settings::CustomAgentServerSettings::Extension { default_model: None, @@ -329,7 +323,7 @@ impl AgentServer for CustomAgentServer { &self, delegate: AgentServerDelegate, cx: &mut App, - ) -> Task, Option)>> { + ) -> Task>> { let name = self.name(); let display_name = delegate .store @@ -338,11 +332,12 @@ impl AgentServer for CustomAgentServer { .unwrap_or_else(|| name.clone()); let default_mode = self.default_mode(cx); let default_model = self.default_model(cx); + let is_previous_built_in = + matches!(name.as_ref(), CLAUDE_AGENT_NAME | CODEX_NAME | GEMINI_NAME); let (default_config_options, is_registry_agent) = cx.read_global(|settings: &SettingsStore, _| { let agent_settings = settings .get::(None) - .custom .get(self.name().as_ref()); let is_registry = agent_settings @@ -374,16 +369,46 @@ impl AgentServer for CustomAgentServer { (config_options, is_registry) }); + // Intermediate step to allow for previous built-ins to also be triggered if they aren't in settings yet. + let is_registry_agent = is_registry_agent || is_previous_built_in; + if is_registry_agent { if let Some(registry_store) = project::AgentRegistryStore::try_global(cx) { registry_store.update(cx, |store, cx| store.refresh_if_stale(cx)); } } + let mut extra_env = load_proxy_env(cx); + if delegate.store.read(cx).no_browser() { + extra_env.insert("NO_BROWSER".to_owned(), "1".to_owned()); + } + if is_registry_agent { + match name.as_ref() { + CLAUDE_AGENT_NAME => { + extra_env.insert("ANTHROPIC_API_KEY".into(), "".into()); + } + CODEX_NAME => { + if let Ok(api_key) = std::env::var("CODEX_API_KEY") { + extra_env.insert("CODEX_API_KEY".into(), api_key); + } + if let Ok(api_key) = std::env::var("OPEN_AI_API_KEY") { + extra_env.insert("OPEN_AI_API_KEY".into(), api_key); + } + } + GEMINI_NAME => { + extra_env.insert("SURFACE".to_owned(), "zed".to_owned()); + } + _ => {} + } + } let store = delegate.store.downgrade(); - let extra_env = load_proxy_env(cx); cx.spawn(async move |cx| { - let (command, login) = store + if is_registry_agent && name.as_ref() == GEMINI_NAME { + if let Some(api_key) = cx.update(api_key_for_gemini_cli).await.ok() { + extra_env.insert("GEMINI_API_KEY".into(), api_key); + } + } + let command = store .update(cx, |store, cx| { let agent = store .get_external_agent(&ExternalAgentServerName(name.clone())) @@ -408,7 +433,7 @@ impl AgentServer for CustomAgentServer { cx, ) .await?; - Ok((connection, login)) + Ok(connection) }) } @@ -416,3 +441,20 @@ impl AgentServer for CustomAgentServer { self } } + +fn api_key_for_gemini_cli(cx: &mut App) -> Task> { + let env_var = EnvVar::new("GEMINI_API_KEY".into()).or(EnvVar::new("GOOGLE_AI_API_KEY".into())); + if let Some(key) = env_var.value { + return Task::ready(Ok(key)); + } + let credentials_provider = ::global(cx); + let api_url = google_ai::API_URL.to_string(); + cx.spawn(async move |cx| { + Ok( + ApiKey::load_from_system_keychain(&api_url, credentials_provider.as_ref(), cx) + .await? + .key() + .to_string(), + ) + }) +} diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index bc91ff958c93b6f1d43cd5d84323b25638b26d36..c5754bcd7610dbf0c858058ea726a746bef37ab1 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -4,8 +4,6 @@ use agent_client_protocol as acp; use futures::{FutureExt, StreamExt, channel::mpsc, select}; use gpui::{Entity, TestAppContext}; use indoc::indoc; -#[cfg(test)] -use project::agent_server_store::BuiltinAgentServerSettings; use project::{FakeFs, Project}; #[cfg(test)] use settings::Settings; @@ -414,18 +412,7 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc { #[cfg(test)] project::agent_server_store::AllAgentServersSettings::override_global( - project::agent_server_store::AllAgentServersSettings { - claude: Some(BuiltinAgentServerSettings { - path: Some("claude-agent-acp".into()), - ..Default::default() - }), - gemini: Some(crate::gemini::tests::local_command().into()), - codex: Some(BuiltinAgentServerSettings { - path: Some("codex-acp".into()), - ..Default::default() - }), - custom: collections::HashMap::default(), - }, + project::agent_server_store::AllAgentServersSettings(collections::HashMap::default()), cx, ); }); @@ -444,7 +431,7 @@ pub async fn new_test_thread( 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(delegate, cx)).await.unwrap(); + let connection = cx.update(|cx| server.connect(delegate, cx)).await.unwrap(); cx.update(|cx| connection.new_session(project.clone(), current_dir.as_ref(), cx)) .await diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs deleted file mode 100644 index 6ec57500f8edd24c72c666282b8f360dd5069605..0000000000000000000000000000000000000000 --- a/crates/agent_servers/src/gemini.rs +++ /dev/null @@ -1,125 +0,0 @@ -use std::any::Any; -use std::rc::Rc; - -use crate::{AgentServer, AgentServerDelegate, load_proxy_env}; -use acp_thread::AgentConnection; -use anyhow::{Context as _, Result}; -use credentials_provider::CredentialsProvider; -use gpui::{App, AppContext as _, SharedString, Task}; -use language_model::{ApiKey, EnvVar}; -use project::agent_server_store::{AllAgentServersSettings, GEMINI_NAME}; -use settings::SettingsStore; - -const GEMINI_API_KEY_VAR_NAME: &str = "GEMINI_API_KEY"; -const GOOGLE_AI_API_KEY_VAR_NAME: &str = "GOOGLE_AI_API_KEY"; - -fn api_key_for_gemini_cli(cx: &mut App) -> Task> { - let env_var = EnvVar::new(GEMINI_API_KEY_VAR_NAME.into()) - .or(EnvVar::new(GOOGLE_AI_API_KEY_VAR_NAME.into())); - if let Some(key) = env_var.value { - return Task::ready(Ok(key)); - } - let credentials_provider = ::global(cx); - let api_url = google_ai::API_URL.to_string(); - cx.spawn(async move |cx| { - Ok( - ApiKey::load_from_system_keychain(&api_url, credentials_provider.as_ref(), cx) - .await? - .key() - .to_string(), - ) - }) -} - -#[derive(Clone)] -pub struct Gemini; - -impl AgentServer for Gemini { - fn name(&self) -> SharedString { - "Gemini CLI".into() - } - - fn logo(&self) -> ui::IconName { - ui::IconName::AiGemini - } - - fn connect( - &self, - delegate: AgentServerDelegate, - cx: &mut App, - ) -> Task, Option)>> { - let name = self.name(); - let store = delegate.store.downgrade(); - let mut extra_env = load_proxy_env(cx); - let default_mode = self.default_mode(cx); - let default_model = self.default_model(cx); - let default_config_options = cx.read_global(|settings: &SettingsStore, _| { - settings - .get::(None) - .gemini - .as_ref() - .map(|s| s.default_config_options.clone()) - .unwrap_or_default() - }); - - cx.spawn(async move |cx| { - extra_env.insert("SURFACE".to_owned(), "zed".to_owned()); - - if let Some(api_key) = cx.update(api_key_for_gemini_cli).await.ok() { - extra_env.insert("GEMINI_API_KEY".into(), api_key); - } - let (command, 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( - extra_env, - delegate.status_tx, - delegate.new_version_available, - &mut cx.to_async(), - )) - })?? - .await?; - - let connection = crate::acp::connect( - name.clone(), - name, - command, - default_mode, - default_model, - default_config_options, - cx, - ) - .await?; - Ok((connection, login)) - }) - } - - fn into_any(self: Rc) -> Rc { - self - } -} - -#[cfg(test)] -pub(crate) mod tests { - use project::agent_server_store::AgentServerCommand; - - use super::*; - use std::path::Path; - - crate::common_e2e_tests!(async |_, _| Gemini, allow_option_id = "proceed_once"); - - pub fn local_command() -> AgentServerCommand { - let cli_path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../../gemini-cli/packages/cli") - .to_string_lossy() - .to_string(); - - AgentServerCommand { - path: "node".into(), - args: vec![cli_path], - env: None, - } - } -} diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index f6f734158fdc5a3a2827d91d53513410d527b24c..f4291ba5ce168aff4466bb9d5f5bc29194b302a8 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -107,8 +107,8 @@ pub(crate) enum ThreadError { }, } -impl ThreadError { - fn from_err(error: anyhow::Error, agent_name: &str) -> Self { +impl From for ThreadError { + fn from(error: anyhow::Error) -> Self { if error.is::() { Self::PaymentRequired } else if let Some(acp_error) = error.downcast_ref::() @@ -123,18 +123,9 @@ impl ThreadError { .downcast_ref::() .map(|acp_error| SharedString::from(acp_error.code.to_string())); - // TODO: we should have Gemini return better errors here. - if agent_name == "Gemini CLI" - && message.contains("Could not load the default credentials") - || message.contains("API key not valid") - || message.contains("Request had invalid authentication credentials") - { - Self::AuthenticationRequired(message) - } else { - Self::Other { - message, - acp_error_code, - } + Self::Other { + message, + acp_error_code, } } } @@ -286,7 +277,6 @@ pub struct AcpServerView { thread_store: Option>, prompt_store: Option>, server_state: ServerState, - login: Option, // is some <=> Active | Unauthenticated history: Entity, focus_handle: FocusHandle, notifications: Vec>, @@ -296,6 +286,12 @@ pub struct AcpServerView { } impl AcpServerView { + pub fn has_auth_methods(&self) -> bool { + self.as_connected().map_or(false, |connected| { + !connected.connection.auth_methods().is_empty() + }) + } + pub fn active_thread(&self) -> Option<&Entity> { match &self.server_state { ServerState::Connected(connected) => connected.active_view(), @@ -487,7 +483,6 @@ impl AcpServerView { window, cx, ), - login: None, notifications: Vec::new(), notification_subscriptions: HashMap::default(), auth_task: None, @@ -598,16 +593,13 @@ impl AcpServerView { let connect_task = agent.connect(delegate, cx); let load_task = cx.spawn_in(window, async move |this, cx| { let connection = match connect_task.await { - Ok((connection, login)) => { - this.update(cx, |this, _| this.login = login).ok(); - connection - } + Ok(connection) => connection, Err(err) => { this.update_in(cx, |this, window, cx| { if err.downcast_ref::().is_some() { this.handle_load_error(err, window, cx); } else if let Some(active) = this.active_thread() { - active.update(cx, |active, cx| active.handle_any_thread_error(err, cx)); + active.update(cx, |active, cx| active.handle_thread_error(err, cx)); } else { this.handle_load_error(err, window, cx); } @@ -922,7 +914,6 @@ impl AcpServerView { parent_id, thread, conversation, - self.login.clone(), weak, agent_icon, agent_name, @@ -1480,7 +1471,7 @@ impl AcpServerView { } if let Some(active) = this.active_thread() { active.update(cx, |active, cx| { - active.handle_any_thread_error(err, cx); + active.handle_thread_error(err, cx); }) } } else { @@ -1496,79 +1487,10 @@ impl AcpServerView { } } - if method.0.as_ref() == "gemini-api-key" { - let registry = LanguageModelRegistry::global(cx); - let provider = registry - .read(cx) - .provider(&language_model::GOOGLE_PROVIDER_ID) - .unwrap(); - if !provider.is_authenticated(cx) { - let this = cx.weak_entity(); - let agent_name = self.agent.name(); - let connection = connection.clone(); - window.defer(cx, |window, cx| { - Self::handle_auth_required( - this, - AuthRequired { - description: Some("GEMINI_API_KEY must be set".to_owned()), - provider_id: Some(language_model::GOOGLE_PROVIDER_ID), - }, - agent_name, - connection, - window, - cx, - ); - }); - return; - } - } else if method.0.as_ref() == "vertex-ai" - && std::env::var("GOOGLE_API_KEY").is_err() - && (std::env::var("GOOGLE_CLOUD_PROJECT").is_err() - || (std::env::var("GOOGLE_CLOUD_PROJECT").is_err())) - { - let this = cx.weak_entity(); - let agent_name = self.agent.name(); - let connection = connection.clone(); - - window.defer(cx, |window, cx| { - Self::handle_auth_required( - this, - AuthRequired { - description: Some( - "GOOGLE_API_KEY must be set in the environment to use Vertex AI authentication for Gemini CLI. Please export it and restart Zed." - .to_owned(), - ), - provider_id: None, - }, - agent_name, - connection, - window, - cx, - ) - }); - return; - } - configuration_view.take(); pending_auth_method.replace(method.clone()); - let authenticate = if let Some(login) = self.login.clone() { - if let Some(workspace) = self.workspace.upgrade() { - let project = self.project.clone(); - Self::spawn_external_agent_login( - login, - workspace, - project, - method.clone(), - false, - window, - cx, - ) - } else { - Task::ready(Ok(())) - } - } else { - connection.authenticate(method, cx) - }; + + let authenticate = connection.authenticate(method, cx); cx.notify(); self.auth_task = Some(cx.spawn_in(window, { async move |this, cx| { @@ -1598,7 +1520,7 @@ impl AcpServerView { pending_auth_method.take(); } if let Some(active) = this.active_thread() { - active.update(cx, |active, cx| active.handle_any_thread_error(err, cx)); + active.update(cx, |active, cx| active.handle_thread_error(err, cx)); } } else { this.reset(window, cx); @@ -1843,15 +1765,7 @@ impl AcpServerView { .enumerate() .rev() .map(|(ix, method)| { - 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()) - }; - + let (method_id, name) = (method.id.0.clone(), method.name.clone()); let agent_telemetry_id = connection.telemetry_id(); Button::new(method_id.clone(), name) @@ -3617,8 +3531,8 @@ pub(crate) mod tests { &self, _delegate: AgentServerDelegate, _cx: &mut App, - ) -> Task, Option)>> { - Task::ready(Ok((Rc::new(self.connection.clone()), None))) + ) -> Task>> { + Task::ready(Ok(Rc::new(self.connection.clone()))) } fn into_any(self: Rc) -> Rc { @@ -3641,7 +3555,7 @@ pub(crate) mod tests { &self, _delegate: AgentServerDelegate, _cx: &mut App, - ) -> Task, Option)>> { + ) -> Task>> { Task::ready(Err(anyhow!( "extracting downloaded asset for \ https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/\ diff --git a/crates/agent_ui/src/acp/thread_view/active_thread.rs b/crates/agent_ui/src/acp/thread_view/active_thread.rs index ff01b244aed79b9f228f487364227368deb53c3f..2a98e528238edd4763d1a14e09e6f8916776fa7f 100644 --- a/crates/agent_ui/src/acp/thread_view/active_thread.rs +++ b/crates/agent_ui/src/acp/thread_view/active_thread.rs @@ -189,7 +189,6 @@ impl DiffStats { pub struct AcpThreadView { pub id: acp::SessionId, pub parent_id: Option, - pub login: Option, // is some <=> Active | Unauthenticated pub thread: Entity, pub(crate) conversation: Entity, pub server_view: WeakEntity, @@ -281,7 +280,6 @@ impl AcpThreadView { parent_id: Option, thread: Entity, conversation: Entity, - login: Option, server_view: WeakEntity, agent_icon: IconName, agent_name: SharedString, @@ -387,7 +385,6 @@ impl AcpThreadView { focus_handle: cx.focus_handle(), thread, conversation, - login, server_view, agent_icon, agent_name, @@ -658,7 +655,7 @@ impl AcpThreadView { let text = text.trim(); if text == "/login" || text == "/logout" { let connection = thread.read(cx).connection().clone(); - let can_login = !connection.auth_methods().is_empty() || self.login.is_some(); + let can_login = !connection.auth_methods().is_empty(); // Does the agent have a specific logout command? Prefer that in case they need to reset internal state. let logout_supported = text == "/logout" && self @@ -833,7 +830,7 @@ impl AcpThreadView { cx.spawn(async move |this, cx| { if let Err(err) = task.await { this.update(cx, |this, cx| { - this.handle_any_thread_error(err, cx); + this.handle_thread_error(err, cx); }) .ok(); } else { @@ -891,12 +888,12 @@ impl AcpThreadView { .detach(); } - pub(crate) fn handle_any_thread_error(&mut self, error: anyhow::Error, cx: &mut Context) { - let error = ThreadError::from_err(error, &self.agent_name); - self.handle_thread_error(error, cx); - } - - pub(crate) fn handle_thread_error(&mut self, error: ThreadError, cx: &mut Context) { + pub(crate) fn handle_thread_error( + &mut self, + error: impl Into, + cx: &mut Context, + ) { + let error = error.into(); self.emit_thread_error_telemetry(&error, cx); self.thread_error = Some(error); cx.notify(); @@ -964,7 +961,7 @@ impl AcpThreadView { this.update(cx, |this, cx| { if let Err(err) = result { - this.handle_any_thread_error(err, cx); + this.handle_thread_error(err, cx); } }) }) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index e667a6c1d0507f399b4339c84fec4fc7099eab4e..9126d289c94563e99d9bda2212bda5259e9e4fa3 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -8,7 +8,6 @@ use std::{ops::Range, sync::Arc}; use agent::ContextServerRegistry; use anyhow::Result; -use client::zed_urls; use cloud_api_types::Plan; use collections::HashMap; use context_server::ContextServerId; @@ -20,6 +19,7 @@ use gpui::{ Action, AnyView, App, AsyncWindowContext, Corner, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle, Subscription, Task, WeakEntity, }; +use itertools::Itertools; use language::LanguageRegistry; use language_model::{ IconOrSvg, LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, @@ -28,10 +28,7 @@ use language_model::{ use language_models::AllLanguageModelSettings; use notifications::status_toast::{StatusToast, ToastIcon}; use project::{ - agent_server_store::{ - AgentServerStore, CLAUDE_AGENT_NAME, CODEX_NAME, ExternalAgentServerName, - ExternalAgentSource, GEMINI_NAME, - }, + agent_server_store::{AgentServerStore, ExternalAgentServerName, ExternalAgentSource}, context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, }; use settings::{Settings, SettingsStore, update_settings_file}; @@ -941,9 +938,6 @@ impl AgentConfiguration { let user_defined_agents = agent_server_store .external_agents() - .filter(|name| { - name.0 != GEMINI_NAME && name.0 != CLAUDE_AGENT_NAME && name.0 != CODEX_NAME - }) .cloned() .collect::>(); @@ -961,6 +955,7 @@ impl AgentConfiguration { let source = agent_server_store.agent_source(&name).unwrap_or_default(); (name, icon, display_name, source) }) + .sorted_unstable_by_key(|(_, _, display_name, _)| display_name.to_lowercase()) .collect(); let add_agent_popover = PopoverMenu::new("add-agent-server-popover") @@ -998,22 +993,6 @@ impl AgentConfiguration { }) .separator() .header("Learn More") - .item( - ContextMenuEntry::new("Agent Servers Docs") - .icon(IconName::ArrowUpRight) - .icon_color(Color::Muted) - .icon_position(IconPosition::End) - .handler({ - move |window, cx| { - window.dispatch_action( - Box::new(OpenBrowser { - url: zed_urls::agent_server_docs(cx), - }), - cx, - ); - } - }), - ) .item( ContextMenuEntry::new("ACP Docs") .icon(IconName::ArrowUpRight) @@ -1049,51 +1028,24 @@ impl AgentConfiguration { "All agents connected through the Agent Client Protocol.", add_agent_popover.into_any_element(), )) - .child( - v_flex() - .p_4() - .pt_0() - .gap_2() - .child(self.render_agent_server( - AgentIcon::Name(IconName::AiClaude), - "Claude Agent", - "Claude Agent", - ExternalAgentSource::Builtin, - cx, - )) - .child(Divider::horizontal().color(DividerColor::BorderFaded)) - .child(self.render_agent_server( - AgentIcon::Name(IconName::AiOpenAi), - "Codex CLI", - "Codex CLI", - ExternalAgentSource::Builtin, - cx, - )) - .child(Divider::horizontal().color(DividerColor::BorderFaded)) - .child(self.render_agent_server( - AgentIcon::Name(IconName::AiGemini), - "Gemini CLI", - "Gemini CLI", - ExternalAgentSource::Builtin, + .child(v_flex().p_4().pt_0().gap_2().map(|mut parent| { + let mut first = true; + for (name, icon, display_name, source) in user_defined_agents { + if !first { + parent = parent + .child(Divider::horizontal().color(DividerColor::BorderFaded)); + } + first = false; + parent = parent.child(self.render_agent_server( + icon, + name, + display_name, + source, cx, - )) - .map(|mut parent| { - for (name, icon, display_name, source) in user_defined_agents { - parent = parent - .child( - Divider::horizontal().color(DividerColor::BorderFaded), - ) - .child(self.render_agent_server( - icon, - name, - display_name, - source, - cx, - )); - } - parent - }), - ), + )); + } + parent + })), ) } @@ -1134,7 +1086,7 @@ impl AgentConfiguration { )), IconName::AcpRegistry, )), - ExternalAgentSource::Builtin | ExternalAgentSource::Custom => None, + ExternalAgentSource::Custom => None, }; let agent_server_name = ExternalAgentServerName(id.clone()); @@ -1176,19 +1128,19 @@ impl AgentConfiguration { let Some(agent_servers) = settings.agent_servers.as_mut() else { return; }; - if let Some(entry) = agent_servers.custom.get(agent_name.0.as_ref()) + if let Some(entry) = agent_servers.get(agent_name.0.as_ref()) && matches!( entry, settings::CustomAgentServerSettings::Registry { .. } ) { - agent_servers.custom.remove(agent_name.0.as_ref()); + agent_servers.remove(agent_name.0.as_ref()); } }); })), ) } - ExternalAgentSource::Builtin | ExternalAgentSource::Custom => None, + ExternalAgentSource::Custom => None, }; h_flex() @@ -1367,29 +1319,23 @@ async fn open_new_agent_servers_entry_in_settings_editor( !settings .agent_servers .as_ref() - .is_some_and(|agent_servers| { - agent_servers.custom.contains_key(name.as_str()) - }) + .is_some_and(|agent_servers| agent_servers.contains_key(name.as_str())) }); if let Some(server_name) = server_name { unique_server_name = Some(SharedString::from(server_name.clone())); - settings - .agent_servers - .get_or_insert_default() - .custom - .insert( - server_name, - settings::CustomAgentServerSettings::Custom { - path: "path_to_executable".into(), - args: vec![], - env: HashMap::default(), - default_mode: None, - default_model: None, - favorite_models: vec![], - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - }, - ); + settings.agent_servers.get_or_insert_default().insert( + server_name, + settings::CustomAgentServerSettings::Custom { + path: "path_to_executable".into(), + args: vec![], + env: HashMap::default(), + default_mode: None, + default_model: None, + favorite_models: vec![], + default_config_options: Default::default(), + favorite_config_option_values: Default::default(), + }, + ); } }); diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index cdc0ee0b1fd9287f065e2bc7c8f7c84086689050..d6756cdabe4b75f9a3d67b3816dba7297bdfe2a8 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -14,6 +14,7 @@ use agent::{ContextServerRegistry, SharedThread, ThreadStore}; use agent_client_protocol as acp; use agent_servers::AgentServer; use db::kvp::{Dismissable, KEY_VALUE_STORE}; +use itertools::Itertools; use project::{ ExternalAgentServerName, agent_server_store::{CLAUDE_AGENT_NAME, CODEX_NAME, GEMINI_NAME}, @@ -366,9 +367,6 @@ pub enum AgentType { #[default] NativeAgent, TextThread, - Gemini, - ClaudeAgent, - Codex, Custom { name: SharedString, }, @@ -378,9 +376,6 @@ impl AgentType { fn label(&self) -> SharedString { match self { Self::NativeAgent | Self::TextThread => "Zed Agent".into(), - Self::Gemini => "Gemini CLI".into(), - Self::ClaudeAgent => "Claude Agent".into(), - Self::Codex => "Codex".into(), Self::Custom { name, .. } => name.into(), } } @@ -388,9 +383,6 @@ impl AgentType { fn icon(&self) -> Option { match self { Self::NativeAgent | Self::TextThread => None, - Self::Gemini => Some(IconName::AiGemini), - Self::ClaudeAgent => Some(IconName::AiClaude), - Self::Codex => Some(IconName::AiOpenAi), Self::Custom { .. } => Some(IconName::Sparkle), } } @@ -399,9 +391,6 @@ impl AgentType { impl From for AgentType { fn from(value: ExternalAgent) -> Self { match value { - ExternalAgent::Gemini => Self::Gemini, - ExternalAgent::ClaudeCode => Self::ClaudeAgent, - ExternalAgent::Codex => Self::Codex, ExternalAgent::Custom { name } => Self::Custom { name }, ExternalAgent::NativeAgent => Self::NativeAgent, } @@ -1117,10 +1106,7 @@ impl AgentPanel { match self.selected_agent { AgentType::NativeAgent => Some(HistoryKind::AgentThreads), AgentType::TextThread => Some(HistoryKind::TextThreads), - AgentType::Gemini - | AgentType::ClaudeAgent - | AgentType::Codex - | AgentType::Custom { .. } => { + AgentType::Custom { .. } => { if self.acp_history.read(cx).has_session_list() { Some(HistoryKind::AgentThreads) } else { @@ -1759,9 +1745,6 @@ impl AgentPanel { fn selected_external_agent(&self) -> Option { match &self.selected_agent { AgentType::NativeAgent => Some(ExternalAgent::NativeAgent), - AgentType::Gemini => Some(ExternalAgent::Gemini), - AgentType::ClaudeAgent => Some(ExternalAgent::ClaudeCode), - AgentType::Codex => Some(ExternalAgent::Codex), AgentType::Custom { name } => Some(ExternalAgent::Custom { name: name.clone() }), AgentType::TextThread => None, } @@ -1827,25 +1810,6 @@ impl AgentPanel { window, cx, ), - AgentType::Gemini => { - self.external_thread(Some(crate::ExternalAgent::Gemini), None, None, window, cx) - } - AgentType::ClaudeAgent => { - self.selected_agent = AgentType::ClaudeAgent; - self.serialize(cx); - self.external_thread( - Some(crate::ExternalAgent::ClaudeCode), - None, - None, - window, - cx, - ) - } - AgentType::Codex => { - self.selected_agent = AgentType::Codex; - self.serialize(cx); - self.external_thread(Some(crate::ExternalAgent::Codex), None, None, window, cx) - } AgentType::Custom { name } => self.external_thread( Some(crate::ExternalAgent::Custom { name }), None, @@ -2196,8 +2160,6 @@ impl AgentPanel { "Enable Full Screen" }; - let selected_agent = self.selected_agent.clone(); - let text_thread_view = match &self.active_view { ActiveView::TextThread { text_thread_editor, .. @@ -2226,6 +2188,10 @@ impl AgentPanel { } _ => false, }; + let has_auth_methods = match &self.active_view { + ActiveView::AgentThread { server_view } => server_view.read(cx).has_auth_methods(), + _ => false, + }; PopoverMenu::new("agent-options-menu") .trigger_with_tooltip( @@ -2301,7 +2267,7 @@ impl AgentPanel { .separator() .action(full_screen_label, Box::new(ToggleZoom)); - if selected_agent == AgentType::Gemini { + if has_auth_methods { menu = menu.action("Reauthenticate", Box::new(ReauthenticateAgent)) } @@ -2510,140 +2476,73 @@ impl AgentPanel { ) .separator() .header("External Agents") - .item( - ContextMenuEntry::new("Claude Agent") - .when(is_agent_selected(AgentType::ClaudeAgent), |this| { - this.action(Box::new(NewExternalAgentThread { - agent: None, - })) - }) - .icon(IconName::AiClaude) - .disabled(is_via_collab) - .icon_color(Color::Muted) - .handler({ - let workspace = workspace.clone(); - move |window, cx| { - if let Some(workspace) = workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - if let Some(panel) = - workspace.panel::(cx) - { - panel.update(cx, |panel, cx| { - panel.new_agent_thread( - AgentType::ClaudeAgent, - window, - cx, - ); - }); - } - }); - } - } - }), - ) - .item( - ContextMenuEntry::new("Codex CLI") - .when(is_agent_selected(AgentType::Codex), |this| { - this.action(Box::new(NewExternalAgentThread { - agent: None, - })) - }) - .icon(IconName::AiOpenAi) - .disabled(is_via_collab) - .icon_color(Color::Muted) - .handler({ - let workspace = workspace.clone(); - move |window, cx| { - if let Some(workspace) = workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - if let Some(panel) = - workspace.panel::(cx) - { - panel.update(cx, |panel, cx| { - panel.new_agent_thread( - AgentType::Codex, - window, - cx, - ); - }); - } - }); - } - } - }), - ) - .item( - ContextMenuEntry::new("Gemini CLI") - .when(is_agent_selected(AgentType::Gemini), |this| { - this.action(Box::new(NewExternalAgentThread { - agent: None, - })) - }) - .icon(IconName::AiGemini) - .icon_color(Color::Muted) - .disabled(is_via_collab) - .handler({ - let workspace = workspace.clone(); - move |window, cx| { - if let Some(workspace) = workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - if let Some(panel) = - workspace.panel::(cx) - { - panel.update(cx, |panel, cx| { - panel.new_agent_thread( - AgentType::Gemini, - window, - cx, - ); - }); - } - }); - } - } - }), - ) .map(|mut menu| { let agent_server_store = agent_server_store.read(cx); - let agent_names = agent_server_store + let registry_store = + project::AgentRegistryStore::try_global(cx); + let registry_store_ref = + registry_store.as_ref().map(|s| s.read(cx)); + + struct AgentMenuItem { + id: ExternalAgentServerName, + display_name: SharedString, + } + + let agent_items = agent_server_store .external_agents() - .filter(|name| { - name.0 != GEMINI_NAME - && name.0 != CLAUDE_AGENT_NAME - && name.0 != CODEX_NAME + .map(|name| { + let display_name = agent_server_store + .agent_display_name(name) + .or_else(|| { + registry_store_ref + .as_ref() + .and_then(|store| store.agent(name.0.as_ref())) + .map(|a| a.name().clone()) + }) + .unwrap_or_else(|| name.0.clone()); + AgentMenuItem { + id: name.clone(), + display_name, + } }) - .cloned() + .sorted_unstable_by_key(|e| e.display_name.to_lowercase()) .collect::>(); - for agent_name in agent_names { - let icon_path = agent_server_store.agent_icon(&agent_name); - let display_name = agent_server_store - .agent_display_name(&agent_name) - .unwrap_or_else(|| agent_name.0.clone()); - - let mut entry = ContextMenuEntry::new(display_name); + for item in &agent_items { + let mut entry = + ContextMenuEntry::new(item.display_name.clone()); + + let icon_path = agent_server_store + .agent_icon(&item.id) + .or_else(|| { + registry_store_ref + .as_ref() + .and_then(|store| store.agent(item.id.0.as_str())) + .and_then(|a| a.icon_path().cloned()) + }); if let Some(icon_path) = icon_path { entry = entry.custom_icon_svg(icon_path); } else { entry = entry.icon(IconName::Sparkle); } + entry = entry .when( is_agent_selected(AgentType::Custom { - name: agent_name.0.clone(), + name: item.id.0.clone(), }), |this| { - this.action(Box::new(NewExternalAgentThread { - agent: None, - })) + this.action(Box::new( + NewExternalAgentThread { agent: None }, + )) }, ) .icon_color(Color::Muted) .disabled(is_via_collab) .handler({ let workspace = workspace.clone(); - let agent_name = agent_name.clone(); + let agent_id = item.id.clone(); move |window, cx| { if let Some(workspace) = workspace.upgrade() { workspace.update(cx, |workspace, cx| { @@ -2653,9 +2552,7 @@ impl AgentPanel { panel.update(cx, |panel, cx| { panel.new_agent_thread( AgentType::Custom { - name: agent_name - .clone() - .into(), + name: agent_id.0.clone(), }, window, cx, @@ -2673,6 +2570,102 @@ impl AgentPanel { menu }) .separator() + .map(|mut menu| { + let agent_server_store = agent_server_store.read(cx); + let registry_store = + project::AgentRegistryStore::try_global(cx); + let registry_store_ref = + registry_store.as_ref().map(|s| s.read(cx)); + + let previous_built_in_ids: &[ExternalAgentServerName] = + &[CLAUDE_AGENT_NAME.into(), CODEX_NAME.into(), GEMINI_NAME.into()]; + + let promoted_items = previous_built_in_ids + .iter() + .filter(|id| { + !agent_server_store.external_agents.contains_key(*id) + }) + .map(|name| { + let display_name = registry_store_ref + .as_ref() + .and_then(|store| store.agent(name.0.as_ref())) + .map(|a| a.name().clone()) + .unwrap_or_else(|| name.0.clone()); + (name.clone(), display_name) + }) + .sorted_unstable_by_key(|(_, display_name)| display_name.to_lowercase()) + .collect::>(); + + for (agent_id, display_name) in &promoted_items { + let mut entry = + ContextMenuEntry::new(display_name.clone()); + + let icon_path = registry_store_ref + .as_ref() + .and_then(|store| store.agent(agent_id.0.as_str())) + .and_then(|a| a.icon_path().cloned()); + + if let Some(icon_path) = icon_path { + entry = entry.custom_icon_svg(icon_path); + } else { + entry = entry.icon(IconName::Sparkle); + } + + entry = entry + .icon_color(Color::Muted) + .disabled(is_via_collab) + .handler({ + let workspace = workspace.clone(); + let agent_id = agent_id.clone(); + move |window, cx| { + let fs = ::global(cx); + let agent_id_string = + agent_id.to_string(); + settings::update_settings_file( + fs, + cx, + move |settings, _| { + let agent_servers = settings + .agent_servers + .get_or_insert_default(); + agent_servers.entry(agent_id_string).or_insert_with(|| { + settings::CustomAgentServerSettings::Registry { + default_mode: None, + default_model: None, + env: Default::default(), + favorite_models: Vec::new(), + default_config_options: Default::default(), + favorite_config_option_values: Default::default(), + } + }); + }, + ); + + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = + workspace.panel::(cx) + { + panel.update(cx, |panel, cx| { + panel.new_agent_thread( + AgentType::Custom { + name: agent_id.0.clone(), + }, + window, + cx, + ); + }); + } + }); + } + } + }); + + menu = menu.item(entry); + } + + menu + }) .item( ContextMenuEntry::new("Add More Agents") .icon(IconName::Plus) @@ -3565,7 +3558,9 @@ mod tests { panel_b.update(cx, |panel, _cx| { panel.width = Some(px(400.0)); - panel.selected_agent = AgentType::ClaudeAgent; + panel.selected_agent = AgentType::Custom { + name: "claude-acp".into(), + }; }); // --- Serialize both panels --- @@ -3612,7 +3607,9 @@ mod tests { ); assert_eq!( panel.selected_agent, - AgentType::ClaudeAgent, + AgentType::Custom { + name: "claude-acp".into() + }, "workspace B agent type should be restored" ); assert!( diff --git a/crates/agent_ui/src/agent_registry_ui.rs b/crates/agent_ui/src/agent_registry_ui.rs index 77539dd7c7deac569e63eeebae90d85d9da80131..45361b9ee26d287a233c02c25f7fba8fd0de37f6 100644 --- a/crates/agent_ui/src/agent_registry_ui.rs +++ b/crates/agent_ui/src/agent_registry_ui.rs @@ -173,7 +173,7 @@ impl AgentRegistryPage { .global::() .get::(None); self.installed_statuses.clear(); - for (id, settings) in &settings.custom { + for (id, settings) in settings.iter() { let status = match settings { CustomAgentServerSettings::Registry { .. } => { RegistryInstallStatus::InstalledRegistry @@ -583,7 +583,7 @@ impl AgentRegistryPage { let agent_id = agent_id.clone(); update_settings_file(fs.clone(), cx, move |settings, _| { let agent_servers = settings.agent_servers.get_or_insert_default(); - agent_servers.custom.entry(agent_id).or_insert_with(|| { + agent_servers.entry(agent_id).or_insert_with(|| { settings::CustomAgentServerSettings::Registry { default_mode: None, default_model: None, @@ -607,13 +607,13 @@ impl AgentRegistryPage { let Some(agent_servers) = settings.agent_servers.as_mut() else { return; }; - if let Some(entry) = agent_servers.custom.get(agent_id.as_str()) + if let Some(entry) = agent_servers.get(agent_id.as_str()) && matches!( entry, settings::CustomAgentServerSettings::Registry { .. } ) { - agent_servers.custom.remove(agent_id.as_str()); + agent_servers.remove(agent_id.as_str()); } }); }) diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 736f69855f0e1cac0c7eb82f6596360e32489939..61a8fd8779c125b29772959a4d1947a9a23325ef 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -203,9 +203,6 @@ pub struct NewNativeAgentThreadFromSummary { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ExternalAgent { - Gemini, - ClaudeCode, - Codex, NativeAgent, Custom { name: SharedString }, } @@ -217,9 +214,6 @@ impl ExternalAgent { thread_store: Entity, ) -> Rc { match self { - Self::Gemini => Rc::new(agent_servers::Gemini), - Self::ClaudeCode => Rc::new(agent_servers::ClaudeCode), - Self::Codex => Rc::new(agent_servers::Codex), Self::NativeAgent => Rc::new(agent::NativeAgentServer::new(fs, thread_store)), Self::Custom { name } => Rc::new(agent_servers::CustomAgentServer::new(name.clone())), } diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 2302417a3c5b9d54bf2070ed40119992ff35f24a..3b2a65372de957ec57577108e4acea1ab2e9944e 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -549,7 +549,7 @@ impl MentionSet { ); let connection = server.connect(delegate, cx); cx.spawn(async move |_, cx| { - let (agent, _) = connection.await?; + let agent = connection.await?; let agent = agent.downcast::().unwrap(); let summary = agent .0 diff --git a/crates/agent_ui/src/ui/acp_onboarding_modal.rs b/crates/agent_ui/src/ui/acp_onboarding_modal.rs index e48a36bd5af3eff578e230195dc2247900977173..23f3eadc4b259aa854f6c2cbb6bb3a68ec46deb5 100644 --- a/crates/agent_ui/src/ui/acp_onboarding_modal.rs +++ b/crates/agent_ui/src/ui/acp_onboarding_modal.rs @@ -1,8 +1,8 @@ -use client::zed_urls; use gpui::{ ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render, linear_color_stop, linear_gradient, }; +use project::agent_server_store::GEMINI_NAME; use ui::{TintColor, Vector, VectorName, prelude::*}; use workspace::{ModalView, Workspace}; @@ -37,7 +37,13 @@ impl AcpOnboardingModal { if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.new_agent_thread(AgentType::Gemini, window, cx); + panel.new_agent_thread( + AgentType::Custom { + name: GEMINI_NAME.into(), + }, + window, + cx, + ); }); } }); @@ -47,11 +53,11 @@ impl AcpOnboardingModal { acp_onboarding_event!("Open Panel Clicked"); } - fn view_docs(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { - cx.open_url(&zed_urls::external_agents_docs(cx)); + fn open_agent_registry(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { + window.dispatch_action(Box::new(zed_actions::AcpRegistry), cx); cx.notify(); - acp_onboarding_event!("Documentation Link Clicked"); + acp_onboarding_event!("Open Agent Registry Clicked"); } fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { @@ -197,7 +203,7 @@ impl Render for AcpOnboardingModal { .icon_size(IconSize::Indicator) .icon_color(Color::Muted) .full_width() - .on_click(cx.listener(Self::view_docs)); + .on_click(cx.listener(Self::open_agent_registry)); let close_button = h_flex().absolute().top_2().right_2().child( IconButton::new("cancel", IconName::Close).on_click(cx.listener( diff --git a/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs b/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs index 5a90d430295c921ea6e11f14921694990cbbc27c..9e499690efcb797e28f32ca8b3bd0f2c2f0da9db 100644 --- a/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs +++ b/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs @@ -1,8 +1,8 @@ -use client::zed_urls; use gpui::{ ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render, linear_color_stop, linear_gradient, }; +use project::agent_server_store::CLAUDE_AGENT_NAME; use ui::{TintColor, Vector, VectorName, prelude::*}; use workspace::{ModalView, Workspace}; @@ -37,7 +37,13 @@ impl ClaudeCodeOnboardingModal { if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.new_agent_thread(AgentType::ClaudeAgent, window, cx); + panel.new_agent_thread( + AgentType::Custom { + name: CLAUDE_AGENT_NAME.into(), + }, + window, + cx, + ); }); } }); @@ -47,8 +53,8 @@ impl ClaudeCodeOnboardingModal { claude_agent_onboarding_event!("Open Panel Clicked"); } - fn view_docs(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { - cx.open_url(&zed_urls::external_agents_docs(cx)); + fn view_docs(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { + window.dispatch_action(Box::new(zed_actions::AcpRegistry), cx); cx.notify(); claude_agent_onboarding_event!("Documentation Link Clicked"); diff --git a/crates/client/src/zed_urls.rs b/crates/client/src/zed_urls.rs index 2d0eedc9179a0ba2b640a8ed28366e47c0f0c397..e8d5a1e3bbade8ef357043cc993d33c03f05a9da 100644 --- a/crates/client/src/zed_urls.rs +++ b/crates/client/src/zed_urls.rs @@ -44,22 +44,6 @@ pub fn ai_privacy_and_security(cx: &App) -> String { ) } -/// Returns the URL to Zed AI's external agents documentation. -pub fn external_agents_docs(cx: &App) -> String { - format!( - "{server_url}/docs/ai/external-agents", - server_url = server_url(cx) - ) -} - -/// Returns the URL to Zed agent servers documentation. -pub fn agent_server_docs(cx: &App) -> String { - format!( - "{server_url}/docs/extensions/agent-servers", - server_url = server_url(cx) - ) -} - /// Returns the URL to Zed's edit prediction documentation. pub fn edit_prediction_docs(cx: &App) -> String { format!( diff --git a/crates/migrator/src/migrations.rs b/crates/migrator/src/migrations.rs index 82afed3c461f5d8875b6ac03c4bfac27c0cd7716..d10116be6032486c92d9f27afcf922178463e151 100644 --- a/crates/migrator/src/migrations.rs +++ b/crates/migrator/src/migrations.rs @@ -292,3 +292,9 @@ pub(crate) mod m_2026_02_04 { pub(crate) use settings::migrate_tool_permission_defaults; } + +pub(crate) mod m_2026_02_25 { + mod settings; + + pub(crate) use settings::migrate_builtin_agent_servers_to_registry; +} diff --git a/crates/migrator/src/migrations/m_2026_02_25/settings.rs b/crates/migrator/src/migrations/m_2026_02_25/settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..56b85efa0047cd17aef5f20da617430c2f4adcb3 --- /dev/null +++ b/crates/migrator/src/migrations/m_2026_02_25/settings.rs @@ -0,0 +1,161 @@ +use anyhow::Result; +use serde_json::Value; + +use crate::migrations::migrate_settings; + +const AGENT_SERVERS_KEY: &str = "agent_servers"; + +struct BuiltinMapping { + old_key: &'static str, + registry_key: &'static str, +} + +const BUILTIN_MAPPINGS: &[BuiltinMapping] = &[ + BuiltinMapping { + old_key: "gemini", + registry_key: "gemini", + }, + BuiltinMapping { + old_key: "claude", + registry_key: "claude-acp", + }, + BuiltinMapping { + old_key: "codex", + registry_key: "codex-acp", + }, +]; + +const REGISTRY_COMPATIBLE_FIELDS: &[&str] = &[ + "env", + "default_mode", + "default_model", + "favorite_models", + "default_config_options", + "favorite_config_option_values", +]; + +pub fn migrate_builtin_agent_servers_to_registry(value: &mut Value) -> Result<()> { + migrate_settings(value, &mut migrate_one) +} + +fn migrate_one(obj: &mut serde_json::Map) -> Result<()> { + let Some(agent_servers) = obj.get_mut(AGENT_SERVERS_KEY) else { + return Ok(()); + }; + let Some(servers_map) = agent_servers.as_object_mut() else { + return Ok(()); + }; + + for mapping in BUILTIN_MAPPINGS { + migrate_builtin_entry(servers_map, mapping); + } + + Ok(()) +} + +fn migrate_builtin_entry( + servers_map: &mut serde_json::Map, + mapping: &BuiltinMapping, +) { + // Check if the old key exists and needs migration before taking ownership. + let needs_migration = servers_map + .get(mapping.old_key) + .and_then(|v| v.as_object()) + .is_some_and(|obj| !obj.contains_key("type")); + + if !needs_migration { + return; + } + + // When the registry key differs from the old key and the target already + // exists, just remove the stale old entry to avoid overwriting user data. + if mapping.old_key != mapping.registry_key && servers_map.contains_key(mapping.registry_key) { + servers_map.remove(mapping.old_key); + return; + } + + let Some(old_entry) = servers_map.remove(mapping.old_key) else { + return; + }; + let Some(old_obj) = old_entry.as_object() else { + return; + }; + + let has_command = old_obj.contains_key("command"); + let ignore_system_version = old_obj + .get("ignore_system_version") + .and_then(|v| v.as_bool()); + + // A custom entry is needed when the user configured a custom binary + // or explicitly opted into using the system version via + // `ignore_system_version: false` (only meaningful for gemini). + let needs_custom = has_command + || (mapping.old_key == "gemini" && matches!(ignore_system_version, Some(false))); + + if needs_custom { + let local_key = format!("{}-custom", mapping.registry_key); + + // Don't overwrite an existing `-custom` entry. + if servers_map.contains_key(&local_key) { + return; + } + + let mut custom_obj = serde_json::Map::new(); + custom_obj.insert("type".to_string(), Value::String("custom".to_string())); + + if has_command { + if let Some(command) = old_obj.get("command") { + custom_obj.insert("command".to_string(), command.clone()); + } + if let Some(args) = old_obj.get("args") { + if !args.as_array().is_some_and(|a| a.is_empty()) { + custom_obj.insert("args".to_string(), args.clone()); + } + } + } else { + // ignore_system_version: false — the user wants the binary from $PATH + custom_obj.insert( + "command".to_string(), + Value::String(mapping.old_key.to_string()), + ); + } + + // Carry over all compatible fields to the custom entry. + for &field in REGISTRY_COMPATIBLE_FIELDS { + if let Some(value) = old_obj.get(field) { + match value { + Value::Array(arr) if arr.is_empty() => continue, + Value::Object(map) if map.is_empty() => continue, + Value::Null => continue, + _ => { + custom_obj.insert(field.to_string(), value.clone()); + } + } + } + } + + servers_map.insert(local_key, Value::Object(custom_obj)); + } else { + // Build a registry entry with compatible fields only. + let mut registry_obj = serde_json::Map::new(); + registry_obj.insert("type".to_string(), Value::String("registry".to_string())); + + for &field in REGISTRY_COMPATIBLE_FIELDS { + if let Some(value) = old_obj.get(field) { + match value { + Value::Array(arr) if arr.is_empty() => continue, + Value::Object(map) if map.is_empty() => continue, + Value::Null => continue, + _ => { + registry_obj.insert(field.to_string(), value.clone()); + } + } + } + } + + servers_map.insert( + mapping.registry_key.to_string(), + Value::Object(registry_obj), + ); + } +} diff --git a/crates/migrator/src/migrator.rs b/crates/migrator/src/migrator.rs index 5372fc126cc7ecd88db0eb751354bdc81e67495a..8b501020a559c74d81c5ad5b37e1adf60a964927 100644 --- a/crates/migrator/src/migrator.rs +++ b/crates/migrator/src/migrator.rs @@ -237,6 +237,7 @@ pub fn migrate_settings(text: &str) -> Result> { ), MigrationType::Json(migrations::m_2026_02_03::migrate_experimental_sweep_mercury), MigrationType::Json(migrations::m_2026_02_04::migrate_tool_permission_defaults), + MigrationType::Json(migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry), ]; run_migrations(text, migrations) } @@ -3820,4 +3821,415 @@ mod tests { ), ); } + + #[test] + fn test_migrate_builtin_agent_servers_to_registry_simple() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, + )], + r#"{ + "agent_servers": { + "gemini": { + "default_model": "gemini-2.0-flash" + }, + "claude": { + "default_mode": "plan" + }, + "codex": { + "default_model": "o4-mini" + } + } +}"#, + Some( + r#"{ + "agent_servers": { + "codex-acp": { + "type": "registry", + "default_model": "o4-mini" + }, + "claude-acp": { + "type": "registry", + "default_mode": "plan" + }, + "gemini": { + "type": "registry", + "default_model": "gemini-2.0-flash" + } + } +}"#, + ), + ); + } + + #[test] + fn test_migrate_builtin_agent_servers_empty_entries() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, + )], + r#"{ + "agent_servers": { + "gemini": {}, + "claude": {}, + "codex": {} + } +}"#, + Some( + r#"{ + "agent_servers": { + "codex-acp": { + "type": "registry" + }, + "claude-acp": { + "type": "registry" + }, + "gemini": { + "type": "registry" + } + } +}"#, + ), + ); + } + + #[test] + fn test_migrate_builtin_agent_servers_with_command() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, + )], + r#"{ + "agent_servers": { + "claude": { + "command": "/usr/local/bin/claude", + "args": ["--verbose"], + "env": {"CLAUDE_KEY": "abc123"}, + "default_mode": "plan", + "default_model": "claude-sonnet-4" + } + } +}"#, + Some( + r#"{ + "agent_servers": { + "claude-acp-custom": { + "type": "custom", + "command": "/usr/local/bin/claude", + "args": [ + "--verbose" + ], + "env": { + "CLAUDE_KEY": "abc123" + }, + "default_mode": "plan", + "default_model": "claude-sonnet-4" + } + } +}"#, + ), + ); + } + + #[test] + fn test_migrate_builtin_agent_servers_gemini_with_command() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, + )], + r#"{ + "agent_servers": { + "gemini": { + "command": "/opt/gemini/bin/gemini", + "default_model": "gemini-2.0-flash" + } + } +}"#, + Some( + r#"{ + "agent_servers": { + "gemini-custom": { + "type": "custom", + "command": "/opt/gemini/bin/gemini", + "default_model": "gemini-2.0-flash" + } + } +}"#, + ), + ); + } + + #[test] + fn test_migrate_builtin_agent_servers_gemini_ignore_system_version_false() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, + )], + r#"{ + "agent_servers": { + "gemini": { + "ignore_system_version": false, + "default_model": "gemini-2.0-flash" + } + } +}"#, + Some( + r#"{ + "agent_servers": { + "gemini-custom": { + "type": "custom", + "command": "gemini", + "default_model": "gemini-2.0-flash" + } + } +}"#, + ), + ); + } + + #[test] + fn test_migrate_builtin_agent_servers_gemini_ignore_system_version_true() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, + )], + r#"{ + "agent_servers": { + "gemini": { + "ignore_system_version": true, + "default_model": "gemini-2.0-flash" + } + } +}"#, + Some( + r#"{ + "agent_servers": { + "gemini": { + "type": "registry", + "default_model": "gemini-2.0-flash" + } + } +}"#, + ), + ); + } + + #[test] + fn test_migrate_builtin_agent_servers_already_typed_unchanged() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, + )], + r#"{ + "agent_servers": { + "gemini": { + "type": "registry", + "default_model": "gemini-2.0-flash" + }, + "claude-acp": { + "type": "registry", + "default_mode": "plan" + } + } +}"#, + None, + ); + } + + #[test] + fn test_migrate_builtin_agent_servers_preserves_custom_entries() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, + )], + r#"{ + "agent_servers": { + "claude": { + "default_mode": "plan" + }, + "my-custom-agent": { + "type": "custom", + "command": "/path/to/agent" + } + } +}"#, + Some( + r#"{ + "agent_servers": { + "claude-acp": { + "type": "registry", + "default_mode": "plan" + }, + "my-custom-agent": { + "type": "custom", + "command": "/path/to/agent" + } + } +}"#, + ), + ); + } + + #[test] + fn test_migrate_builtin_agent_servers_target_already_exists() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, + )], + r#"{ + "agent_servers": { + "claude": { + "default_mode": "plan" + }, + "claude-acp": { + "type": "registry", + "default_model": "claude-sonnet-4" + } + } +}"#, + Some( + r#"{ + "agent_servers": { + "claude-acp": { + "type": "registry", + "default_model": "claude-sonnet-4" + } + } +}"#, + ), + ); + } + + #[test] + fn test_migrate_builtin_agent_servers_no_agent_servers_key() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, + )], + r#"{ + "agent": { + "enabled": true + } +}"#, + None, + ); + } + + #[test] + fn test_migrate_builtin_agent_servers_all_fields() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, + )], + r#"{ + "agent_servers": { + "codex": { + "env": {"OPENAI_API_KEY": "sk-123"}, + "default_mode": "read-only", + "default_model": "o4-mini", + "favorite_models": ["o4-mini", "codex-mini-latest"], + "default_config_options": {"approval_mode": "auto-edit"}, + "favorite_config_option_values": {"approval_mode": ["auto-edit", "suggest"]} + } + } +}"#, + Some( + r#"{ + "agent_servers": { + "codex-acp": { + "type": "registry", + "env": { + "OPENAI_API_KEY": "sk-123" + }, + "default_mode": "read-only", + "default_model": "o4-mini", + "favorite_models": [ + "o4-mini", + "codex-mini-latest" + ], + "default_config_options": { + "approval_mode": "auto-edit" + }, + "favorite_config_option_values": { + "approval_mode": [ + "auto-edit", + "suggest" + ] + } + } + } +}"#, + ), + ); + } + + #[test] + fn test_migrate_builtin_agent_servers_codex_with_command() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, + )], + r#"{ + "agent_servers": { + "codex": { + "command": "/usr/local/bin/codex", + "args": ["--full-auto"], + "default_model": "o4-mini" + } + } +}"#, + Some( + r#"{ + "agent_servers": { + "codex-acp-custom": { + "type": "custom", + "command": "/usr/local/bin/codex", + "args": [ + "--full-auto" + ], + "default_model": "o4-mini" + } + } +}"#, + ), + ); + } + + #[test] + fn test_migrate_builtin_agent_servers_mixed_migrated_and_not() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2026_02_25::migrate_builtin_agent_servers_to_registry, + )], + r#"{ + "agent_servers": { + "gemini": { + "type": "registry", + "default_model": "gemini-2.0-flash" + }, + "claude": { + "default_mode": "plan" + }, + "codex": {} + } +}"#, + Some( + r#"{ + "agent_servers": { + "codex-acp": { + "type": "registry" + }, + "claude-acp": { + "type": "registry", + "default_mode": "plan" + }, + "gemini": { + "type": "registry", + "default_model": "gemini-2.0-flash" + } + } +}"#, + ), + ); + } } diff --git a/crates/project/src/agent_registry_store.rs b/crates/project/src/agent_registry_store.rs index 5b047a815096d778b4d120132f0e024eaf128942..a6fc56b7dadaeb0e89443479c108d999d70b37bd 100644 --- a/crates/project/src/agent_registry_store.rs +++ b/crates/project/src/agent_registry_store.rs @@ -9,7 +9,7 @@ use futures::AsyncReadExt; use gpui::{App, AppContext as _, Context, Entity, Global, SharedString, Task}; use http_client::{AsyncBody, HttpClient}; use serde::Deserialize; -use settings::Settings; +use settings::Settings as _; use crate::agent_server_store::AllAgentServersSettings; diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index c0f33d868c82d226fb1071a1edf33f838c1f2670..b0c10086cac1c39c4570b416e790df85cdc55cf0 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -3,18 +3,14 @@ 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::{ - AppContext as _, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task, -}; +use fs::Fs; +use gpui::{AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task}; use http_client::{HttpClient, github::AssetKind}; use node_runtime::NodeRuntime; use remote::RemoteClient; @@ -23,10 +19,9 @@ use rpc::{ proto::{self, ExternalExtensionAgent}, }; use schemars::JsonSchema; -use semver::Version; use serde::{Deserialize, Serialize}; use settings::{RegisterSetting, SettingsStore}; -use task::{Shell, SpawnInTerminal}; +use task::Shell; use util::{ResultExt as _, debug_panic}; use crate::ProjectEnvironment; @@ -66,7 +61,7 @@ impl std::fmt::Debug for AgentServerCommand { } } -#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct ExternalAgentServerName(pub SharedString); impl std::fmt::Display for ExternalAgentServerName { @@ -95,7 +90,6 @@ impl Borrow for ExternalAgentServerName { #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ExternalAgentSource { - Builtin, #[default] Custom, Extension, @@ -109,7 +103,7 @@ pub trait ExternalAgentServer { status_tx: Option>, new_version_available_tx: Option>>, cx: &mut AsyncApp, - ) -> Task)>>; + ) -> Task>; fn as_any_mut(&mut self) -> &mut dyn Any; } @@ -409,86 +403,13 @@ impl AgentServerStore { // If we don't have agents from the registry loaded yet, trigger a // refresh, which will cause this function to be called again + let registry_store = AgentRegistryStore::try_global(cx); if new_settings.has_registry_agents() - && let Some(registry) = AgentRegistryStore::try_global(cx) + && let Some(registry) = registry_store.as_ref() { registry.update(cx, |registry, cx| registry.refresh_if_stale(cx)); } - self.external_agents.clear(); - self.external_agents.insert( - GEMINI_NAME.into(), - ExternalAgentEntry::new( - 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()), - settings_env: new_settings - .gemini - .as_ref() - .and_then(|settings| settings.env.clone()), - ignore_system_version: new_settings - .gemini - .as_ref() - .and_then(|settings| settings.ignore_system_version) - .unwrap_or(true), - }), - ExternalAgentSource::Builtin, - None, - None, - ), - ); - self.external_agents.insert( - CODEX_NAME.into(), - ExternalAgentEntry::new( - Box::new(LocalCodex { - fs: fs.clone(), - project_environment: project_environment.clone(), - custom_command: new_settings - .codex - .clone() - .and_then(|settings| settings.custom_command()), - settings_env: new_settings - .codex - .as_ref() - .and_then(|settings| settings.env.clone()), - http_client: http_client.clone(), - no_browser: downstream_client - .as_ref() - .is_some_and(|(_, client)| !client.has_wsl_interop()), - }), - ExternalAgentSource::Builtin, - None, - None, - ), - ); - self.external_agents.insert( - CLAUDE_AGENT_NAME.into(), - ExternalAgentEntry::new( - Box::new(LocalClaudeCode { - fs: fs.clone(), - node_runtime: node_runtime.clone(), - project_environment: project_environment.clone(), - custom_command: new_settings - .claude - .clone() - .and_then(|settings| settings.custom_command()), - settings_env: new_settings - .claude - .as_ref() - .and_then(|settings| settings.env.clone()), - }), - ExternalAgentSource::Builtin, - None, - None, - ), - ); - - let registry_store = AgentRegistryStore::try_global(cx); let registry_agents_by_id = registry_store .as_ref() .map(|store| { @@ -502,13 +423,14 @@ impl AgentServerStore { }) .unwrap_or_default(); + self.external_agents.clear(); + // Insert extension agents before custom/registry so registry entries override extensions. for (agent_name, ext_id, targets, env, icon_path, display_name) in extension_agents.iter() { let name = ExternalAgentServerName(agent_name.clone().into()); let mut env = env.clone(); if let Some(settings_env) = new_settings - .custom .get(agent_name.as_ref()) .and_then(|settings| match settings { CustomAgentServerSettings::Extension { env, .. } => Some(env.clone()), @@ -541,7 +463,7 @@ impl AgentServerStore { ); } - for (name, settings) in &new_settings.custom { + for (name, settings) in new_settings.iter() { match settings { CustomAgentServerSettings::Custom { command, .. } => { let agent_name = ExternalAgentServerName(name.clone().into()); @@ -671,7 +593,7 @@ impl AgentServerStore { extension_agents: vec![], _subscriptions: subscriptions, }, - external_agents: Default::default(), + external_agents: HashMap::default(), }; if let Some(_events) = extension::ExtensionEvents::try_global(cx) {} this.agent_servers_settings_changed(cx); @@ -679,70 +601,19 @@ impl AgentServerStore { } pub(crate) fn remote(project_id: u64, upstream_client: Entity) -> 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: [(ExternalAgentServerName, ExternalAgentEntry); 3] = [ - ( - CLAUDE_AGENT_NAME.into(), - ExternalAgentEntry::new( - Box::new(RemoteExternalAgentServer { - project_id, - upstream_client: upstream_client.clone(), - name: CLAUDE_AGENT_NAME.into(), - status_tx: None, - new_version_available_tx: None, - }) as Box, - ExternalAgentSource::Builtin, - None, - None, - ), - ), - ( - CODEX_NAME.into(), - ExternalAgentEntry::new( - Box::new(RemoteExternalAgentServer { - project_id, - upstream_client: upstream_client.clone(), - name: CODEX_NAME.into(), - status_tx: None, - new_version_available_tx: None, - }) as Box, - ExternalAgentSource::Builtin, - None, - None, - ), - ), - ( - GEMINI_NAME.into(), - ExternalAgentEntry::new( - Box::new(RemoteExternalAgentServer { - project_id, - upstream_client: upstream_client.clone(), - name: GEMINI_NAME.into(), - status_tx: None, - new_version_available_tx: None, - }) as Box, - ExternalAgentSource::Builtin, - None, - None, - ), - ), - ]; - Self { state: AgentServerStoreState::Remote { project_id, upstream_client, }, - external_agents: external_agents.into_iter().collect(), + external_agents: HashMap::default(), } } pub fn collab() -> Self { Self { state: AgentServerStoreState::Collab, - external_agents: Default::default(), + external_agents: HashMap::default(), } } @@ -789,6 +660,17 @@ impl AgentServerStore { .map(|entry| entry.server.as_mut()) } + pub fn no_browser(&self) -> bool { + match &self.state { + AgentServerStoreState::Local { + downstream_client, .. + } => downstream_client + .as_ref() + .is_some_and(|(_, client)| !client.has_wsl_interop()), + _ => false, + } + } + pub fn external_agents(&self) -> impl Iterator { self.external_agents.keys() } @@ -798,7 +680,7 @@ impl AgentServerStore { envelope: TypedEnvelope, mut cx: AsyncApp, ) -> Result { - let (command, login_command) = this + let command = this .update(&mut cx, |this, cx| { let AgentServerStoreState::Local { downstream_client, .. @@ -807,6 +689,7 @@ impl AgentServerStore { debug_panic!("should not receive GetAgentServerCommand in a non-local project"); bail!("unexpected GetAgentServerCommand request in a non-local project"); }; + let no_browser = this.no_browser(); let agent = this .external_agents .get_mut(&*envelope.payload.name) @@ -856,8 +739,12 @@ impl AgentServerStore { (status_tx, new_version_available_tx) }) .unzip(); + let mut extra_env = HashMap::default(); + if no_browser { + extra_env.insert("NO_BROWSER".to_owned(), "1".to_owned()); + } anyhow::Ok(agent.get_command( - HashMap::default(), + extra_env, status_tx, new_version_available_tx, &mut cx.to_async(), @@ -871,9 +758,9 @@ impl AgentServerStore { .env .map(|env| env.into_iter().collect()) .unwrap_or_default(), - // This is no longer used, but returned for backwards compatibility + // root_dir and login are no longer used, but returned for backwards compatibility root_dir: paths::home_dir().to_string_lossy().to_string(), - login: login_command.map(|cmd| cmd.to_proto()), + login: None, }) } @@ -914,13 +801,7 @@ impl AgentServerStore { .names .into_iter() .map(|name| { - let agent_name = ExternalAgentServerName(name.clone().into()); - let fallback_source = - if name == GEMINI_NAME || name == CLAUDE_AGENT_NAME || name == CODEX_NAME { - ExternalAgentSource::Builtin - } else { - ExternalAgentSource::Custom - }; + let agent_name = ExternalAgentServerName(name.into()); let (icon, display_name, source) = metadata .remove(&agent_name) .or_else(|| { @@ -934,12 +815,7 @@ impl AgentServerStore { ) }) }) - .unwrap_or((None, None, fallback_source)); - let source = if fallback_source == ExternalAgentSource::Builtin { - ExternalAgentSource::Builtin - } else { - source - }; + .unwrap_or((None, None, ExternalAgentSource::default())); let agent = RemoteExternalAgentServer { project_id: *project_id, upstream_client: upstream_client.clone(), @@ -1056,192 +932,6 @@ impl AgentServerStore { } } -fn get_or_npm_install_builtin_agent( - binary_name: SharedString, - package_name: SharedString, - entrypoint_path: PathBuf, - minimum_version: Option, - status_tx: Option>, - new_version_available: Option>>, - fs: Arc, - node_runtime: NodeRuntime, - cx: &mut AsyncApp, -) -> Task> { - cx.spawn(async move |cx| { - let node_path = node_runtime.binary_path().await?; - let dir = paths::external_agents_dir().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, _)) = versions.last().cloned() - && minimum_version.is_none_or(|minimum_version| version >= minimum_version) - { - versions.pop() - } 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((version, file_name)) = newest_version { - cx.background_spawn({ - let dir = dir.clone(); - let fs = fs.clone(); - async move { - let latest_version = node_runtime - .npm_package_latest_version(&package_name) - .await - .ok(); - if let Some(latest_version) = latest_version - && latest_version != version - { - let download_result = download_latest_version( - fs, - dir.clone(), - node_runtime, - package_name.clone(), - ) - .await - .log_err(); - if let Some(mut new_version_available) = new_version_available - && download_result.is_some() - { - new_version_available - .send(Some(latest_version.to_string())) - .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? - .to_string() - .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().into_owned()], - env: None, - }) - }) -} - -fn find_bin_in_path( - bin_name: SharedString, - root_dir: PathBuf, - env: HashMap, - cx: &mut AsyncApp, -) -> Task> { - 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, - dir: PathBuf, - node_runtime: NodeRuntime, - package_name: SharedString, -) -> Result { - 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.to_string()), - RenameOptions { - ignore_if_exists: true, - overwrite: true, - create_parents: false, - }, - ) - .await?; - - anyhow::Ok(version) -} - struct RemoteExternalAgentServer { project_id: u64, upstream_client: Entity, @@ -1257,7 +947,7 @@ impl ExternalAgentServer for RemoteExternalAgentServer { status_tx: Option>, new_version_available_tx: Option>>, cx: &mut AsyncApp, - ) -> Task)>> { + ) -> Task> { let project_id = self.project_id; let name = self.name.to_string(); let upstream_client = self.upstream_client.downgrade(); @@ -1287,358 +977,11 @@ impl ExternalAgentServer for RemoteExternalAgentServer { Interactive::No, ) })??; - Ok(( - AgentServerCommand { - path: command.program.into(), - args: command.args, - env: Some(command.env), - }, - response.login.map(SpawnInTerminal::from_proto), - )) - }) - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } -} - -struct LocalGemini { - fs: Arc, - node_runtime: NodeRuntime, - project_environment: Entity, - custom_command: Option, - settings_env: Option>, - ignore_system_version: bool, -} - -impl ExternalAgentServer for LocalGemini { - fn get_command( - &mut self, - extra_env: HashMap, - status_tx: Option>, - new_version_available_tx: Option>>, - cx: &mut AsyncApp, - ) -> Task)>> { - 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 settings_env = self.settings_env.clone(); - let ignore_system_version = self.ignore_system_version; - let home_dir = paths::home_dir(); - - cx.spawn(async move |cx| { - let mut env = project_environment - .update(cx, |project_environment, cx| { - project_environment.local_directory_environment( - &Shell::System, - home_dir.as_path().into(), - cx, - ) - })? - .await - .unwrap_or_default(); - - env.extend(settings_env.unwrap_or_default()); - - let mut command = if let Some(mut custom_command) = custom_command { - custom_command.env = Some(env); - custom_command - } else if !ignore_system_version - && let Some(bin) = - find_bin_in_path("gemini".into(), home_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(), - if cfg!(windows) { - // v0.8.x on Windows has a bug that causes the initialize request to hang forever - Some("0.9.0".parse().unwrap()) - } else { - 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.to_string_lossy().into_owned()), - 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, Some(login))) - }) - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } -} - -struct LocalClaudeCode { - fs: Arc, - node_runtime: NodeRuntime, - project_environment: Entity, - custom_command: Option, - settings_env: Option>, -} - -impl ExternalAgentServer for LocalClaudeCode { - fn get_command( - &mut self, - extra_env: HashMap, - status_tx: Option>, - new_version_available_tx: Option>>, - cx: &mut AsyncApp, - ) -> Task)>> { - 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 settings_env = self.settings_env.clone(); - - cx.spawn(async move |cx| { - let mut env = project_environment - .update(cx, |project_environment, cx| { - project_environment.local_directory_environment( - &Shell::System, - paths::home_dir().as_path().into(), - cx, - ) - })? - .await - .unwrap_or_default(); - env.insert("ANTHROPIC_API_KEY".into(), "".into()); - - env.extend(settings_env.unwrap_or_default()); - - let (mut command, login_command) = if let Some(mut custom_command) = custom_command { - custom_command.env = Some(env); - (custom_command, None) - } else { - let mut command = get_or_npm_install_builtin_agent( - "claude-agent-acp".into(), - "@zed-industries/claude-agent-acp".into(), - "node_modules/@zed-industries/claude-agent-acp/dist/index.js".into(), - Some("0.17.0".parse().unwrap()), - status_tx, - new_version_available_tx, - fs, - node_runtime, - cx, - ) - .await?; - command.env = Some(env); - - (command, None) - }; - - command.env.get_or_insert_default().extend(extra_env); - Ok((command, login_command)) - }) - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } -} - -struct LocalCodex { - fs: Arc, - project_environment: Entity, - http_client: Arc, - custom_command: Option, - settings_env: Option>, - no_browser: bool, -} - -impl ExternalAgentServer for LocalCodex { - fn get_command( - &mut self, - extra_env: HashMap, - mut status_tx: Option>, - _new_version_available_tx: Option>>, - cx: &mut AsyncApp, - ) -> Task)>> { - let fs = self.fs.clone(); - let project_environment = self.project_environment.downgrade(); - let http = self.http_client.clone(); - let custom_command = self.custom_command.clone(); - let settings_env = self.settings_env.clone(); - let no_browser = self.no_browser; - - cx.spawn(async move |cx| { - let mut env = project_environment - .update(cx, |project_environment, cx| { - project_environment.local_directory_environment( - &Shell::System, - paths::home_dir().as_path().into(), - cx, - ) - })? - .await - .unwrap_or_default(); - if no_browser { - env.insert("NO_BROWSER".to_owned(), "1".to_owned()); - } - - env.extend(settings_env.unwrap_or_default()); - - let mut command = if let Some(mut custom_command) = custom_command { - custom_command.env = Some(env); - custom_command - } else { - let dir = paths::external_agents_dir().join(CODEX_NAME); - fs.create_dir(&dir).await?; - - let bin_name = if cfg!(windows) { - "codex-acp.exe" - } else { - "codex-acp" - }; - - let find_latest_local_version = async || -> Option { - let mut local_versions: Vec<(semver::Version, String)> = Vec::new(); - let mut stream = fs.read_dir(&dir).await.ok()?; - while let Some(entry) = stream.next().await { - let Ok(entry) = entry else { continue }; - let Some(file_name) = entry.file_name() else { - continue; - }; - let version_path = dir.join(&file_name); - if fs.is_file(&version_path.join(bin_name)).await { - let version_str = file_name.to_string_lossy(); - if let Ok(version) = - semver::Version::from_str(version_str.trim_start_matches('v')) - { - local_versions.push((version, version_str.into_owned())); - } - } - } - local_versions.sort_by(|(a, _), (b, _)| a.cmp(b)); - local_versions.last().map(|(_, v)| dir.join(v)) - }; - - let fallback_to_latest_local_version = - async |err: anyhow::Error| -> Result { - if let Some(local) = find_latest_local_version().await { - log::info!( - "Falling back to locally installed Codex version: {}", - local.display() - ); - Ok(local) - } else { - Err(err) - } - }; - - let version_dir = match ::http_client::github::latest_github_release( - CODEX_ACP_REPO, - true, - false, - http.clone(), - ) - .await - { - Ok(release) => { - let version_dir = dir.join(&release.tag_name); - if !fs.is_dir(&version_dir).await { - if let Some(ref mut status_tx) = status_tx { - status_tx.send("Installing…".into()).ok(); - } - - let tag = release.tag_name.clone(); - let version_number = tag.trim_start_matches('v'); - let asset_name = asset_name(version_number) - .context("codex acp is not supported for this architecture")?; - let asset = release - .assets - .into_iter() - .find(|asset| asset.name == asset_name) - .with_context(|| { - format!("no asset found matching `{asset_name:?}`") - })?; - // Strip "sha256:" prefix from digest if present (GitHub API format) - let digest = asset - .digest - .as_deref() - .and_then(|d| d.strip_prefix("sha256:").or(Some(d))); - match ::http_client::github_download::download_server_binary( - &*http, - &asset.browser_download_url, - digest, - &version_dir, - if cfg!(target_os = "windows") { - AssetKind::Zip - } else { - AssetKind::TarGz - }, - ) - .await - { - Ok(()) => { - // remove older versions - util::fs::remove_matching(&dir, |entry| entry != version_dir) - .await; - version_dir - } - Err(err) => { - log::error!( - "Failed to download Codex release {}: {err:#}", - release.tag_name - ); - fallback_to_latest_local_version(err).await? - } - } - } else { - version_dir - } - } - Err(err) => { - log::error!("Failed to fetch Codex latest release: {err:#}"); - fallback_to_latest_local_version(err).await? - } - }; - - let bin_path = version_dir.join(bin_name); - anyhow::ensure!( - fs.is_file(&bin_path).await, - "Missing Codex binary at {} after installation", - bin_path.to_string_lossy() - ); - - let mut cmd = AgentServerCommand { - path: bin_path, - args: Vec::new(), - env: None, - }; - cmd.env = Some(env); - cmd - }; - - command.env.get_or_insert_default().extend(extra_env); - Ok((command, None)) + Ok(AgentServerCommand { + path: command.program.into(), + args: command.args, + env: Some(command.env), + }) }) } @@ -1647,42 +990,6 @@ impl ExternalAgentServer for LocalCodex { } } -pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp"; - -fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> { - let arch = if cfg!(target_arch = "x86_64") { - "x86_64" - } else if cfg!(target_arch = "aarch64") { - "aarch64" - } else { - return None; - }; - - let platform = if cfg!(target_os = "macos") { - "apple-darwin" - } else if cfg!(target_os = "windows") { - "pc-windows-msvc" - } else if cfg!(target_os = "linux") { - "unknown-linux-gnu" - } else { - return None; - }; - - // Windows uses .zip in release assets - let ext = if cfg!(target_os = "windows") { - "zip" - } else { - "tar.gz" - }; - - Some((arch, platform, ext)) -} - -fn asset_name(version: &str) -> Option { - let (arch, platform, ext) = get_platform_info()?; - Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}")) -} - pub struct LocalExtensionArchiveAgent { pub fs: Arc, pub http_client: Arc, @@ -1701,7 +1008,7 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent { _status_tx: Option>, _new_version_available_tx: Option>>, cx: &mut AsyncApp, - ) -> Task)>> { + ) -> Task> { let fs = self.fs.clone(); let http_client = self.http_client.clone(); let node_runtime = self.node_runtime.clone(); @@ -1877,7 +1184,7 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent { env: Some(env), }; - Ok((command, None)) + Ok(command) }) } @@ -1903,7 +1210,7 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent { _status_tx: Option>, _new_version_available_tx: Option>>, cx: &mut AsyncApp, - ) -> Task)>> { + ) -> Task> { let fs = self.fs.clone(); let http_client = self.http_client.clone(); let node_runtime = self.node_runtime.clone(); @@ -2061,7 +1368,7 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent { env: Some(env), }; - Ok((command, None)) + Ok(command) }) } @@ -2086,7 +1393,7 @@ impl ExternalAgentServer for LocalRegistryNpxAgent { _status_tx: Option>, _new_version_available_tx: Option>>, cx: &mut AsyncApp, - ) -> Task)>> { + ) -> Task> { let node_runtime = self.node_runtime.clone(); let project_environment = self.project_environment.downgrade(); let package = self.package.clone(); @@ -2132,7 +1439,7 @@ impl ExternalAgentServer for LocalRegistryNpxAgent { env: Some(env), }; - Ok((command, None)) + Ok(command) }) } @@ -2153,7 +1460,7 @@ impl ExternalAgentServer for LocalCustomAgent { _status_tx: Option>, _new_version_available_tx: Option>>, cx: &mut AsyncApp, - ) -> Task)>> { + ) -> Task> { let mut command = self.command.clone(); let project_environment = self.project_environment.downgrade(); cx.spawn(async move |cx| { @@ -2170,7 +1477,7 @@ impl ExternalAgentServer for LocalCustomAgent { env.extend(command.env.unwrap_or_default()); env.extend(extra_env); command.env = Some(env); - Ok((command, None)) + Ok(command) }) } @@ -2179,76 +1486,31 @@ impl ExternalAgentServer for LocalCustomAgent { } } -pub const GEMINI_NAME: &'static str = "gemini"; -pub const CLAUDE_AGENT_NAME: &'static str = "claude"; -pub const CODEX_NAME: &'static str = "codex"; +pub const GEMINI_NAME: &str = "gemini"; +pub const CLAUDE_AGENT_NAME: &str = "claude-acp"; +pub const CODEX_NAME: &str = "codex-acp"; #[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)] -pub struct AllAgentServersSettings { - pub gemini: Option, - pub claude: Option, - pub codex: Option, - pub custom: HashMap, -} +pub struct AllAgentServersSettings(pub HashMap); -impl AllAgentServersSettings { - pub fn has_registry_agents(&self) -> bool { - self.custom - .values() - .any(|s| matches!(s, CustomAgentServerSettings::Registry { .. })) - } -} +impl std::ops::Deref for AllAgentServersSettings { + type Target = HashMap; -#[derive(Default, Clone, JsonSchema, Debug, PartialEq)] -pub struct BuiltinAgentServerSettings { - pub path: Option, - pub args: Option>, - pub env: Option>, - pub ignore_system_version: Option, - pub default_mode: Option, - pub default_model: Option, - pub favorite_models: Vec, - pub default_config_options: HashMap, - pub favorite_config_option_values: HashMap>, -} - -impl BuiltinAgentServerSettings { - fn custom_command(self) -> Option { - self.path.map(|path| AgentServerCommand { - path, - args: self.args.unwrap_or_default(), - // Settings env are always applied, so we don't need to supply them here as well - env: None, - }) + fn deref(&self) -> &Self::Target { + &self.0 } } -impl From for BuiltinAgentServerSettings { - fn from(value: settings::BuiltinAgentServerSettings) -> Self { - BuiltinAgentServerSettings { - path: value - .path - .map(|p| PathBuf::from(shellexpand::tilde(&p.to_string_lossy()).as_ref())), - args: value.args, - env: value.env, - ignore_system_version: value.ignore_system_version, - default_mode: value.default_mode, - default_model: value.default_model, - favorite_models: value.favorite_models, - default_config_options: value.default_config_options, - favorite_config_option_values: value.favorite_config_option_values, - } +impl std::ops::DerefMut for AllAgentServersSettings { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 } } -impl From for BuiltinAgentServerSettings { - fn from(value: AgentServerCommand) -> Self { - BuiltinAgentServerSettings { - path: Some(value.path), - args: Some(value.args), - env: value.env, - ..Default::default() - } +impl AllAgentServersSettings { + pub fn has_registry_agents(&self) -> bool { + self.values() + .any(|s| matches!(s, CustomAgentServerSettings::Registry { .. })) } } @@ -2492,15 +1754,12 @@ impl From for CustomAgentServerSettings { impl settings::Settings for AllAgentServersSettings { fn from_settings(content: &settings::SettingsContent) -> Self { let agent_settings = content.agent_servers.clone().unwrap(); - Self { - gemini: agent_settings.gemini.map(Into::into), - claude: agent_settings.claude.map(Into::into), - codex: agent_settings.codex.map(Into::into), - custom: agent_settings - .custom + Self( + agent_settings + .0 .into_iter() .map(|(k, v)| (k, v.into())) .collect(), - } + ) } } diff --git a/crates/project/tests/integration/ext_agent_tests.rs b/crates/project/tests/integration/ext_agent_tests.rs index 5cb75f54bc366a0661f6fe6c360e2b863974deda..f3c398a619a81ee81146de16f8e58b1093569e8a 100644 --- a/crates/project/tests/integration/ext_agent_tests.rs +++ b/crates/project/tests/integration/ext_agent_tests.rs @@ -13,15 +13,12 @@ impl ExternalAgentServer for NoopExternalAgent { _status_tx: Option>, _new_version_available_tx: Option>>, _cx: &mut AsyncApp, - ) -> Task)>> { - Task::ready(Ok(( - AgentServerCommand { - path: PathBuf::from("noop"), - args: Vec::new(), - env: None, - }, - None, - ))) + ) -> Task> { + Task::ready(Ok(AgentServerCommand { + path: PathBuf::from("noop"), + args: Vec::new(), + env: None, + })) } fn as_any_mut(&mut self) -> &mut dyn Any { diff --git a/crates/project/tests/integration/extension_agent_tests.rs b/crates/project/tests/integration/extension_agent_tests.rs index ca73612d07bb5f9efd7ffeb21e00b9ad35ab0347..eff41a99cab878336206f232450f3c1b490d1fc8 100644 --- a/crates/project/tests/integration/extension_agent_tests.rs +++ b/crates/project/tests/integration/extension_agent_tests.rs @@ -29,15 +29,12 @@ impl ExternalAgentServer for NoopExternalAgent { _status_tx: Option>, _new_version_available_tx: Option>>, _cx: &mut AsyncApp, - ) -> Task)>> { - Task::ready(Ok(( - AgentServerCommand { - path: PathBuf::from("noop"), - args: Vec::new(), - env: None, - }, - None, - ))) + ) -> Task> { + Task::ready(Ok(AgentServerCommand { + path: PathBuf::from("noop"), + args: Vec::new(), + env: None, + })) } fn as_any_mut(&mut self) -> &mut dyn Any { @@ -299,26 +296,6 @@ async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) { #[test] fn test_tilde_expansion_in_settings() { - let settings = settings::BuiltinAgentServerSettings { - path: Some(PathBuf::from("~/bin/agent")), - args: Some(vec!["--flag".into()]), - env: None, - ignore_system_version: None, - default_mode: None, - default_model: None, - favorite_models: vec![], - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - }; - - let BuiltinAgentServerSettings { path, .. } = settings.into(); - - let path = path.unwrap(); - assert!( - !path.to_string_lossy().starts_with("~"), - "Tilde should be expanded for builtin agent path" - ); - let settings = settings::CustomAgentServerSettings::Custom { path: PathBuf::from("~/custom/agent"), args: vec!["serve".into()], diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index b3fe30a472c2d098bc6fb9b2a4e276be8867e94b..778f7292d2a032df6995169852deeecee6fa76a7 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -2129,7 +2129,7 @@ async fn test_remote_external_agent_server( .map(|name| name.to_string()) .collect::>() }); - pretty_assertions::assert_eq!(names, ["codex", "gemini", "claude"]); + pretty_assertions::assert_eq!(names, Vec::::new()); server_cx.update_global::(|settings_store, cx| { settings_store .set_server_settings( @@ -2160,8 +2160,8 @@ async fn test_remote_external_agent_server( .map(|name| name.to_string()) .collect::>() }); - pretty_assertions::assert_eq!(names, ["gemini", "codex", "claude", "foo"]); - let (command, login) = project + pretty_assertions::assert_eq!(names, ["foo"]); + let command = project .update(cx, |project, cx| { project.agent_server_store().update(cx, |store, cx| { store @@ -2183,12 +2183,12 @@ async fn test_remote_external_agent_server( path: "mock".into(), args: vec!["foo-cli".into(), "--flag".into()], env: Some(HashMap::from_iter([ + ("NO_BROWSER".into(), "1".into()), ("VAR".into(), "val".into()), ("OTHER_VAR".into(), "other-val".into()) ])) } ); - assert!(login.is_none()); } pub async fn init_test( diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index b03a6efbe5771b5ca47e3711492eab5106ba11f8..02aa26156de3a66a160e2f0da25b3e57088aa8a3 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -316,73 +316,21 @@ impl From<&str> for LanguageModelProviderSetting { #[with_fallible_options] #[derive(Default, PartialEq, Deserialize, Serialize, Clone, JsonSchema, MergeFrom, Debug)] -pub struct AllAgentServersSettings { - pub gemini: Option, - pub claude: Option, - pub codex: Option, - - /// Custom agent servers configured by the user - #[serde(flatten)] - pub custom: HashMap, +#[serde(transparent)] +pub struct AllAgentServersSettings(pub HashMap); + +impl std::ops::Deref for AllAgentServersSettings { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } } -#[with_fallible_options] -#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, MergeFrom, Debug, PartialEq)] -pub struct BuiltinAgentServerSettings { - /// Absolute path to a binary to be used when launching this agent. - /// - /// This can be used to run a specific binary without automatic downloads or searching `$PATH`. - #[serde(rename = "command")] - pub path: Option, - /// If a binary is specified in `command`, it will be passed these arguments. - pub args: Option>, - /// If a binary is specified in `command`, it will be passed these environment variables. - pub env: Option>, - /// Whether to skip searching `$PATH` for an agent server binary when - /// launching this agent. - /// - /// This has no effect if a `command` is specified. Otherwise, when this is - /// `false`, Zed will search `$PATH` for an agent server binary and, if one - /// is found, use it for threads with this agent. If no agent binary is - /// found on `$PATH`, Zed will automatically install and use its own binary. - /// When this is `true`, Zed will not search `$PATH`, and will always use - /// its own binary. - /// - /// Default: true - pub ignore_system_version: Option, - /// The default mode to use for this agent. - /// - /// Note: Not only all agents support modes. - /// - /// Default: None - pub default_mode: Option, - /// The default model to use for this agent. - /// - /// This should be the model ID as reported by the agent. - /// - /// Default: None - pub default_model: Option, - /// The favorite models for this agent. - /// - /// These are the model IDs as reported by the agent. - /// - /// Default: [] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub favorite_models: Vec, - /// Default values for session config options. - /// - /// This is a map from config option ID to value ID. - /// - /// Default: {} - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub default_config_options: HashMap, - /// Favorited values for session config options. - /// - /// This is a map from config option ID to a list of favorited value IDs. - /// - /// Default: {} - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub favorite_config_option_values: HashMap>, +impl std::ops::DerefMut for AllAgentServersSettings { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } } #[with_fallible_options] diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index b7471321db203075ac6c71eee0b3ef29c5edaefc..0ae98d510aa34b05f7fa1766176f21ea353394d9 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -1947,8 +1947,8 @@ impl AgentServer for StubAgentServer { &self, _delegate: AgentServerDelegate, _cx: &mut App, - ) -> gpui::Task, Option)>> { - gpui::Task::ready(Ok((Rc::new(self.connection.clone()), None))) + ) -> gpui::Task>> { + gpui::Task::ready(Ok(Rc::new(self.connection.clone()))) } fn into_any(self: Rc) -> Rc { diff --git a/docs/src/ai/external-agents.md b/docs/src/ai/external-agents.md index 74afac9604988afe952f4735ed0f81fb994b57c7..7a76e795f127651201a6483986ebbc917088bf96 100644 --- a/docs/src/ai/external-agents.md +++ b/docs/src/ai/external-agents.md @@ -27,7 +27,10 @@ If you'd like to bind this to a keyboard shortcut, you can do so by editing your [ { "bindings": { - "cmd-alt-g": ["agent::NewExternalAgentThread", { "agent": "gemini" }] + "cmd-alt-g": [ + "agent::NewExternalAgentThread", + { "agent": { "custom": { "name": "gemini" } } } + ] } } ] @@ -38,32 +41,14 @@ If you'd like to bind this to a keyboard shortcut, you can do so by editing your The first time you create a Gemini CLI thread, Zed will install [@google/gemini-cli](https://github.com/google-gemini/gemini-cli). This installation is only available to Zed and is kept up to date as you use the agent. -By default, Zed will use this managed version of Gemini CLI even if you have it installed globally. -However, you can configure it to use a version in your `PATH` by adding this to your settings: - -```json [settings] -{ - "agent_servers": { - "gemini": { - "ignore_system_version": false - } - } -} -``` - #### Authentication -After you have Gemini CLI running, you'll be prompted to choose your authentication method. +After you have Gemini CLI running, you'll be prompted to authenticate. -Most users should click the "Log in with Google". -This will cause a browser window to pop-up and auth directly with Gemini CLI. +Click the "Login" button to open the Gemini CLI interactively, where you can log in with your Google account or [Vertex AI](https://cloud.google.com/vertex-ai) credentials. Zed does not see your OAuth or access tokens in this case. -You can also use the "Gemini API Key". -If you select this, and have the `GEMINI_API_KEY` set, then we will use that. -Otherwise Zed will prompt you for an API key which will be stored securely in your keychain, and used to start Gemini CLI from within Zed. - -The "Vertex AI" option is for those who are using [Vertex AI](https://cloud.google.com/vertex-ai), and have already configured their environment correctly. +If the `GEMINI_API_KEY` environment variable (or `GOOGLE_AI_API_KEY`) is already set, or you have configured a Google AI API key in Zed's [language model provider settings](./llm-providers.md#google-ai), it will be passed to Gemini CLI automatically. For more information, see the [Gemini CLI docs](https://github.com/google-gemini/gemini-cli/blob/main/docs/index.md). @@ -88,7 +73,10 @@ If you'd like to bind this to a keyboard shortcut, you can do so by editing your [ { "bindings": { - "cmd-alt-c": ["agent::NewExternalAgentThread", { "agent": "claude_code" }] + "cmd-alt-c": [ + "agent::NewExternalAgentThread", + { "agent": { "custom": { "name": "claude-acp" } } } + ] } } ] @@ -114,7 +102,8 @@ If you want to override the executable used by the adapter, you can set the `CLA ```json { "agent_servers": { - "claude": { + "claude-acp": { + "type": "registry", "env": { "CLAUDE_CODE_EXECUTABLE": "/path/to/alternate-claude-code-executable" } @@ -159,7 +148,10 @@ If you'd like to bind this to a keyboard shortcut, you can do so by editing your [ { "bindings": { - "cmd-alt-c": ["agent::NewExternalAgentThread", { "agent": "codex" }] + "cmd-alt-c": [ + "agent::NewExternalAgentThread", + { "agent": { "custom": { "name": "codex-acp" } } } + ] } } ] @@ -248,7 +240,7 @@ You can also add agents through your settings file ([how to edit](../configuring This can be useful if you're in the middle of developing a new agent that speaks the protocol and you want to debug it. -It's also possible to specify a custom path, arguments, or environment for the builtin integrations by using the `claude` and `gemini` names. +It's also possible to customize environment variables for registry-installed agents like Claude Agent, Codex, and Gemini CLI by using their registry names (`claude-acp`, `codex-acp`, `gemini`) with `"type": "registry"` in your settings. ## Debugging Agents