agent_registry_ui.rs

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