agent_registry_ui.rs

  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(&registry_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}