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_settings::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        let website_button = agent.website().map(|website| {
407            let website = website.clone();
408            let website_for_click = website.clone();
409            IconButton::new(
410                SharedString::from(format!("agent-website-{}", agent.id())),
411                IconName::Link,
412            )
413            .icon_size(IconSize::Small)
414            .tooltip(move |_, cx| {
415                Tooltip::with_meta("Visit Agent Website", None, website.clone(), cx)
416            })
417            .on_click(move |_, _, cx| {
418                cx.open_url(&website_for_click);
419            })
420        });
421
422        AgentRegistryCard::new()
423            .child(
424                h_flex()
425                    .justify_between()
426                    .child(
427                        h_flex()
428                            .gap_2()
429                            .child(icon)
430                            .child(Headline::new(agent.name().clone()).size(HeadlineSize::Small))
431                            .child(Label::new(format!("v{}", agent.version())).color(Color::Muted))
432                            .when(!supports_current_platform, |this| {
433                                this.child(
434                                    Label::new("Not supported on this platform")
435                                        .size(LabelSize::Small)
436                                        .color(Color::Warning),
437                                )
438                            }),
439                    )
440                    .child(install_button),
441            )
442            .child(
443                h_flex()
444                    .gap_2()
445                    .justify_between()
446                    .child(
447                        Label::new(agent.description().clone())
448                            .size(LabelSize::Small)
449                            .truncate(),
450                    )
451                    .child(
452                        h_flex()
453                            .gap_1()
454                            .child(
455                                Label::new(format!("ID: {}", agent.id()))
456                                    .size(LabelSize::Small)
457                                    .color(Color::Muted)
458                                    .truncate(),
459                            )
460                            .when_some(repository_button, |this, button| this.child(button))
461                            .when_some(website_button, |this, button| this.child(button)),
462                    ),
463            )
464    }
465
466    fn install_button(
467        &self,
468        agent: &RegistryAgent,
469        install_status: RegistryInstallStatus,
470        supports_current_platform: bool,
471        cx: &mut Context<Self>,
472    ) -> Button {
473        let button_id = SharedString::from(format!("install-agent-{}", agent.id()));
474
475        if !supports_current_platform {
476            return Button::new(button_id, "Unavailable")
477                .style(ButtonStyle::OutlinedGhost)
478                .disabled(true);
479        }
480
481        match install_status {
482            RegistryInstallStatus::NotInstalled => {
483                let fs = <dyn Fs>::global(cx);
484                let agent_id = agent.id().to_string();
485                Button::new(button_id, "Install")
486                    .style(ButtonStyle::Tinted(ui::TintColor::Accent))
487                    .start_icon(
488                        Icon::new(IconName::Download)
489                            .size(IconSize::Small)
490                            .color(Color::Muted),
491                    )
492                    .on_click(move |_, _, cx| {
493                        let agent_id = agent_id.clone();
494                        update_settings_file(fs.clone(), cx, move |settings, _| {
495                            let agent_servers = settings.agent_servers.get_or_insert_default();
496                            agent_servers.entry(agent_id).or_insert_with(|| {
497                                settings::CustomAgentServerSettings::Registry {
498                                    default_mode: None,
499                                    default_model: None,
500                                    env: Default::default(),
501                                    favorite_models: Vec::new(),
502                                    default_config_options: HashMap::default(),
503                                    favorite_config_option_values: HashMap::default(),
504                                }
505                            });
506                        });
507                    })
508            }
509            RegistryInstallStatus::InstalledRegistry => {
510                let fs = <dyn Fs>::global(cx);
511                let agent_id = agent.id().to_string();
512                Button::new(button_id, "Remove")
513                    .style(ButtonStyle::OutlinedGhost)
514                    .on_click(move |_, _, cx| {
515                        let agent_id = agent_id.clone();
516                        update_settings_file(fs.clone(), cx, move |settings, _| {
517                            let Some(agent_servers) = settings.agent_servers.as_mut() else {
518                                return;
519                            };
520                            if let Some(entry) = agent_servers.get(agent_id.as_str())
521                                && matches!(
522                                    entry,
523                                    settings::CustomAgentServerSettings::Registry { .. }
524                                )
525                            {
526                                agent_servers.remove(agent_id.as_str());
527                            }
528                        });
529                    })
530            }
531            RegistryInstallStatus::InstalledCustom => Button::new(button_id, "Installed")
532                .style(ButtonStyle::OutlinedGhost)
533                .disabled(true),
534            RegistryInstallStatus::InstalledExtension => Button::new(button_id, "Installed")
535                .style(ButtonStyle::OutlinedGhost)
536                .disabled(true),
537        }
538    }
539}
540
541impl Render for AgentRegistryPage {
542    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
543        v_flex()
544            .size_full()
545            .bg(cx.theme().colors().editor_background)
546            .child(
547                v_flex()
548                    .p_4()
549                    .gap_4()
550                    .border_b_1()
551                    .border_color(cx.theme().colors().border_variant)
552                    .child(
553                        h_flex()
554                            .w_full()
555                            .gap_1p5()
556                            .justify_between()
557                            .child(Headline::new("ACP Registry").size(HeadlineSize::Large))
558                            .child(
559                                Button::new("learn-more", "Learn More")
560                                    .style(ButtonStyle::Outlined)
561                                    .size(ButtonSize::Medium)
562                                    .end_icon(
563                                        Icon::new(IconName::ArrowUpRight)
564                                            .size(IconSize::Small)
565                                            .color(Color::Muted),
566                                    )
567                                    .on_click(move |_, _, cx| {
568                                        cx.open_url(&zed_urls::acp_registry_blog(cx))
569                                    }),
570                            ),
571                    )
572                    .child(
573                        h_flex()
574                            .w_full()
575                            .flex_wrap()
576                            .gap_2()
577                            .child(self.render_search(cx))
578                            .child(
579                                div().child(
580                                    ToggleButtonGroup::single_row(
581                                        "registry-filter-buttons",
582                                        [
583                                            ToggleButtonSimple::new(
584                                                "All",
585                                                cx.listener(|this, _event, _, cx| {
586                                                    this.filter = RegistryFilter::All;
587                                                    this.filter_registry_agents(cx);
588                                                    this.scroll_to_top(cx);
589                                                }),
590                                            ),
591                                            ToggleButtonSimple::new(
592                                                "Installed",
593                                                cx.listener(|this, _event, _, cx| {
594                                                    this.filter = RegistryFilter::Installed;
595                                                    this.filter_registry_agents(cx);
596                                                    this.scroll_to_top(cx);
597                                                }),
598                                            ),
599                                            ToggleButtonSimple::new(
600                                                "Not Installed",
601                                                cx.listener(|this, _event, _, cx| {
602                                                    this.filter = RegistryFilter::NotInstalled;
603                                                    this.filter_registry_agents(cx);
604                                                    this.scroll_to_top(cx);
605                                                }),
606                                            ),
607                                        ],
608                                    )
609                                    .style(ToggleButtonGroupStyle::Outlined)
610                                    .size(ToggleButtonGroupSize::Custom(rems_from_px(30.)))
611                                    .label_size(LabelSize::Default)
612                                    .auto_width()
613                                    .selected_index(match self.filter {
614                                        RegistryFilter::All => 0,
615                                        RegistryFilter::Installed => 1,
616                                        RegistryFilter::NotInstalled => 2,
617                                    })
618                                    .into_any_element(),
619                                ),
620                            ),
621                    ),
622            )
623            .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
624                let count = self.filtered_registry_indices.len();
625                if count == 0 {
626                    this.child(self.render_empty_state(cx)).into_any_element()
627                } else {
628                    let scroll_handle = &self.list;
629                    this.child(
630                        uniform_list("registry-entries", count, cx.processor(Self::render_agents))
631                            .flex_grow()
632                            .pb_4()
633                            .track_scroll(scroll_handle),
634                    )
635                    .vertical_scrollbar_for(scroll_handle, window, cx)
636                    .into_any_element()
637                }
638            }))
639    }
640}
641
642impl EventEmitter<ItemEvent> for AgentRegistryPage {}
643
644impl Focusable for AgentRegistryPage {
645    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
646        self.query_editor.read(cx).focus_handle(cx)
647    }
648}
649
650impl Item for AgentRegistryPage {
651    type Event = ItemEvent;
652
653    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
654        "ACP Registry".into()
655    }
656
657    fn telemetry_event_text(&self) -> Option<&'static str> {
658        Some("ACP Registry Page Opened")
659    }
660
661    fn show_toolbar(&self) -> bool {
662        false
663    }
664
665    fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(workspace::item::ItemEvent)) {
666        f(*event)
667    }
668}