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 GitHub
parent 5fd29d37a6
commit c786c0150f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 254 additions and 22 deletions

View file

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

View file

@ -53,7 +53,7 @@ impl AgentServer for Gemini {
return Err(LoadError::NotInstalled {
error_message: "Failed to find Gemini CLI binary".into(),
install_message: "Install Gemini CLI".into(),
install_command: "npm install -g @google/gemini-cli@preview".into()
install_command: Self::install_command().into(),
}.into());
};
@ -88,7 +88,7 @@ impl AgentServer for Gemini {
current_version
).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())
}
}
@ -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)]
pub(crate) mod tests {
use super::*;

View file

@ -2811,7 +2811,7 @@ impl AcpThreadView {
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".to_string()),
id: task::TaskId(install_command.clone()),
full_label: install_command.clone(),
label: install_command.clone(),
command: Some(install_command.clone()),
@ -2868,7 +2868,7 @@ impl AcpThreadView {
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("upgrade".to_string()),
id: task::TaskId(upgrade_command.to_string()),
full_label: upgrade_command.clone(),
label: upgrade_command.clone(),
command: Some(upgrade_command.clone()),

View file

@ -5,6 +5,7 @@ mod tool_picker;
use std::{sync::Arc, time::Duration};
use agent_servers::{AgentServerCommand, AllAgentServersSettings, Gemini};
use agent_settings::AgentSettings;
use assistant_tool::{ToolSource, ToolWorkingSet};
use cloud_llm_client::Plan;
@ -15,7 +16,7 @@ use extension_host::ExtensionStore;
use fs::Fs;
use gpui::{
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_model::{
@ -23,10 +24,11 @@ use language_model::{
};
use notifications::status_toast::{StatusToast, ToastIcon};
use project::{
Project,
context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
project_settings::{ContextServerSettings, ProjectSettings},
};
use settings::{Settings, update_settings_file};
use settings::{Settings, SettingsStore, update_settings_file};
use ui::{
Chip, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu,
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;
use crate::{
AddContextServer,
AddContextServer, ExternalAgent, NewExternalAgentThread,
agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider},
};
@ -47,6 +49,7 @@ 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>,
@ -56,6 +59,8 @@ pub struct AgentConfiguration {
_registry_subscription: Subscription,
scroll_handle: ScrollHandle,
scrollbar_state: ScrollbarState,
gemini_is_installed: bool,
_check_for_gemini: Task<()>,
}
impl AgentConfiguration {
@ -65,6 +70,7 @@ impl AgentConfiguration {
tools: Entity<ToolWorkingSet>,
language_registry: Arc<LanguageRegistry>,
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@ -89,6 +95,11 @@ 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());
@ -97,6 +108,7 @@ impl AgentConfiguration {
fs,
language_registry,
workspace,
project,
focus_handle,
configuration_views_by_provider: HashMap::default(),
context_server_store,
@ -106,8 +118,11 @@ 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
}
@ -137,6 +152,34 @@ 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 {
@ -211,7 +254,6 @@ impl AgentConfiguration {
.child(
h_flex()
.id(provider_id_string.clone())
.cursor_pointer()
.px_2()
.py_0p5()
.w_full()
@ -231,10 +273,7 @@ impl AgentConfiguration {
h_flex()
.w_full()
.gap_1()
.child(
Label::new(provider_name.clone())
.size(LabelSize::Large),
)
.child(Label::new(provider_name.clone()))
.map(|this| {
if is_zed_provider && is_signed_in {
this.child(
@ -279,7 +318,7 @@ impl AgentConfiguration {
"Start New Thread",
)
.icon_position(IconPosition::Start)
.icon(IconName::Plus)
.icon(IconName::Thread)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.label_size(LabelSize::Small)
@ -378,7 +417,7 @@ impl AgentConfiguration {
),
)
.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),
),
),
@ -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(
&mut self,
window: &mut Window,
@ -536,7 +583,12 @@ impl AgentConfiguration {
v_flex()
.gap_0p5()
.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(
context_server_ids.into_iter().map(|context_server_id| {
@ -546,7 +598,7 @@ impl AgentConfiguration {
.child(
h_flex()
.justify_between()
.gap_2()
.gap_1p5()
.child(
h_flex().w_full().child(
Button::new("add-context-server", "Add Custom Server")
@ -637,8 +689,6 @@ impl AgentConfiguration {
.map_or([].as_slice(), |tools| tools.as_slice());
let tool_count = tools.len();
let border_color = cx.theme().colors().border.opacity(0.6);
let (source_icon, source_tooltip) = if is_from_extension {
(
IconName::ZedMcpExtension,
@ -781,8 +831,8 @@ impl AgentConfiguration {
.id(item_id.clone())
.border_1()
.rounded_md()
.border_color(border_color)
.bg(cx.theme().colors().background.opacity(0.2))
.border_color(self.card_item_border_color(cx))
.bg(self.card_item_bg_color(cx))
.overflow_hidden()
.child(
h_flex()
@ -790,7 +840,11 @@ impl AgentConfiguration {
.justify_between()
.when(
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(
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 {
@ -991,6 +1205,7 @@ impl Render for AgentConfiguration {
.size_full()
.overflow_y_scroll()
.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_provider_configuration_section(cx)),
)

View file

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

View file

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