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