agent_servers: Migrate all built-in agents to go via registry (#50094)

This has lots of benefits, but mainly allows users to uninstall agents.

Release Notes:

- N/A

---------

Co-authored-by: Anthony Eid <hello@anthonyeid.me>
Co-authored-by: cameron <cameron.studdstreet@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-02-26 12:24:39 +00:00
1 parent bf6a737e59
commit f4e65d8988
29 files changed
+1031 -2054

No files matched your search

+2 -10
View File
@@ -37,12 +37,7 @@ impl AgentServer for NativeAgentServer {
&self,
delegate: AgentServerDelegate,
cx: &mut App,
) -> Task<
Result<(
Rc<dyn acp_thread::AgentConnection>,
Option<task::SpawnInTerminal>,
)>,
> {
) -> Task<Result<Rc<dyn acp_thread::AgentConnection>>> {
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<dyn acp_thread::AgentConnection>,
None,
))
Ok(Rc::new(connection) as Rc<dyn acp_thread::AgentConnection>)
})
}
+21 -2
View File
@@ -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,
+1 -7
View File
@@ -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<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>>;
) -> Task<Result<Rc<dyn AgentConnection>>>;
fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
-258
View File
@@ -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<String>,
}
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<acp::SessionModeId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(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<acp::SessionModeId>, fs: Arc<dyn Fs>, 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<acp::ModelId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(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<acp::ModelId>, fs: Arc<dyn Fs>, 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<acp::ModelId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(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<dyn Fs>,
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<String> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(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<dyn Fs>,
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<acp::SessionConfigValueId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(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<dyn Fs>,
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<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
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::<AllAgentServersSettings>(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<Self>) -> Rc<dyn Any> {
self
}
}
-270
View File
@@ -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<acp::SessionModeId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(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<acp::SessionModeId>, fs: Arc<dyn Fs>, 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<acp::ModelId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(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<acp::ModelId>, fs: Arc<dyn Fs>, 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<acp::ModelId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(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<dyn Fs>,
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<String> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(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<dyn Fs>,
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<acp::SessionConfigValueId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(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<dyn Fs>,
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<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
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::<AllAgentServersSettings>(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<Self>) -> Rc<dyn Any> {
self
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
crate::common_e2e_tests!(async |_, _| Codex, allow_option_id = "proceed_once");
}
+58 -16
View File
@@ -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::<AllAgentServersSettings>(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::<AllAgentServersSettings>(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::<AllAgentServersSettings>(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::<AllAgentServersSettings>(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::<AllAgentServersSettings>(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<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
) -> Task<Result<Rc<dyn AgentConnection>>> {
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::<AllAgentServersSettings>(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<Result<String>> {
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 = <dyn CredentialsProvider>::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(),
)
})
}
+2 -15
View File
@@ -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<FakeFs> {
#[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
-125
View File
@@ -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<Result<String>> {
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 = <dyn CredentialsProvider>::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<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
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::<AllAgentServersSettings>(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<Self>) -> Rc<dyn Any> {
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,
}
}
}
+21 -107
View File
@@ -107,8 +107,8 @@ pub(crate) enum ThreadError {
},
}
impl ThreadError {
fn from_err(error: anyhow::Error, agent_name: &str) -> Self {
impl From<anyhow::Error> for ThreadError {
fn from(error: anyhow::Error) -> Self {
if error.is::<language_model::PaymentRequiredError>() {
Self::PaymentRequired
} else if let Some(acp_error) = error.downcast_ref::<acp::Error>()
@@ -123,18 +123,9 @@ impl ThreadError {
.downcast_ref::<acp::Error>()
.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<Entity<ThreadStore>>,
prompt_store: Option<Entity<PromptStore>>,
server_state: ServerState,
login: Option<task::SpawnInTerminal>, // is some <=> Active | Unauthenticated
history: Entity<AcpThreadHistory>,
focus_handle: FocusHandle,
notifications: Vec<WindowHandle<AgentNotification>>,
@@ -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<AcpThreadView>> {
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::<LoadError>().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<gpui::Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
Task::ready(Ok((Rc::new(self.connection.clone()), None)))
) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
Task::ready(Ok(Rc::new(self.connection.clone())))
}
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
@@ -3641,7 +3555,7 @@ pub(crate) mod tests {
&self,
_delegate: AgentServerDelegate,
_cx: &mut App,
) -> Task<gpui::Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
Task::ready(Err(anyhow!(
"extracting downloaded asset for \
https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/\
@@ -189,7 +189,6 @@ impl DiffStats {
pub struct AcpThreadView {
pub id: acp::SessionId,
pub parent_id: Option<acp::SessionId>,
pub login: Option<task::SpawnInTerminal>, // is some <=> Active | Unauthenticated
pub thread: Entity<AcpThread>,
pub(crate) conversation: Entity<super::Conversation>,
pub server_view: WeakEntity<AcpServerView>,
@@ -281,7 +280,6 @@ impl AcpThreadView {
parent_id: Option<acp::SessionId>,
thread: Entity<AcpThread>,
conversation: Entity<super::Conversation>,
login: Option<task::SpawnInTerminal>,
server_view: WeakEntity<AcpServerView>,
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<Self>) {
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<Self>) {
pub(crate) fn handle_thread_error(
&mut self,
error: impl Into<ThreadError>,
cx: &mut Context<Self>,
) {
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);
}
})
})
+38 -92
View File
@@ -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::<Vec<_>>();
@@ -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,
.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,
))
.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,
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(),
},
);
}
});
+153 -156
View File
@@ -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<IconName> {
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<ExternalAgent> 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<ExternalAgent> {
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::<AgentPanel>(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::<AgentPanel>(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::<AgentPanel>(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::<Vec<_>>();
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());
for item in &agent_items {
let mut entry =
ContextMenuEntry::new(item.display_name.clone());
let mut entry = ContextMenuEntry::new(display_name);
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::<Vec<_>>();
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 = <dyn fs::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::<AgentPanel>(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!(
+4 -4
View File
@@ -173,7 +173,7 @@ impl AgentRegistryPage {
.global::<SettingsStore>()
.get::<AllAgentServersSettings>(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());
}
});
})
-6
View File
@@ -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<agent::ThreadStore>,
) -> Rc<dyn agent_servers::AgentServer> {
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())),
}
+1 -1
View File
@@ -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::<agent::NativeAgentConnection>().unwrap();
let summary = agent
.0
+12 -6
View File
@@ -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::<AgentPanel>(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<Self>) {
cx.open_url(&zed_urls::external_agents_docs(cx));
fn open_agent_registry(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
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<Self>) {
@@ -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(
@@ -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::<AgentPanel>(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<Self>) {
cx.open_url(&zed_urls::external_agents_docs(cx));
fn view_docs(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
window.dispatch_action(Box::new(zed_actions::AcpRegistry), cx);
cx.notify();
claude_agent_onboarding_event!("Documentation Link Clicked");
-16
View File
@@ -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!(
+6
View File
@@ -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;
}
@@ -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<String, Value>) -> 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<String, Value>,
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),
);
}
}
+412
View File
@@ -237,6 +237,7 @@ pub fn migrate_settings(text: &str) -> Result<Option<String>> {
),
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"
}
}
}"#,
),
);
}
}
+1 -1
View File
@@ -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;
File diff suppressed because it is too large. Load diff
@@ -13,15 +13,12 @@ impl ExternalAgentServer for NoopExternalAgent {
_status_tx: Option<watch::Sender<SharedString>>,
_new_version_available_tx: Option<watch::Sender<Option<String>>>,
_cx: &mut AsyncApp,
) -> Task<Result<(AgentServerCommand, Option<task::SpawnInTerminal>)>> {
Task::ready(Ok((
AgentServerCommand {
path: PathBuf::from("noop"),
args: Vec::new(),
env: None,
},
None,
)))
) -> Task<Result<AgentServerCommand>> {
Task::ready(Ok(AgentServerCommand {
path: PathBuf::from("noop"),
args: Vec::new(),
env: None,
}))
}
fn as_any_mut(&mut self) -> &mut dyn Any {
@@ -29,15 +29,12 @@ impl ExternalAgentServer for NoopExternalAgent {
_status_tx: Option<watch::Sender<SharedString>>,
_new_version_available_tx: Option<watch::Sender<Option<String>>>,
_cx: &mut AsyncApp,
) -> Task<Result<(AgentServerCommand, Option<task::SpawnInTerminal>)>> {
Task::ready(Ok((
AgentServerCommand {
path: PathBuf::from("noop"),
args: Vec::new(),
env: None,
},
None,
)))
) -> Task<Result<AgentServerCommand>> {
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()],
@@ -2129,7 +2129,7 @@ async fn test_remote_external_agent_server(
.map(|name| name.to_string())
.collect::<Vec<_>>()
});
pretty_assertions::assert_eq!(names, ["codex", "gemini", "claude"]);
pretty_assertions::assert_eq!(names, Vec::<String>::new());
server_cx.update_global::<SettingsStore, _>(|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::<Vec<_>>()
});
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(
+12 -64
View File
@@ -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<BuiltinAgentServerSettings>,
pub claude: Option<BuiltinAgentServerSettings>,
pub codex: Option<BuiltinAgentServerSettings>,
#[serde(transparent)]
pub struct AllAgentServersSettings(pub HashMap<String, CustomAgentServerSettings>);
/// Custom agent servers configured by the user
#[serde(flatten)]
pub custom: HashMap<String, CustomAgentServerSettings>,
impl std::ops::Deref for AllAgentServersSettings {
type Target = HashMap<String, CustomAgentServerSettings>;
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<PathBuf>,
/// If a binary is specified in `command`, it will be passed these arguments.
pub args: Option<Vec<String>>,
/// If a binary is specified in `command`, it will be passed these environment variables.
pub env: Option<HashMap<String, String>>,
/// Whether to skip searching `$PATH` for an agent server binary when
/// launching this agent.
///
/// This has no effect if a `command` is specified. Otherwise, when this is
/// `false`, Zed will search `$PATH` for an agent server binary and, if one
/// is found, use it for threads with this agent. If no agent binary is
/// found on `$PATH`, Zed will automatically install and use its own binary.
/// When this is `true`, Zed will not search `$PATH`, and will always use
/// its own binary.
///
/// Default: true
pub ignore_system_version: Option<bool>,
/// The default mode to use for this agent.
///
/// Note: Not only all agents support modes.
///
/// Default: None
pub default_mode: Option<String>,
/// 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<String>,
/// 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<String>,
/// 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<String, String>,
/// 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<String, Vec<String>>,
impl std::ops::DerefMut for AllAgentServersSettings {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
#[with_fallible_options]
+2 -2
View File
@@ -1947,8 +1947,8 @@ impl AgentServer for StubAgentServer {
&self,
_delegate: AgentServerDelegate,
_cx: &mut App,
) -> gpui::Task<gpui::Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
gpui::Task::ready(Ok((Rc::new(self.connection.clone()), None)))
) -> gpui::Task<gpui::Result<Rc<dyn AgentConnection>>> {
gpui::Task::ready(Ok(Rc::new(self.connection.clone())))
}
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+18 -26
View File
@@ -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