Skip to main content

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}