Skip to main content

gpui_ai/
context_card.rs

1//! Cards for retrieved knowledge chunks with source attribution.
2
3use crate::control::composed_button;
4use crate::decoration::{DecoratedExt as _, Decoration};
5use crate::handlers::SharedHandler;
6use crate::surface::CardFrameExt as _;
7use crate::theme::SemanticStyledExt as _;
8use gpui::{
9    App, ClickEvent, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, Role,
10    SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
11    prelude::FluentBuilder as _,
12};
13use gpui_component::{
14    ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _, h_flex, v_flex,
15};
16use std::rc::Rc;
17
18/// A retrieved knowledge chunk: source, snippet, and optional relevance.
19///
20/// # Example
21///
22/// ```
23/// # use gpui_ai::prelude::*;
24/// ContextCard::new("ctx-1", "pricing.md")
25///     .snippet("Enterprise plans include SSO and a dedicated…")
26///     .relevance(0.92)
27///     .on_event(|event, _, _| { /* open the stable event id */ });
28/// ```
29#[derive(IntoElement)]
30pub struct ContextCard {
31    id: SharedString,
32    style: StyleRefinement,
33    decoration: Decoration,
34    source: SharedString,
35    snippet: Option<SharedString>,
36    relevance: Option<f32>,
37    on_event: Option<SharedHandler<ContextCardEvent>>,
38}
39
40impl ContextCard {
41    /// Layers painted into this card's frame: one under the content, one
42    /// over it, both clipped to its own shape and neither affecting layout.
43    ///
44    /// This crate ships no effects of its own — what goes in a decoration is
45    /// the application's expression.
46    pub fn decoration(mut self, decoration: Decoration) -> Self {
47        self.decoration = decoration;
48        self
49    }
50
51    /// Creates a card for a chunk retrieved from `source` (a document name,
52    /// URL, or collection).
53    pub fn new(id: impl Into<SharedString>, source: impl Into<SharedString>) -> Self {
54        Self {
55            id: id.into(),
56            style: StyleRefinement::default(),
57            decoration: Decoration::default(),
58            source: source.into(),
59            snippet: None,
60            relevance: None,
61            on_event: None,
62        }
63    }
64
65    /// Sets the retrieved text snippet.
66    pub fn snippet(mut self, snippet: impl Into<SharedString>) -> Self {
67        self.snippet = Some(snippet.into());
68        self
69    }
70
71    /// Sets a relevance score in `0.0..=1.0`, shown as a percentage.
72    /// Values are clamped.
73    pub fn relevance(mut self, relevance: f32) -> Self {
74        self.relevance = Some(relevance.clamp(0.0, 1.0));
75        self
76    }
77
78    /// Makes the card clickable to open the underlying source.
79    pub fn on_event(
80        mut self,
81        handler: impl Fn(&ContextCardEvent, &mut Window, &mut App) + 'static,
82    ) -> Self {
83        self.on_event = Some(Rc::new(handler));
84        self
85    }
86
87    fn accessibility_label(&self) -> SharedString {
88        match self.relevance {
89            Some(relevance) => {
90                format!("{}, {:.0}% relevance", self.source, relevance * 100.0).into()
91            }
92            None => self.source.clone(),
93        }
94    }
95}
96
97impl Styled for ContextCard {
98    fn style(&mut self) -> &mut StyleRefinement {
99        &mut self.style
100    }
101}
102
103impl RenderOnce for ContextCard {
104    fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
105        let tokens = cx.theme().semantic_tokens();
106        // Taken once: each layer is placed at most once, and a
107        // component with no decoration adds no elements at all.
108        let mut decoration = std::mem::take(&mut self.decoration);
109        let clickable = self.on_event.is_some();
110        let event = ContextCardEvent::Opened {
111            id: self.id.clone(),
112        };
113        let accessibility_label = self.accessibility_label();
114        let accessibility_description = self.snippet.clone();
115
116        // The source line is a header over the snippet, not a label glued
117        // to it: the card takes panel padding and the two sit a step apart.
118        let content = v_flex()
119            .gap(tokens.spacing.sm)
120            .child(
121                h_flex()
122                    .items_center()
123                    .gap(tokens.spacing.sm)
124                    .child(
125                        Icon::new(IconName::File)
126                            .xsmall()
127                            .text_color(cx.theme().muted_foreground),
128                    )
129                    .child(
130                        crate::surface::hint(self.source, cx)
131                            .flex_1()
132                            .truncate()
133                            .font_family(cx.theme().mono_font_family.clone()),
134                    )
135                    .when_some(self.relevance, |this, relevance| {
136                        this.child(
137                            div()
138                                .flex_none()
139                                .text_token(tokens.typography.xs)
140                                .text_color(cx.theme().muted_foreground)
141                                .child(format!("{:.0}%", relevance * 100.0)),
142                        )
143                    })
144                    .when(clickable, |this| {
145                        this.child(
146                            Icon::new(IconName::ExternalLink)
147                                .xsmall()
148                                .text_color(cx.theme().muted_foreground),
149                        )
150                    }),
151            )
152            .when_some(self.snippet, |this, snippet| {
153                this.child(
154                    div()
155                        .text_token(tokens.typography.sm)
156                        .text_color(cx.theme().foreground)
157                        .line_clamp(3)
158                        .child(snippet),
159                )
160            });
161
162        // One frame, both ways. The interactive card used to be an upstream
163        // button, whose own content layout centred and padded differently
164        // from the plain div the static card used — two cards in a column
165        // sat on different edges. The button now contributes behavior; the
166        // frame is the same frame either way.
167        if let Some(handler) = self.on_event {
168            composed_button(self.id.clone(), accessibility_label)
169                .w_full()
170                .flex()
171                .flex_col()
172                .items_start()
173                .justify_start()
174                .p(tokens.spacing.lg)
175                .card_frame(cx)
176                .decoration_under(&mut decoration)
177                .hover(|style| style.bg(cx.theme().accent.opacity(0.6)))
178                .active(|style| style.bg(cx.theme().accent))
179                .focus_visible(|style| style.border_color(cx.theme().ring))
180                .when_some(accessibility_description, |this, description| {
181                    this.aria_description(description)
182                })
183                .child(content.w_full())
184                .on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
185                .decoration_over(&mut decoration)
186                .refine_style(&self.style)
187                .into_any_element()
188        } else {
189            content
190                .id(self.id)
191                .role(Role::Group)
192                .aria_label(accessibility_label)
193                .when_some(accessibility_description, |this, description| {
194                    this.aria_description(description)
195                })
196                .w_full()
197                .p(tokens.spacing.lg)
198                .card_frame(cx)
199                .decoration_under(&mut decoration)
200                .decoration_over(&mut decoration)
201                .refine_style(&self.style)
202                .into_any_element()
203        }
204    }
205}
206/// An interaction emitted by [`ContextCard`].
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub enum ContextCardEvent {
209    /// The underlying source was selected.
210    Opened {
211        /// Stable context identifier.
212        id: SharedString,
213    },
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn accessibility_name_includes_source_and_relevance() {
222        assert_eq!(
223            ContextCard::new("ctx", "pricing.md")
224                .relevance(0.923)
225                .accessibility_label(),
226            "pricing.md, 92% relevance"
227        );
228        assert_eq!(
229            ContextCard::new("ctx", "pricing.md").accessibility_label(),
230            "pricing.md"
231        );
232    }
233}