diff --git a/assets/icons/acp_registry.svg b/assets/icons/acp_registry.svg
new file mode 100644
index 0000000000000000000000000000000000000000..fb64ea6fbcfe2febe69cdff9773b9383a8bec67a
--- /dev/null
+++ b/assets/icons/acp_registry.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs
index 29cfd3d5c1224f516c1deb15762ded45352ae4e4..c507c3b53f0878c2c117865892f94e2e0d26fd19 100644
--- a/crates/agent_servers/src/custom.rs
+++ b/crates/agent_servers/src/custom.rs
@@ -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()
diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs
index 5351085795f3ea88b0f50c0da80c98edb8dc6b36..8a9dcf8f9f68a405aef8728d29a315d0d82dd508 100644
--- a/crates/agent_ui/src/agent_configuration.rs
+++ b/crates/agent_ui/src/agent_configuration.rs
@@ -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::() {
+ 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,
display_name: impl Into,
- external: bool,
+ source: ExternalAgentSource,
cx: &mut Context,
) -> 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)
+ })
}
}
diff --git a/crates/agent_ui/src/agent_registry_ui.rs b/crates/agent_ui/src/agent_registry_ui.rs
new file mode 100644
index 0000000000000000000000000000000000000000..da0f291fa830252456f320cd6a7aa4e3d5531b40
--- /dev/null
+++ b/crates/agent_ui/src/agent_registry_ui.rs
@@ -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,
+}
+
+impl AgentRegistryCard {
+ fn new() -> Self {
+ Self {
+ children: Vec::new(),
+ }
+ }
+}
+
+impl ParentElement for AgentRegistryCard {
+ fn extend(&mut self, elements: impl IntoIterator- ) {
+ 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,
+ list: UniformListScrollHandle,
+ registry_agents: Vec,
+ filtered_registry_indices: Vec,
+ installed_statuses: HashMap,
+ query_editor: Entity,
+ filter: RegistryFilter,
+ _subscriptions: Vec,
+}
+
+impl AgentRegistryPage {
+ pub fn new(
+ _workspace: &Workspace,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Entity {
+ 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::(|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.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) {
+ let settings = cx
+ .global::()
+ .get::(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 {
+ 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.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.list.set_offset(point(px(0.), px(0.)));
+ cx.notify();
+ }
+
+ fn on_query_change(
+ &mut self,
+ _: Entity,
+ event: &editor::EditorEvent,
+ cx: &mut Context,
+ ) {
+ if let editor::EditorEvent::Edited { .. } = event {
+ self.filter_registry_agents(cx);
+ self.scroll_to_top(cx);
+ }
+ }
+
+ fn render_search(&self, cx: &mut Context) -> 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,
+ cx: &mut Context,
+ ) -> 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) -> 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,
+ _: &mut Window,
+ cx: &mut Context,
+ ) -> Vec {
+ 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,
+ ) -> 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,
+ ) -> 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 = ::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 = ::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) -> 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 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)
+ }
+}
diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs
index 6d8648c9c418b2eda775c671b64a28eb6937dc10..ceb159cbc287fd2cdb82c00cf70c2f4865b49cd9 100644
--- a/crates/agent_ui/src/agent_ui.rs
+++ b/crates/agent_ui/src/agent_ui.rs
@@ -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| {
+ if !cx.has_flag::() {
+ return;
+ }
+ let existing = workspace
+ .active_pane()
+ .read(cx)
+ .items()
+ .find_map(|item| item.downcast::());
+
+ 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
diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs
index ea0fa5da8f19690a3c8f3c52dc0b6fae9a3221f3..386414e7d7ddc223394a91381babf2f446a48be3 100644
--- a/crates/icons/src/icons.rs
+++ b/crates/icons/src/icons.rs
@@ -8,6 +8,7 @@ use strum::{EnumIter, EnumString, IntoStaticStr};
)]
#[strum(serialize_all = "snake_case")]
pub enum IconName {
+ AcpRegistry,
Ai,
AiAnthropic,
AiBedrock,
diff --git a/crates/project/src/agent_registry_store.rs b/crates/project/src/agent_registry_store.rs
new file mode 100644
index 0000000000000000000000000000000000000000..2a59c77fb550fe068bc0fb17271cfe954b2b07ad
--- /dev/null
+++ b/crates/project/src/agent_registry_store.rs
@@ -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,
+ pub icon_path: Option,
+ pub targets: HashMap,
+ pub supports_current_platform: bool,
+}
+
+#[derive(Clone, Debug)]
+pub struct RegistryTargetConfig {
+ pub archive: String,
+ pub cmd: String,
+ pub args: Vec,
+ pub sha256: Option,
+ pub env: HashMap,
+}
+
+struct GlobalAgentRegistryStore(Entity);
+
+impl Global for GlobalAgentRegistryStore {}
+
+pub struct AgentRegistryStore {
+ fs: Arc,
+ http_client: Arc,
+ agents: Vec,
+ is_fetching: bool,
+ fetch_error: Option,
+ pending_refresh: Option>,
+ _poll_task: Task>,
+}
+
+impl AgentRegistryStore {
+ pub fn init_global(cx: &mut App) -> Entity {
+ if let Some(store) = Self::try_global(cx) {
+ return store;
+ }
+
+ let fs = ::global(cx);
+ let http_client: Arc = 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 {
+ cx.global::().0.clone()
+ }
+
+ pub fn try_global(cx: &App) -> Option> {
+ cx.try_global::()
+ .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 {
+ self.fetch_error.clone()
+ }
+
+ pub fn refresh(&mut self, cx: &mut Context) {
+ 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, http_client: Arc, cx: &mut Context) -> 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,
+ http_client: Arc,
+ cx: &mut Context,
+ ) {
+ 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._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,
+}
+
+async fn fetch_registry_index(http_client: Arc) -> Result {
+ 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,
+ http_client: Arc,
+ index: RegistryIndex,
+ raw_body: Vec,
+ update_cache: bool,
+) -> Result> {
+ 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,
+ http_client: Arc,
+) -> Result