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