mermaid_model/question.rs
1//! Structured interactive questions the model poses to the user via the
2//! `ask_user_question` tool.
3//!
4//! Pure data, mirroring the approval flow (`PendingApproval` in `state.rs`):
5//! the tool sends `Msg::QuestionAsked` with a batch of [`Question`]s; the
6//! reducer stores a [`PendingQuestionSet`] and renders a modal; the key
7//! handler resolves it into `Cmd::ResolveQuestion` carrying a
8//! [`QuestionResolution`], which the `QuestionBroker` delivers back to the
9//! parked tool task.
10//!
11//! The schema-facing types (`Question`/`QuestionOption`) are kind-agnostic for
12//! now — Stage 1 ships Select + Multi-select. They're deliberately shaped like
13//! `ToolMetadata` (a `#[serde(tag=...)]` union) so later stages can add rank,
14//! slider, date, and path input kinds without reshaping the tool schema.
15
16use serde::{Deserialize, Serialize};
17
18use crate::ids::{ToolCallId, TurnId};
19
20/// One question in a batch. Stage 1: a labeled choice, single- or multi-select.
21/// `camelCase` so the model's `multiSelect` field deserializes directly.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct Question {
25 /// Short chip label shown above the question (e.g. "Database"). Kept short
26 /// (~12 cells) by the render layer.
27 pub header: String,
28 /// The question text itself.
29 pub question: String,
30 /// How the question is answered — choice kinds (`Select`/`MultiSelect`/
31 /// `Rank`) use `options`; input kinds (`Text`/`Number`/`Date`/`Path`) use a
32 /// single typed value.
33 #[serde(default)]
34 pub kind: QuestionKind,
35 /// The selectable options (choice kinds only), in display order. A
36 /// recommended option is listed first and flagged. Empty for input kinds.
37 #[serde(default)]
38 pub options: Vec<QuestionOption>,
39 /// Stable key for remembering the user's answer across sessions. When set
40 /// and the user opts to remember, a later question with the same key
41 /// auto-answers without prompting.
42 #[serde(default)]
43 pub memory_key: Option<String>,
44}
45
46impl Question {
47 /// Choice kinds present a list of options.
48 #[must_use]
49 pub fn is_choice(&self) -> bool {
50 matches!(
51 self.kind,
52 QuestionKind::Select | QuestionKind::MultiSelect | QuestionKind::Rank
53 )
54 }
55 #[must_use]
56 pub fn is_multi(&self) -> bool {
57 matches!(self.kind, QuestionKind::MultiSelect)
58 }
59 #[must_use]
60 pub fn is_rank(&self) -> bool {
61 matches!(self.kind, QuestionKind::Rank)
62 }
63 /// Input kinds present a single typed value field.
64 #[must_use]
65 pub fn is_input(&self) -> bool {
66 !self.is_choice()
67 }
68}
69
70/// How a question is answered. Choice kinds carry `options`; input kinds carry
71/// their own validation parameters. Shaped like `ToolMetadata` (a tagged union)
72/// so new kinds slot in without reshaping the schema.
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
74#[serde(tag = "type", rename_all = "camelCase")]
75pub enum QuestionKind {
76 /// Pick exactly one option.
77 #[default]
78 Select,
79 /// Pick any number of options (checkboxes + explicit Submit).
80 MultiSelect,
81 /// Reorder the options into a ranked list.
82 Rank,
83 /// Free-text with an optional validator.
84 Text {
85 #[serde(default)]
86 validate: TextValidate,
87 },
88 /// A number with optional bounds and step; `slider` adds a bar.
89 Number {
90 #[serde(default)]
91 min: Option<f64>,
92 #[serde(default)]
93 max: Option<f64>,
94 #[serde(default)]
95 step: Option<f64>,
96 #[serde(default)]
97 slider: bool,
98 },
99 /// An ISO date, `YYYY-MM-DD`.
100 Date,
101 /// A filesystem path; `must_exist` is advisory (the pure reducer performs no
102 /// filesystem I/O, so existence isn't enforced live).
103 Path {
104 #[serde(default)]
105 must_exist: bool,
106 },
107}
108
109/// Validator for a `Text` question.
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
111#[serde(tag = "rule", content = "pattern", rename_all = "camelCase")]
112pub enum TextValidate {
113 /// Any non-empty text.
114 #[default]
115 Any,
116 /// Must parse as a number.
117 Number,
118 /// Must match this regular expression.
119 Regex(String),
120}
121
122/// Validate a typed input value against its question kind.
123///
124/// `Ok(())` means valid (an empty value is treated as "skipped"). Pure —
125/// performs no filesystem I/O, so `Path { must_exist }` is not enforced here.
126///
127/// # Errors
128///
129/// Returns the message to show under the field: out of a `Number` question's
130/// `min`/`max` range, unparseable as a number for `Number` or
131/// [`TextValidate::Number`], not matching a [`TextValidate::Regex`] pattern,
132/// or not `YYYY-MM-DD` for [`QuestionKind::Date`]. A regex that itself fails
133/// to compile is `Ok` — an author's broken pattern must not lock the user out
134/// of answering.
135pub fn validate_input(kind: &QuestionKind, value: &str) -> Result<(), String> {
136 let v = value.trim();
137 if v.is_empty() {
138 return Ok(());
139 }
140 match kind {
141 QuestionKind::Number { min, max, .. } => {
142 let n: f64 = v.parse().map_err(|_| "must be a number".to_string())?;
143 if let Some(lo) = min
144 && n < *lo
145 {
146 return Err(format!("must be >= {lo}"));
147 }
148 if let Some(hi) = max
149 && n > *hi
150 {
151 return Err(format!("must be <= {hi}"));
152 }
153 Ok(())
154 },
155 QuestionKind::Text { validate } => match validate {
156 TextValidate::Any => Ok(()),
157 TextValidate::Number => v
158 .parse::<f64>()
159 .map(|_| ())
160 .map_err(|_| "must be a number".to_string()),
161 TextValidate::Regex(pat) => match regex::Regex::new(pat) {
162 Ok(re) if re.is_match(v) => Ok(()),
163 Ok(_) => Err(format!("must match /{pat}/")),
164 Err(_) => Ok(()),
165 },
166 },
167 QuestionKind::Date => chrono::NaiveDate::parse_from_str(v, "%Y-%m-%d")
168 .map(|_| ())
169 .map_err(|_| "must be a date (YYYY-MM-DD)".to_string()),
170 _ => Ok(()),
171 }
172}
173
174/// One selectable option: a label plus an optional one-line description.
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176pub struct QuestionOption {
177 pub label: String,
178 #[serde(default)]
179 pub description: Option<String>,
180 /// Rendered with a "(Recommended)" tag. Held as a flag (rather than parsing
181 /// the label) so the render layer can style it and the answer can note it.
182 #[serde(default)]
183 pub recommended: bool,
184 /// Optional side-by-side preview: an ASCII mockup, config, code, or a
185 /// unified diff. Rendered in a right-hand pane when the option is focused.
186 #[serde(default)]
187 pub preview: Option<OptionPreview>,
188}
189
190/// A per-option preview payload (Stage 2). The model supplies the content; the
191/// tool only renders it. `diff` switches on `+`/`-` line coloring for showing
192/// the change an option would produce — the standout for a coding agent.
193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
194pub struct OptionPreview {
195 /// The preview body, rendered as monospace lines.
196 pub content: String,
197 /// Language hint (e.g. "rust", "yaml"). Reserved for future syntax
198 /// highlighting; today the body renders as plain monospace.
199 #[serde(default)]
200 pub language: Option<String>,
201 /// Render `content` as a unified diff: `+` lines green, `-` lines red,
202 /// `@@` hunk headers cyan.
203 #[serde(default)]
204 pub diff: bool,
205}
206
207/// A batch of questions awaiting the user's answers, plus live modal selection
208/// state. The reducer owns this; it mirrors `PendingApproval`.
209#[derive(Debug, Clone, PartialEq)]
210pub struct PendingQuestionSet {
211 pub turn: TurnId,
212 pub call_id: ToolCallId,
213 pub questions: Vec<Question>,
214 /// Active tab. `0..questions.len()` selects a question; a value equal to
215 /// `questions.len()` is the "Review your answers" screen.
216 pub active: usize,
217 /// Per-question live selection state, parallel to `questions`.
218 pub selections: Vec<QuestionSelection>,
219 /// Highlighted row on the review screen: 0 = Submit answers, 1 = Cancel.
220 pub review_cursor: usize,
221 /// When true, keystrokes edit the active question's note (toggled with `n`).
222 pub editing_note: bool,
223 /// When true, the user opted to remember these answers across sessions
224 /// (toggled with `r`); the tool persists answers keyed by `memory_key`.
225 pub remember: bool,
226}
227
228/// Live selection state for one question.
229#[derive(Debug, Clone, PartialEq, Default)]
230pub struct QuestionSelection {
231 /// Highlighted row for arrow-key navigation. Row layout:
232 /// `0..n` options, `n` = the "Other" free-text row, and for multi-select
233 /// `n+1` = the Submit row.
234 pub cursor: usize,
235 /// Chosen option indices. Single-select holds at most one; multi-select any.
236 pub chosen: Vec<usize>,
237 /// Free-text typed into the "Other" row — the universal escape hatch. When
238 /// non-empty it contributes to the answer alongside (multi) or instead of
239 /// (single) the chosen options.
240 pub other_text: String,
241 /// Optional free-text note attached to this question (press `n` to edit).
242 /// Rides back with the answer to capture intent the options didn't cover.
243 pub note: String,
244 /// Typed value for input kinds (Text/Number/Date/Path).
245 pub value: String,
246 /// Current option ordering for a Rank question (indices into `options`);
247 /// empty means the default `0..n` order.
248 pub order: Vec<usize>,
249 /// Rank: whether the item under the cursor is "picked up" for moving.
250 pub grabbed: bool,
251}
252
253impl PendingQuestionSet {
254 #[must_use]
255 pub fn new(turn: TurnId, call_id: ToolCallId, questions: Vec<Question>) -> Self {
256 let selections = questions
257 .iter()
258 .map(|_| QuestionSelection::default())
259 .collect();
260 Self {
261 turn,
262 call_id,
263 questions,
264 active: 0,
265 selections,
266 review_cursor: 0,
267 editing_note: false,
268 remember: false,
269 }
270 }
271
272 /// Skip the review screen only for the atomic case: a single single-select
273 /// question, where picking an option is the whole answer (Claude Code
274 /// resolves it immediately). Every other shape (multi-question, or any
275 /// multi-select) confirms via the Submit/review screen.
276 #[must_use]
277 pub fn skips_review(&self) -> bool {
278 self.questions.len() == 1 && !self.questions[0].is_multi()
279 }
280
281 /// Number of navigable rows for a Select/MultiSelect question at `idx`:
282 /// options, the Other row, and (multi-select only) the Submit row.
283 #[must_use]
284 pub fn row_count(&self, idx: usize) -> usize {
285 let q = &self.questions[idx];
286 q.options.len() + 1 + usize::from(q.is_multi())
287 }
288
289 /// Row index of the "Other" free-text row for the question at `idx`.
290 #[must_use]
291 pub fn other_row(&self, idx: usize) -> usize {
292 self.questions[idx].options.len()
293 }
294
295 /// Row index of the Submit row for a multi-select question, if any.
296 #[must_use]
297 pub fn submit_row(&self, idx: usize) -> Option<usize> {
298 let q = &self.questions[idx];
299 q.is_multi().then_some(q.options.len() + 1)
300 }
301
302 /// Build the final answers from current selections. Each answer carries the
303 /// selected option labels plus any typed "Other" text; a question left
304 /// untouched yields an empty `selected` (surfaced to the model as "(no
305 /// selection)").
306 #[must_use]
307 pub fn build_answers(&self) -> Vec<QuestionAnswer> {
308 self.questions
309 .iter()
310 .zip(&self.selections)
311 .map(|(q, sel)| {
312 let selected = if q.is_input() {
313 let v = sel.value.trim();
314 if v.is_empty() {
315 Vec::new()
316 } else {
317 vec![v.to_string()]
318 }
319 } else if q.is_rank() {
320 rank_order(q, sel)
321 .iter()
322 .filter_map(|&i| q.options.get(i).map(|o| o.label.clone()))
323 .collect()
324 } else {
325 let mut s: Vec<String> = sel
326 .chosen
327 .iter()
328 .filter_map(|&i| q.options.get(i).map(|o| o.label.clone()))
329 .collect();
330 let other = sel.other_text.trim();
331 if !other.is_empty() {
332 s.push(other.to_string());
333 }
334 s
335 };
336 let note = sel.note.trim();
337 QuestionAnswer {
338 header: q.header.clone(),
339 question: q.question.clone(),
340 selected,
341 note: (!note.is_empty()).then(|| note.to_string()),
342 }
343 })
344 .collect()
345 }
346}
347
348/// The current ranked ordering for a Rank question: the selection's `order`,
349/// or the default `0..n` when it hasn't been reordered yet.
350#[must_use]
351pub fn rank_order(q: &Question, sel: &QuestionSelection) -> Vec<usize> {
352 if sel.order.is_empty() {
353 (0..q.options.len()).collect()
354 } else {
355 sel.order.clone()
356 }
357}
358
359/// How a question set resolved. Delivered via `Cmd::ResolveQuestion` and the
360/// `QuestionBroker` to the parked tool task.
361#[derive(Debug, Clone, PartialEq)]
362pub enum QuestionResolution {
363 /// The user submitted answers (some questions may be unanswered).
364 Answered {
365 answers: Vec<QuestionAnswer>,
366 /// The user asked to remember these answers across sessions (`r`).
367 remember: bool,
368 },
369 /// The user dismissed the prompt (Esc / Cancel) or the turn was cancelled.
370 Dismissed,
371 /// The user chose "Chat about this" — bounce the set back to the model to
372 /// reformulate, rather than answering.
373 Reformulate,
374}
375
376/// One question's resolved answer, keyed by its header + text so the model can
377/// unambiguously match answers to questions when several are batched.
378#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
379pub struct QuestionAnswer {
380 pub header: String,
381 pub question: String,
382 /// Selected option labels; includes the typed "Other" text when used.
383 /// Empty means the user skipped this question.
384 pub selected: Vec<String>,
385 /// Optional free-text note the user attached to this question.
386 #[serde(default)]
387 pub note: Option<String>,
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 fn num(min: f64, max: f64) -> QuestionKind {
395 QuestionKind::Number {
396 min: Some(min),
397 max: Some(max),
398 step: None,
399 slider: false,
400 }
401 }
402
403 #[test]
404 fn validate_number_bounds() {
405 assert!(validate_input(&num(0.0, 5.0), "3").is_ok());
406 assert!(validate_input(&num(0.0, 5.0), "9").is_err());
407 assert!(validate_input(&num(0.0, 5.0), "x").is_err());
408 // Empty is treated as "skipped", always valid.
409 assert!(validate_input(&num(0.0, 5.0), "").is_ok());
410 }
411
412 #[test]
413 fn validate_date_and_regex() {
414 assert!(validate_input(&QuestionKind::Date, "2026-07-07").is_ok());
415 assert!(validate_input(&QuestionKind::Date, "nope").is_err());
416 let re = QuestionKind::Text {
417 validate: TextValidate::Regex("^a+$".to_string()),
418 };
419 assert!(validate_input(&re, "aaa").is_ok());
420 assert!(validate_input(&re, "abc").is_err());
421 }
422}