acp: Add upsell banner for built-in agents in registry page (#47853)

Danilo Leal created

This PR adds the upsell banner for built-in agents in the ACP registry
page, meaning: if you search for Claude Code, Codex, or Gemini, we'll
display a banner communicating they're already available in Zed. This
may change one day, though, whenever we rely on their registry
implementation. I'm also removing the beta chip from the page here.

Release Notes:

- N/A

Change summary

crates/agent_ui/src/agent_registry_ui.rs | 134 ++++++++++++++++++++-----
1 file changed, 108 insertions(+), 26 deletions(-)

Detailed changes

crates/agent_ui/src/agent_registry_ui.rs 🔗

@@ -1,4 +1,6 @@
+use std::collections::{BTreeMap, BTreeSet};
 use std::ops::Range;
+use std::sync::OnceLock;
 
 use client::zed_urls;
 use collections::HashMap;
@@ -14,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::{
@@ -41,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>,
@@ -86,6 +107,7 @@ pub struct AgentRegistryPage {
     installed_statuses: HashMap<String, RegistryInstallStatus>,
     query_editor: Entity<Editor>,
     filter: RegistryFilter,
+    upsells: BTreeSet<BuiltInAgent>,
     _subscriptions: Vec<gpui::Subscription>,
 }
 
@@ -120,6 +142,7 @@ impl AgentRegistryPage {
                 installed_statuses: HashMap::default(),
                 query_editor,
                 filter: RegistryFilter::All,
+                upsells: BTreeSet::new(),
                 _subscriptions: subscriptions,
             };
 
@@ -179,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();
@@ -242,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");
@@ -540,30 +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: SharedString =
-                                            zed_urls::acp_registry_blog(cx).into();
-                                        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)
@@ -627,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(