@@ -1,5 +1,8 @@
+use std::collections::{BTreeMap, BTreeSet};
use std::ops::Range;
+use std::sync::OnceLock;
+use client::zed_urls;
use collections::HashMap;
use editor::{Editor, EditorElement, EditorStyle};
use fs::Fs;
@@ -13,7 +16,7 @@ use project::{AgentRegistryStore, RegistryAgent};
use settings::{Settings, SettingsStore, update_settings_file};
use theme::ThemeSettings;
use ui::{
- ButtonLink, ButtonStyle, Chip, ScrollableHandle, ToggleButtonGroup, ToggleButtonGroupSize,
+ Banner, ButtonStyle, ScrollableHandle, Severity, ToggleButtonGroup, ToggleButtonGroupSize,
ToggleButtonGroupStyle, ToggleButtonSimple, Tooltip, WithScrollbar, prelude::*,
};
use workspace::{
@@ -40,6 +43,25 @@ enum RegistryInstallStatus {
InstalledExtension,
}
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
+enum BuiltInAgent {
+ Claude,
+ Codex,
+ Gemini,
+}
+
+fn keywords_by_agent_feature() -> &'static BTreeMap<BuiltInAgent, Vec<&'static str>> {
+ static KEYWORDS_BY_FEATURE: OnceLock<BTreeMap<BuiltInAgent, Vec<&'static str>>> =
+ OnceLock::new();
+ KEYWORDS_BY_FEATURE.get_or_init(|| {
+ BTreeMap::from_iter([
+ (BuiltInAgent::Claude, vec!["claude", "claude code"]),
+ (BuiltInAgent::Codex, vec!["codex", "codex cli"]),
+ (BuiltInAgent::Gemini, vec!["gemini", "gemini cli"]),
+ ])
+ })
+}
+
#[derive(IntoElement)]
struct AgentRegistryCard {
children: Vec<AnyElement>,
@@ -85,6 +107,7 @@ pub struct AgentRegistryPage {
installed_statuses: HashMap<String, RegistryInstallStatus>,
query_editor: Entity<Editor>,
filter: RegistryFilter,
+ upsells: BTreeSet<BuiltInAgent>,
_subscriptions: Vec<gpui::Subscription>,
}
@@ -119,6 +142,7 @@ impl AgentRegistryPage {
installed_statuses: HashMap::default(),
query_editor,
filter: RegistryFilter::All,
+ upsells: BTreeSet::new(),
_subscriptions: subscriptions,
};
@@ -178,6 +202,7 @@ impl AgentRegistryPage {
fn filter_registry_agents(&mut self, cx: &mut Context<Self>) {
self.refresh_installed_statuses(cx);
+ self.refresh_feature_upsells(cx);
let search = self.search_query(cx).map(|search| search.to_lowercase());
let filter = self.filter;
let installed_statuses = self.installed_statuses.clone();
@@ -241,6 +266,83 @@ impl AgentRegistryPage {
}
}
+ fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) {
+ let Some(search) = self.search_query(cx) else {
+ self.upsells.clear();
+ return;
+ };
+
+ let search = search.to_lowercase();
+ let search_terms = search
+ .split_whitespace()
+ .map(|term| term.trim())
+ .collect::<Vec<_>>();
+
+ for (feature, keywords) in keywords_by_agent_feature() {
+ if keywords
+ .iter()
+ .any(|keyword| search_terms.contains(keyword))
+ {
+ self.upsells.insert(*feature);
+ } else {
+ self.upsells.remove(feature);
+ }
+ }
+ }
+
+ fn render_feature_upsell_banner(
+ &self,
+ label: SharedString,
+ docs_url: SharedString,
+ ) -> impl IntoElement {
+ let docs_url_button = Button::new("open_docs", "View Documentation")
+ .icon(IconName::ArrowUpRight)
+ .icon_size(IconSize::Small)
+ .icon_position(IconPosition::End)
+ .icon_color(Color::Muted)
+ .on_click({
+ move |_event, _window, cx| {
+ telemetry::event!(
+ "Documentation Viewed",
+ source = "Agent Registry Feature Upsell",
+ url = docs_url,
+ );
+ cx.open_url(&docs_url)
+ }
+ });
+
+ div().pt_4().px_4().child(
+ Banner::new()
+ .severity(Severity::Success)
+ .child(Label::new(label).mt_0p5())
+ .action_slot(docs_url_button),
+ )
+ }
+
+ fn render_feature_upsells(&self) -> impl IntoElement {
+ let mut container = v_flex();
+
+ for feature in &self.upsells {
+ let banner = match feature {
+ BuiltInAgent::Claude => self.render_feature_upsell_banner(
+ "Claude Code support is built-in to Zed!".into(),
+ "https://zed.dev/docs/ai/external-agents#claude-code".into(),
+ ),
+ BuiltInAgent::Codex => self.render_feature_upsell_banner(
+ "Codex CLI support is built-in to Zed!".into(),
+ "https://zed.dev/docs/ai/external-agents#codex-cli".into(),
+ ),
+ BuiltInAgent::Gemini => self.render_feature_upsell_banner(
+ "Gemini CLI support is built-in to Zed!".into(),
+ "https://zed.dev/docs/ai/external-agents#gemini-cli".into(),
+ ),
+ };
+ container = container.child(banner);
+ }
+
+ container
+ }
+
fn render_search(&self, cx: &mut Context<Self>) -> Div {
let mut key_context = KeyContext::new_with_defaults();
key_context.add("BufferSearchBar");
@@ -525,8 +627,6 @@ impl AgentRegistryPage {
impl Render for AgentRegistryPage {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- let learn_more_url = "https://zed.dev/blog/acp-registry";
-
v_flex()
.size_full()
.bg(cx.theme().colors().editor_background)
@@ -541,29 +641,7 @@ impl Render for AgentRegistryPage {
.w_full()
.gap_1p5()
.justify_between()
- .child(
- h_flex()
- .id("title")
- .gap_2()
- .child(Headline::new("ACP Registry").size(HeadlineSize::Large))
- .child(Chip::new("Beta"))
- .hoverable_tooltip({
- let learn_more_url = learn_more_url.to_string();
- let tooltip_fn = Tooltip::element(move |_, _| {
- v_flex()
- .gap_1()
- .child(Label::new(
- "The ACP Registry is still in testing phase.",
- ))
- .child(ButtonLink::new(
- "Learn more about it",
- learn_more_url.as_str(),
- ))
- .into_any_element()
- });
- move |window, cx| tooltip_fn(window, cx)
- }),
- )
+ .child(Headline::new("ACP Registry").size(HeadlineSize::Large))
.child(
Button::new("learn-more", "Learn More")
.style(ButtonStyle::Outlined)
@@ -571,7 +649,9 @@ impl Render for AgentRegistryPage {
.icon(IconName::ArrowUpRight)
.icon_color(Color::Muted)
.icon_size(IconSize::Small)
- .on_click(move |_, _, cx| cx.open_url(learn_more_url)),
+ .on_click(move |_, _, cx| {
+ cx.open_url(&zed_urls::acp_registry_blog(cx))
+ }),
),
)
.child(
@@ -625,10 +705,14 @@ impl Render for AgentRegistryPage {
),
),
)
+ .child(self.render_feature_upsells())
.child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
let count = self.filtered_registry_indices.len();
- if count == 0 {
+ let has_upsells = !self.upsells.is_empty();
+ if count == 0 && !has_upsells {
this.child(self.render_empty_state(cx)).into_any_element()
+ } else if count == 0 {
+ this.into_any_element()
} else {
let scroll_handle = &self.list;
this.child(
@@ -7,7 +7,7 @@ use std::time::Duration;
use std::{ops::Range, sync::Arc};
use anyhow::Context as _;
-use client::{ExtensionMetadata, ExtensionProvides};
+use client::{ExtensionMetadata, ExtensionProvides, zed_urls};
use collections::{BTreeMap, BTreeSet};
use editor::{Editor, EditorElement, EditorStyle};
use extension_host::{ExtensionManifest, ExtensionOperation, ExtensionStore};
@@ -287,6 +287,23 @@ fn keywords_by_feature() -> &'static BTreeMap<Feature, Vec<&'static str>> {
})
}
+fn acp_registry_upsell_keywords() -> &'static [&'static str] {
+ &[
+ "opencode",
+ "mistral",
+ "auggie",
+ "stakpak",
+ "codebuddy",
+ "autohand",
+ "factory droid",
+ "corust",
+ ]
+}
+
+fn extension_button_id(extension_id: &Arc<str>, operation: ExtensionOperation) -> ElementId {
+ (SharedString::from(extension_id.clone()), operation as usize).into()
+}
+
struct ExtensionCardButtons {
install_or_uninstall: Button,
upgrade: Option<Button>,
@@ -308,6 +325,7 @@ pub struct ExtensionsPage {
_subscriptions: [gpui::Subscription; 2],
extension_fetch_task: Option<Task<()>>,
upsells: BTreeSet<Feature>,
+ show_acp_registry_upsell: bool,
}
impl ExtensionsPage {
@@ -369,6 +387,7 @@ impl ExtensionsPage {
_subscriptions: subscriptions,
query_editor,
upsells: BTreeSet::default(),
+ show_acp_registry_upsell: false,
};
this.fetch_extensions(
this.search_query(cx),
@@ -642,7 +661,7 @@ impl ExtensionsPage {
}),
)
.child(
- Button::new(SharedString::from(extension.id.clone()), "Uninstall")
+ Button::new(extension_button_id(&extension.id, ExtensionOperation::Remove), "Uninstall")
.color(Color::Accent)
.disabled(matches!(status, ExtensionStatus::Removing))
.on_click({
@@ -989,7 +1008,7 @@ impl ExtensionsPage {
// The button here is a placeholder, as it won't be interactable anyways.
return ExtensionCardButtons {
install_or_uninstall: Button::new(
- SharedString::from(extension.id.clone()),
+ extension_button_id(&extension.id, ExtensionOperation::Install),
"Install",
),
configure: None,
@@ -1005,7 +1024,7 @@ impl ExtensionsPage {
match status.clone() {
ExtensionStatus::NotInstalled => ExtensionCardButtons {
install_or_uninstall: Button::new(
- SharedString::from(extension.id.clone()),
+ extension_button_id(&extension.id, ExtensionOperation::Install),
"Install",
)
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
@@ -1027,7 +1046,7 @@ impl ExtensionsPage {
},
ExtensionStatus::Installing => ExtensionCardButtons {
install_or_uninstall: Button::new(
- SharedString::from(extension.id.clone()),
+ extension_button_id(&extension.id, ExtensionOperation::Install),
"Install",
)
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
@@ -1041,7 +1060,7 @@ impl ExtensionsPage {
},
ExtensionStatus::Upgrading => ExtensionCardButtons {
install_or_uninstall: Button::new(
- SharedString::from(extension.id.clone()),
+ extension_button_id(&extension.id, ExtensionOperation::Remove),
"Uninstall",
)
.style(ButtonStyle::OutlinedGhost)
@@ -1054,12 +1073,16 @@ impl ExtensionsPage {
.disabled(true)
}),
upgrade: Some(
- Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
+ Button::new(
+ extension_button_id(&extension.id, ExtensionOperation::Upgrade),
+ "Upgrade",
+ )
+ .disabled(true),
),
},
ExtensionStatus::Installed(installed_version) => ExtensionCardButtons {
install_or_uninstall: Button::new(
- SharedString::from(extension.id.clone()),
+ extension_button_id(&extension.id, ExtensionOperation::Remove),
"Uninstall",
)
.style(ButtonStyle::OutlinedGhost)
@@ -1103,7 +1126,7 @@ impl ExtensionsPage {
None
} else {
Some(
- Button::new(SharedString::from(extension.id.clone()), "Upgrade")
+ Button::new(extension_button_id(&extension.id, ExtensionOperation::Upgrade), "Upgrade")
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.when(!is_compatible, |upgrade_button| {
upgrade_button.disabled(true).tooltip({
@@ -1140,7 +1163,7 @@ impl ExtensionsPage {
},
ExtensionStatus::Removing => ExtensionCardButtons {
install_or_uninstall: Button::new(
- SharedString::from(extension.id.clone()),
+ extension_button_id(&extension.id, ExtensionOperation::Remove),
"Uninstall",
)
.style(ButtonStyle::OutlinedGhost)
@@ -1367,11 +1390,13 @@ impl ExtensionsPage {
fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) {
let Some(search) = self.search_query(cx) else {
self.upsells.clear();
+ self.show_acp_registry_upsell = false;
return;
};
if let Some(id) = search.strip_prefix("id:") {
self.upsells.clear();
+ self.show_acp_registry_upsell = false;
let upsell = match id.to_lowercase().as_str() {
"ruff" => Some(Feature::ExtensionRuff),
@@ -1403,6 +1428,60 @@ impl ExtensionsPage {
self.upsells.remove(feature);
}
}
+
+ self.show_acp_registry_upsell = acp_registry_upsell_keywords()
+ .iter()
+ .any(|keyword| search_terms.iter().any(|term| keyword.contains(term)));
+ }
+
+ fn render_acp_registry_upsell(&self, cx: &mut Context<Self>) -> impl IntoElement {
+ let registry_url = zed_urls::acp_registry_blog(cx);
+
+ let view_registry = Button::new("view_registry", "View Registry")
+ .style(ButtonStyle::Tinted(ui::TintColor::Warning))
+ .on_click({
+ let registry_url = registry_url.clone();
+ move |_, window, cx| {
+ telemetry::event!(
+ "ACP Registry Opened from Extensions",
+ source = "ACP Registry Upsell",
+ url = registry_url,
+ );
+ window.dispatch_action(Box::new(zed_actions::AcpRegistry), cx)
+ }
+ });
+ let open_registry_button = Button::new("open_registry", "Learn More")
+ .icon(IconName::ArrowUpRight)
+ .icon_size(IconSize::Small)
+ .icon_position(IconPosition::End)
+ .icon_color(Color::Muted)
+ .on_click({
+ move |_event, _window, cx| {
+ telemetry::event!(
+ "ACP Registry Viewed",
+ source = "ACP Registry Upsell",
+ url = registry_url,
+ );
+ cx.open_url(®istry_url)
+ }
+ });
+
+ div().pt_4().px_4().child(
+ Banner::new()
+ .severity(Severity::Warning)
+ .child(
+ Label::new(
+ "Agent Server extensions will be deprecated in favor of the ACP registry.",
+ )
+ .mt_0p5(),
+ )
+ .action_slot(
+ h_flex()
+ .gap_1()
+ .child(open_registry_button)
+ .child(view_registry),
+ ),
+ )
}
fn render_feature_upsell_banner(
@@ -1704,8 +1783,7 @@ impl Render for ExtensionsPage {
)
.children(ExtensionProvides::iter().filter_map(|provides| {
match provides {
- ExtensionProvides::AgentServers
- | ExtensionProvides::SlashCommands
+ ExtensionProvides::SlashCommands
| ExtensionProvides::IndexedDocsProviders => return None,
_ => {}
}
@@ -1729,6 +1807,11 @@ impl Render for ExtensionsPage {
)
})),
)
+ .when(
+ self.provides_filter == Some(ExtensionProvides::AgentServers)
+ || self.show_acp_registry_upsell,
+ |this| this.child(self.render_acp_registry_upsell(cx)),
+ )
.child(self.render_feature_upsells(cx))
.child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
let mut count = self.filtered_remote_extension_indices.len();