agent: Add section for agent servers in settings view (#35206)

Release Notes:

- N/A

---------

Co-authored-by: Cole Miller <cole@zed.dev>
This commit is contained in:
Danilo Leal 2025-08-25 15:45:24 -03:00 committed by Joseph T. Lyons
parent 5d8e0f6ad1
commit 92a6ae1559
6 changed files with 254 additions and 22 deletions

View file

@ -97,7 +97,7 @@ pub struct AgentServerCommand {
} }
impl AgentServerCommand { impl AgentServerCommand {
pub(crate) async fn resolve( pub async fn resolve(
path_bin_name: &'static str, path_bin_name: &'static str,
extra_args: &[&'static str], extra_args: &[&'static str],
fallback_path: Option<&Path>, fallback_path: Option<&Path>,

View file

@ -53,7 +53,7 @@ impl AgentServer for Gemini {
return Err(LoadError::NotInstalled { return Err(LoadError::NotInstalled {
error_message: "Failed to find Gemini CLI binary".into(), error_message: "Failed to find Gemini CLI binary".into(),
install_message: "Install Gemini CLI".into(), install_message: "Install Gemini CLI".into(),
install_command: "npm install -g @google/gemini-cli@preview".into() install_command: Self::install_command().into(),
}.into()); }.into());
}; };
@ -88,7 +88,7 @@ impl AgentServer for Gemini {
current_version current_version
).into(), ).into(),
upgrade_message: "Upgrade Gemini CLI to latest".into(), upgrade_message: "Upgrade Gemini CLI to latest".into(),
upgrade_command: "npm install -g @google/gemini-cli@preview".into(), upgrade_command: Self::upgrade_command().into(),
}.into()) }.into())
} }
} }
@ -101,6 +101,20 @@ impl AgentServer for Gemini {
} }
} }
impl Gemini {
pub fn binary_name() -> &'static str {
"gemini"
}
pub fn install_command() -> &'static str {
"npm install -g @google/gemini-cli@preview"
}
pub fn upgrade_command() -> &'static str {
"npm install -g @google/gemini-cli@preview"
}
}
#[cfg(test)] #[cfg(test)]
pub(crate) mod tests { pub(crate) mod tests {
use super::*; use super::*;

View file

@ -2811,7 +2811,7 @@ impl AcpThreadView {
let cwd = project.first_project_directory(cx); let cwd = project.first_project_directory(cx);
let shell = project.terminal_settings(&cwd, cx).shell.clone(); let shell = project.terminal_settings(&cwd, cx).shell.clone();
let spawn_in_terminal = task::SpawnInTerminal { let spawn_in_terminal = task::SpawnInTerminal {
id: task::TaskId("install".to_string()), id: task::TaskId(install_command.clone()),
full_label: install_command.clone(), full_label: install_command.clone(),
label: install_command.clone(), label: install_command.clone(),
command: Some(install_command.clone()), command: Some(install_command.clone()),
@ -2868,7 +2868,7 @@ impl AcpThreadView {
let cwd = project.first_project_directory(cx); let cwd = project.first_project_directory(cx);
let shell = project.terminal_settings(&cwd, cx).shell.clone(); let shell = project.terminal_settings(&cwd, cx).shell.clone();
let spawn_in_terminal = task::SpawnInTerminal { let spawn_in_terminal = task::SpawnInTerminal {
id: task::TaskId("upgrade".to_string()), id: task::TaskId(upgrade_command.to_string()),
full_label: upgrade_command.clone(), full_label: upgrade_command.clone(),
label: upgrade_command.clone(), label: upgrade_command.clone(),
command: Some(upgrade_command.clone()), command: Some(upgrade_command.clone()),

View file

@ -5,6 +5,7 @@ mod tool_picker;
use std::{sync::Arc, time::Duration}; use std::{sync::Arc, time::Duration};
use agent_servers::{AgentServerCommand, AllAgentServersSettings, Gemini};
use agent_settings::AgentSettings; use agent_settings::AgentSettings;
use assistant_tool::{ToolSource, ToolWorkingSet}; use assistant_tool::{ToolSource, ToolWorkingSet};
use cloud_llm_client::Plan; use cloud_llm_client::Plan;
@ -15,7 +16,7 @@ use extension_host::ExtensionStore;
use fs::Fs; use fs::Fs;
use gpui::{ use gpui::{
Action, Animation, AnimationExt as _, AnyView, App, Corner, Entity, EventEmitter, FocusHandle, Action, Animation, AnimationExt as _, AnyView, App, Corner, Entity, EventEmitter, FocusHandle,
Focusable, ScrollHandle, Subscription, Task, Transformation, WeakEntity, percentage, Focusable, Hsla, ScrollHandle, Subscription, Task, Transformation, WeakEntity, percentage,
}; };
use language::LanguageRegistry; use language::LanguageRegistry;
use language_model::{ use language_model::{
@ -23,10 +24,11 @@ use language_model::{
}; };
use notifications::status_toast::{StatusToast, ToastIcon}; use notifications::status_toast::{StatusToast, ToastIcon};
use project::{ use project::{
Project,
context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
project_settings::{ContextServerSettings, ProjectSettings}, project_settings::{ContextServerSettings, ProjectSettings},
}; };
use settings::{Settings, update_settings_file}; use settings::{Settings, SettingsStore, update_settings_file};
use ui::{ use ui::{
Chip, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu, Chip, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu,
Scrollbar, ScrollbarState, Switch, SwitchColor, SwitchField, Tooltip, prelude::*, Scrollbar, ScrollbarState, Switch, SwitchColor, SwitchField, Tooltip, prelude::*,
@ -39,7 +41,7 @@ pub(crate) use configure_context_server_modal::ConfigureContextServerModal;
pub(crate) use manage_profiles_modal::ManageProfilesModal; pub(crate) use manage_profiles_modal::ManageProfilesModal;
use crate::{ use crate::{
AddContextServer, AddContextServer, ExternalAgent, NewExternalAgentThread,
agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider}, agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider},
}; };
@ -47,6 +49,7 @@ pub struct AgentConfiguration {
fs: Arc<dyn Fs>, fs: Arc<dyn Fs>,
language_registry: Arc<LanguageRegistry>, language_registry: Arc<LanguageRegistry>,
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
focus_handle: FocusHandle, focus_handle: FocusHandle,
configuration_views_by_provider: HashMap<LanguageModelProviderId, AnyView>, configuration_views_by_provider: HashMap<LanguageModelProviderId, AnyView>,
context_server_store: Entity<ContextServerStore>, context_server_store: Entity<ContextServerStore>,
@ -56,6 +59,8 @@ pub struct AgentConfiguration {
_registry_subscription: Subscription, _registry_subscription: Subscription,
scroll_handle: ScrollHandle, scroll_handle: ScrollHandle,
scrollbar_state: ScrollbarState, scrollbar_state: ScrollbarState,
gemini_is_installed: bool,
_check_for_gemini: Task<()>,
} }
impl AgentConfiguration { impl AgentConfiguration {
@ -65,6 +70,7 @@ impl AgentConfiguration {
tools: Entity<ToolWorkingSet>, tools: Entity<ToolWorkingSet>,
language_registry: Arc<LanguageRegistry>, language_registry: Arc<LanguageRegistry>,
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
@ -89,6 +95,11 @@ impl AgentConfiguration {
cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify()) cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify())
.detach(); .detach();
cx.observe_global_in::<SettingsStore>(window, |this, _, cx| {
this.check_for_gemini(cx);
cx.notify();
})
.detach();
let scroll_handle = ScrollHandle::new(); let scroll_handle = ScrollHandle::new();
let scrollbar_state = ScrollbarState::new(scroll_handle.clone()); let scrollbar_state = ScrollbarState::new(scroll_handle.clone());
@ -97,6 +108,7 @@ impl AgentConfiguration {
fs, fs,
language_registry, language_registry,
workspace, workspace,
project,
focus_handle, focus_handle,
configuration_views_by_provider: HashMap::default(), configuration_views_by_provider: HashMap::default(),
context_server_store, context_server_store,
@ -106,8 +118,11 @@ impl AgentConfiguration {
_registry_subscription: registry_subscription, _registry_subscription: registry_subscription,
scroll_handle, scroll_handle,
scrollbar_state, scrollbar_state,
gemini_is_installed: false,
_check_for_gemini: Task::ready(()),
}; };
this.build_provider_configuration_views(window, cx); this.build_provider_configuration_views(window, cx);
this.check_for_gemini(cx);
this this
} }
@ -137,6 +152,34 @@ impl AgentConfiguration {
self.configuration_views_by_provider self.configuration_views_by_provider
.insert(provider.id(), configuration_view); .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 { impl Focusable for AgentConfiguration {
@ -211,7 +254,6 @@ impl AgentConfiguration {
.child( .child(
h_flex() h_flex()
.id(provider_id_string.clone()) .id(provider_id_string.clone())
.cursor_pointer()
.px_2() .px_2()
.py_0p5() .py_0p5()
.w_full() .w_full()
@ -231,10 +273,7 @@ impl AgentConfiguration {
h_flex() h_flex()
.w_full() .w_full()
.gap_1() .gap_1()
.child( .child(Label::new(provider_name.clone()))
Label::new(provider_name.clone())
.size(LabelSize::Large),
)
.map(|this| { .map(|this| {
if is_zed_provider && is_signed_in { if is_zed_provider && is_signed_in {
this.child( this.child(
@ -279,7 +318,7 @@ impl AgentConfiguration {
"Start New Thread", "Start New Thread",
) )
.icon_position(IconPosition::Start) .icon_position(IconPosition::Start)
.icon(IconName::Plus) .icon(IconName::Thread)
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
.icon_color(Color::Muted) .icon_color(Color::Muted)
.label_size(LabelSize::Small) .label_size(LabelSize::Small)
@ -378,7 +417,7 @@ impl AgentConfiguration {
), ),
) )
.child( .child(
Label::new("Add at least one provider to use AI-powered features.") Label::new("Add at least one provider to use AI-powered features with Zed's native agent.")
.color(Color::Muted), .color(Color::Muted),
), ),
), ),
@ -519,6 +558,14 @@ impl AgentConfiguration {
} }
} }
fn card_item_bg_color(&self, cx: &mut Context<Self>) -> Hsla {
cx.theme().colors().background.opacity(0.25)
}
fn card_item_border_color(&self, cx: &mut Context<Self>) -> Hsla {
cx.theme().colors().border.opacity(0.6)
}
fn render_context_servers_section( fn render_context_servers_section(
&mut self, &mut self,
window: &mut Window, window: &mut Window,
@ -536,7 +583,12 @@ impl AgentConfiguration {
v_flex() v_flex()
.gap_0p5() .gap_0p5()
.child(Headline::new("Model Context Protocol (MCP) Servers")) .child(Headline::new("Model Context Protocol (MCP) Servers"))
.child(Label::new("Connect to context servers through the Model Context Protocol, either using Zed extensions or directly.").color(Color::Muted)), .child(
Label::new(
"All context servers connected through the Model Context Protocol.",
)
.color(Color::Muted),
),
) )
.children( .children(
context_server_ids.into_iter().map(|context_server_id| { context_server_ids.into_iter().map(|context_server_id| {
@ -546,7 +598,7 @@ impl AgentConfiguration {
.child( .child(
h_flex() h_flex()
.justify_between() .justify_between()
.gap_2() .gap_1p5()
.child( .child(
h_flex().w_full().child( h_flex().w_full().child(
Button::new("add-context-server", "Add Custom Server") Button::new("add-context-server", "Add Custom Server")
@ -637,8 +689,6 @@ impl AgentConfiguration {
.map_or([].as_slice(), |tools| tools.as_slice()); .map_or([].as_slice(), |tools| tools.as_slice());
let tool_count = tools.len(); let tool_count = tools.len();
let border_color = cx.theme().colors().border.opacity(0.6);
let (source_icon, source_tooltip) = if is_from_extension { let (source_icon, source_tooltip) = if is_from_extension {
( (
IconName::ZedMcpExtension, IconName::ZedMcpExtension,
@ -781,8 +831,8 @@ impl AgentConfiguration {
.id(item_id.clone()) .id(item_id.clone())
.border_1() .border_1()
.rounded_md() .rounded_md()
.border_color(border_color) .border_color(self.card_item_border_color(cx))
.bg(cx.theme().colors().background.opacity(0.2)) .bg(self.card_item_bg_color(cx))
.overflow_hidden() .overflow_hidden()
.child( .child(
h_flex() h_flex()
@ -790,7 +840,11 @@ impl AgentConfiguration {
.justify_between() .justify_between()
.when( .when(
error.is_some() || are_tools_expanded && tool_count >= 1, error.is_some() || are_tools_expanded && tool_count >= 1,
|element| element.border_b_1().border_color(border_color), |element| {
element
.border_b_1()
.border_color(self.card_item_border_color(cx))
},
) )
.child( .child(
h_flex() h_flex()
@ -972,6 +1026,166 @@ impl AgentConfiguration {
)) ))
}) })
} }
fn render_agent_servers_section(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let settings = AllAgentServersSettings::get_global(cx).clone();
let user_defined_agents = settings
.custom
.iter()
.map(|(name, settings)| {
self.render_agent_server(
IconName::Ai,
name.clone(),
ExternalAgent::Custom {
name: name.clone(),
settings: settings.clone(),
},
None,
cx,
)
.into_any_element()
})
.collect::<Vec<_>>();
v_flex()
.border_b_1()
.border_color(cx.theme().colors().border)
.child(
v_flex()
.p(DynamicSpacing::Base16.rems(cx))
.pr(DynamicSpacing::Base20.rems(cx))
.gap_2()
.child(
v_flex()
.gap_0p5()
.child(Headline::new("External Agents"))
.child(
Label::new(
"Use the full power of Zed's UI with your favorite agent, connected via the Agent Client Protocol.",
)
.color(Color::Muted),
),
)
.child(self.render_agent_server(
IconName::AiGemini,
"Gemini CLI",
ExternalAgent::Gemini,
(!self.gemini_is_installed).then_some(Gemini::install_command().into()),
cx,
))
// TODO add CC
.children(user_defined_agents),
)
}
fn render_agent_server(
&self,
icon: IconName,
name: impl Into<SharedString>,
agent: ExternalAgent,
install_command: Option<SharedString>,
cx: &mut Context<Self>,
) -> impl IntoElement {
let name = name.into();
h_flex()
.p_1()
.pl_2()
.gap_1p5()
.justify_between()
.border_1()
.rounded_md()
.border_color(self.card_item_border_color(cx))
.bg(self.card_item_bg_color(cx))
.overflow_hidden()
.child(
h_flex()
.gap_1p5()
.child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
.child(Label::new(name.clone())),
)
.map(|this| {
if let Some(install_command) = install_command {
this.child(
Button::new(
SharedString::from(format!("install_external_agent-{name}")),
"Install Agent",
)
.label_size(LabelSize::Small)
.icon(IconName::Plus)
.icon_position(IconPosition::Start)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
.tooltip(Tooltip::text(install_command.clone()))
.on_click(cx.listener(
move |this, _, window, cx| {
let Some(project) = this.project.upgrade() else {
return;
};
let Some(workspace) = this.workspace.upgrade() else {
return;
};
let cwd = project.read(cx).first_project_directory(cx);
let shell =
project.read(cx).terminal_settings(&cwd, cx).shell.clone();
let spawn_in_terminal = task::SpawnInTerminal {
id: task::TaskId(install_command.to_string()),
full_label: install_command.to_string(),
label: install_command.to_string(),
command: Some(install_command.to_string()),
args: Vec::new(),
command_label: install_command.to_string(),
cwd,
env: Default::default(),
use_new_terminal: true,
allow_concurrent_runs: true,
reveal: Default::default(),
reveal_target: Default::default(),
hide: Default::default(),
shell,
show_summary: true,
show_command: true,
show_rerun: false,
};
let task = workspace.update(cx, |workspace, cx| {
workspace.spawn_in_terminal(spawn_in_terminal, window, cx)
});
cx.spawn(async move |this, cx| {
task.await;
this.update(cx, |this, cx| {
this.check_for_gemini(cx);
})
.ok();
})
.detach();
},
)),
)
} else {
this.child(
h_flex().gap_1().child(
Button::new(
SharedString::from(format!("start_acp_thread-{name}")),
"Start New Thread",
)
.label_size(LabelSize::Small)
.icon(IconName::Thread)
.icon_position(IconPosition::Start)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
.on_click(move |_, window, cx| {
window.dispatch_action(
NewExternalAgentThread {
agent: Some(agent.clone()),
}
.boxed_clone(),
cx,
);
}),
),
)
}
})
}
} }
impl Render for AgentConfiguration { impl Render for AgentConfiguration {
@ -991,6 +1205,7 @@ impl Render for AgentConfiguration {
.size_full() .size_full()
.overflow_y_scroll() .overflow_y_scroll()
.child(self.render_general_settings_section(cx)) .child(self.render_general_settings_section(cx))
.child(self.render_agent_servers_section(cx))
.child(self.render_context_servers_section(window, cx)) .child(self.render_context_servers_section(window, cx))
.child(self.render_provider_configuration_section(cx)), .child(self.render_provider_configuration_section(cx)),
) )

View file

@ -241,6 +241,7 @@ enum WhichFontSize {
None, None,
} }
// TODO unify this with ExternalAgent
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
pub enum AgentType { pub enum AgentType {
#[default] #[default]
@ -1474,6 +1475,7 @@ impl AgentPanel {
tools, tools,
self.language_registry.clone(), self.language_registry.clone(),
self.workspace.clone(), self.workspace.clone(),
self.project.downgrade(),
window, window,
cx, cx,
) )

View file

@ -160,6 +160,7 @@ pub struct NewNativeAgentThreadFromSummary {
from_session_id: agent_client_protocol::SessionId, from_session_id: agent_client_protocol::SessionId,
} }
// TODO unify this with AgentType
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
enum ExternalAgent { enum ExternalAgent {