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(®istry_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}