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