Skip to main content

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}