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