Detailed changes
@@ -0,0 +1,4 @@
+<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path opacity="0.3" d="M9 1.5H3C2.17157 1.5 1.5 2.17157 1.5 3V9C1.5 9.82843 2.17157 10.5 3 10.5H9C9.82843 10.5 10.5 9.82843 10.5 9V3C10.5 2.17157 9.82843 1.5 9 1.5Z" stroke="#C6CAD0" stroke-width="0.9"/>
+<path d="M4.32058 8.53711C3.99873 8.53711 3.79672 8.34879 3.79672 8.05092C3.79672 7.96532 3.82069 7.84891 3.8652 7.72222L5.21422 4.04838C5.35802 3.64778 5.60112 3.46289 5.99144 3.46289C6.39546 3.46289 6.63514 3.64093 6.78236 4.04495L8.13823 7.72222C8.18616 7.85575 8.20328 7.9482 8.20328 8.05092C8.20328 8.3351 7.98758 8.53711 7.68627 8.53711C7.39524 8.53711 7.24117 8.40358 7.14872 8.08173L6.8885 7.30108H5.10465L4.84444 8.07146C4.74857 8.40015 4.59449 8.53711 4.32058 8.53711ZM5.33063 6.49989H6.64883L5.99487 4.47636H5.9709L5.33063 6.49989Z" fill="#C6CAD0"/>
+</svg>
@@ -104,6 +104,10 @@ impl AgentServer for CustomAgentServer {
| settings::CustomAgentServerSettings::Extension {
favorite_config_option_values,
..
+ }
+ | settings::CustomAgentServerSettings::Registry {
+ favorite_config_option_values,
+ ..
} => {
let entry = favorite_config_option_values
.entry(config_id.clone())
@@ -142,7 +146,8 @@ impl AgentServer for CustomAgentServer {
match settings {
settings::CustomAgentServerSettings::Custom { default_mode, .. }
- | settings::CustomAgentServerSettings::Extension { default_mode, .. } => {
+ | settings::CustomAgentServerSettings::Extension { default_mode, .. }
+ | settings::CustomAgentServerSettings::Registry { default_mode, .. } => {
*default_mode = mode_id.map(|m| m.to_string());
}
}
@@ -181,7 +186,8 @@ impl AgentServer for CustomAgentServer {
match settings {
settings::CustomAgentServerSettings::Custom { default_model, .. }
- | settings::CustomAgentServerSettings::Extension { default_model, .. } => {
+ | settings::CustomAgentServerSettings::Extension { default_model, .. }
+ | settings::CustomAgentServerSettings::Registry { default_model, .. } => {
*default_model = model_id.map(|m| m.to_string());
}
}
@@ -236,6 +242,9 @@ impl AgentServer for CustomAgentServer {
}
| settings::CustomAgentServerSettings::Extension {
favorite_models, ..
+ }
+ | settings::CustomAgentServerSettings::Registry {
+ favorite_models, ..
} => favorite_models,
};
@@ -296,6 +305,10 @@ impl AgentServer for CustomAgentServer {
| settings::CustomAgentServerSettings::Extension {
default_config_options,
..
+ }
+ | settings::CustomAgentServerSettings::Registry {
+ default_config_options,
+ ..
} => {
if let Some(value) = value_id.clone() {
default_config_options.insert(config_id.clone(), value);
@@ -331,6 +344,10 @@ impl AgentServer for CustomAgentServer {
| project::agent_server_store::CustomAgentServerSettings::Extension {
default_config_options,
..
+ }
+ | project::agent_server_store::CustomAgentServerSettings::Registry {
+ default_config_options,
+ ..
} => default_config_options.clone(),
})
.unwrap_or_default()
@@ -15,6 +15,7 @@ use context_server::ContextServerId;
use editor::{Editor, MultiBufferOffset, SelectionEffects, scroll::Autoscroll};
use extension::ExtensionManifest;
use extension_host::ExtensionStore;
+use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _};
use fs::Fs;
use gpui::{
Action, AnyView, App, AsyncWindowContext, Corner, Entity, EventEmitter, FocusHandle, Focusable,
@@ -29,7 +30,8 @@ use language_models::AllLanguageModelSettings;
use notifications::status_toast::{StatusToast, ToastIcon};
use project::{
agent_server_store::{
- AgentServerStore, CLAUDE_CODE_NAME, CODEX_NAME, ExternalAgentServerName, GEMINI_NAME,
+ AgentServerStore, CLAUDE_CODE_NAME, CODEX_NAME, ExternalAgentServerName,
+ ExternalAgentSource, GEMINI_NAME,
},
context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
};
@@ -980,7 +982,8 @@ impl AgentConfiguration {
let display_name = agent_server_store
.agent_display_name(&name)
.unwrap_or_else(|| name.0.clone());
- (name, icon, display_name)
+ let source = agent_server_store.agent_source(&name).unwrap_or_default();
+ (name, icon, display_name, source)
})
.collect();
@@ -996,7 +999,14 @@ impl AgentConfiguration {
)
.menu({
move |window, cx| {
- Some(ContextMenu::build(window, cx, |menu, _window, _cx| {
+ Some(ContextMenu::build(window, cx, |mut menu, _window, _cx| {
+ if _cx.has_flag::<AcpBetaFeatureFlag>() {
+ menu = menu.entry("Install from Registry", None, {
+ |window, cx| {
+ window.dispatch_action(Box::new(zed_actions::AgentRegistry), cx)
+ }
+ });
+ }
menu.entry("Install from Extensions", None, {
|window, cx| {
window.dispatch_action(
@@ -1088,7 +1098,7 @@ impl AgentConfiguration {
AgentIcon::Name(IconName::AiClaude),
"Claude Code",
"Claude Code",
- false,
+ ExternalAgentSource::Builtin,
cx,
))
.child(Divider::horizontal().color(DividerColor::BorderFaded))
@@ -1096,7 +1106,7 @@ impl AgentConfiguration {
AgentIcon::Name(IconName::AiOpenAi),
"Codex CLI",
"Codex CLI",
- false,
+ ExternalAgentSource::Builtin,
cx,
))
.child(Divider::horizontal().color(DividerColor::BorderFaded))
@@ -1104,11 +1114,11 @@ impl AgentConfiguration {
AgentIcon::Name(IconName::AiGemini),
"Gemini CLI",
"Gemini CLI",
- false,
+ ExternalAgentSource::Builtin,
cx,
))
.map(|mut parent| {
- for (name, icon, display_name) in user_defined_agents {
+ for (name, icon, display_name, source) in user_defined_agents {
parent = parent
.child(
Divider::horizontal().color(DividerColor::BorderFaded),
@@ -1117,7 +1127,7 @@ impl AgentConfiguration {
icon,
name,
display_name,
- true,
+ source,
cx,
));
}
@@ -1132,7 +1142,7 @@ impl AgentConfiguration {
icon: AgentIcon,
id: impl Into<SharedString>,
display_name: impl Into<SharedString>,
- external: bool,
+ source: ExternalAgentSource,
cx: &mut Context<Self>,
) -> impl IntoElement {
let id = id.into();
@@ -1147,30 +1157,79 @@ impl AgentConfiguration {
.color(Color::Muted),
};
- let tooltip_id = SharedString::new(format!("agent-source-{}", id));
- let tooltip_message = format!(
- "The {} agent was installed from an extension.",
- display_name
- );
+ let source_badge = match source {
+ ExternalAgentSource::Extension => Some((
+ SharedString::new(format!("agent-source-{}", id)),
+ SharedString::from(format!(
+ "The {} agent was installed from an extension.",
+ display_name
+ )),
+ IconName::ZedSrcExtension,
+ )),
+ ExternalAgentSource::Registry => Some((
+ SharedString::new(format!("agent-source-{}", id)),
+ SharedString::from(format!(
+ "The {} agent was installed from the ACP registry.",
+ display_name
+ )),
+ IconName::AcpRegistry,
+ )),
+ ExternalAgentSource::Builtin | ExternalAgentSource::Custom => None,
+ };
let agent_server_name = ExternalAgentServerName(id.clone());
- let uninstall_btn_id = SharedString::from(format!("uninstall-{}", id));
- let uninstall_button = IconButton::new(uninstall_btn_id, IconName::Trash)
- .icon_color(Color::Muted)
- .icon_size(IconSize::Small)
- .tooltip(Tooltip::text("Uninstall Agent Extension"))
- .on_click(cx.listener(move |this, _, _window, cx| {
- let agent_name = agent_server_name.clone();
-
- if let Some(ext_id) = this.agent_server_store.update(cx, |store, _cx| {
- store.get_extension_id_for_agent(&agent_name)
- }) {
- ExtensionStore::global(cx)
- .update(cx, |store, cx| store.uninstall_extension(ext_id, cx))
- .detach_and_log_err(cx);
- }
- }));
+ let uninstall_button = match source {
+ ExternalAgentSource::Extension => Some(
+ IconButton::new(
+ SharedString::from(format!("uninstall-{}", id)),
+ IconName::Trash,
+ )
+ .icon_color(Color::Muted)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Uninstall Agent Extension"))
+ .on_click(cx.listener(move |this, _, _window, cx| {
+ let agent_name = agent_server_name.clone();
+
+ if let Some(ext_id) = this.agent_server_store.update(cx, |store, _cx| {
+ store.get_extension_id_for_agent(&agent_name)
+ }) {
+ ExtensionStore::global(cx)
+ .update(cx, |store, cx| store.uninstall_extension(ext_id, cx))
+ .detach_and_log_err(cx);
+ }
+ })),
+ ),
+ ExternalAgentSource::Registry => {
+ let fs = self.fs.clone();
+ Some(
+ IconButton::new(
+ SharedString::from(format!("uninstall-{}", id)),
+ IconName::Trash,
+ )
+ .icon_color(Color::Muted)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Remove Registry Agent"))
+ .on_click(cx.listener(move |_, _, _window, cx| {
+ let agent_name = agent_server_name.clone();
+ update_settings_file(fs.clone(), cx, move |settings, _| {
+ let Some(agent_servers) = settings.agent_servers.as_mut() else {
+ return;
+ };
+ if let Some(entry) = agent_servers.custom.get(agent_name.0.as_ref())
+ && matches!(
+ entry,
+ settings::CustomAgentServerSettings::Registry { .. }
+ )
+ {
+ agent_servers.custom.remove(agent_name.0.as_ref());
+ }
+ });
+ })),
+ )
+ }
+ ExternalAgentSource::Builtin | ExternalAgentSource::Custom => None,
+ };
h_flex()
.gap_1()
@@ -1180,17 +1239,13 @@ impl AgentConfiguration {
.gap_1p5()
.child(icon)
.child(Label::new(display_name))
- .when(external, |this| {
+ .when_some(source_badge, |this, (tooltip_id, tooltip_message, icon)| {
this.child(
div()
.id(tooltip_id)
.flex_none()
.tooltip(Tooltip::text(tooltip_message))
- .child(
- Icon::new(IconName::ZedSrcExtension)
- .size(IconSize::Small)
- .color(Color::Muted),
- ),
+ .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)),
)
})
.child(
@@ -1199,7 +1254,9 @@ impl AgentConfiguration {
.size(IconSize::Small),
),
)
- .when(external, |this| this.child(uninstall_button))
+ .when_some(uninstall_button, |this, uninstall_button| {
+ this.child(uninstall_button)
+ })
}
}
@@ -0,0 +1,632 @@
+use std::ops::Range;
+
+use collections::HashMap;
+use editor::{Editor, EditorElement, EditorStyle};
+use fs::Fs;
+use gpui::{
+ AnyElement, App, Context, Entity, EventEmitter, Focusable, KeyContext, ParentElement, Render,
+ RenderOnce, SharedString, Styled, TextStyle, UniformListScrollHandle, Window, point,
+ uniform_list,
+};
+use project::agent_server_store::{AllAgentServersSettings, CustomAgentServerSettings};
+use project::{AgentRegistryStore, RegistryAgent};
+use settings::{Settings, SettingsStore, update_settings_file};
+use theme::ThemeSettings;
+use ui::{
+ ButtonStyle, ScrollableHandle, ToggleButtonGroup, ToggleButtonGroupSize,
+ ToggleButtonGroupStyle, ToggleButtonSimple, Tooltip, WithScrollbar, prelude::*,
+};
+use workspace::{
+ Workspace,
+ item::{Item, ItemEvent},
+};
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum RegistryFilter {
+ All,
+ Installed,
+ NotInstalled,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum RegistryInstallStatus {
+ NotInstalled,
+ InstalledRegistry,
+ InstalledCustom,
+ InstalledExtension,
+}
+
+#[derive(IntoElement)]
+struct AgentRegistryCard {
+ children: Vec<AnyElement>,
+}
+
+impl AgentRegistryCard {
+ fn new() -> Self {
+ Self {
+ children: Vec::new(),
+ }
+ }
+}
+
+impl ParentElement for AgentRegistryCard {
+ fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
+ self.children.extend(elements)
+ }
+}
+
+impl RenderOnce for AgentRegistryCard {
+ fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+ div().w_full().child(
+ v_flex()
+ .mt_4()
+ .w_full()
+ .min_h(rems_from_px(110.))
+ .p_3()
+ .gap_2()
+ .bg(cx.theme().colors().elevated_surface_background.opacity(0.5))
+ .border_1()
+ .border_color(cx.theme().colors().border_variant)
+ .rounded_md()
+ .children(self.children),
+ )
+ }
+}
+
+pub struct AgentRegistryPage {
+ registry_store: Entity<AgentRegistryStore>,
+ list: UniformListScrollHandle,
+ registry_agents: Vec<RegistryAgent>,
+ filtered_registry_indices: Vec<usize>,
+ installed_statuses: HashMap<String, RegistryInstallStatus>,
+ query_editor: Entity<Editor>,
+ filter: RegistryFilter,
+ _subscriptions: Vec<gpui::Subscription>,
+}
+
+impl AgentRegistryPage {
+ pub fn new(
+ _workspace: &Workspace,
+ window: &mut Window,
+ cx: &mut Context<Workspace>,
+ ) -> Entity<Self> {
+ cx.new(|cx| {
+ let registry_store = AgentRegistryStore::global(cx);
+ let query_editor = cx.new(|cx| {
+ let mut input = Editor::single_line(window, cx);
+ input.set_placeholder_text("Search agents...", window, cx);
+ input
+ });
+ cx.subscribe(&query_editor, Self::on_query_change).detach();
+
+ let mut subscriptions = Vec::new();
+ subscriptions.push(cx.observe(®istry_store, |this, _, cx| {
+ this.reload_registry_agents(cx);
+ }));
+ subscriptions.push(cx.observe_global::<SettingsStore>(|this, cx| {
+ this.filter_registry_agents(cx);
+ }));
+
+ let mut this = Self {
+ registry_store,
+ list: UniformListScrollHandle::new(),
+ registry_agents: Vec::new(),
+ filtered_registry_indices: Vec::new(),
+ installed_statuses: HashMap::default(),
+ query_editor,
+ filter: RegistryFilter::All,
+ _subscriptions: subscriptions,
+ };
+
+ this.reload_registry_agents(cx);
+ this.registry_store
+ .update(cx, |store, cx| store.refresh(cx));
+
+ this
+ })
+ }
+
+ fn reload_registry_agents(&mut self, cx: &mut Context<Self>) {
+ self.registry_agents = self.registry_store.read(cx).agents().to_vec();
+ self.registry_agents.sort_by(|left, right| {
+ left.name
+ .as_ref()
+ .cmp(right.name.as_ref())
+ .then_with(|| left.id.as_ref().cmp(right.id.as_ref()))
+ });
+ self.filter_registry_agents(cx);
+ }
+
+ fn refresh_installed_statuses(&mut self, cx: &mut Context<Self>) {
+ let settings = cx
+ .global::<SettingsStore>()
+ .get::<AllAgentServersSettings>(None);
+ self.installed_statuses.clear();
+ for (id, settings) in &settings.custom {
+ let status = match settings {
+ CustomAgentServerSettings::Registry { .. } => {
+ RegistryInstallStatus::InstalledRegistry
+ }
+ CustomAgentServerSettings::Custom { .. } => RegistryInstallStatus::InstalledCustom,
+ CustomAgentServerSettings::Extension { .. } => {
+ RegistryInstallStatus::InstalledExtension
+ }
+ };
+ self.installed_statuses.insert(id.clone(), status);
+ }
+ }
+
+ fn install_status(&self, id: &str) -> RegistryInstallStatus {
+ self.installed_statuses
+ .get(id)
+ .copied()
+ .unwrap_or(RegistryInstallStatus::NotInstalled)
+ }
+
+ fn search_query(&self, cx: &mut App) -> Option<String> {
+ let search = self.query_editor.read(cx).text(cx);
+ if search.trim().is_empty() {
+ None
+ } else {
+ Some(search)
+ }
+ }
+
+ fn filter_registry_agents(&mut self, cx: &mut Context<Self>) {
+ self.refresh_installed_statuses(cx);
+ let search = self.search_query(cx).map(|search| search.to_lowercase());
+ let filter = self.filter;
+ let installed_statuses = self.installed_statuses.clone();
+
+ let filtered_indices = self
+ .registry_agents
+ .iter()
+ .enumerate()
+ .filter(|(_, agent)| {
+ let matches_search = search.as_ref().is_none_or(|query| {
+ let query = query.as_str();
+ agent.id.as_ref().to_lowercase().contains(query)
+ || agent.name.as_ref().to_lowercase().contains(query)
+ || agent.description.as_ref().to_lowercase().contains(query)
+ });
+
+ let install_status = installed_statuses
+ .get(agent.id.as_ref())
+ .copied()
+ .unwrap_or(RegistryInstallStatus::NotInstalled);
+ let matches_filter = match filter {
+ RegistryFilter::All => true,
+ RegistryFilter::Installed => {
+ install_status != RegistryInstallStatus::NotInstalled
+ }
+ RegistryFilter::NotInstalled => {
+ install_status == RegistryInstallStatus::NotInstalled
+ }
+ };
+
+ matches_search && matches_filter
+ })
+ .map(|(index, _)| index)
+ .collect();
+
+ self.filtered_registry_indices = filtered_indices;
+
+ cx.notify();
+ }
+
+ fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
+ self.list.set_offset(point(px(0.), px(0.)));
+ cx.notify();
+ }
+
+ fn on_query_change(
+ &mut self,
+ _: Entity<Editor>,
+ event: &editor::EditorEvent,
+ cx: &mut Context<Self>,
+ ) {
+ if let editor::EditorEvent::Edited { .. } = event {
+ self.filter_registry_agents(cx);
+ self.scroll_to_top(cx);
+ }
+ }
+
+ fn render_search(&self, cx: &mut Context<Self>) -> Div {
+ let mut key_context = KeyContext::new_with_defaults();
+ key_context.add("BufferSearchBar");
+
+ h_flex()
+ .key_context(key_context)
+ .h_8()
+ .min_w(rems_from_px(384.))
+ .flex_1()
+ .pl_1p5()
+ .pr_2()
+ .gap_2()
+ .border_1()
+ .border_color(cx.theme().colors().border)
+ .rounded_md()
+ .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
+ .child(self.render_text_input(&self.query_editor, cx))
+ }
+
+ fn render_text_input(
+ &self,
+ editor: &Entity<Editor>,
+ cx: &mut Context<Self>,
+ ) -> impl IntoElement {
+ let settings = ThemeSettings::get_global(cx);
+ let text_style = TextStyle {
+ color: if editor.read(cx).read_only(cx) {
+ cx.theme().colors().text_disabled
+ } else {
+ cx.theme().colors().text
+ },
+ font_family: settings.ui_font.family.clone(),
+ font_features: settings.ui_font.features.clone(),
+ font_fallbacks: settings.ui_font.fallbacks.clone(),
+ font_size: rems(0.875).into(),
+ font_weight: settings.ui_font.weight,
+ line_height: relative(1.3),
+ ..Default::default()
+ };
+
+ EditorElement::new(
+ editor,
+ EditorStyle {
+ background: cx.theme().colors().editor_background,
+ local_player: cx.theme().players().local(),
+ text: text_style,
+ ..Default::default()
+ },
+ )
+ }
+
+ fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
+ let has_search = self.search_query(cx).is_some();
+ let registry_store = self.registry_store.read(cx);
+
+ let message = if registry_store.is_fetching() {
+ "Loading registry..."
+ } else if registry_store.fetch_error().is_some() {
+ "Failed to load the agent registry. Please check your connection and try again."
+ } else {
+ match self.filter {
+ RegistryFilter::All => {
+ if has_search {
+ "No agents match your search."
+ } else {
+ "No agents available."
+ }
+ }
+ RegistryFilter::Installed => {
+ if has_search {
+ "No installed agents match your search."
+ } else {
+ "No installed agents."
+ }
+ }
+ RegistryFilter::NotInstalled => {
+ if has_search {
+ "No uninstalled agents match your search."
+ } else {
+ "No uninstalled agents."
+ }
+ }
+ }
+ };
+
+ h_flex()
+ .py_4()
+ .gap_1p5()
+ .when(registry_store.fetch_error().is_some(), |this| {
+ this.child(
+ Icon::new(IconName::Warning)
+ .size(IconSize::Small)
+ .color(Color::Warning),
+ )
+ })
+ .child(Label::new(message))
+ }
+
+ fn render_agents(
+ &mut self,
+ range: Range<usize>,
+ _: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Vec<AgentRegistryCard> {
+ range
+ .map(|index| {
+ let Some(agent_index) = self.filtered_registry_indices.get(index).copied() else {
+ return self.render_missing_agent();
+ };
+ let Some(agent) = self.registry_agents.get(agent_index) else {
+ return self.render_missing_agent();
+ };
+ self.render_registry_agent(agent, cx)
+ })
+ .collect()
+ }
+
+ fn render_missing_agent(&self) -> AgentRegistryCard {
+ AgentRegistryCard::new().child(
+ Label::new("Missing registry entry.")
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ }
+
+ fn render_registry_agent(
+ &self,
+ agent: &RegistryAgent,
+ cx: &mut Context<Self>,
+ ) -> AgentRegistryCard {
+ let install_status = self.install_status(agent.id.as_ref());
+ let supports_current_platform = agent.supports_current_platform;
+
+ let icon = match agent.icon_path.as_ref() {
+ Some(icon_path) => Icon::from_external_svg(icon_path.clone()),
+ None => Icon::new(IconName::Sparkle),
+ }
+ .size(IconSize::Medium)
+ .color(Color::Muted);
+
+ let install_button =
+ self.install_button(agent, install_status, supports_current_platform, cx);
+
+ let repository_button = agent.repository.as_ref().map(|repository| {
+ let repository = repository.clone();
+ IconButton::new(
+ SharedString::from(format!("agent-repo-{}", agent.id)),
+ IconName::Link,
+ )
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Visit agent repository"))
+ .on_click(move |_, _, cx| {
+ cx.open_url(repository.as_ref());
+ })
+ });
+
+ AgentRegistryCard::new()
+ .child(
+ h_flex()
+ .justify_between()
+ .items_start()
+ .child(
+ h_flex().gap_2().items_center().child(icon).child(
+ v_flex().gap_0p5().child(
+ h_flex()
+ .gap_2()
+ .items_end()
+ .child(
+ Headline::new(agent.name.clone()).size(HeadlineSize::Small),
+ )
+ .child(
+ Headline::new(format!("v{}", agent.version))
+ .size(HeadlineSize::XSmall),
+ ),
+ ),
+ ),
+ )
+ .child(install_button),
+ )
+ .child(
+ h_flex()
+ .gap_2()
+ .justify_between()
+ .child(
+ Label::new(agent.description.clone())
+ .size(LabelSize::Small)
+ .color(Color::Default)
+ .truncate(),
+ )
+ .when_some(repository_button, |this, button| this.child(button)),
+ )
+ .child(
+ h_flex()
+ .gap_2()
+ .justify_between()
+ .child(
+ Label::new(format!("ID: {}", agent.id))
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .truncate(),
+ )
+ .when(!supports_current_platform, |this| {
+ this.child(
+ Label::new("Not supported on this platform")
+ .size(LabelSize::Small)
+ .color(Color::Warning),
+ )
+ }),
+ )
+ }
+
+ fn install_button(
+ &self,
+ agent: &RegistryAgent,
+ install_status: RegistryInstallStatus,
+ supports_current_platform: bool,
+ cx: &mut Context<Self>,
+ ) -> Button {
+ let button_id = SharedString::from(format!("install-agent-{}", agent.id));
+
+ if !supports_current_platform {
+ return Button::new(button_id, "Unavailable")
+ .style(ButtonStyle::OutlinedGhost)
+ .disabled(true);
+ }
+
+ match install_status {
+ RegistryInstallStatus::NotInstalled => {
+ let fs = <dyn Fs>::global(cx);
+ let agent_id = agent.id.to_string();
+ Button::new(button_id, "Install")
+ .style(ButtonStyle::Tinted(ui::TintColor::Accent))
+ .icon(IconName::Download)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted)
+ .icon_position(IconPosition::Start)
+ .on_click(move |_, _, cx| {
+ 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(|| {
+ settings::CustomAgentServerSettings::Registry {
+ default_mode: None,
+ default_model: None,
+ favorite_models: Vec::new(),
+ default_config_options: HashMap::default(),
+ favorite_config_option_values: HashMap::default(),
+ }
+ });
+ });
+ })
+ }
+ RegistryInstallStatus::InstalledRegistry => {
+ let fs = <dyn Fs>::global(cx);
+ let agent_id = agent.id.to_string();
+ Button::new(button_id, "Remove")
+ .style(ButtonStyle::OutlinedGhost)
+ .on_click(move |_, _, cx| {
+ let agent_id = agent_id.clone();
+ update_settings_file(fs.clone(), cx, move |settings, _| {
+ let Some(agent_servers) = settings.agent_servers.as_mut() else {
+ return;
+ };
+ if let Some(entry) = agent_servers.custom.get(agent_id.as_str())
+ && matches!(
+ entry,
+ settings::CustomAgentServerSettings::Registry { .. }
+ )
+ {
+ agent_servers.custom.remove(agent_id.as_str());
+ }
+ });
+ })
+ }
+ RegistryInstallStatus::InstalledCustom => Button::new(button_id, "Installed")
+ .style(ButtonStyle::OutlinedGhost)
+ .disabled(true),
+ RegistryInstallStatus::InstalledExtension => Button::new(button_id, "Installed")
+ .style(ButtonStyle::OutlinedGhost)
+ .disabled(true),
+ }
+ }
+}
+
+impl Render for AgentRegistryPage {
+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ v_flex()
+ .size_full()
+ .bg(cx.theme().colors().editor_background)
+ .child(
+ v_flex()
+ .gap_4()
+ .pt_4()
+ .px_4()
+ .bg(cx.theme().colors().editor_background)
+ .child(
+ h_flex()
+ .w_full()
+ .gap_1p5()
+ .justify_between()
+ .child(Headline::new("ACP Agent Registry").size(HeadlineSize::XLarge)),
+ )
+ .child(
+ h_flex()
+ .w_full()
+ .flex_wrap()
+ .gap_2()
+ .child(self.render_search(cx))
+ .child(
+ div().child(
+ ToggleButtonGroup::single_row(
+ "registry-filter-buttons",
+ [
+ ToggleButtonSimple::new(
+ "All",
+ cx.listener(|this, _event, _, cx| {
+ this.filter = RegistryFilter::All;
+ this.filter_registry_agents(cx);
+ this.scroll_to_top(cx);
+ }),
+ ),
+ ToggleButtonSimple::new(
+ "Installed",
+ cx.listener(|this, _event, _, cx| {
+ this.filter = RegistryFilter::Installed;
+ this.filter_registry_agents(cx);
+ this.scroll_to_top(cx);
+ }),
+ ),
+ ToggleButtonSimple::new(
+ "Not Installed",
+ cx.listener(|this, _event, _, cx| {
+ this.filter = RegistryFilter::NotInstalled;
+ this.filter_registry_agents(cx);
+ this.scroll_to_top(cx);
+ }),
+ ),
+ ],
+ )
+ .style(ToggleButtonGroupStyle::Outlined)
+ .size(ToggleButtonGroupSize::Custom(rems_from_px(30.)))
+ .label_size(LabelSize::Default)
+ .auto_width()
+ .selected_index(match self.filter {
+ RegistryFilter::All => 0,
+ RegistryFilter::Installed => 1,
+ RegistryFilter::NotInstalled => 2,
+ })
+ .into_any_element(),
+ ),
+ ),
+ ),
+ )
+ .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
+ let count = self.filtered_registry_indices.len();
+ if count == 0 {
+ this.child(self.render_empty_state(cx)).into_any_element()
+ } else {
+ let scroll_handle = &self.list;
+ this.child(
+ uniform_list("registry-entries", count, cx.processor(Self::render_agents))
+ .flex_grow()
+ .pb_4()
+ .track_scroll(scroll_handle),
+ )
+ .vertical_scrollbar_for(scroll_handle, window, cx)
+ .into_any_element()
+ }
+ }))
+ }
+}
+
+impl EventEmitter<ItemEvent> for AgentRegistryPage {}
+
+impl Focusable for AgentRegistryPage {
+ fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
+ self.query_editor.read(cx).focus_handle(cx)
+ }
+}
+
+impl Item for AgentRegistryPage {
+ type Event = ItemEvent;
+
+ fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
+ "ACP Agent Registry".into()
+ }
+
+ fn telemetry_event_text(&self) -> Option<&'static str> {
+ Some("ACP Agent Registry Page Opened")
+ }
+
+ fn show_toolbar(&self) -> bool {
+ false
+ }
+
+ fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
+ f(*event)
+ }
+}
@@ -3,6 +3,7 @@ mod agent_configuration;
mod agent_diff;
mod agent_model_selector;
mod agent_panel;
+mod agent_registry_ui;
mod buffer_codegen;
mod completion_provider;
mod context;
@@ -28,9 +29,9 @@ use agent_settings::{AgentProfileId, AgentSettings};
use assistant_slash_command::SlashCommandRegistry;
use client::Client;
use command_palette_hooks::CommandPaletteFilter;
-use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _};
+use feature_flags::{AcpBetaFeatureFlag, AgentV2FeatureFlag, FeatureFlagAppExt as _};
use fs::Fs;
-use gpui::{Action, App, Entity, SharedString, actions};
+use gpui::{Action, App, Context, Entity, SharedString, Window, actions};
use language::{
LanguageRegistry,
language_settings::{AllLanguageSettings, EditPredictionProvider},
@@ -44,9 +45,11 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{LanguageModelSelection, Settings as _, SettingsStore};
use std::any::TypeId;
+use workspace::Workspace;
use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal};
pub use crate::agent_panel::{AgentPanel, ConcreteAssistantPanelDelegate};
+use crate::agent_registry_ui::AgentRegistryPage;
pub use crate::inline_assistant::InlineAssistant;
pub use agent_diff::{AgentDiffPane, AgentDiffToolbar};
pub use text_thread_editor::{AgentPanelDelegate, TextThreadEditor};
@@ -267,6 +270,37 @@ pub fn init(
ConfigureContextServerModal::register(workspace, language_registry.clone(), window, cx)
})
.detach();
+ cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
+ workspace.register_action(
+ move |workspace: &mut Workspace,
+ _: &zed_actions::AgentRegistry,
+ window: &mut Window,
+ cx: &mut Context<Workspace>| {
+ if !cx.has_flag::<AcpBetaFeatureFlag>() {
+ return;
+ }
+ let existing = workspace
+ .active_pane()
+ .read(cx)
+ .items()
+ .find_map(|item| item.downcast::<AgentRegistryPage>());
+
+ if let Some(existing) = existing {
+ workspace.activate_item(&existing, true, true, window, cx);
+ } else {
+ let registry_page = AgentRegistryPage::new(workspace, window, cx);
+ workspace.add_item_to_active_pane(
+ Box::new(registry_page),
+ None,
+ true,
+ window,
+ cx,
+ );
+ }
+ },
+ );
+ })
+ .detach();
cx.observe_new(ManageProfilesModal::register).detach();
// Update command palette filter based on AI settings
@@ -8,6 +8,7 @@ use strum::{EnumIter, EnumString, IntoStaticStr};
)]
#[strum(serialize_all = "snake_case")]
pub enum IconName {
+ AcpRegistry,
Ai,
AiAnthropic,
AiBedrock,
@@ -0,0 +1,460 @@
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+use std::time::Duration;
+
+use anyhow::{Context as _, Result, bail};
+use collections::HashMap;
+use fs::Fs;
+use futures::AsyncReadExt;
+use gpui::{App, AppContext as _, Context, Entity, Global, SharedString, Task};
+use http_client::{AsyncBody, HttpClient};
+use serde::Deserialize;
+
+const REGISTRY_URL: &str =
+ "https://github.com/agentclientprotocol/registry/releases/latest/download/registry.json";
+const REGISTRY_REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60);
+
+#[derive(Clone, Debug)]
+pub struct RegistryAgent {
+ pub id: SharedString,
+ pub name: SharedString,
+ pub description: SharedString,
+ pub version: SharedString,
+ pub repository: Option<SharedString>,
+ pub icon_path: Option<SharedString>,
+ pub targets: HashMap<String, RegistryTargetConfig>,
+ pub supports_current_platform: bool,
+}
+
+#[derive(Clone, Debug)]
+pub struct RegistryTargetConfig {
+ pub archive: String,
+ pub cmd: String,
+ pub args: Vec<String>,
+ pub sha256: Option<String>,
+ pub env: HashMap<String, String>,
+}
+
+struct GlobalAgentRegistryStore(Entity<AgentRegistryStore>);
+
+impl Global for GlobalAgentRegistryStore {}
+
+pub struct AgentRegistryStore {
+ fs: Arc<dyn Fs>,
+ http_client: Arc<dyn HttpClient>,
+ agents: Vec<RegistryAgent>,
+ is_fetching: bool,
+ fetch_error: Option<SharedString>,
+ pending_refresh: Option<Task<()>>,
+ _poll_task: Task<Result<()>>,
+}
+
+impl AgentRegistryStore {
+ pub fn init_global(cx: &mut App) -> Entity<Self> {
+ if let Some(store) = Self::try_global(cx) {
+ return store;
+ }
+
+ let fs = <dyn Fs>::global(cx);
+ let http_client: Arc<dyn HttpClient> = cx.http_client();
+
+ let store = cx.new(|cx| Self::new(fs, http_client, cx));
+ store.update(cx, |store, cx| {
+ store.refresh(cx);
+ store.start_polling(cx);
+ });
+ cx.set_global(GlobalAgentRegistryStore(store.clone()));
+ store
+ }
+
+ pub fn global(cx: &App) -> Entity<Self> {
+ cx.global::<GlobalAgentRegistryStore>().0.clone()
+ }
+
+ pub fn try_global(cx: &App) -> Option<Entity<Self>> {
+ cx.try_global::<GlobalAgentRegistryStore>()
+ .map(|store| store.0.clone())
+ }
+
+ pub fn agents(&self) -> &[RegistryAgent] {
+ &self.agents
+ }
+
+ pub fn agent(&self, id: &str) -> Option<&RegistryAgent> {
+ self.agents.iter().find(|agent| agent.id == id)
+ }
+
+ pub fn is_fetching(&self) -> bool {
+ self.is_fetching
+ }
+
+ pub fn fetch_error(&self) -> Option<SharedString> {
+ self.fetch_error.clone()
+ }
+
+ pub fn refresh(&mut self, cx: &mut Context<Self>) {
+ if self.pending_refresh.is_some() {
+ return;
+ }
+
+ self.is_fetching = true;
+ self.fetch_error = None;
+ cx.notify();
+
+ let fs = self.fs.clone();
+ let http_client = self.http_client.clone();
+
+ self.pending_refresh = Some(cx.spawn(async move |this, cx| {
+ let result = match fetch_registry_index(http_client.clone()).await {
+ Ok(data) => {
+ build_registry_agents(fs.clone(), http_client, data.index, data.raw_body, true)
+ .await
+ }
+ Err(error) => Err(error),
+ };
+
+ this.update(cx, |this, cx| {
+ this.pending_refresh = None;
+ this.is_fetching = false;
+ match result {
+ Ok(agents) => {
+ this.agents = agents;
+ this.fetch_error = None;
+ }
+ Err(error) => {
+ this.fetch_error = Some(SharedString::from(error.to_string()));
+ }
+ }
+ cx.notify();
+ })
+ .ok();
+ }));
+ }
+
+ fn new(fs: Arc<dyn Fs>, http_client: Arc<dyn HttpClient>, cx: &mut Context<Self>) -> Self {
+ let mut store = Self {
+ fs: fs.clone(),
+ http_client,
+ agents: Vec::new(),
+ is_fetching: false,
+ fetch_error: None,
+ pending_refresh: None,
+ _poll_task: Task::ready(Ok(())),
+ };
+
+ store.load_cached_registry(fs, store.http_client.clone(), cx);
+
+ store
+ }
+
+ fn load_cached_registry(
+ &mut self,
+ fs: Arc<dyn Fs>,
+ http_client: Arc<dyn HttpClient>,
+ cx: &mut Context<Self>,
+ ) {
+ cx.spawn(async move |this, cx| -> Result<()> {
+ let cache_path = registry_cache_path();
+ if !fs.is_file(&cache_path).await {
+ return Ok(());
+ }
+
+ let bytes = fs
+ .load_bytes(&cache_path)
+ .await
+ .context("reading cached registry")?;
+ let index: RegistryIndex =
+ serde_json::from_slice(&bytes).context("parsing cached registry")?;
+
+ let agents = build_registry_agents(fs, http_client, index, bytes, false).await?;
+
+ this.update(cx, |this, cx| {
+ this.agents = agents;
+ cx.notify();
+ })?;
+
+ Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+
+ fn start_polling(&mut self, cx: &mut Context<Self>) {
+ self._poll_task = cx.spawn(async move |this, cx| -> Result<()> {
+ loop {
+ this.update(cx, |this, cx| this.refresh(cx))?;
+ cx.background_executor()
+ .timer(REGISTRY_REFRESH_INTERVAL)
+ .await;
+ }
+ });
+ }
+}
+
+struct RegistryFetchResult {
+ index: RegistryIndex,
+ raw_body: Vec<u8>,
+}
+
+async fn fetch_registry_index(http_client: Arc<dyn HttpClient>) -> Result<RegistryFetchResult> {
+ let mut response = http_client
+ .get(REGISTRY_URL, AsyncBody::default(), true)
+ .await
+ .context("requesting ACP registry")?;
+
+ let mut body = Vec::new();
+ response
+ .body_mut()
+ .read_to_end(&mut body)
+ .await
+ .context("reading ACP registry response")?;
+
+ if response.status().is_client_error() {
+ let text = String::from_utf8_lossy(body.as_slice());
+ bail!(
+ "registry status error {}, response: {text:?}",
+ response.status().as_u16()
+ );
+ }
+
+ let index: RegistryIndex = serde_json::from_slice(&body).context("parsing ACP registry")?;
+ Ok(RegistryFetchResult {
+ index,
+ raw_body: body,
+ })
+}
+
+async fn build_registry_agents(
+ fs: Arc<dyn Fs>,
+ http_client: Arc<dyn HttpClient>,
+ index: RegistryIndex,
+ raw_body: Vec<u8>,
+ update_cache: bool,
+) -> Result<Vec<RegistryAgent>> {
+ let cache_dir = registry_cache_dir();
+ fs.create_dir(&cache_dir).await?;
+
+ let cache_path = cache_dir.join("registry.json");
+ if update_cache {
+ fs.write(&cache_path, &raw_body).await?;
+ }
+
+ let icons_dir = cache_dir.join("icons");
+ if update_cache {
+ fs.create_dir(&icons_dir).await?;
+ }
+
+ let current_platform = current_platform_key();
+
+ let mut agents = Vec::new();
+ for entry in index.agents {
+ let Some(binary) = entry.distribution.binary.as_ref() else {
+ continue;
+ };
+
+ if binary.is_empty() {
+ continue;
+ }
+
+ let mut targets = HashMap::default();
+ for (platform, target) in binary.iter() {
+ targets.insert(
+ platform.clone(),
+ RegistryTargetConfig {
+ archive: target.archive.clone(),
+ cmd: target.cmd.clone(),
+ args: target.args.clone(),
+ sha256: None,
+ env: target.env.clone(),
+ },
+ );
+ }
+
+ let supports_current_platform = current_platform
+ .as_ref()
+ .is_some_and(|platform| targets.contains_key(*platform));
+
+ let icon_path = resolve_icon_path(
+ &entry,
+ &icons_dir,
+ update_cache,
+ fs.clone(),
+ http_client.clone(),
+ )
+ .await?;
+
+ agents.push(RegistryAgent {
+ id: entry.id.into(),
+ name: entry.name.into(),
+ description: entry.description.into(),
+ version: entry.version.into(),
+ repository: entry.repository.map(Into::into),
+ icon_path,
+ targets,
+ supports_current_platform,
+ });
+ }
+
+ Ok(agents)
+}
+
+async fn resolve_icon_path(
+ entry: &RegistryEntry,
+ icons_dir: &Path,
+ update_cache: bool,
+ fs: Arc<dyn Fs>,
+ http_client: Arc<dyn HttpClient>,
+) -> Result<Option<SharedString>> {
+ let icon_url = resolve_icon_url(entry);
+ let Some(icon_url) = icon_url else {
+ return Ok(None);
+ };
+
+ let icon_path = icons_dir.join(format!("{}.svg", entry.id));
+ if update_cache && !fs.is_file(&icon_path).await {
+ if let Err(error) = download_icon(fs.clone(), http_client, &icon_url, entry).await {
+ log::warn!(
+ "Failed to download ACP registry icon for {}: {error:#}",
+ entry.id
+ );
+ }
+ }
+
+ if fs.is_file(&icon_path).await {
+ Ok(Some(SharedString::from(
+ icon_path.to_string_lossy().into_owned(),
+ )))
+ } else {
+ Ok(None)
+ }
+}
+
+async fn download_icon(
+ fs: Arc<dyn Fs>,
+ http_client: Arc<dyn HttpClient>,
+ icon_url: &str,
+ entry: &RegistryEntry,
+) -> Result<()> {
+ let mut response = http_client
+ .get(icon_url, AsyncBody::default(), true)
+ .await
+ .with_context(|| format!("requesting icon for {}", entry.id))?;
+
+ let mut body = Vec::new();
+ response
+ .body_mut()
+ .read_to_end(&mut body)
+ .await
+ .with_context(|| format!("reading icon for {}", entry.id))?;
+
+ if response.status().is_client_error() {
+ let text = String::from_utf8_lossy(body.as_slice());
+ bail!(
+ "icon status error {}, response: {text:?}",
+ response.status().as_u16()
+ );
+ }
+
+ let icon_path = registry_cache_dir()
+ .join("icons")
+ .join(format!("{}.svg", entry.id));
+ fs.write(&icon_path, &body).await?;
+ Ok(())
+}
+
+fn resolve_icon_url(entry: &RegistryEntry) -> Option<String> {
+ let icon = entry.icon.as_ref()?;
+ if icon.starts_with("https://") || icon.starts_with("http://") {
+ return Some(icon.to_string());
+ }
+
+ let relative_icon = icon.trim_start_matches("./");
+ Some(format!(
+ "https://raw.githubusercontent.com/agentclientprotocol/registry/main/{}/{relative_icon}",
+ entry.id
+ ))
+}
+
+fn current_platform_key() -> Option<&'static str> {
+ let os = if cfg!(target_os = "macos") {
+ "darwin"
+ } else if cfg!(target_os = "linux") {
+ "linux"
+ } else if cfg!(target_os = "windows") {
+ "windows"
+ } else {
+ return None;
+ };
+
+ let arch = if cfg!(target_arch = "aarch64") {
+ "aarch64"
+ } else if cfg!(target_arch = "x86_64") {
+ "x86_64"
+ } else {
+ return None;
+ };
+
+ Some(match os {
+ "darwin" => match arch {
+ "aarch64" => "darwin-aarch64",
+ "x86_64" => "darwin-x86_64",
+ _ => return None,
+ },
+ "linux" => match arch {
+ "aarch64" => "linux-aarch64",
+ "x86_64" => "linux-x86_64",
+ _ => return None,
+ },
+ "windows" => match arch {
+ "aarch64" => "windows-aarch64",
+ "x86_64" => "windows-x86_64",
+ _ => return None,
+ },
+ _ => return None,
+ })
+}
+
+fn registry_cache_dir() -> PathBuf {
+ paths::external_agents_dir().join("registry")
+}
+
+fn registry_cache_path() -> PathBuf {
+ registry_cache_dir().join("registry.json")
+}
+
+#[derive(Deserialize)]
+struct RegistryIndex {
+ #[serde(rename = "version")]
+ _version: String,
+ agents: Vec<RegistryEntry>,
+ #[serde(rename = "extensions")]
+ _extensions: Vec<RegistryEntry>,
+}
+
+#[derive(Deserialize)]
+struct RegistryEntry {
+ id: String,
+ name: String,
+ version: String,
+ description: String,
+ #[serde(default)]
+ repository: Option<String>,
+ #[serde(default)]
+ icon: Option<String>,
+ distribution: RegistryDistribution,
+}
+
+#[derive(Deserialize)]
+struct RegistryDistribution {
+ #[serde(default)]
+ binary: Option<HashMap<String, RegistryBinaryTarget>>,
+}
+
+#[derive(Deserialize)]
+struct RegistryBinaryTarget {
+ archive: String,
+ cmd: String,
+ #[serde(default)]
+ args: Vec<String>,
+ #[serde(default)]
+ env: HashMap<String, String>,
+}
@@ -30,6 +30,7 @@ use task::{Shell, SpawnInTerminal};
use util::{ResultExt as _, debug_panic};
use crate::ProjectEnvironment;
+use crate::agent_registry_store::{AgentRegistryStore, RegistryTargetConfig};
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
pub struct AgentServerCommand {
@@ -92,6 +93,15 @@ impl Borrow<str> for ExternalAgentServerName {
}
}
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum ExternalAgentSource {
+ Builtin,
+ #[default]
+ Custom,
+ Extension,
+ Registry,
+}
+
pub trait ExternalAgentServer {
fn get_command(
&mut self,
@@ -125,8 +135,9 @@ enum AgentServerStoreState {
HashMap<String, extension::TargetConfig>,
HashMap<String, String>,
Option<String>,
+ Option<SharedString>,
)>,
- _subscriptions: [Subscription; 1],
+ _subscriptions: Vec<Subscription>,
},
Remote {
project_id: u64,
@@ -135,11 +146,32 @@ enum AgentServerStoreState {
Collab,
}
+struct ExternalAgentEntry {
+ server: Box<dyn ExternalAgentServer>,
+ icon: Option<SharedString>,
+ display_name: Option<SharedString>,
+ source: ExternalAgentSource,
+}
+
+impl ExternalAgentEntry {
+ fn new(
+ server: Box<dyn ExternalAgentServer>,
+ source: ExternalAgentSource,
+ icon: Option<SharedString>,
+ display_name: Option<SharedString>,
+ ) -> Self {
+ Self {
+ server,
+ icon,
+ display_name,
+ source,
+ }
+ }
+}
+
pub struct AgentServerStore {
state: AgentServerStoreState,
- external_agents: HashMap<ExternalAgentServerName, Box<dyn ExternalAgentServer>>,
- agent_icons: HashMap<ExternalAgentServerName, SharedString>,
- agent_display_names: HashMap<ExternalAgentServerName, SharedString>,
+ external_agents: HashMap<ExternalAgentServerName, ExternalAgentEntry>,
}
pub struct AgentServersUpdated;
@@ -157,8 +189,6 @@ mod ext_agent_tests {
AgentServerStore {
state: AgentServerStoreState::Collab,
external_agents: HashMap::default(),
- agent_icons: HashMap::default(),
- agent_display_names: HashMap::default(),
}
}
@@ -205,15 +235,30 @@ mod ext_agent_tests {
// Seed with a couple of agents that will be replaced by extensions
store.external_agents.insert(
ExternalAgentServerName(SharedString::from("foo-agent")),
- Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
+ ExternalAgentEntry::new(
+ Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
+ ExternalAgentSource::Custom,
+ None,
+ None,
+ ),
);
store.external_agents.insert(
ExternalAgentServerName(SharedString::from("bar-agent")),
- Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
+ ExternalAgentEntry::new(
+ Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
+ ExternalAgentSource::Custom,
+ None,
+ None,
+ ),
);
store.external_agents.insert(
ExternalAgentServerName(SharedString::from("custom")),
- Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
+ ExternalAgentEntry::new(
+ Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
+ ExternalAgentSource::Custom,
+ None,
+ None,
+ ),
);
// Simulate the removal phase: if we're syncing extensions that provide
@@ -376,17 +421,8 @@ impl AgentServerStore {
// Remove all extension-provided agents
// (They will be re-added below if they're in the currently installed extensions)
- self.external_agents.retain(|name, agent| {
- if agent.downcast_mut::<LocalExtensionArchiveAgent>().is_some() {
- self.agent_icons.remove(name);
- self.agent_display_names.remove(name);
- false
- } else {
- // Keep the hardcoded external agents that don't come from extensions
- // (In the future we may move these over to being extensions too.)
- true
- }
- });
+ self.external_agents
+ .retain(|_, entry| entry.source != ExternalAgentSource::Extension);
// Insert agent servers from extension manifests
match &mut self.state {
@@ -396,27 +432,10 @@ impl AgentServerStore {
extension_agents.clear();
for (ext_id, manifest) in manifests {
for (agent_name, agent_entry) in &manifest.agent_servers {
- // Store display name from manifest
- self.agent_display_names.insert(
- ExternalAgentServerName(agent_name.clone().into()),
- SharedString::from(agent_entry.name.clone()),
- );
-
- let icon_path = if let Some(icon) = &agent_entry.icon {
- if let Some(absolute_icon_path) =
- resolve_extension_icon_path(&extensions_dir, ext_id, icon)
- {
- self.agent_icons.insert(
- ExternalAgentServerName(agent_name.clone().into()),
- SharedString::from(absolute_icon_path.clone()),
- );
- Some(absolute_icon_path)
- } else {
- None
- }
- } else {
- None
- };
+ let display_name = SharedString::from(agent_entry.name.clone());
+ let icon_path = agent_entry.icon.as_ref().and_then(|icon| {
+ resolve_extension_icon_path(&extensions_dir, ext_id, icon)
+ });
extension_agents.push((
agent_name.clone(),
@@ -424,6 +443,7 @@ impl AgentServerStore {
agent_entry.targets.clone(),
agent_entry.env.clone(),
icon_path,
+ Some(display_name),
));
}
}
@@ -436,27 +456,22 @@ impl AgentServerStore {
let mut agents = vec![];
for (ext_id, manifest) in manifests {
for (agent_name, agent_entry) in &manifest.agent_servers {
- // Store display name from manifest
- self.agent_display_names.insert(
- ExternalAgentServerName(agent_name.clone().into()),
- SharedString::from(agent_entry.name.clone()),
- );
-
- let icon = if let Some(icon) = &agent_entry.icon {
- if let Some(absolute_icon_path) =
- resolve_extension_icon_path(&extensions_dir, ext_id, icon)
- {
- self.agent_icons.insert(
- ExternalAgentServerName(agent_name.clone().into()),
- SharedString::from(absolute_icon_path.clone()),
- );
- Some(absolute_icon_path)
- } else {
- None
- }
- } else {
- None
- };
+ let display_name = SharedString::from(agent_entry.name.clone());
+ let icon_path = agent_entry.icon.as_ref().and_then(|icon| {
+ resolve_extension_icon_path(&extensions_dir, ext_id, icon)
+ });
+ let icon_shared = icon_path
+ .as_ref()
+ .map(|path| SharedString::from(path.clone()));
+ let icon = icon_path;
+ let agent_server_name = ExternalAgentServerName(agent_name.clone().into());
+ self.external_agents
+ .entry(agent_server_name)
+ .and_modify(|entry| {
+ entry.icon = icon_shared.clone();
+ entry.display_name = Some(display_name.clone());
+ entry.source = ExternalAgentSource::Extension;
+ });
agents.push(ExternalExtensionAgent {
name: agent_name.to_string(),
@@ -493,7 +508,13 @@ impl AgentServerStore {
}
pub fn agent_icon(&self, name: &ExternalAgentServerName) -> Option<SharedString> {
- self.agent_icons.get(name).cloned()
+ self.external_agents
+ .get(name)
+ .and_then(|entry| entry.icon.clone())
+ }
+
+ pub fn agent_source(&self, name: &ExternalAgentServerName) -> Option<ExternalAgentSource> {
+ self.external_agents.get(name).map(|entry| entry.source)
}
}
@@ -539,7 +560,9 @@ fn resolve_extension_icon_path(
impl AgentServerStore {
pub fn agent_display_name(&self, name: &ExternalAgentServerName) -> Option<SharedString> {
- self.agent_display_names.get(name).cloned()
+ self.external_agents
+ .get(name)
+ .and_then(|entry| entry.display_name.clone())
}
pub fn init_remote(session: &AnyProtoClient) {
@@ -601,88 +624,153 @@ impl AgentServerStore {
self.external_agents.clear();
self.external_agents.insert(
GEMINI_NAME.into(),
- Box::new(LocalGemini {
- fs: fs.clone(),
- node_runtime: node_runtime.clone(),
- project_environment: project_environment.clone(),
- custom_command: new_settings
- .gemini
- .clone()
- .and_then(|settings| settings.custom_command()),
- settings_env: new_settings
- .gemini
- .as_ref()
- .and_then(|settings| settings.env.clone()),
- ignore_system_version: new_settings
- .gemini
- .as_ref()
- .and_then(|settings| settings.ignore_system_version)
- .unwrap_or(true),
- }),
+ ExternalAgentEntry::new(
+ Box::new(LocalGemini {
+ fs: fs.clone(),
+ node_runtime: node_runtime.clone(),
+ project_environment: project_environment.clone(),
+ custom_command: new_settings
+ .gemini
+ .clone()
+ .and_then(|settings| settings.custom_command()),
+ settings_env: new_settings
+ .gemini
+ .as_ref()
+ .and_then(|settings| settings.env.clone()),
+ ignore_system_version: new_settings
+ .gemini
+ .as_ref()
+ .and_then(|settings| settings.ignore_system_version)
+ .unwrap_or(true),
+ }),
+ ExternalAgentSource::Builtin,
+ None,
+ None,
+ ),
);
self.external_agents.insert(
CODEX_NAME.into(),
- Box::new(LocalCodex {
- fs: fs.clone(),
- project_environment: project_environment.clone(),
- custom_command: new_settings
- .codex
- .clone()
- .and_then(|settings| settings.custom_command()),
- settings_env: new_settings
- .codex
- .as_ref()
- .and_then(|settings| settings.env.clone()),
- http_client: http_client.clone(),
- no_browser: downstream_client
- .as_ref()
- .is_some_and(|(_, client)| !client.has_wsl_interop()),
- }),
+ ExternalAgentEntry::new(
+ Box::new(LocalCodex {
+ fs: fs.clone(),
+ project_environment: project_environment.clone(),
+ custom_command: new_settings
+ .codex
+ .clone()
+ .and_then(|settings| settings.custom_command()),
+ settings_env: new_settings
+ .codex
+ .as_ref()
+ .and_then(|settings| settings.env.clone()),
+ http_client: http_client.clone(),
+ no_browser: downstream_client
+ .as_ref()
+ .is_some_and(|(_, client)| !client.has_wsl_interop()),
+ }),
+ ExternalAgentSource::Builtin,
+ None,
+ None,
+ ),
);
self.external_agents.insert(
CLAUDE_CODE_NAME.into(),
- Box::new(LocalClaudeCode {
- fs: fs.clone(),
- node_runtime: node_runtime.clone(),
- project_environment: project_environment.clone(),
- custom_command: new_settings
- .claude
- .clone()
- .and_then(|settings| settings.custom_command()),
- settings_env: new_settings
- .claude
- .as_ref()
- .and_then(|settings| settings.env.clone()),
- }),
+ ExternalAgentEntry::new(
+ Box::new(LocalClaudeCode {
+ fs: fs.clone(),
+ node_runtime: node_runtime.clone(),
+ project_environment: project_environment.clone(),
+ custom_command: new_settings
+ .claude
+ .clone()
+ .and_then(|settings| settings.custom_command()),
+ settings_env: new_settings
+ .claude
+ .as_ref()
+ .and_then(|settings| settings.env.clone()),
+ }),
+ ExternalAgentSource::Builtin,
+ None,
+ None,
+ ),
);
- self.external_agents
- .extend(
- new_settings
- .custom
+
+ let registry_store = AgentRegistryStore::try_global(cx);
+ let registry_agents_by_id = registry_store
+ .as_ref()
+ .map(|store| {
+ store
+ .read(cx)
+ .agents()
.iter()
- .filter_map(|(name, settings)| match settings {
- CustomAgentServerSettings::Custom { command, .. } => Some((
- ExternalAgentServerName(name.clone().into()),
+ .cloned()
+ .map(|agent| (agent.id.to_string(), agent))
+ .collect::<HashMap<_, _>>()
+ })
+ .unwrap_or_default();
+
+ for (name, settings) in &new_settings.custom {
+ match settings {
+ CustomAgentServerSettings::Custom { command, .. } => {
+ let agent_name = ExternalAgentServerName(name.clone().into());
+ self.external_agents.insert(
+ agent_name.clone(),
+ ExternalAgentEntry::new(
Box::new(LocalCustomAgent {
command: command.clone(),
project_environment: project_environment.clone(),
}) as Box<dyn ExternalAgentServer>,
- )),
- CustomAgentServerSettings::Extension { .. } => None,
- }),
- );
- self.external_agents.extend(extension_agents.iter().map(
- |(agent_name, ext_id, targets, env, icon_path)| {
- let name = ExternalAgentServerName(agent_name.clone().into());
-
- // Restore icon if present
- if let Some(icon) = icon_path {
- self.agent_icons
- .insert(name.clone(), SharedString::from(icon.clone()));
+ ExternalAgentSource::Custom,
+ None,
+ None,
+ ),
+ );
}
+ CustomAgentServerSettings::Registry { .. } => {
+ let Some(agent) = registry_agents_by_id.get(name) else {
+ if registry_store.is_some() {
+ log::warn!("Registry agent '{}' not found in ACP registry", name);
+ }
+ continue;
+ };
+ if !agent.supports_current_platform {
+ log::warn!(
+ "Registry agent '{}' has no compatible binary for this platform",
+ name
+ );
+ continue;
+ }
- (
- name,
+ let agent_name = ExternalAgentServerName(name.clone().into());
+ self.external_agents.insert(
+ agent_name.clone(),
+ ExternalAgentEntry::new(
+ Box::new(LocalRegistryArchiveAgent {
+ fs: fs.clone(),
+ http_client: http_client.clone(),
+ node_runtime: node_runtime.clone(),
+ project_environment: project_environment.clone(),
+ registry_id: Arc::from(name.as_str()),
+ targets: agent.targets.clone(),
+ }) as Box<dyn ExternalAgentServer>,
+ ExternalAgentSource::Registry,
+ agent.icon_path.clone(),
+ Some(agent.name.clone()),
+ ),
+ );
+ }
+ CustomAgentServerSettings::Extension { .. } => {}
+ }
+ }
+
+ for (agent_name, ext_id, targets, env, icon_path, display_name) in extension_agents.iter() {
+ let name = ExternalAgentServerName(agent_name.clone().into());
+ let icon = icon_path
+ .as_ref()
+ .map(|path| SharedString::from(path.clone()));
+
+ self.external_agents.insert(
+ name.clone(),
+ ExternalAgentEntry::new(
Box::new(LocalExtensionArchiveAgent {
fs: fs.clone(),
http_client: http_client.clone(),
@@ -693,11 +781,14 @@ impl AgentServerStore {
env: env.clone(),
agent_id: agent_name.clone(),
}) as Box<dyn ExternalAgentServer>,
- )
- },
- ));
+ ExternalAgentSource::Extension,
+ icon,
+ display_name.clone(),
+ ),
+ );
+ }
- *old_settings = Some(new_settings.clone());
+ *old_settings = Some(new_settings);
if let Some((project_id, downstream_client)) = downstream_client {
downstream_client
@@ -728,9 +819,14 @@ impl AgentServerStore {
http_client: Arc<dyn HttpClient>,
cx: &mut Context<Self>,
) -> Self {
- let subscription = cx.observe_global::<SettingsStore>(|this, cx| {
+ let mut subscriptions = vec![cx.observe_global::<SettingsStore>(|this, cx| {
this.agent_servers_settings_changed(cx);
- });
+ })];
+ if let Some(registry_store) = AgentRegistryStore::try_global(cx) {
+ subscriptions.push(cx.observe(®istry_store, |this, _, cx| {
+ this.reregister_agents(cx);
+ }));
+ }
let mut this = Self {
state: AgentServerStoreState::Local {
node_runtime,
@@ -740,11 +836,9 @@ impl AgentServerStore {
downstream_client: None,
settings: None,
extension_agents: vec![],
- _subscriptions: [subscription],
+ _subscriptions: subscriptions,
},
external_agents: Default::default(),
- agent_icons: Default::default(),
- agent_display_names: Default::default(),
};
if let Some(_events) = extension::ExtensionEvents::try_global(cx) {}
this.agent_servers_settings_changed(cx);
@@ -755,36 +849,51 @@ impl AgentServerStore {
// Set up the builtin agents here so they're immediately available in
// remote projects--we know that the HeadlessProject on the other end
// will have them.
- let external_agents: [(ExternalAgentServerName, Box<dyn ExternalAgentServer>); 3] = [
+ let external_agents: [(ExternalAgentServerName, ExternalAgentEntry); 3] = [
(
CLAUDE_CODE_NAME.into(),
- Box::new(RemoteExternalAgentServer {
- project_id,
- upstream_client: upstream_client.clone(),
- name: CLAUDE_CODE_NAME.into(),
- status_tx: None,
- new_version_available_tx: None,
- }) as Box<dyn ExternalAgentServer>,
+ ExternalAgentEntry::new(
+ Box::new(RemoteExternalAgentServer {
+ project_id,
+ upstream_client: upstream_client.clone(),
+ name: CLAUDE_CODE_NAME.into(),
+ status_tx: None,
+ new_version_available_tx: None,
+ }) as Box<dyn ExternalAgentServer>,
+ ExternalAgentSource::Builtin,
+ None,
+ None,
+ ),
),
(
CODEX_NAME.into(),
- Box::new(RemoteExternalAgentServer {
- project_id,
- upstream_client: upstream_client.clone(),
- name: CODEX_NAME.into(),
- status_tx: None,
- new_version_available_tx: None,
- }) as Box<dyn ExternalAgentServer>,
+ ExternalAgentEntry::new(
+ Box::new(RemoteExternalAgentServer {
+ project_id,
+ upstream_client: upstream_client.clone(),
+ name: CODEX_NAME.into(),
+ status_tx: None,
+ new_version_available_tx: None,
+ }) as Box<dyn ExternalAgentServer>,
+ ExternalAgentSource::Builtin,
+ None,
+ None,
+ ),
),
(
GEMINI_NAME.into(),
- Box::new(RemoteExternalAgentServer {
- project_id,
- upstream_client: upstream_client.clone(),
- name: GEMINI_NAME.into(),
- status_tx: None,
- new_version_available_tx: None,
- }) as Box<dyn ExternalAgentServer>,
+ ExternalAgentEntry::new(
+ Box::new(RemoteExternalAgentServer {
+ project_id,
+ upstream_client: upstream_client.clone(),
+ name: GEMINI_NAME.into(),
+ status_tx: None,
+ new_version_available_tx: None,
+ }) as Box<dyn ExternalAgentServer>,
+ ExternalAgentSource::Builtin,
+ None,
+ None,
+ ),
),
];
@@ -794,8 +903,6 @@ impl AgentServerStore {
upstream_client,
},
external_agents: external_agents.into_iter().collect(),
- agent_icons: HashMap::default(),
- agent_display_names: HashMap::default(),
}
}
@@ -803,8 +910,6 @@ impl AgentServerStore {
Self {
state: AgentServerStoreState::Collab,
external_agents: Default::default(),
- agent_icons: Default::default(),
- agent_display_names: Default::default(),
}
}
@@ -820,8 +925,7 @@ impl AgentServerStore {
cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(1)).await;
let names = this.update(cx, |this, _| {
- this.external_agents
- .keys()
+ this.external_agents()
.map(|name| name.to_string())
.collect()
})?;
@@ -849,7 +953,7 @@ impl AgentServerStore {
) -> Option<&mut (dyn ExternalAgentServer + 'static)> {
self.external_agents
.get_mut(name)
- .map(|agent| agent.as_mut())
+ .map(|entry| entry.server.as_mut())
}
pub fn external_agents(&self) -> impl Iterator<Item = &ExternalAgentServerName> {
@@ -873,6 +977,7 @@ impl AgentServerStore {
let agent = this
.external_agents
.get_mut(&*envelope.payload.name)
+ .map(|entry| entry.server.as_mut())
.with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
let (status_tx, new_version_available_tx) = downstream_client
.clone()
@@ -956,50 +1061,59 @@ impl AgentServerStore {
bail!("unexpected ExternalAgentsUpdated message")
};
- let mut status_txs = this
- .external_agents
- .iter_mut()
- .filter_map(|(name, agent)| {
- Some((
- name.clone(),
- agent
- .downcast_mut::<RemoteExternalAgentServer>()?
- .status_tx
- .take(),
- ))
- })
- .collect::<HashMap<_, _>>();
- let mut new_version_available_txs = this
- .external_agents
- .iter_mut()
- .filter_map(|(name, agent)| {
- Some((
- name.clone(),
- agent
- .downcast_mut::<RemoteExternalAgentServer>()?
- .new_version_available_tx
- .take(),
- ))
- })
- .collect::<HashMap<_, _>>();
+ let mut previous_entries = std::mem::take(&mut this.external_agents);
+ let mut status_txs = HashMap::default();
+ let mut new_version_available_txs = HashMap::default();
+ let mut metadata = HashMap::default();
+
+ for (name, mut entry) in previous_entries.drain() {
+ if let Some(agent) = entry.server.downcast_mut::<RemoteExternalAgentServer>() {
+ status_txs.insert(name.clone(), agent.status_tx.take());
+ new_version_available_txs
+ .insert(name.clone(), agent.new_version_available_tx.take());
+ }
+
+ metadata.insert(name, (entry.icon, entry.display_name, entry.source));
+ }
this.external_agents = envelope
.payload
.names
.into_iter()
.map(|name| {
+ let agent_name = ExternalAgentServerName(name.clone().into());
+ let fallback_source =
+ if name == GEMINI_NAME || name == CLAUDE_CODE_NAME || name == CODEX_NAME {
+ ExternalAgentSource::Builtin
+ } else {
+ ExternalAgentSource::Custom
+ };
+ let (icon, display_name, source) =
+ metadata
+ .remove(&agent_name)
+ .unwrap_or((None, None, fallback_source));
+ let source = if fallback_source == ExternalAgentSource::Builtin {
+ ExternalAgentSource::Builtin
+ } else {
+ source
+ };
let agent = RemoteExternalAgentServer {
project_id: *project_id,
upstream_client: upstream_client.clone(),
- name: ExternalAgentServerName(name.clone().into()),
- status_tx: status_txs.remove(&*name).flatten(),
+ name: agent_name.clone(),
+ status_tx: status_txs.remove(&agent_name).flatten(),
new_version_available_tx: new_version_available_txs
- .remove(&*name)
+ .remove(&agent_name)
.flatten(),
};
(
- ExternalAgentServerName(name.into()),
- Box::new(agent) as Box<dyn ExternalAgentServer>,
+ agent_name,
+ ExternalAgentEntry::new(
+ Box::new(agent) as Box<dyn ExternalAgentServer>,
+ source,
+ icon,
+ display_name,
+ ),
)
})
.collect();
@@ -1032,13 +1146,6 @@ impl AgentServerStore {
env,
} in envelope.payload.agents
{
- let icon_path_string = icon_path.clone();
- if let Some(icon_path) = icon_path {
- this.agent_icons.insert(
- ExternalAgentServerName(name.clone().into()),
- icon_path.into(),
- );
- }
extension_agents.push((
Arc::from(&*name),
extension_id,
@@ -1047,7 +1154,8 @@ impl AgentServerStore {
.map(|(k, v)| (k, extension::TargetConfig::from_proto(v)))
.collect(),
env.into_iter().collect(),
- icon_path_string,
+ icon_path,
+ None,
));
}
@@ -1064,7 +1172,7 @@ impl AgentServerStore {
) -> Result<()> {
this.update(&mut cx, |this, _| {
if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
- && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
+ && let Some(agent) = agent.server.downcast_mut::<RemoteExternalAgentServer>()
&& let Some(status_tx) = &mut agent.status_tx
{
status_tx.send(envelope.payload.status.into()).ok();
@@ -1080,7 +1188,7 @@ impl AgentServerStore {
) -> Result<()> {
this.update(&mut cx, |this, _| {
if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
- && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
+ && let Some(agent) = agent.server.downcast_mut::<RemoteExternalAgentServer>()
&& let Some(new_version_available_tx) = &mut agent.new_version_available_tx
{
new_version_available_tx
@@ -1095,8 +1203,9 @@ impl AgentServerStore {
&mut self,
name: &ExternalAgentServerName,
) -> Option<Arc<str>> {
- self.external_agents.get_mut(name).and_then(|agent| {
- agent
+ self.external_agents.get_mut(name).and_then(|entry| {
+ entry
+ .server
.as_any_mut()
.downcast_ref::<LocalExtensionArchiveAgent>()
.map(|ext_agent| ext_agent.extension_id.clone())
@@ -1785,11 +1894,6 @@ struct LocalExtensionArchiveAgent {
env: HashMap<String, String>,
}
-struct LocalCustomAgent {
- project_environment: Entity<ProjectEnvironment>,
- command: AgentServerCommand,
-}
-
impl ExternalAgentServer for LocalExtensionArchiveAgent {
fn get_command(
&mut self,
@@ -1988,6 +2092,198 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent {
}
}
+struct LocalRegistryArchiveAgent {
+ fs: Arc<dyn Fs>,
+ http_client: Arc<dyn HttpClient>,
+ node_runtime: NodeRuntime,
+ project_environment: Entity<ProjectEnvironment>,
+ registry_id: Arc<str>,
+ targets: HashMap<String, RegistryTargetConfig>,
+}
+
+impl ExternalAgentServer for LocalRegistryArchiveAgent {
+ fn get_command(
+ &mut self,
+ root_dir: Option<&str>,
+ extra_env: HashMap<String, String>,
+ _status_tx: Option<watch::Sender<SharedString>>,
+ _new_version_available_tx: Option<watch::Sender<Option<String>>>,
+ cx: &mut AsyncApp,
+ ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
+ let fs = self.fs.clone();
+ let http_client = self.http_client.clone();
+ let node_runtime = self.node_runtime.clone();
+ let project_environment = self.project_environment.downgrade();
+ let registry_id = self.registry_id.clone();
+ let targets = self.targets.clone();
+
+ let root_dir: Arc<Path> = root_dir
+ .map(|root_dir| Path::new(root_dir))
+ .unwrap_or(paths::home_dir())
+ .into();
+
+ cx.spawn(async move |cx| {
+ let mut env = project_environment
+ .update(cx, |project_environment, cx| {
+ project_environment.local_directory_environment(
+ &Shell::System,
+ root_dir.clone(),
+ cx,
+ )
+ })?
+ .await
+ .unwrap_or_default();
+
+ let dir = paths::external_agents_dir()
+ .join("registry")
+ .join(registry_id.as_ref());
+ fs.create_dir(&dir).await?;
+
+ let os = if cfg!(target_os = "macos") {
+ "darwin"
+ } else if cfg!(target_os = "linux") {
+ "linux"
+ } else if cfg!(target_os = "windows") {
+ "windows"
+ } else {
+ anyhow::bail!("unsupported OS");
+ };
+
+ let arch = if cfg!(target_arch = "aarch64") {
+ "aarch64"
+ } else if cfg!(target_arch = "x86_64") {
+ "x86_64"
+ } else {
+ anyhow::bail!("unsupported architecture");
+ };
+
+ let platform_key = format!("{}-{}", os, arch);
+ let target_config = targets.get(&platform_key).with_context(|| {
+ format!(
+ "no target specified for platform '{}'. Available platforms: {}",
+ platform_key,
+ targets
+ .keys()
+ .map(|k| k.as_str())
+ .collect::<Vec<_>>()
+ .join(", ")
+ )
+ })?;
+
+ env.extend(target_config.env.clone());
+ env.extend(extra_env);
+
+ let archive_url = &target_config.archive;
+
+ use std::collections::hash_map::DefaultHasher;
+ use std::hash::{Hash, Hasher};
+ let mut hasher = DefaultHasher::new();
+ archive_url.hash(&mut hasher);
+ let url_hash = hasher.finish();
+ let version_dir = dir.join(format!("v_{:x}", url_hash));
+
+ if !fs.is_dir(&version_dir).await {
+ let sha256 = if let Some(provided_sha) = &target_config.sha256 {
+ Some(provided_sha.clone())
+ } else if archive_url.starts_with("https://github.com/") {
+ if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
+ let parts: Vec<&str> = caps.split('/').collect();
+ if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
+ let repo = format!("{}/{}", parts[0], parts[1]);
+ let tag = parts[4];
+ let filename = parts[5..].join("/");
+
+ if let Ok(release) = ::http_client::github::get_release_by_tag_name(
+ &repo,
+ tag,
+ http_client.clone(),
+ )
+ .await
+ {
+ if let Some(asset) =
+ release.assets.iter().find(|a| a.name == filename)
+ {
+ asset.digest.as_ref().and_then(|d| {
+ d.strip_prefix("sha256:")
+ .map(|s| s.to_string())
+ .or_else(|| Some(d.clone()))
+ })
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ } else {
+ None
+ };
+
+ let asset_kind = if archive_url.ends_with(".zip") {
+ AssetKind::Zip
+ } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
+ AssetKind::TarGz
+ } else {
+ anyhow::bail!("unsupported archive type in URL: {}", archive_url);
+ };
+
+ ::http_client::github_download::download_server_binary(
+ &*http_client,
+ archive_url,
+ sha256.as_deref(),
+ &version_dir,
+ asset_kind,
+ )
+ .await?;
+ }
+
+ let cmd = &target_config.cmd;
+
+ let cmd_path = if cmd == "node" {
+ node_runtime.binary_path().await?
+ } else {
+ if cmd.contains("..") {
+ anyhow::bail!("command path cannot contain '..': {}", cmd);
+ }
+
+ if cmd.starts_with("./") || cmd.starts_with(".\\") {
+ let cmd_path = version_dir.join(&cmd[2..]);
+ anyhow::ensure!(
+ fs.is_file(&cmd_path).await,
+ "Missing command {} after extraction",
+ cmd_path.to_string_lossy()
+ );
+ cmd_path
+ } else {
+ anyhow::bail!("command must be relative (start with './'): {}", cmd);
+ }
+ };
+
+ let command = AgentServerCommand {
+ path: cmd_path,
+ args: target_config.args.clone(),
+ env: Some(env),
+ };
+
+ Ok((command, version_dir.to_string_lossy().into_owned(), None))
+ })
+ }
+
+ fn as_any_mut(&mut self) -> &mut dyn Any {
+ self
+ }
+}
+
+struct LocalCustomAgent {
+ project_environment: Entity<ProjectEnvironment>,
+ command: AgentServerCommand,
+}
+
impl ExternalAgentServer for LocalCustomAgent {
fn get_command(
&mut self,
@@ -2153,29 +2449,60 @@ pub enum CustomAgentServerSettings {
/// Default: {}
favorite_config_option_values: HashMap<String, Vec<String>>,
},
+ Registry {
+ /// The default mode to use for this agent.
+ ///
+ /// Note: Not only all agents support modes.
+ ///
+ /// Default: None
+ 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
+ default_model: Option<String>,
+ /// The favorite models for this agent.
+ ///
+ /// Default: []
+ favorite_models: Vec<String>,
+ /// Default values for session config options.
+ ///
+ /// This is a map from config option ID to value ID.
+ ///
+ /// Default: {}
+ 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: {}
+ favorite_config_option_values: HashMap<String, Vec<String>>,
+ },
}
impl CustomAgentServerSettings {
pub fn command(&self) -> Option<&AgentServerCommand> {
match self {
CustomAgentServerSettings::Custom { command, .. } => Some(command),
- CustomAgentServerSettings::Extension { .. } => None,
+ CustomAgentServerSettings::Extension { .. }
+ | CustomAgentServerSettings::Registry { .. } => None,
}
}
pub fn default_mode(&self) -> Option<&str> {
match self {
CustomAgentServerSettings::Custom { default_mode, .. }
- | CustomAgentServerSettings::Extension { default_mode, .. } => default_mode.as_deref(),
+ | CustomAgentServerSettings::Extension { default_mode, .. }
+ | CustomAgentServerSettings::Registry { default_mode, .. } => default_mode.as_deref(),
}
}
pub fn default_model(&self) -> Option<&str> {
match self {
CustomAgentServerSettings::Custom { default_model, .. }
- | CustomAgentServerSettings::Extension { default_model, .. } => {
- default_model.as_deref()
- }
+ | CustomAgentServerSettings::Extension { default_model, .. }
+ | CustomAgentServerSettings::Registry { default_model, .. } => default_model.as_deref(),
}
}
@@ -1,3 +1,4 @@
+pub mod agent_registry_store;
pub mod agent_server_store;
pub mod buffer_store;
mod color_extractor;
@@ -43,7 +44,10 @@ use crate::{
project_search::SearchResultsHandle,
trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees},
};
-pub use agent_server_store::{AgentServerStore, AgentServersUpdated, ExternalAgentServerName};
+pub use agent_registry_store::{AgentRegistryStore, RegistryAgent};
+pub use agent_server_store::{
+ AgentServerStore, AgentServersUpdated, ExternalAgentServerName, ExternalAgentSource,
+};
pub use git_store::{
ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate,
git_traversal::{ChildEntriesGitIter, GitEntry, GitEntryRef, GitTraversal},
@@ -27,7 +27,7 @@ log.workspace = true
migrator.workspace = true
paths.workspace = true
release_channel.workspace = true
-rust-embed.workspace = true
+rust-embed = { workspace = true, features = ["debug-embed"] }
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -502,6 +502,41 @@ pub enum CustomAgentServerSettings {
#[serde(default)]
favorite_config_option_values: HashMap<String, Vec<String>>,
},
+ Registry {
+ /// The default mode to use for this agent.
+ ///
+ /// Note: Not only all agents support modes.
+ ///
+ /// Default: None
+ 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
+ default_model: Option<String>,
+ /// The favorite models for this agent.
+ ///
+ /// These are the model IDs as reported by the agent.
+ ///
+ /// Default: []
+ #[serde(default)]
+ favorite_models: Vec<String>,
+ /// Default values for session config options.
+ ///
+ /// This is a map from config option ID to value ID.
+ ///
+ /// Default: {}
+ #[serde(default)]
+ 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)]
+ favorite_config_option_values: HashMap<String, Vec<String>>,
+ },
}
#[with_fallible_options]
@@ -17,6 +17,7 @@ use crashes::InitCrashHandler;
use db::kvp::{GLOBAL_KEY_VALUE_STORE, KEY_VALUE_STORE};
use editor::Editor;
use extension::ExtensionHostProxy;
+use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _};
use fs::{Fs, RealFs};
use futures::{StreamExt, channel::oneshot, future};
use git::GitHostingProviderRegistry;
@@ -617,6 +618,15 @@ fn main() {
snippet_provider::init(cx);
edit_prediction_registry::init(app_state.client.clone(), app_state.user_store.clone(), cx);
let prompt_builder = PromptBuilder::load(app_state.fs.clone(), stdout_is_a_pty(), cx);
+ if cx.has_flag::<AcpBetaFeatureFlag>() {
+ project::AgentRegistryStore::init_global(cx);
+ }
+ cx.observe_flag::<AcpBetaFeatureFlag, _>(|is_enabled, cx| {
+ if is_enabled {
+ project::AgentRegistryStore::init_global(cx);
+ }
+ })
+ .detach();
agent_ui::init(
app_state.fs.clone(),
app_state.client.clone(),
@@ -25,7 +25,7 @@ use collections::VecDeque;
use debugger_ui::debugger_panel::DebugPanel;
use editor::{Editor, MultiBuffer};
use extension_host::ExtensionStore;
-use feature_flags::{FeatureFlagAppExt, PanicFeatureFlag};
+use feature_flags::{FeatureFlagAppExt as _, PanicFeatureFlag};
use fs::Fs;
use futures::FutureExt as _;
use futures::future::Either;
@@ -4968,6 +4968,7 @@ mod tests {
web_search::init(cx);
web_search_providers::init(app_state.client.clone(), cx);
let prompt_builder = PromptBuilder::load(app_state.fs.clone(), false, cx);
+ project::AgentRegistryStore::init_global(cx);
agent_ui::init(
app_state.fs.clone(),
app_state.client.clone(),
@@ -104,6 +104,12 @@ pub struct Extensions {
pub id: Option<String>,
}
+/// Opens the external agent registry.
+#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
+#[action(namespace = zed)]
+#[serde(deny_unknown_fields)]
+pub struct AgentRegistry;
+
/// Decreases the font size in the editor buffer.
#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
#[action(namespace = zed)]