1use std::collections::{BTreeMap, BTreeSet};
2use std::ops::Range;
3use std::sync::OnceLock;
4
5use client::zed_urls;
6use collections::HashMap;
7use editor::{Editor, EditorElement, EditorStyle};
8use fs::Fs;
9use gpui::{
10 AnyElement, App, Context, Entity, EventEmitter, Focusable, KeyContext, ParentElement, Render,
11 RenderOnce, SharedString, Styled, TextStyle, UniformListScrollHandle, Window, point,
12 uniform_list,
13};
14use project::agent_server_store::{AllAgentServersSettings, CustomAgentServerSettings};
15use project::{AgentRegistryStore, RegistryAgent};
16use settings::{Settings, SettingsStore, update_settings_file};
17use theme::ThemeSettings;
18use ui::{
19 Banner, ButtonStyle, ScrollableHandle, Severity, ToggleButtonGroup, ToggleButtonGroupSize,
20 ToggleButtonGroupStyle, ToggleButtonSimple, Tooltip, WithScrollbar, prelude::*,
21};
22use workspace::{
23 Workspace,
24 item::{Item, ItemEvent},
25};
26
27/// Registry IDs for built-in agents that Zed already provides first-class support for.
28/// These are filtered out of the ACP Agent Registry UI to avoid showing duplicates.
29const BUILT_IN_REGISTRY_IDS: [&str; 4] = ["claude-acp", "claude-code-acp", "codex-acp", "gemini"];
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32enum RegistryFilter {
33 All,
34 Installed,
35 NotInstalled,
36}
37
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39enum RegistryInstallStatus {
40 NotInstalled,
41 InstalledRegistry,
42 InstalledCustom,
43 InstalledExtension,
44}
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
47enum BuiltInAgent {
48 Claude,
49 Codex,
50 Gemini,
51}
52
53fn keywords_by_agent_feature() -> &'static BTreeMap<BuiltInAgent, Vec<&'static str>> {
54 static KEYWORDS_BY_FEATURE: OnceLock<BTreeMap<BuiltInAgent, Vec<&'static str>>> =
55 OnceLock::new();
56 KEYWORDS_BY_FEATURE.get_or_init(|| {
57 BTreeMap::from_iter([
58 (
59 BuiltInAgent::Claude,
60 vec!["claude", "claude code", "claude agent"],
61 ),
62 (BuiltInAgent::Codex, vec!["codex", "codex cli"]),
63 (BuiltInAgent::Gemini, vec!["gemini", "gemini cli"]),
64 ])
65 })
66}
67
68#[derive(IntoElement)]
69struct AgentRegistryCard {
70 children: Vec<AnyElement>,
71}
72
73impl AgentRegistryCard {
74 fn new() -> Self {
75 Self {
76 children: Vec::new(),
77 }
78 }
79}
80
81impl ParentElement for AgentRegistryCard {
82 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
83 self.children.extend(elements)
84 }
85}
86
87impl RenderOnce for AgentRegistryCard {
88 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
89 div().w_full().child(
90 v_flex()
91 .p_3()
92 .mt_4()
93 .w_full()
94 .min_h(rems_from_px(86.))
95 .gap_2()
96 .bg(cx.theme().colors().elevated_surface_background.opacity(0.5))
97 .border_1()
98 .border_color(cx.theme().colors().border_variant)
99 .rounded_md()
100 .children(self.children),
101 )
102 }
103}
104
105pub struct AgentRegistryPage {
106 registry_store: Entity<AgentRegistryStore>,
107 list: UniformListScrollHandle,
108 registry_agents: Vec<RegistryAgent>,
109 filtered_registry_indices: Vec<usize>,
110 installed_statuses: HashMap<String, RegistryInstallStatus>,
111 query_editor: Entity<Editor>,
112 filter: RegistryFilter,
113 upsells: BTreeSet<BuiltInAgent>,
114 _subscriptions: Vec<gpui::Subscription>,
115}
116
117impl AgentRegistryPage {
118 pub fn new(
119 _workspace: &Workspace,
120 window: &mut Window,
121 cx: &mut Context<Workspace>,
122 ) -> Entity<Self> {
123 cx.new(|cx| {
124 let registry_store = AgentRegistryStore::global(cx);
125 let query_editor = cx.new(|cx| {
126 let mut input = Editor::single_line(window, cx);
127 input.set_placeholder_text("Search agents...", window, cx);
128 input
129 });
130 cx.subscribe(&query_editor, Self::on_query_change).detach();
131
132 let mut subscriptions = Vec::new();
133 subscriptions.push(cx.observe(®istry_store, |this, _, cx| {
134 this.reload_registry_agents(cx);
135 }));
136 subscriptions.push(cx.observe_global::<SettingsStore>(|this, cx| {
137 this.filter_registry_agents(cx);
138 }));
139
140 let mut this = Self {
141 registry_store,
142 list: UniformListScrollHandle::new(),
143 registry_agents: Vec::new(),
144 filtered_registry_indices: Vec::new(),
145 installed_statuses: HashMap::default(),
146 query_editor,
147 filter: RegistryFilter::All,
148 upsells: BTreeSet::new(),
149 _subscriptions: subscriptions,
150 };
151
152 this.reload_registry_agents(cx);
153 this.registry_store
154 .update(cx, |store, cx| store.refresh(cx));
155
156 this
157 })
158 }
159
160 fn reload_registry_agents(&mut self, cx: &mut Context<Self>) {
161 self.registry_agents = self.registry_store.read(cx).agents().to_vec();
162 self.registry_agents.sort_by(|left, right| {
163 left.name()
164 .as_ref()
165 .cmp(right.name().as_ref())
166 .then_with(|| left.id().as_ref().cmp(right.id().as_ref()))
167 });
168 self.filter_registry_agents(cx);
169 }
170
171 fn refresh_installed_statuses(&mut self, cx: &mut Context<Self>) {
172 let settings = cx
173 .global::<SettingsStore>()
174 .get::<AllAgentServersSettings>(None);
175 self.installed_statuses.clear();
176 for (id, settings) in settings.iter() {
177 let status = match settings {
178 CustomAgentServerSettings::Registry { .. } => {
179 RegistryInstallStatus::InstalledRegistry
180 }
181 CustomAgentServerSettings::Custom { .. } => RegistryInstallStatus::InstalledCustom,
182 CustomAgentServerSettings::Extension { .. } => {
183 RegistryInstallStatus::InstalledExtension
184 }
185 };
186 self.installed_statuses.insert(id.clone(), status);
187 }
188 }
189
190 fn install_status(&self, id: &str) -> RegistryInstallStatus {
191 self.installed_statuses
192 .get(id)
193 .copied()
194 .unwrap_or(RegistryInstallStatus::NotInstalled)
195 }
196
197 fn search_query(&self, cx: &mut App) -> Option<String> {
198 let search = self.query_editor.read(cx).text(cx);
199 if search.trim().is_empty() {
200 None
201 } else {
202 Some(search)
203 }
204 }
205
206 fn filter_registry_agents(&mut self, cx: &mut Context<Self>) {
207 self.refresh_installed_statuses(cx);
208 self.refresh_feature_upsells(cx);
209 let search = self.search_query(cx).map(|search| search.to_lowercase());
210 let filter = self.filter;
211 let installed_statuses = self.installed_statuses.clone();
212
213 let filtered_indices = self
214 .registry_agents
215 .iter()
216 .enumerate()
217 .filter(|(_, agent)| {
218 // Filter out built-in agents since they already appear in the main
219 // agent configuration UI and don't need to be installed from the registry.
220 if BUILT_IN_REGISTRY_IDS.contains(&agent.id().as_ref()) {
221 return false;
222 }
223
224 let matches_search = search.as_ref().is_none_or(|query| {
225 let query = query.as_str();
226 agent.id().as_ref().to_lowercase().contains(query)
227 || agent.name().as_ref().to_lowercase().contains(query)
228 || agent.description().as_ref().to_lowercase().contains(query)
229 });
230
231 let install_status = installed_statuses
232 .get(agent.id().as_ref())
233 .copied()
234 .unwrap_or(RegistryInstallStatus::NotInstalled);
235 let matches_filter = match filter {
236 RegistryFilter::All => true,
237 RegistryFilter::Installed => {
238 install_status != RegistryInstallStatus::NotInstalled
239 }
240 RegistryFilter::NotInstalled => {
241 install_status == RegistryInstallStatus::NotInstalled
242 }
243 };
244
245 matches_search && matches_filter
246 })
247 .map(|(index, _)| index)
248 .collect();
249
250 self.filtered_registry_indices = filtered_indices;
251
252 cx.notify();
253 }
254
255 fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
256 self.list.set_offset(point(px(0.), px(0.)));
257 cx.notify();
258 }
259
260 fn on_query_change(
261 &mut self,
262 _: Entity<Editor>,
263 event: &editor::EditorEvent,
264 cx: &mut Context<Self>,
265 ) {
266 if let editor::EditorEvent::Edited { .. } = event {
267 self.filter_registry_agents(cx);
268 self.scroll_to_top(cx);
269 }
270 }
271
272 fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) {
273 let Some(search) = self.search_query(cx) else {
274 self.upsells.clear();
275 return;
276 };
277
278 let search = search.to_lowercase();
279 let search_terms = search
280 .split_whitespace()
281 .map(|term| term.trim())
282 .collect::<Vec<_>>();
283
284 for (feature, keywords) in keywords_by_agent_feature() {
285 if keywords
286 .iter()
287 .any(|keyword| search_terms.contains(keyword))
288 {
289 self.upsells.insert(*feature);
290 } else {
291 self.upsells.remove(feature);
292 }
293 }
294 }
295
296 fn render_feature_upsell_banner(
297 &self,
298 label: SharedString,
299 docs_url: SharedString,
300 ) -> impl IntoElement {
301 let docs_url_button = Button::new("open_docs", "View Documentation")
302 .icon(IconName::ArrowUpRight)
303 .icon_size(IconSize::Small)
304 .icon_position(IconPosition::End)
305 .icon_color(Color::Muted)
306 .on_click({
307 move |_event, _window, cx| {
308 telemetry::event!(
309 "Documentation Viewed",
310 source = "Agent Registry Feature Upsell",
311 url = docs_url,
312 );
313 cx.open_url(&docs_url)
314 }
315 });
316
317 div().pt_4().px_4().child(
318 Banner::new()
319 .severity(Severity::Success)
320 .child(Label::new(label).mt_0p5())
321 .action_slot(docs_url_button),
322 )
323 }
324
325 fn render_feature_upsells(&self) -> impl IntoElement {
326 let mut container = v_flex();
327
328 for feature in &self.upsells {
329 let banner = match feature {
330 BuiltInAgent::Claude => self.render_feature_upsell_banner(
331 "Claude Agent support is built-in to Zed!".into(),
332 "https://zed.dev/docs/ai/external-agents#claude-agent".into(),
333 ),
334 BuiltInAgent::Codex => self.render_feature_upsell_banner(
335 "Codex CLI support is built-in to Zed!".into(),
336 "https://zed.dev/docs/ai/external-agents#codex-cli".into(),
337 ),
338 BuiltInAgent::Gemini => self.render_feature_upsell_banner(
339 "Gemini CLI support is built-in to Zed!".into(),
340 "https://zed.dev/docs/ai/external-agents#gemini-cli".into(),
341 ),
342 };
343 container = container.child(banner);
344 }
345
346 container
347 }
348
349 fn render_search(&self, cx: &mut Context<Self>) -> Div {
350 let mut key_context = KeyContext::new_with_defaults();
351 key_context.add("BufferSearchBar");
352
353 h_flex()
354 .key_context(key_context)
355 .h_8()
356 .min_w(rems_from_px(384.))
357 .flex_1()
358 .pl_1p5()
359 .pr_2()
360 .gap_2()
361 .border_1()
362 .border_color(cx.theme().colors().border)
363 .rounded_md()
364 .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
365 .child(self.render_text_input(&self.query_editor, cx))
366 }
367
368 fn render_text_input(
369 &self,
370 editor: &Entity<Editor>,
371 cx: &mut Context<Self>,
372 ) -> impl IntoElement {
373 let settings = ThemeSettings::get_global(cx);
374 let text_style = TextStyle {
375 color: if editor.read(cx).read_only(cx) {
376 cx.theme().colors().text_disabled
377 } else {
378 cx.theme().colors().text
379 },
380 font_family: settings.ui_font.family.clone(),
381 font_features: settings.ui_font.features.clone(),
382 font_fallbacks: settings.ui_font.fallbacks.clone(),
383 font_size: rems(0.875).into(),
384 font_weight: settings.ui_font.weight,
385 line_height: relative(1.3),
386 ..Default::default()
387 };
388
389 EditorElement::new(
390 editor,
391 EditorStyle {
392 background: cx.theme().colors().editor_background,
393 local_player: cx.theme().players().local(),
394 text: text_style,
395 ..Default::default()
396 },
397 )
398 }
399
400 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
401 let has_search = self.search_query(cx).is_some();
402 let registry_store = self.registry_store.read(cx);
403
404 let message = if registry_store.is_fetching() {
405 "Loading registry..."
406 } else if registry_store.fetch_error().is_some() {
407 "Failed to load the agent registry. Please check your connection and try again."
408 } else {
409 match self.filter {
410 RegistryFilter::All => {
411 if has_search {
412 "No agents match your search."
413 } else {
414 "No agents available."
415 }
416 }
417 RegistryFilter::Installed => {
418 if has_search {
419 "No installed agents match your search."
420 } else {
421 "No installed agents."
422 }
423 }
424 RegistryFilter::NotInstalled => {
425 if has_search {
426 "No uninstalled agents match your search."
427 } else {
428 "No uninstalled agents."
429 }
430 }
431 }
432 };
433
434 h_flex()
435 .py_4()
436 .gap_1p5()
437 .when(registry_store.fetch_error().is_some(), |this| {
438 this.child(
439 Icon::new(IconName::Warning)
440 .size(IconSize::Small)
441 .color(Color::Warning),
442 )
443 })
444 .child(Label::new(message))
445 }
446
447 fn render_agents(
448 &mut self,
449 range: Range<usize>,
450 _: &mut Window,
451 cx: &mut Context<Self>,
452 ) -> Vec<AgentRegistryCard> {
453 range
454 .map(|index| {
455 let Some(agent_index) = self.filtered_registry_indices.get(index).copied() else {
456 return self.render_missing_agent();
457 };
458 let Some(agent) = self.registry_agents.get(agent_index) else {
459 return self.render_missing_agent();
460 };
461 self.render_registry_agent(agent, cx)
462 })
463 .collect()
464 }
465
466 fn render_missing_agent(&self) -> AgentRegistryCard {
467 AgentRegistryCard::new().child(
468 Label::new("Missing registry entry.")
469 .size(LabelSize::Small)
470 .color(Color::Muted),
471 )
472 }
473
474 fn render_registry_agent(
475 &self,
476 agent: &RegistryAgent,
477 cx: &mut Context<Self>,
478 ) -> AgentRegistryCard {
479 let install_status = self.install_status(agent.id().as_ref());
480 let supports_current_platform = agent.supports_current_platform();
481
482 let icon = match agent.icon_path() {
483 Some(icon_path) => Icon::from_external_svg(icon_path.clone()),
484 None => Icon::new(IconName::Sparkle),
485 }
486 .size(IconSize::Medium)
487 .color(Color::Muted);
488
489 let install_button =
490 self.install_button(agent, install_status, supports_current_platform, cx);
491
492 let repository_button = agent.repository().map(|repository| {
493 let repository_for_tooltip: SharedString = repository.to_string().into();
494 let repository_for_click = repository.to_string();
495
496 IconButton::new(
497 SharedString::from(format!("agent-repo-{}", agent.id())),
498 IconName::Github,
499 )
500 .icon_size(IconSize::Small)
501 .tooltip(move |_, cx| {
502 Tooltip::with_meta(
503 "Visit Agent Repository",
504 None,
505 repository_for_tooltip.clone(),
506 cx,
507 )
508 })
509 .on_click(move |_, _, cx| {
510 cx.open_url(&repository_for_click);
511 })
512 });
513
514 AgentRegistryCard::new()
515 .child(
516 h_flex()
517 .justify_between()
518 .child(
519 h_flex()
520 .gap_2()
521 .child(icon)
522 .child(Headline::new(agent.name().clone()).size(HeadlineSize::Small))
523 .child(Label::new(format!("v{}", agent.version())).color(Color::Muted))
524 .when(!supports_current_platform, |this| {
525 this.child(
526 Label::new("Not supported on this platform")
527 .size(LabelSize::Small)
528 .color(Color::Warning),
529 )
530 }),
531 )
532 .child(install_button),
533 )
534 .child(
535 h_flex()
536 .gap_2()
537 .justify_between()
538 .child(
539 Label::new(agent.description().clone())
540 .size(LabelSize::Small)
541 .truncate(),
542 )
543 .child(
544 h_flex()
545 .gap_1()
546 .child(
547 Label::new(format!("ID: {}", agent.id()))
548 .size(LabelSize::Small)
549 .color(Color::Muted)
550 .truncate(),
551 )
552 .when_some(repository_button, |this, button| this.child(button)),
553 ),
554 )
555 }
556
557 fn install_button(
558 &self,
559 agent: &RegistryAgent,
560 install_status: RegistryInstallStatus,
561 supports_current_platform: bool,
562 cx: &mut Context<Self>,
563 ) -> Button {
564 let button_id = SharedString::from(format!("install-agent-{}", agent.id()));
565
566 if !supports_current_platform {
567 return Button::new(button_id, "Unavailable")
568 .style(ButtonStyle::OutlinedGhost)
569 .disabled(true);
570 }
571
572 match install_status {
573 RegistryInstallStatus::NotInstalled => {
574 let fs = <dyn Fs>::global(cx);
575 let agent_id = agent.id().to_string();
576 Button::new(button_id, "Install")
577 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
578 .icon(IconName::Download)
579 .icon_size(IconSize::Small)
580 .icon_color(Color::Muted)
581 .icon_position(IconPosition::Start)
582 .on_click(move |_, _, cx| {
583 let agent_id = agent_id.clone();
584 update_settings_file(fs.clone(), cx, move |settings, _| {
585 let agent_servers = settings.agent_servers.get_or_insert_default();
586 agent_servers.entry(agent_id).or_insert_with(|| {
587 settings::CustomAgentServerSettings::Registry {
588 default_mode: None,
589 default_model: None,
590 env: Default::default(),
591 favorite_models: Vec::new(),
592 default_config_options: HashMap::default(),
593 favorite_config_option_values: HashMap::default(),
594 }
595 });
596 });
597 })
598 }
599 RegistryInstallStatus::InstalledRegistry => {
600 let fs = <dyn Fs>::global(cx);
601 let agent_id = agent.id().to_string();
602 Button::new(button_id, "Remove")
603 .style(ButtonStyle::OutlinedGhost)
604 .on_click(move |_, _, cx| {
605 let agent_id = agent_id.clone();
606 update_settings_file(fs.clone(), cx, move |settings, _| {
607 let Some(agent_servers) = settings.agent_servers.as_mut() else {
608 return;
609 };
610 if let Some(entry) = agent_servers.get(agent_id.as_str())
611 && matches!(
612 entry,
613 settings::CustomAgentServerSettings::Registry { .. }
614 )
615 {
616 agent_servers.remove(agent_id.as_str());
617 }
618 });
619 })
620 }
621 RegistryInstallStatus::InstalledCustom => Button::new(button_id, "Installed")
622 .style(ButtonStyle::OutlinedGhost)
623 .disabled(true),
624 RegistryInstallStatus::InstalledExtension => Button::new(button_id, "Installed")
625 .style(ButtonStyle::OutlinedGhost)
626 .disabled(true),
627 }
628 }
629}
630
631impl Render for AgentRegistryPage {
632 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
633 v_flex()
634 .size_full()
635 .bg(cx.theme().colors().editor_background)
636 .child(
637 v_flex()
638 .p_4()
639 .gap_4()
640 .border_b_1()
641 .border_color(cx.theme().colors().border_variant)
642 .child(
643 h_flex()
644 .w_full()
645 .gap_1p5()
646 .justify_between()
647 .child(Headline::new("ACP Registry").size(HeadlineSize::Large))
648 .child(
649 Button::new("learn-more", "Learn More")
650 .style(ButtonStyle::Outlined)
651 .size(ButtonSize::Medium)
652 .icon(IconName::ArrowUpRight)
653 .icon_color(Color::Muted)
654 .icon_size(IconSize::Small)
655 .on_click(move |_, _, cx| {
656 cx.open_url(&zed_urls::acp_registry_blog(cx))
657 }),
658 ),
659 )
660 .child(
661 h_flex()
662 .w_full()
663 .flex_wrap()
664 .gap_2()
665 .child(self.render_search(cx))
666 .child(
667 div().child(
668 ToggleButtonGroup::single_row(
669 "registry-filter-buttons",
670 [
671 ToggleButtonSimple::new(
672 "All",
673 cx.listener(|this, _event, _, cx| {
674 this.filter = RegistryFilter::All;
675 this.filter_registry_agents(cx);
676 this.scroll_to_top(cx);
677 }),
678 ),
679 ToggleButtonSimple::new(
680 "Installed",
681 cx.listener(|this, _event, _, cx| {
682 this.filter = RegistryFilter::Installed;
683 this.filter_registry_agents(cx);
684 this.scroll_to_top(cx);
685 }),
686 ),
687 ToggleButtonSimple::new(
688 "Not Installed",
689 cx.listener(|this, _event, _, cx| {
690 this.filter = RegistryFilter::NotInstalled;
691 this.filter_registry_agents(cx);
692 this.scroll_to_top(cx);
693 }),
694 ),
695 ],
696 )
697 .style(ToggleButtonGroupStyle::Outlined)
698 .size(ToggleButtonGroupSize::Custom(rems_from_px(30.)))
699 .label_size(LabelSize::Default)
700 .auto_width()
701 .selected_index(match self.filter {
702 RegistryFilter::All => 0,
703 RegistryFilter::Installed => 1,
704 RegistryFilter::NotInstalled => 2,
705 })
706 .into_any_element(),
707 ),
708 ),
709 ),
710 )
711 .child(self.render_feature_upsells())
712 .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
713 let count = self.filtered_registry_indices.len();
714 let has_upsells = !self.upsells.is_empty();
715 if count == 0 && !has_upsells {
716 this.child(self.render_empty_state(cx)).into_any_element()
717 } else if count == 0 {
718 this.into_any_element()
719 } else {
720 let scroll_handle = &self.list;
721 this.child(
722 uniform_list("registry-entries", count, cx.processor(Self::render_agents))
723 .flex_grow()
724 .pb_4()
725 .track_scroll(scroll_handle),
726 )
727 .vertical_scrollbar_for(scroll_handle, window, cx)
728 .into_any_element()
729 }
730 }))
731 }
732}
733
734impl EventEmitter<ItemEvent> for AgentRegistryPage {}
735
736impl Focusable for AgentRegistryPage {
737 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
738 self.query_editor.read(cx).focus_handle(cx)
739 }
740}
741
742impl Item for AgentRegistryPage {
743 type Event = ItemEvent;
744
745 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
746 "ACP Registry".into()
747 }
748
749 fn telemetry_event_text(&self) -> Option<&'static str> {
750 Some("ACP Registry Page Opened")
751 }
752
753 fn show_toolbar(&self) -> bool {
754 false
755 }
756
757 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(workspace::item::ItemEvent)) {
758 f(*event)
759 }
760}