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