Skip to main content

gpui_ai/
recommendation.rs

1//! Agent suggestion cards with confidence and alternatives.
2
3use crate::ButtonLabelExt as _;
4use crate::control::ControlMetricsExt as _;
5use crate::decoration::{DecoratedExt as _, Decoration};
6use crate::handlers::Handler;
7use crate::surface::{card, description, eyebrow, title};
8use crate::theme::SemanticStyledExt as _;
9use gpui::{
10    App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement as _,
11    RenderOnce, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
12    Window, div, prelude::FluentBuilder as _, relative,
13};
14
15/// An interaction emitted by [`RecommendationCard`].
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum RecommendationEvent {
18    /// The recommendation was accepted.
19    Accepted {
20        /// Stable recommendation identifier.
21        id: SharedString,
22    },
23}
24use crate::motion::MotionTokens;
25use gpui_base::motion::{Transition, transition};
26use gpui_component::{
27    ActiveTheme as _, Sizable as _, StyledExt as _,
28    button::{Button, ButtonVariants as _},
29    h_flex, v_flex,
30};
31
32/// An agent suggestion: what it proposes, how confident it is, and what else
33/// it considered.
34///
35/// # Example
36///
37/// ```
38/// # use gpui_ai::prelude::*;
39/// RecommendationCard::new("rec-1", "Switch supplier to Alpenrose Dairy")
40///     .description("Lower unit cost at equal volume; delivery risk unchanged.")
41///     .confidence(0.87)
42///     .alternatives(["Keep current supplier", "Split volume 60/40"])
43///     .on_event(|event, _, _| { /* apply the stable event id */ });
44/// ```
45#[derive(IntoElement)]
46pub struct RecommendationCard {
47    id: SharedString,
48    style: StyleRefinement,
49    decoration: Decoration,
50    title: SharedString,
51    description: Option<SharedString>,
52    confidence: Option<f32>,
53    alternatives: Vec<SharedString>,
54    accept_label: SharedString,
55    on_event: Option<Handler<RecommendationEvent>>,
56}
57
58impl RecommendationCard {
59    /// Layers painted into this card's frame: one under the content, one
60    /// over it, both clipped to its own shape and neither affecting layout.
61    ///
62    /// This crate ships no effects of its own — what goes in a decoration is
63    /// the application's expression.
64    pub fn decoration(mut self, decoration: Decoration) -> Self {
65        self.decoration = decoration;
66        self
67    }
68
69    /// Creates a card with the recommendation headline.
70    pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
71        Self {
72            id: id.into(),
73            style: StyleRefinement::default(),
74            decoration: Decoration::default(),
75            title: title.into(),
76            description: None,
77            confidence: None,
78            alternatives: Vec::new(),
79            accept_label: "Apply".into(),
80            on_event: None,
81        }
82    }
83
84    /// Sets the supporting explanation.
85    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
86        self.description = Some(description.into());
87        self
88    }
89
90    /// Sets the confidence in `0.0..=1.0`, rendered as a meter. Values are
91    /// clamped.
92    pub fn confidence(mut self, confidence: f32) -> Self {
93        self.confidence = Some(confidence.clamp(0.0, 1.0));
94        self
95    }
96
97    /// Lists the alternatives the agent considered but ranked lower.
98    pub fn alternatives(
99        mut self,
100        alternatives: impl IntoIterator<Item = impl Into<SharedString>>,
101    ) -> Self {
102        self.alternatives = alternatives.into_iter().map(Into::into).collect();
103        self
104    }
105
106    /// Adds an accept button. Default label is "Apply"; override with
107    /// [`Self::accept_label`].
108    pub fn on_event(
109        mut self,
110        handler: impl Fn(&RecommendationEvent, &mut Window, &mut App) + 'static,
111    ) -> Self {
112        self.on_event = Some(Box::new(handler));
113        self
114    }
115
116    /// Overrides the accept button label.
117    pub fn accept_label(mut self, label: impl Into<SharedString>) -> Self {
118        self.accept_label = label.into();
119        self
120    }
121}
122
123impl Styled for RecommendationCard {
124    fn style(&mut self) -> &mut StyleRefinement {
125        &mut self.style
126    }
127}
128
129impl RenderOnce for RecommendationCard {
130    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
131        let tokens = cx.theme().semantic_tokens();
132        // Taken once: each layer is placed at most once, and a
133        // component with no decoration adds no elements at all.
134        let mut decoration = std::mem::take(&mut self.decoration);
135        let event = RecommendationEvent::Accepted {
136            id: self.id.clone(),
137        };
138        let accessibility_label = self.title.clone();
139        let accessibility_description = self.description.clone();
140        card(self.id.clone(), cx)
141            .decoration_under(&mut decoration)
142            .role(Role::Group)
143            .aria_label(accessibility_label)
144            .when_some(accessibility_description, |this, description| {
145                this.aria_description(description)
146            })
147            .child(title(self.title, cx))
148            .when_some(self.description, |this, text| {
149                this.child(description(text, cx))
150            })
151            .when_some(self.confidence, |this, confidence| {
152                let meter_color = if confidence < 0.4 {
153                    cx.theme().danger
154                } else if confidence < 0.7 {
155                    cx.theme().warning
156                } else {
157                    cx.theme().success
158                };
159                this.child(
160                    h_flex()
161                        .id(format!("{}-confidence", self.id))
162                        .role(Role::Meter)
163                        .aria_label("Confidence")
164                        .aria_min_numeric_value(0.)
165                        .aria_max_numeric_value(100.)
166                        .aria_numeric_value((confidence * 100.0) as f64)
167                        .items_center()
168                        .gap(tokens.spacing.sm)
169                        .child(
170                            div()
171                                .flex_1()
172                                .h_1p5()
173                                .rounded(tokens.radius.full)
174                                .bg(cx.theme().muted)
175                                .child(
176                                    // The fill retargets from its current
177                                    // sample when the controlled confidence
178                                    // changes; the first render adopts the
179                                    // supplied value and reduced motion
180                                    // snaps — the transition contract. The
181                                    // meter's numeric value above is the
182                                    // controlled figure, never the sample:
183                                    // no animated count-up.
184                                    div()
185                                        .h_full()
186                                        .w(relative(transition(
187                                            (
188                                                ElementId::from(SharedString::from(format!(
189                                                    "{}-confidence-fill",
190                                                    self.id
191                                                ))),
192                                                "value",
193                                            ),
194                                            confidence,
195                                            Transition::new(crate::motion::retarget_duration(
196                                                cx,
197                                                MotionTokens::read(cx).standard(),
198                                            ))
199                                            .ease(crate::motion::ease_in_out_quart),
200                                            window,
201                                            cx,
202                                        )))
203                                        .rounded(tokens.radius.full)
204                                        .bg(meter_color),
205                                ),
206                        )
207                        .child(
208                            div()
209                                .flex_none()
210                                .text_token(tokens.typography.xs)
211                                .font_family(cx.theme().mono_font_family.clone())
212                                .text_color(cx.theme().muted_foreground)
213                                .child(format!("{:.0}%", confidence * 100.0)),
214                        ),
215                )
216            })
217            .when(!self.alternatives.is_empty(), |this| {
218                this.child(
219                    v_flex()
220                        .id(format!("{}-alternatives", self.id))
221                        .role(Role::List)
222                        .aria_label("Also considered")
223                        .gap(tokens.spacing.xs)
224                        .child(eyebrow("Also considered", cx))
225                        .children(self.alternatives.into_iter().enumerate().map(|(ix, alt)| {
226                            let accessibility_label = alt.clone();
227                            // The bullet rides a first-line slot so a
228                            // wrapping alternative keeps it seated on its
229                            // first line instead of floating mid-block.
230                            h_flex()
231                                .id(format!("{}-alternative-{ix}", self.id))
232                                .role(Role::ListItem)
233                                .aria_label(accessibility_label)
234                                .items_start()
235                                .gap(tokens.spacing.sm)
236                                .text_token(tokens.typography.sm)
237                                .text_color(cx.theme().muted_foreground)
238                                .child(crate::surface::leading_glyph_slot(
239                                    crate::sizing::slot_md(cx),
240                                    div()
241                                        .size_1()
242                                        .rounded(tokens.radius.full)
243                                        .bg(cx.theme().muted_foreground),
244                                ))
245                                .child(div().min_w_0().child(alt))
246                        })),
247                )
248            })
249            .when_some(self.on_event, |this, handler| {
250                this.child(
251                    h_flex().child(
252                        Button::new(format!("{}-accept", self.id))
253                            .primary()
254                            .small()
255                            .control_metrics(cx)
256                            .accessibility_id(format!("{}-accept", self.id))
257                            .text_label(self.accept_label)
258                            .on_click(move |_: &ClickEvent, window, cx| {
259                                handler(&event, window, cx)
260                            }),
261                    ),
262                )
263            })
264            .decoration_over(&mut decoration)
265            .refine_style(&self.style)
266    }
267}