Skip to main content

gpui_ai/
suggestions.rs

1//! Suggestion chips: starter prompts and follow-ups as one quiet row.
2//!
3//! The chips ripple into place with a staggered reveal, sit on the surface
4//! token as outlined pills, and report a stable ID when chosen. The
5//! application decides whether a selection is sent immediately or merely
6//! populates the composer.
7
8use crate::cues::{self, Cue};
9use crate::{
10    control::{PressReleaseExt as _, composed_button},
11    handlers::SharedHandler,
12    motion::reveal_staggered,
13    theme::SemanticStyledExt as _,
14};
15use gpui::{
16    App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement as _,
17    RenderOnce, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
18    Window, div, prelude::FluentBuilder as _,
19};
20use gpui_component::{ActiveTheme as _, StyledExt as _, h_flex};
21use std::rc::Rc;
22
23/// One suggested prompt with stable identity.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Suggestion {
26    id: SharedString,
27    label: SharedString,
28    description: Option<SharedString>,
29}
30
31impl Suggestion {
32    /// Creates a suggestion with a stable identifier and the visible prompt.
33    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
34        Self {
35            id: id.into(),
36            label: label.into(),
37            description: None,
38        }
39    }
40
41    /// Adds an accessible description (what choosing this will do).
42    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
43        self.description = Some(description.into());
44        self
45    }
46
47    /// Returns the stable suggestion identifier.
48    pub fn id(&self) -> &SharedString {
49        &self.id
50    }
51
52    /// Returns the visible prompt text.
53    pub fn label(&self) -> &SharedString {
54        &self.label
55    }
56}
57
58/// An interaction emitted by [`Suggestions`].
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum SuggestionsEvent {
61    /// A suggestion was chosen.
62    Selected {
63        /// Stable suggestion identifier.
64        id: SharedString,
65    },
66}
67
68/// A wrapping row of suggestion chips.
69///
70/// # Example
71///
72/// ```
73/// # use gpui_ai::prelude::*;
74/// Suggestions::new("starters")
75///     .items([
76///         Suggestion::new("compare", "Compare supplier prices"),
77///         Suggestion::new("risk", "Explain delivery risk"),
78///     ])
79///     .on_event(|event, _, _| { /* SuggestionsEvent::Selected { id } */ });
80/// ```
81#[derive(IntoElement)]
82pub struct Suggestions {
83    id: ElementId,
84    style: StyleRefinement,
85    items: Vec<Suggestion>,
86    on_event: Option<SharedHandler<SuggestionsEvent>>,
87}
88
89impl Suggestions {
90    /// Creates an empty suggestion row.
91    pub fn new(id: impl Into<ElementId>) -> Self {
92        Self {
93            id: id.into(),
94            style: StyleRefinement::default(),
95            items: Vec::new(),
96            on_event: None,
97        }
98    }
99
100    /// Sets the suggestions, in display order.
101    pub fn items(mut self, items: impl IntoIterator<Item = Suggestion>) -> Self {
102        self.items = items.into_iter().collect();
103        self
104    }
105
106    /// Handles typed selections. Without a handler the chips are static.
107    pub fn on_event(
108        mut self,
109        handler: impl Fn(&SuggestionsEvent, &mut Window, &mut App) + 'static,
110    ) -> Self {
111        self.on_event = Some(Rc::new(handler));
112        self
113    }
114}
115
116impl Styled for Suggestions {
117    fn style(&mut self) -> &mut StyleRefinement {
118        &mut self.style
119    }
120}
121
122impl RenderOnce for Suggestions {
123    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
124        let tokens = cx.theme().semantic_tokens();
125        let root_id = self.id.clone();
126        let handler = self.on_event;
127        h_flex()
128            .id(self.id)
129            .role(Role::Group)
130            .aria_label("Suggestions")
131            .flex_wrap()
132            .gap(tokens.spacing.xs)
133            .children(self.items.into_iter().enumerate().map(|(ix, item)| {
134                let chip_id = ElementId::from((root_id.clone(), format!("chip-{}", item.id)));
135                let debug_id = item.id.clone();
136                let chip = match handler.clone() {
137                    Some(handler) => {
138                        let event = SuggestionsEvent::Selected {
139                            id: item.id.clone(),
140                        };
141                        composed_button(chip_id.clone(), item.label.clone())
142                            .debug_selector(move || format!("suggestion-{debug_id}"))
143                            .when_some(item.description.clone(), |this, description| {
144                                this.aria_description(description)
145                            })
146                            .flex()
147                            .items_center()
148                            // Chip geometry from the size policy: prompt
149                            // pills stand a large control tall, which is
150                            // the height the composer's own controls take
151                            // and the proportion a pill wants — roughly
152                            // twice the vertical room the medium tier left
153                            // around a small-text line, against the same
154                            // horizontal inset.
155                            .h(crate::sizing::control_lg(cx))
156                            .px(tokens.spacing.md)
157                            .border_1()
158                            .border_color(cx.theme().border)
159                            .rounded(tokens.radius.full)
160                            .bg(tokens.colors.surface)
161                            .text_token(tokens.typography.sm)
162                            .text_color(cx.theme().foreground)
163                            // Hover fills; only keyboard focus recolors the
164                            // border — the ring stays a focus signal.
165                            .hover(|style| style.bg(cx.theme().accent.opacity(0.6)))
166                            .active(|style| style.bg(cx.theme().accent))
167                            .focus_visible(|style| style.border_color(cx.theme().ring))
168                            .press_release(
169                                ElementId::from((chip_id, "press")),
170                                tokens.radius.full,
171                                window,
172                                cx,
173                            )
174                            .child({
175                                let label_debug = item.id.clone();
176                                div()
177                                    .debug_selector(move || {
178                                        format!("suggestion-label-{label_debug}")
179                                    })
180                                    .child(item.label)
181                            })
182                            .on_click(move |_: &ClickEvent, window, cx| {
183                                cues::emit(cx, Cue::SuggestionSelected);
184                                handler(&event, window, cx)
185                            })
186                            .into_any_element()
187                    }
188                    None => div()
189                        .id(chip_id)
190                        .role(Role::ListItem)
191                        .aria_label(item.label.clone())
192                        .flex()
193                        .items_center()
194                        .h(crate::sizing::control_lg(cx))
195                        .px(tokens.spacing.md)
196                        .border_1()
197                        .border_color(cx.theme().border)
198                        .rounded(tokens.radius.full)
199                        .bg(tokens.colors.surface)
200                        .text_token(tokens.typography.sm)
201                        .text_color(cx.theme().muted_foreground)
202                        .child(item.label)
203                        .into_any_element(),
204                };
205                reveal_staggered(
206                    div().child(chip),
207                    (root_id.clone(), format!("reveal-{}", item.id)),
208                    ix,
209                    window,
210                    cx,
211                )
212            }))
213            .refine_style(&self.style)
214    }
215}