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