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