Skip to main content

gpui_ai/
question_flow.rs

1//! A short sequence of questions an agent asks before it acts.
2//!
3//! One question at a time, each a single choice, with the place in the
4//! sequence stated plainly and a way past a question that does not apply.
5//! Where [`crate::approval::ApprovalCard`] asks for one decision about
6//! something already proposed, this gathers what the agent needs in order to
7//! propose anything at all.
8//!
9//! Controlled, like the rest of the library: the application owns the step
10//! and every answer, and this reports what a person asked for. Nothing here
11//! advances on its own — an application that wants to record an answer and
12//! move on does both when the answer arrives.
13
14use crate::ButtonLabelExt as _;
15use crate::control::ControlMetricsExt as _;
16use crate::decoration::{DecoratedExt as _, Decoration};
17use crate::form::{ChoiceEvent, ChoiceGroup, ChoiceOption};
18use crate::handlers::SharedHandler;
19use crate::surface::{CardFrameExt as _, description, meta, title};
20use gpui::{
21    App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement as _,
22    RenderOnce, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
23    Window, div, prelude::FluentBuilder as _,
24};
25use gpui_component::{
26    ActiveTheme as _, Disableable as _, Sizable as _, StyledExt as _,
27    button::{Button, ButtonVariants as _},
28    h_flex, v_flex,
29};
30use std::rc::Rc;
31
32/// One question, and the answers it will accept.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Question {
35    id: SharedString,
36    prompt: SharedString,
37    note: Option<SharedString>,
38    options: Vec<ChoiceOption>,
39    answer: Option<SharedString>,
40    optional: bool,
41}
42
43impl Question {
44    /// Creates a question with a stable identifier and what it asks.
45    pub fn new(id: impl Into<SharedString>, prompt: impl Into<SharedString>) -> Self {
46        Self {
47            id: id.into(),
48            prompt: prompt.into(),
49            note: None,
50            options: Vec::new(),
51            answer: None,
52            optional: false,
53        }
54    }
55
56    /// A line under the question, for why it is being asked.
57    pub fn note(mut self, note: impl Into<SharedString>) -> Self {
58        self.note = Some(note.into());
59        self
60    }
61
62    /// Sets the answers on offer, in order.
63    pub fn options(mut self, options: impl IntoIterator<Item = ChoiceOption>) -> Self {
64        self.options = options.into_iter().collect();
65        self
66    }
67
68    /// Records the answer this question already has.
69    pub fn answer(mut self, option: impl Into<SharedString>) -> Self {
70        self.answer = Some(option.into());
71        self
72    }
73
74    /// Records the answer, or the absence of one.
75    pub fn answered(mut self, option: Option<SharedString>) -> Self {
76        self.answer = option;
77        self
78    }
79
80    /// Lets the sequence move past this question unanswered.
81    pub fn optional(mut self, optional: bool) -> Self {
82        self.optional = optional;
83        self
84    }
85}
86
87/// What a question flow reports.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum QuestionFlowEvent {
90    /// A person answered the question on screen.
91    Answered {
92        /// The flow's identifier.
93        flow: SharedString,
94        /// The question answered.
95        question: SharedString,
96        /// The option chosen.
97        option: SharedString,
98    },
99    /// A person asked to move past the question on screen without answering.
100    Skipped {
101        /// The flow's identifier.
102        flow: SharedString,
103        /// The question skipped.
104        question: SharedString,
105    },
106    /// A person asked to move on from an answered question.
107    Advanced {
108        /// The flow's identifier.
109        flow: SharedString,
110        /// The step being asked for.
111        step: usize,
112    },
113    /// A person answered the last question and asked to finish.
114    Completed {
115        /// The flow's identifier.
116        flow: SharedString,
117    },
118}
119
120/// A sequence of single-choice questions, asked one at a time.
121///
122/// # Example
123///
124/// ```
125/// use gpui_ai::prelude::{ChoiceOption, Question, QuestionFlow};
126///
127/// let flow = QuestionFlow::new("launch", "Before I draft the plan")
128///     .questions([
129///         Question::new("flavours", "How many flavours should we launch?").options([
130///             ChoiceOption::new("three", "Three").description("The core line"),
131///             ChoiceOption::new("five", "Five"),
132///         ]),
133///         Question::new("market", "Which market do we enter first?")
134///             .options([ChoiceOption::new("trucks", "Food trucks")]),
135///     ])
136///     .step(0);
137/// ```
138#[derive(IntoElement)]
139pub struct QuestionFlow {
140    id: SharedString,
141    title: Option<SharedString>,
142    questions: Vec<Question>,
143    step: usize,
144    skip_label: SharedString,
145    continue_label: SharedString,
146    finish_label: SharedString,
147    on_event: Option<SharedHandler<QuestionFlowEvent>>,
148    style: StyleRefinement,
149    decoration: Decoration,
150}
151
152impl QuestionFlow {
153    /// Layers painted into this flow's frame: one under the content, one
154    /// over it, both clipped to its own shape and neither affecting layout.
155    ///
156    /// This crate ships no effects of its own — what goes in a decoration is
157    /// the application's expression.
158    pub fn decoration(mut self, decoration: Decoration) -> Self {
159        self.decoration = decoration;
160        self
161    }
162
163    /// Creates a flow with a stable identifier and what it is gathering for.
164    pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
165        Self {
166            id: id.into(),
167            title: Some(title.into()),
168            questions: Vec::new(),
169            step: 0,
170            skip_label: "Skip".into(),
171            continue_label: "Continue".into(),
172            finish_label: "Done".into(),
173            on_event: None,
174            style: StyleRefinement::default(),
175            decoration: Decoration::default(),
176        }
177    }
178
179    /// Sets the questions, in the order they are asked.
180    pub fn questions(mut self, questions: impl IntoIterator<Item = Question>) -> Self {
181        self.questions = questions.into_iter().collect();
182        self
183    }
184
185    /// Shows the question at `step`. Out of range shows the last one.
186    pub fn step(mut self, step: usize) -> Self {
187        self.step = step;
188        self
189    }
190
191    /// Renames the control that moves past a question.
192    pub fn skip_label(mut self, label: impl Into<SharedString>) -> Self {
193        self.skip_label = label.into();
194        self
195    }
196
197    /// Renames the control that moves on from an answered question.
198    pub fn continue_label(mut self, label: impl Into<SharedString>) -> Self {
199        self.continue_label = label.into();
200        self
201    }
202
203    /// Renames the control that closes the last question.
204    pub fn finish_label(mut self, label: impl Into<SharedString>) -> Self {
205        self.finish_label = label.into();
206        self
207    }
208
209    /// Handles the typed request.
210    pub fn on_event(
211        mut self,
212        handler: impl Fn(&QuestionFlowEvent, &mut Window, &mut App) + 'static,
213    ) -> Self {
214        self.on_event = Some(Rc::new(handler));
215        self
216    }
217
218    /// The step actually shown, which is the last question once `step` runs
219    /// past the end.
220    fn shown(&self) -> usize {
221        self.step.min(self.questions.len().saturating_sub(1))
222    }
223}
224
225impl Styled for QuestionFlow {
226    fn style(&mut self) -> &mut StyleRefinement {
227        &mut self.style
228    }
229}
230
231impl RenderOnce for QuestionFlow {
232    fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
233        let tokens = cx.theme().semantic_tokens();
234        // Taken once: each layer is placed at most once, and a
235        // component with no decoration adds no elements at all.
236        let mut decoration = std::mem::take(&mut self.decoration);
237        let total = self.questions.len();
238        let shown = self.shown();
239        let root_id = ElementId::from(self.id.clone());
240        let flow = self.id.clone();
241        let handler = self.on_event;
242        let card_title = self.title.clone();
243        let debug_id = self.id.clone();
244
245        let Some(question) = self.questions.into_iter().nth(shown) else {
246            // A flow with nothing to ask draws nothing rather than an empty
247            // frame with a "0 of 0" under it.
248            return div().into_any_element();
249        };
250
251        let answered = question.answer.is_some();
252        let last = shown + 1 == total;
253        let question_id = question.id.clone();
254        let group = ChoiceGroup::unlabelled(format!("{flow}-{question_id}"))
255            .options(question.options.clone())
256            .selection(question.answer.clone())
257            .on_event({
258                let handler = handler.clone();
259                let flow = flow.clone();
260                let question_id = question_id.clone();
261                move |event, window, cx| {
262                    let ChoiceEvent::Chosen { option, .. } = event;
263                    if let Some(handler) = handler.as_ref() {
264                        handler(
265                            &QuestionFlowEvent::Answered {
266                                flow: flow.clone(),
267                                question: question_id.clone(),
268                                option: option.clone(),
269                            },
270                            window,
271                            cx,
272                        );
273                    }
274                }
275            });
276
277        let skip_event = QuestionFlowEvent::Skipped {
278            flow: flow.clone(),
279            question: question_id.clone(),
280        };
281        let advance_event = if last {
282            QuestionFlowEvent::Completed { flow: flow.clone() }
283        } else {
284            QuestionFlowEvent::Advanced {
285                flow: flow.clone(),
286                step: shown + 1,
287            }
288        };
289        let advance_label = if last {
290            self.finish_label.clone()
291        } else {
292            self.continue_label.clone()
293        };
294        let counter = format!("{} of {total}", shown + 1);
295        let counter_debug = debug_id.clone();
296        let skip_handler = handler.clone();
297        let advance_handler = handler;
298
299        v_flex()
300            .id(root_id.clone())
301            .debug_selector(move || format!("question-flow-{debug_id}"))
302            .card_frame(cx)
303            .decoration_under(&mut decoration)
304            .p(tokens.spacing.lg)
305            .gap(tokens.spacing.md)
306            .role(Role::Group)
307            .aria_label(
308                card_title
309                    .clone()
310                    .unwrap_or_else(|| question.prompt.clone()),
311            )
312            .decoration_over(&mut decoration)
313            .refine_style(&self.style)
314            .when_some(card_title, |card, text| card.child(title(text, cx)))
315            .child(
316                v_flex()
317                    .gap(tokens.spacing.xs)
318                    .child(description(question.prompt.clone(), cx))
319                    .when_some(question.note.clone(), |body, note| {
320                        body.child(crate::surface::hint(note, cx))
321                    }),
322            )
323            .child(group)
324            .child(
325                h_flex()
326                    .w_full()
327                    .items_center()
328                    .justify_between()
329                    .gap(tokens.spacing.sm)
330                    .child(
331                        // The place in the sequence is a status, not a
332                        // decoration: it is the only thing that says how much
333                        // is left to answer.
334                        div()
335                            .id(ElementId::from((root_id.clone(), "counter")))
336                            .debug_selector(move || {
337                                format!("question-flow-counter-{counter_debug}")
338                            })
339                            .role(Role::Status)
340                            .aria_label(format!("Question {counter}"))
341                            .child(meta(counter.clone(), cx)),
342                    )
343                    .child(
344                        h_flex()
345                            .gap(tokens.spacing.sm)
346                            .when(question.optional || !answered, |row| {
347                                row.child(
348                                    Button::new(ElementId::from((root_id.clone(), "skip")))
349                                        .ghost()
350                                        .small()
351                                        .control_metrics(cx)
352                                        .accessibility_id(format!("{flow}-skip"))
353                                        .debug_selector({
354                                            let flow = flow.clone();
355                                            move || format!("question-flow-skip-{flow}")
356                                        })
357                                        .text_label(self.skip_label.clone())
358                                        .when_some(skip_handler, |button, handler| {
359                                            button.on_click(move |_: &ClickEvent, window, cx| {
360                                                handler(&skip_event, window, cx)
361                                            })
362                                        }),
363                                )
364                            })
365                            .child(
366                                Button::new(ElementId::from((root_id, "advance")))
367                                    .primary()
368                                    .small()
369                                    .control_metrics(cx)
370                                    .accessibility_id(format!("{flow}-advance"))
371                                    .debug_selector({
372                                        let flow = flow.clone();
373                                        move || format!("question-flow-advance-{flow}")
374                                    })
375                                    .text_label(advance_label)
376                                    .disabled(!answered)
377                                    .when_some(
378                                        advance_handler.filter(|_| answered),
379                                        |button, handler| {
380                                            button.on_click(move |_: &ClickEvent, window, cx| {
381                                                handler(&advance_event, window, cx)
382                                            })
383                                        },
384                                    ),
385                            ),
386                    ),
387            )
388            .into_any_element()
389    }
390}