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

Closes: https://github.com/zed-industries/zed/issues/37089

Instead of looking for the gemini command on `$PATH`, by default we'll
install our own copy on demand under our data dir, as we already do for
language servers and debug adapters. This also means we can handle
keeping the binary up to date instead of prompting the user to upgrade.

Notes:

- The download is only triggered if you open a new Gemini thread
- Custom commands from `agent_servers.gemini` in settings are respected
as before
- A new `agent_servers.gemini.ignore_system_version` setting is added,
similar to the existing settings for language servers. It's `true` by
default, and setting it to `false` disables the automatic download and
makes Zed search `$PATH` as before.
- If `agent_servers.gemini.ignore_system_version` is `false` and no
binary is found on `$PATH`, we'll fall back to automatic installation.
If it's `false` and a binary is found, but the version is older than
v0.2.1, we'll show an error.

Release Notes:

- acp: By default, Zed will now download and use a private copy of the
Gemini CLI binary, instead of searching your `$PATH`. To make Zed search
your `$PATH` for Gemini CLI before attempting to download it, use the
following setting:

```
{
  "agent_servers": {
    "gemini": {
      "ignore_system_version": false
    }
  }
}
```
This commit is contained in:
Cole Miller
2025-08-28 19:33:00 +00:00
committed by GitHub
parent 47aaaa8bcf
commit 8697b91ea0
15 changed files with 331 additions and 430 deletions
+3 -2
View File
@@ -4,7 +4,7 @@ use crate::{
};
use acp_thread::{MentionUri, selection_name};
use agent_client_protocol as acp;
use agent_servers::AgentServer;
use agent_servers::{AgentServer, AgentServerDelegate};
use agent2::HistoryStore;
use anyhow::{Result, anyhow};
use assistant_slash_commands::codeblock_fence_for_path;
@@ -645,7 +645,8 @@ impl MessageEditor {
self.project.read(cx).fs().clone(),
self.history_store.clone(),
));
let connection = server.connect(Path::new(""), &self.project, cx);
let delegate = AgentServerDelegate::new(self.project.clone(), watch::channel("".into()).0);
let connection = server.connect(Path::new(""), delegate, cx);
cx.spawn(async move |_, cx| {
let agent = connection.await?;
let agent = agent.downcast::<agent2::NativeAgentConnection>().unwrap();
+68 -149
View File
@@ -6,7 +6,7 @@ use acp_thread::{
use acp_thread::{AgentConnection, Plan};
use action_log::ActionLog;
use agent_client_protocol::{self as acp, PromptCapabilities};
use agent_servers::{AgentServer, ClaudeCode};
use agent_servers::{AgentServer, AgentServerDelegate, ClaudeCode};
use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting};
use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore};
use anyhow::{Result, anyhow, bail};
@@ -46,7 +46,7 @@ use text::Anchor;
use theme::ThemeSettings;
use ui::{
Callout, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle,
Scrollbar, ScrollbarState, SpinnerLabel, TintColor, Tooltip, prelude::*,
Scrollbar, ScrollbarState, SpinnerLabel, Tooltip, prelude::*,
};
use util::{ResultExt, size::format_file_size, time::duration_alt_display};
use workspace::{CollaboratorId, Workspace};
@@ -285,15 +285,12 @@ pub struct AcpThreadView {
editing_message: Option<usize>,
prompt_capabilities: Rc<Cell<PromptCapabilities>>,
is_loading_contents: bool,
install_command_markdown: Entity<Markdown>,
_cancel_task: Option<Task<()>>,
_subscriptions: [Subscription; 3],
}
enum ThreadState {
Loading {
_task: Task<()>,
},
Loading(Entity<LoadingView>),
Ready {
thread: Entity<AcpThread>,
title_editor: Option<Entity<Editor>>,
@@ -309,6 +306,12 @@ enum ThreadState {
},
}
struct LoadingView {
title: SharedString,
_load_task: Task<()>,
_update_title_task: Task<anyhow::Result<()>>,
}
impl AcpThreadView {
pub fn new(
agent: Rc<dyn AgentServer>,
@@ -399,7 +402,6 @@ impl AcpThreadView {
hovered_recent_history_item: None,
prompt_capabilities,
is_loading_contents: false,
install_command_markdown: cx.new(|cx| Markdown::new("".into(), None, None, cx)),
_subscriptions: subscriptions,
_cancel_task: None,
focus_handle: cx.focus_handle(),
@@ -420,8 +422,10 @@ impl AcpThreadView {
.next()
.map(|worktree| worktree.read(cx).abs_path())
.unwrap_or_else(|| paths::home_dir().as_path().into());
let (tx, mut rx) = watch::channel("Loading…".into());
let delegate = AgentServerDelegate::new(project.clone(), tx);
let connect_task = agent.connect(&root_dir, &project, cx);
let connect_task = agent.connect(&root_dir, delegate, cx);
let load_task = cx.spawn_in(window, async move |this, cx| {
let connection = match connect_task.await {
Ok(connection) => connection,
@@ -574,7 +578,25 @@ impl AcpThreadView {
.log_err();
});
ThreadState::Loading { _task: load_task }
let loading_view = cx.new(|cx| {
let update_title_task = cx.spawn(async move |this, cx| {
loop {
let status = rx.recv().await?;
this.update(cx, |this: &mut LoadingView, cx| {
this.title = status;
cx.notify();
})?;
}
});
LoadingView {
title: "Loading…".into(),
_load_task: load_task,
_update_title_task: update_title_task,
}
});
ThreadState::Loading(loading_view)
}
fn handle_auth_required(
@@ -674,13 +696,15 @@ impl AcpThreadView {
}
}
pub fn title(&self) -> SharedString {
pub fn title(&self, cx: &App) -> SharedString {
match &self.thread_state {
ThreadState::Ready { .. } | ThreadState::Unauthenticated { .. } => "New Thread".into(),
ThreadState::Loading { .. } => "Loading…".into(),
ThreadState::Loading(loading_view) => loading_view.read(cx).title.clone(),
ThreadState::LoadError(error) => match error {
LoadError::NotInstalled { .. } => format!("Install {}", self.agent.name()).into(),
LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(),
LoadError::FailedToInstall(_) => {
format!("Failed to Install {}", self.agent.name()).into()
}
LoadError::Exited { .. } => format!("{} Exited", self.agent.name()).into(),
LoadError::Other(_) => format!("Error Loading {}", self.agent.name()).into(),
},
@@ -2950,18 +2974,26 @@ impl AcpThreadView {
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let (message, action_slot): (SharedString, _) = match e {
LoadError::NotInstalled => {
return self.render_not_installed(None, window, cx);
}
let (title, message, action_slot): (_, SharedString, _) = match e {
LoadError::Unsupported {
command: path,
current_version,
minimum_version,
} => {
return self.render_not_installed(Some((path, current_version)), window, cx);
return self.render_unsupported(path, current_version, minimum_version, window, cx);
}
LoadError::Exited { .. } => ("Server exited with status {status}".into(), None),
LoadError::FailedToInstall(msg) => (
"Failed to Install",
msg.into(),
Some(self.create_copy_button(msg.to_string()).into_any_element()),
),
LoadError::Exited { status } => (
"Failed to Launch",
format!("Server exited with status {status}").into(),
None,
),
LoadError::Other(msg) => (
"Failed to Launch",
msg.into(),
Some(self.create_copy_button(msg.to_string()).into_any_element()),
),
@@ -2970,95 +3002,34 @@ impl AcpThreadView {
Callout::new()
.severity(Severity::Error)
.icon(IconName::XCircleFilled)
.title("Failed to Launch")
.title(title)
.description(message)
.actions_slot(div().children(action_slot))
.into_any_element()
}
fn install_agent(&self, window: &mut Window, cx: &mut Context<Self>) {
telemetry::event!("Agent Install CLI", agent = self.agent.telemetry_id());
let Some(install_command) = self.agent.install_command().map(|s| s.to_owned()) else {
return;
};
let task = self
.workspace
.update(cx, |workspace, cx| {
let project = workspace.project().read(cx);
let cwd = project.first_project_directory(cx);
let shell = project.terminal_settings(&cwd, cx).shell.clone();
let spawn_in_terminal = task::SpawnInTerminal {
id: task::TaskId(install_command.clone()),
full_label: install_command.clone(),
label: install_command.clone(),
command: Some(install_command.clone()),
args: Vec::new(),
command_label: install_command.clone(),
cwd,
env: Default::default(),
use_new_terminal: true,
allow_concurrent_runs: true,
reveal: Default::default(),
reveal_target: Default::default(),
hide: Default::default(),
shell,
show_summary: true,
show_command: true,
show_rerun: false,
};
workspace.spawn_in_terminal(spawn_in_terminal, window, cx)
})
.ok();
let Some(task) = task else { return };
cx.spawn_in(window, async move |this, cx| {
if let Some(Ok(_)) = task.await {
this.update_in(cx, |this, window, cx| {
this.reset(window, cx);
})
.ok();
}
})
.detach()
}
fn render_not_installed(
fn render_unsupported(
&self,
existing_version: Option<(&SharedString, &SharedString)>,
window: &mut Window,
path: &SharedString,
version: &SharedString,
minimum_version: &SharedString,
_window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let install_command = self.agent.install_command().unwrap_or_default();
self.install_command_markdown.update(cx, |markdown, cx| {
if !markdown.source().contains(&install_command) {
markdown.replace(format!("```\n{}\n```", install_command), cx);
}
});
let (heading_label, description_label, button_label) =
if let Some((path, version)) = existing_version {
(
format!("Upgrade {} to work with Zed", self.agent.name()),
if version.is_empty() {
format!(
"Currently using {}, which does not report a valid --version",
path,
)
} else {
format!(
"Currently using {}, which is only version {}",
path, version
)
},
format!("Upgrade {}", self.agent.name()),
let (heading_label, description_label) = (
format!("Upgrade {} to work with Zed", self.agent.name()),
if version.is_empty() {
format!(
"Currently using {}, which does not report a valid --version",
path,
)
} else {
(
format!("Get Started with {} in Zed", self.agent.name()),
"Use Google's new coding agent directly in Zed.".to_string(),
format!("Install {}", self.agent.name()),
format!(
"Currently using {}, which is only version {} (need at least {minimum_version})",
path, version
)
};
},
);
v_flex()
.w_full()
@@ -3078,34 +3049,6 @@ impl AcpThreadView {
.color(Color::Muted),
),
)
.child(
Button::new("install_gemini", button_label)
.full_width()
.size(ButtonSize::Medium)
.style(ButtonStyle::Tinted(TintColor::Accent))
.label_size(LabelSize::Small)
.icon(IconName::TerminalGhost)
.icon_color(Color::Muted)
.icon_size(IconSize::Small)
.icon_position(IconPosition::Start)
.on_click(cx.listener(|this, _, window, cx| this.install_agent(window, cx))),
)
.child(
Label::new("Or, run the following command in your terminal:")
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(MarkdownElement::new(
self.install_command_markdown.clone(),
default_markdown_style(false, false, window, cx),
))
.when_some(existing_version, |el, (path, _)| {
el.child(
Label::new(format!("If this does not work you will need to upgrade manually, or uninstall your existing version from {}", path))
.size(LabelSize::Small)
.color(Color::Muted),
)
})
.into_any_element()
}
@@ -4994,18 +4937,6 @@ impl AcpThreadView {
}))
}
fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.thread_state = Self::initial_state(
self.agent.clone(),
None,
self.workspace.clone(),
self.project.clone(),
window,
cx,
);
cx.notify();
}
pub fn delete_history_entry(&mut self, entry: HistoryEntry, cx: &mut Context<Self>) {
let task = match entry {
HistoryEntry::AcpThread(thread) => self.history_store.update(cx, |history, cx| {
@@ -5534,22 +5465,10 @@ pub(crate) mod tests {
"Test".into()
}
fn empty_state_headline(&self) -> SharedString {
"Test".into()
}
fn empty_state_message(&self) -> SharedString {
"Test".into()
}
fn install_command(&self) -> Option<&'static str> {
None
}
fn connect(
&self,
_root_dir: &Path,
_project: &Entity<Project>,
_delegate: AgentServerDelegate,
_cx: &mut App,
) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
Task::ready(Ok(Rc::new(self.connection.clone())))
+24 -127
View File
@@ -5,7 +5,7 @@ mod tool_picker;
use std::{ops::Range, sync::Arc, time::Duration};
use agent_servers::{AgentServerCommand, AgentServerSettings, AllAgentServersSettings, Gemini};
use agent_servers::{AgentServerCommand, AllAgentServersSettings, CustomAgentServerSettings};
use agent_settings::AgentSettings;
use anyhow::Result;
use assistant_tool::{ToolSource, ToolWorkingSet};
@@ -27,7 +27,6 @@ use language_model::{
};
use notifications::status_toast::{StatusToast, ToastIcon};
use project::{
Project,
context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
project_settings::{ContextServerSettings, ProjectSettings},
};
@@ -52,7 +51,6 @@ pub struct AgentConfiguration {
fs: Arc<dyn Fs>,
language_registry: Arc<LanguageRegistry>,
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
focus_handle: FocusHandle,
configuration_views_by_provider: HashMap<LanguageModelProviderId, AnyView>,
context_server_store: Entity<ContextServerStore>,
@@ -62,7 +60,6 @@ pub struct AgentConfiguration {
_registry_subscription: Subscription,
scroll_handle: ScrollHandle,
scrollbar_state: ScrollbarState,
gemini_is_installed: bool,
_check_for_gemini: Task<()>,
}
@@ -73,7 +70,6 @@ impl AgentConfiguration {
tools: Entity<ToolWorkingSet>,
language_registry: Arc<LanguageRegistry>,
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -98,11 +94,6 @@ impl AgentConfiguration {
cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify())
.detach();
cx.observe_global_in::<SettingsStore>(window, |this, _, cx| {
this.check_for_gemini(cx);
cx.notify();
})
.detach();
let scroll_handle = ScrollHandle::new();
let scrollbar_state = ScrollbarState::new(scroll_handle.clone());
@@ -111,7 +102,6 @@ impl AgentConfiguration {
fs,
language_registry,
workspace,
project,
focus_handle,
configuration_views_by_provider: HashMap::default(),
context_server_store,
@@ -121,11 +111,9 @@ impl AgentConfiguration {
_registry_subscription: registry_subscription,
scroll_handle,
scrollbar_state,
gemini_is_installed: false,
_check_for_gemini: Task::ready(()),
};
this.build_provider_configuration_views(window, cx);
this.check_for_gemini(cx);
this
}
@@ -155,34 +143,6 @@ impl AgentConfiguration {
self.configuration_views_by_provider
.insert(provider.id(), configuration_view);
}
fn check_for_gemini(&mut self, cx: &mut Context<Self>) {
let project = self.project.clone();
let settings = AllAgentServersSettings::get_global(cx).clone();
self._check_for_gemini = cx.spawn({
async move |this, cx| {
let Some(project) = project.upgrade() else {
return;
};
let gemini_is_installed = AgentServerCommand::resolve(
Gemini::binary_name(),
&[],
// TODO expose fallback path from the Gemini/CC types so we don't have to hardcode it again here
None,
settings.gemini,
&project,
cx,
)
.await
.is_some();
this.update(cx, |this, cx| {
this.gemini_is_installed = gemini_is_installed;
cx.notify();
})
.ok();
}
});
}
}
impl Focusable for AgentConfiguration {
@@ -1041,9 +1001,8 @@ impl AgentConfiguration {
name.clone(),
ExternalAgent::Custom {
name: name.clone(),
settings: settings.clone(),
command: settings.command.clone(),
},
None,
cx,
)
.into_any_element()
@@ -1102,7 +1061,6 @@ impl AgentConfiguration {
IconName::AiGemini,
"Gemini CLI",
ExternalAgent::Gemini,
(!self.gemini_is_installed).then_some(Gemini::install_command().into()),
cx,
))
// TODO add CC
@@ -1115,7 +1073,6 @@ impl AgentConfiguration {
icon: IconName,
name: impl Into<SharedString>,
agent: ExternalAgent,
install_command: Option<SharedString>,
cx: &mut Context<Self>,
) -> impl IntoElement {
let name = name.into();
@@ -1135,88 +1092,28 @@ impl AgentConfiguration {
.child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
.child(Label::new(name.clone())),
)
.map(|this| {
if let Some(install_command) = install_command {
this.child(
Button::new(
SharedString::from(format!("install_external_agent-{name}")),
"Install Agent",
)
.label_size(LabelSize::Small)
.icon(IconName::Plus)
.icon_position(IconPosition::Start)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
.tooltip(Tooltip::text(install_command.clone()))
.on_click(cx.listener(
move |this, _, window, cx| {
let Some(project) = this.project.upgrade() else {
return;
};
let Some(workspace) = this.workspace.upgrade() else {
return;
};
let cwd = project.read(cx).first_project_directory(cx);
let shell =
project.read(cx).terminal_settings(&cwd, cx).shell.clone();
let spawn_in_terminal = task::SpawnInTerminal {
id: task::TaskId(install_command.to_string()),
full_label: install_command.to_string(),
label: install_command.to_string(),
command: Some(install_command.to_string()),
args: Vec::new(),
command_label: install_command.to_string(),
cwd,
env: Default::default(),
use_new_terminal: true,
allow_concurrent_runs: true,
reveal: Default::default(),
reveal_target: Default::default(),
hide: Default::default(),
shell,
show_summary: true,
show_command: true,
show_rerun: false,
};
let task = workspace.update(cx, |workspace, cx| {
workspace.spawn_in_terminal(spawn_in_terminal, window, cx)
});
cx.spawn(async move |this, cx| {
task.await;
this.update(cx, |this, cx| {
this.check_for_gemini(cx);
})
.ok();
})
.detach();
},
)),
.child(
h_flex().gap_1().child(
Button::new(
SharedString::from(format!("start_acp_thread-{name}")),
"Start New Thread",
)
} else {
this.child(
h_flex().gap_1().child(
Button::new(
SharedString::from(format!("start_acp_thread-{name}")),
"Start New Thread",
)
.label_size(LabelSize::Small)
.icon(IconName::Thread)
.icon_position(IconPosition::Start)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
.on_click(move |_, window, cx| {
window.dispatch_action(
NewExternalAgentThread {
agent: Some(agent.clone()),
}
.boxed_clone(),
cx,
);
}),
),
)
}
})
.label_size(LabelSize::Small)
.icon(IconName::Thread)
.icon_position(IconPosition::Start)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
.on_click(move |_, window, cx| {
window.dispatch_action(
NewExternalAgentThread {
agent: Some(agent.clone()),
}
.boxed_clone(),
cx,
);
}),
),
)
}
}
@@ -1393,7 +1290,7 @@ async fn open_new_agent_servers_entry_in_settings_editor(
unique_server_name = Some(server_name.clone());
file.custom.insert(
server_name,
AgentServerSettings {
CustomAgentServerSettings {
command: AgentServerCommand {
path: "path_to_executable".into(),
args: vec![],
+8 -9
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use std::time::Duration;
use acp_thread::AcpThread;
use agent_servers::AgentServerSettings;
use agent_servers::AgentServerCommand;
use agent2::{DbThreadMetadata, HistoryEntry};
use db::kvp::{Dismissable, KEY_VALUE_STORE};
use serde::{Deserialize, Serialize};
@@ -259,7 +259,7 @@ pub enum AgentType {
NativeAgent,
Custom {
name: SharedString,
settings: AgentServerSettings,
command: AgentServerCommand,
},
}
@@ -1479,7 +1479,6 @@ impl AgentPanel {
tools,
self.language_registry.clone(),
self.workspace.clone(),
self.project.downgrade(),
window,
cx,
)
@@ -1896,8 +1895,8 @@ impl AgentPanel {
window,
cx,
),
AgentType::Custom { name, settings } => self.external_thread(
Some(crate::ExternalAgent::Custom { name, settings }),
AgentType::Custom { name, command } => self.external_thread(
Some(crate::ExternalAgent::Custom { name, command }),
None,
None,
window,
@@ -2115,7 +2114,7 @@ impl AgentPanel {
.child(title_editor)
.into_any_element()
} else {
Label::new(thread_view.read(cx).title())
Label::new(thread_view.read(cx).title(cx))
.color(Color::Muted)
.truncate()
.into_any_element()
@@ -2664,9 +2663,9 @@ impl AgentPanel {
AgentType::Custom {
name: agent_name
.clone(),
settings:
agent_settings
.clone(),
command: agent_settings
.command
.clone(),
},
window,
cx,
+4 -4
View File
@@ -28,7 +28,7 @@ use std::rc::Rc;
use std::sync::Arc;
use agent::{Thread, ThreadId};
use agent_servers::AgentServerSettings;
use agent_servers::AgentServerCommand;
use agent_settings::{AgentProfileId, AgentSettings, LanguageModelSelection};
use assistant_slash_command::SlashCommandRegistry;
use client::Client;
@@ -170,7 +170,7 @@ enum ExternalAgent {
NativeAgent,
Custom {
name: SharedString,
settings: AgentServerSettings,
command: AgentServerCommand,
},
}
@@ -193,9 +193,9 @@ impl ExternalAgent {
Self::Gemini => Rc::new(agent_servers::Gemini),
Self::ClaudeCode => Rc::new(agent_servers::ClaudeCode),
Self::NativeAgent => Rc::new(agent2::NativeAgentServer::new(fs, history)),
Self::Custom { name, settings } => Rc::new(agent_servers::CustomAgentServer::new(
Self::Custom { name, command } => Rc::new(agent_servers::CustomAgentServer::new(
name.clone(),
settings,
command.clone(),
)),
}
}