Skip to main content

gpui_kit/agent/
offering_catalog.rs

1//! Searchable offerings aggregated across caller-owned server sources.
2//!
3//! This component displays what callers already know. It does not discover,
4//! install, invoke, trust, permit, or connect to anything. A result is always
5//! identified by both its server and offering because names and offering ids
6//! are only unique within one server.
7
8use std::rc::Rc;
9
10use gpui::{
11    AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
12    StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px,
13};
14use gpui_kit_assets::{Icon, icon};
15use gpui_kit_semantics::{NodeSpec, Role, Semantic};
16use gpui_kit_theme::{
17    ActiveTheme, ControlSize, Elevation, Radius, Space, Surface, TextTone, TypeScale,
18};
19
20use crate::agent::server_list::{Offering, OfferingKind};
21use crate::display::badge::{Badge, Tone};
22use crate::display::empty::{EmptyKind, EmptyState};
23use crate::display::status::StatusDot;
24use crate::foundation::{Disableable, FocusRing, Ident, Pressable, Sizable, StyledExt, text};
25use crate::strings::{ActiveStrings, StringKey};
26
27type ActivateHandler = Rc<dyn Fn(OfferingIdentity, &mut Window, &mut App)>;
28
29/// Stable identity for one server-owned offering.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct OfferingIdentity {
32    pub server_id: SharedString,
33    pub offering_id: SharedString,
34}
35
36impl OfferingIdentity {
37    pub fn new(server_id: impl Into<SharedString>, offering_id: impl Into<SharedString>) -> Self {
38        Self {
39            server_id: server_id.into(),
40            offering_id: offering_id.into(),
41        }
42    }
43}
44
45/// An offering and the caller-authored text used to search it.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct SearchableOffering {
48    offering: Offering,
49    searchable_text: SharedString,
50}
51
52impl SearchableOffering {
53    pub fn new(offering: Offering, searchable_text: impl Into<SharedString>) -> Self {
54        Self {
55            offering,
56            searchable_text: searchable_text.into(),
57        }
58    }
59
60    pub fn offering(&self) -> &Offering {
61        &self.offering
62    }
63}
64
65/// What is truthfully known about one source's offerings.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum OfferingSourceState {
68    Loading,
69    Empty,
70    Unavailable(SharedString),
71    Error(SharedString),
72    Ready(Vec<SearchableOffering>),
73    /// The source's last verified results remain visible beside the failure.
74    Stale {
75        offerings: Vec<SearchableOffering>,
76        reason: SharedString,
77    },
78}
79
80impl OfferingSourceState {
81    pub fn name(&self) -> &'static str {
82        match self {
83            Self::Loading => "loading",
84            Self::Empty => "empty",
85            Self::Unavailable(_) => "unavailable",
86            Self::Error(_) => "error",
87            Self::Ready(offerings) if offerings.is_empty() => "empty",
88            Self::Ready(_) => "ready",
89            Self::Stale { .. } => "stale",
90        }
91    }
92
93    fn offerings(&self) -> &[SearchableOffering] {
94        match self {
95            Self::Ready(offerings) | Self::Stale { offerings, .. } => offerings,
96            _ => &[],
97        }
98    }
99}
100
101/// One attributed source and its independently truthful state.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct OfferingSource {
104    id: SharedString,
105    name: SharedString,
106    state: OfferingSourceState,
107}
108
109impl OfferingSource {
110    pub fn new(
111        id: impl Into<SharedString>,
112        name: impl Into<SharedString>,
113        state: OfferingSourceState,
114    ) -> Self {
115        Self {
116            id: id.into(),
117            name: name.into(),
118            state,
119        }
120    }
121
122    pub fn id(&self) -> &SharedString {
123        &self.id
124    }
125
126    pub fn state(&self) -> &OfferingSourceState {
127        &self.state
128    }
129}
130
131/// A searchable, kind-filterable catalog of offerings from multiple servers.
132#[derive(IntoElement)]
133pub struct OfferingCatalog {
134    ident: Ident,
135    sources: Vec<OfferingSource>,
136    query: SharedString,
137    kinds: Vec<OfferingKind>,
138    selected: Option<OfferingIdentity>,
139    size: ControlSize,
140    disabled: bool,
141    on_activate: Option<ActivateHandler>,
142}
143
144impl std::fmt::Debug for OfferingCatalog {
145    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        formatter
147            .debug_struct("OfferingCatalog")
148            .field("ident", &self.ident)
149            .field("sources", &self.sources)
150            .field("query", &self.query)
151            .field("kinds", &self.kinds)
152            .field("selected", &self.selected)
153            .field("disabled", &self.disabled)
154            .finish()
155    }
156}
157
158impl OfferingCatalog {
159    pub fn new(ident: impl Into<Ident>) -> Self {
160        Self {
161            ident: ident.into(),
162            sources: Vec::new(),
163            query: SharedString::default(),
164            kinds: Vec::new(),
165            selected: None,
166            size: ControlSize::Md,
167            disabled: false,
168            on_activate: None,
169        }
170    }
171
172    pub fn source(mut self, source: OfferingSource) -> Self {
173        self.sources.push(source);
174        self
175    }
176
177    pub fn sources(mut self, sources: impl IntoIterator<Item = OfferingSource>) -> Self {
178        self.sources.extend(sources);
179        self
180    }
181
182    /// The caller-owned query matched against each offering's searchable text.
183    pub fn query(mut self, query: impl Into<SharedString>) -> Self {
184        self.query = query.into();
185        self
186    }
187
188    /// Included kinds. An empty collection includes all kinds.
189    pub fn kinds(mut self, kinds: impl IntoIterator<Item = OfferingKind>) -> Self {
190        self.kinds = kinds.into_iter().collect();
191        self
192    }
193
194    pub fn selected(mut self, identity: OfferingIdentity) -> Self {
195        self.selected = Some(identity);
196        self
197    }
198
199    /// Reports activation with both identities. The component performs no action.
200    pub fn on_activate(
201        mut self,
202        handler: impl Fn(OfferingIdentity, &mut Window, &mut App) + 'static,
203    ) -> Self {
204        self.on_activate = Some(Rc::new(handler));
205        self
206    }
207}
208
209impl Disableable for OfferingCatalog {
210    fn disabled(mut self, disabled: bool) -> Self {
211        self.disabled = disabled;
212        self
213    }
214}
215
216impl Sizable for OfferingCatalog {
217    fn control_size(mut self, size: ControlSize) -> Self {
218        self.size = size;
219        self
220    }
221}
222
223impl RenderOnce for OfferingCatalog {
224    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
225        let theme = cx.theme().clone();
226        let state_name = self.aggregate_state();
227        let busy = self
228            .sources
229            .iter()
230            .any(|source| matches!(source.state, OfferingSourceState::Loading));
231        let invalid = self
232            .sources
233            .iter()
234            .any(|source| matches!(source.state, OfferingSourceState::Error(_)));
235        let statuses: Vec<AnyElement> = self
236            .sources
237            .iter()
238            .filter(|source| source.state.name() != "ready")
239            .map(|source| self.source_status(source, &theme, cx))
240            .collect();
241        let body = self.results(&theme, cx);
242
243        div()
244            .id(self.ident.element_id())
245            .column()
246            .w_full()
247            .gap_token(&theme, Space::Sm)
248            .p_token(&theme, Space::Sm)
249            .radius(&theme, Radius::Card)
250            .frame(&theme, Surface::Panel, Elevation::Raised)
251            .children(statuses)
252            .child(body)
253            .semantic_in(
254                cx,
255                NodeSpec::new(self.ident.semantic_id(), Role::Region)
256                    .value(state_name)
257                    .busy(busy)
258                    .invalid(invalid),
259            )
260    }
261}
262
263impl OfferingCatalog {
264    fn aggregate_state(&self) -> &'static str {
265        let Some(first) = self.sources.first().map(|source| source.state.name()) else {
266            return "empty";
267        };
268        if self
269            .sources
270            .iter()
271            .all(|source| source.state.name() == first)
272        {
273            first
274        } else {
275            "mixed"
276        }
277    }
278
279    fn source_status(
280        &self,
281        source: &OfferingSource,
282        theme: &gpui_kit_theme::Theme,
283        cx: &mut App,
284    ) -> AnyElement {
285        let ident = self
286            .ident
287            .child("source")
288            .child(encoded_segment(source.id.as_ref()));
289        let (label_key, reason, tone) = match &source.state {
290            OfferingSourceState::Loading => (StringKey::OfferingSourceLoading, None, Tone::Info),
291            OfferingSourceState::Empty | OfferingSourceState::Ready(_) => {
292                (StringKey::OfferingSourceEmpty, None, Tone::Neutral)
293            }
294            OfferingSourceState::Unavailable(reason) => (
295                StringKey::OfferingSourceUnavailable,
296                Some(reason.clone()),
297                Tone::Neutral,
298            ),
299            OfferingSourceState::Error(reason) => (
300                StringKey::OfferingSourceError,
301                Some(reason.clone()),
302                Tone::Danger,
303            ),
304            OfferingSourceState::Stale { reason, .. } => (
305                StringKey::OfferingSourceStale,
306                Some(reason.clone()),
307                Tone::Warning,
308            ),
309        };
310        let label = cx.strings().format(label_key, &[source.name.as_ref()]);
311        div()
312            .row()
313            .w_full()
314            .items_start()
315            .gap_token(theme, Space::Sm)
316            .p_token(theme, Space::Sm)
317            .radius(theme, Radius::Control)
318            .bg(tone.color(theme).opacity(0.12))
319            .child(div().mt(px(4.0)).child(StatusDot::new(tone)))
320            .child(
321                div()
322                    .column()
323                    .min_w_0()
324                    .child(
325                        text(theme, TypeScale::Caption, label.clone())
326                            .text_color(tone.color(theme)),
327                    )
328                    .children(reason.clone().map(|reason| {
329                        text(theme, TypeScale::Body, reason).text_color(tone.color(theme))
330                    })),
331            )
332            .semantic_in(
333                cx,
334                NodeSpec::new(ident.semantic_id(), Role::Status)
335                    .parent(self.ident.semantic_id())
336                    .text(label)
337                    .value(source.state.name())
338                    .description(reason.unwrap_or_default())
339                    .busy(matches!(source.state, OfferingSourceState::Loading))
340                    .invalid(matches!(source.state, OfferingSourceState::Error(_))),
341            )
342            .into_any_element()
343    }
344
345    fn results(&self, theme: &gpui_kit_theme::Theme, cx: &mut App) -> AnyElement {
346        let query = self.query.to_lowercase();
347        let filtered: Vec<(&OfferingSource, &SearchableOffering)> = self
348            .sources
349            .iter()
350            .flat_map(|source| {
351                source
352                    .state
353                    .offerings()
354                    .iter()
355                    .map(move |offering| (source, offering))
356            })
357            .filter(|(_, result)| {
358                self.kinds.is_empty() || self.kinds.contains(&result.offering.kind())
359            })
360            .filter(|(_, result)| {
361                query.is_empty() || result.searchable_text.to_lowercase().contains(&query)
362            })
363            .collect();
364        if filtered.is_empty() {
365            let key = if self
366                .sources
367                .iter()
368                .any(|source| !source.state.offerings().is_empty())
369            {
370                StringKey::OfferingCatalogNoMatch
371            } else {
372                StringKey::OfferingCatalogEmpty
373            };
374            return EmptyState::new(self.ident.child("empty"), cx.strings().text(key))
375                .kind(EmptyKind::Empty)
376                .into_any_element();
377        }
378
379        let list_ident = self.ident.child("results");
380        div()
381            .column()
382            .w_full()
383            .gap_token(theme, Space::Xs)
384            .children(
385                filtered
386                    .iter()
387                    .map(|(source, result)| self.result(source, result, &list_ident, theme, cx)),
388            )
389            .semantic_in(
390                cx,
391                NodeSpec::new(list_ident.semantic_id(), Role::List)
392                    .parent(self.ident.semantic_id())
393                    .value(filtered.len().to_string()),
394            )
395            .into_any_element()
396    }
397
398    fn result(
399        &self,
400        source: &OfferingSource,
401        result: &SearchableOffering,
402        list_ident: &Ident,
403        theme: &gpui_kit_theme::Theme,
404        cx: &mut App,
405    ) -> AnyElement {
406        let identity = OfferingIdentity::new(source.id.clone(), result.offering.id().clone());
407        let ident = list_ident
408            .child(encoded_segment(identity.server_id.as_ref()))
409            .child(encoded_segment(identity.offering_id.as_ref()));
410        let selected = self.selected.as_ref() == Some(&identity);
411        let actionable = !self.disabled && self.on_activate.is_some();
412        let metrics = theme.control.get(self.size);
413        let kind = result.offering.kind();
414        let glyph = match kind {
415            OfferingKind::Tool => Icon::Tuning,
416            OfferingKind::Skill => Icon::Command,
417            OfferingKind::Resource => Icon::Document,
418        };
419        let mut row = div()
420            .id(ident.element_id())
421            .row()
422            .w_full()
423            .items_start()
424            .gap_token(theme, Space::Sm)
425            .p_token(theme, Space::Sm)
426            .radius(theme, Radius::Control)
427            .when(selected, |element| element.bg(theme.colors.selected))
428            .when(actionable, |element| {
429                element
430                    .cursor_pointer()
431                    .tab_index(0)
432                    .pressable(cx)
433                    .when(!selected, |element| {
434                        element.hover(|style| style.bg(theme.colors.hover))
435                    })
436                    .focus_ring(theme)
437            })
438            .child(
439                icon(glyph)
440                    .size(px(metrics.icon_size))
441                    .text_color(theme.colors.text_faint),
442            )
443            .child(
444                div()
445                    .column()
446                    .flex_1()
447                    .min_w_0()
448                    .child(text(
449                        theme,
450                        TypeScale::Label,
451                        result.offering.name().clone(),
452                    ))
453                    .when_some(
454                        result.offering.summary_text().cloned(),
455                        |element, summary| {
456                            element.child(
457                                text(theme, TypeScale::Body, summary)
458                                    .text_tone(theme, TextTone::Muted),
459                            )
460                        },
461                    )
462                    .when_some(
463                        result.offering.qualifier_text().cloned(),
464                        |element, qualifier| {
465                            element.child(
466                                text(theme, TypeScale::Code, qualifier)
467                                    .text_tone(theme, TextTone::Faint)
468                                    .font_family(theme.typography.mono.clone()),
469                            )
470                        },
471                    ),
472            )
473            .child(
474                div()
475                    .column()
476                    .items_end()
477                    .gap_token(theme, Space::Xs)
478                    .child(Badge::new(kind.name()).tone(Tone::Neutral))
479                    .child(
480                        text(theme, TypeScale::Caption, source.name.clone())
481                            .text_tone(theme, TextTone::Muted)
482                            .semantic_in(
483                                cx,
484                                NodeSpec::new(ident.child("server").semantic_id(), Role::Status)
485                                    .parent(ident.semantic_id())
486                                    .text(source.name.clone())
487                                    .value(source.id.clone()),
488                            ),
489                    ),
490            );
491        if let (true, Some(handler)) = (actionable, self.on_activate.clone()) {
492            row = row.on_click(move |_, window, cx| handler(identity.clone(), window, cx));
493        }
494        row.semantic_in(
495            cx,
496            NodeSpec::new(ident.semantic_id(), Role::Row)
497                .parent(list_ident.semantic_id())
498                .text(result.offering.name().clone())
499                .value(kind.name())
500                .selected(selected)
501                .disabled(self.disabled),
502        )
503        .into_any_element()
504    }
505}
506
507fn encoded_segment(value: &str) -> String {
508    value.replace('%', "%25").replace('.', "%2E")
509}