Skip to main content

mermaid_cli/render/widgets/
question.rs

1//! Bottom-zone modal for the `ask_user_question` tool.
2//!
3//! Renders a batch of questions Claude-Code-style: a header chip, the question
4//! text, numbered options with a `>` cursor (and `[x]`/`[ ]` for multi-select),
5//! an "Other" free-text row, and muted footer hints. Batched questions get a
6//! top tab strip and a final "Review your answers" screen. Presentational only
7//! — all selection state lives on `PendingQuestionSet` and is driven by the
8//! reducer's `handle_question_key`.
9
10use ratatui::buffer::Buffer;
11use ratatui::layout::{Constraint, Direction, Layout, Rect};
12use ratatui::style::{Color, Modifier, Style};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::{Block, Borders, Paragraph, Widget};
15
16use super::truncate_to_cells;
17use crate::domain::{OptionPreview, PendingQuestionSet, Question, QuestionSelection};
18use crate::render::theme::Theme;
19
20pub struct QuestionModalWidget<'a> {
21    pub theme: &'a Theme,
22    pub set: &'a PendingQuestionSet,
23}
24
25/// A header "chip": label on the brand accent, like Claude Code's blue tag.
26/// `fg` is the theme background so the label stays readable on the accent.
27fn chip(label: &str, fg: Color, bg: Color) -> Span<'static> {
28    Span::styled(
29        format!(" {} ", label),
30        Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD),
31    )
32}
33
34/// Scroll window for a long option list: `(start, end)` indices to render,
35/// keeping the cursor visible. Rows beyond the window get "N more" markers.
36fn option_window(cursor: usize, n: usize, max: usize) -> (usize, usize) {
37    if n <= max {
38        return (0, n);
39    }
40    let c = cursor.min(n - 1);
41    let start = c.saturating_sub(max / 2).min(n - max);
42    (start, start + max)
43}
44
45fn input_placeholder(kind: &crate::domain::QuestionKind) -> &'static str {
46    match kind {
47        crate::domain::QuestionKind::Number { .. } => "a number",
48        crate::domain::QuestionKind::Date => "YYYY-MM-DD",
49        crate::domain::QuestionKind::Path { .. } => "a path",
50        _ => "type a value",
51    }
52}
53
54/// Render Select/MultiSelect options (with a scroll window for long lists), the
55/// Other row, and the Submit row (multi-select only).
56fn push_choice_lines(
57    lines: &mut Vec<Line<'static>>,
58    q: &Question,
59    sel: &QuestionSelection,
60    theme: &Theme,
61) {
62    let brand = theme.colors.brand.to_color();
63    let dim = theme.colors.text_disabled.to_color();
64    let white = theme.colors.text_primary.to_color();
65    let n = q.options.len();
66    let multi = q.is_multi();
67    const MAX_VISIBLE: usize = 8;
68    let (start, end) = option_window(sel.cursor, n, MAX_VISIBLE);
69    if start > 0 {
70        lines.push(Line::from(Span::styled(
71            format!("  ... {start} more above"),
72            Style::default().fg(dim),
73        )));
74    }
75    for i in start..end {
76        let opt = &q.options[i];
77        let focused = sel.cursor == i;
78        let checked = sel.chosen.contains(&i);
79        let mut spans: Vec<Span<'static>> = vec![
80            Span::styled(
81                if focused { "> " } else { "  " },
82                Style::default().fg(brand),
83            ),
84            Span::styled(format!("{}. ", i + 1), Style::default().fg(dim)),
85        ];
86        if multi {
87            spans.push(Span::styled(
88                if checked { "[x] " } else { "[ ] " },
89                Style::default().fg(if checked { brand } else { dim }),
90            ));
91        }
92        let label_style = if focused {
93            Style::default().fg(brand).add_modifier(Modifier::BOLD)
94        } else if !multi && checked {
95            Style::default().fg(brand)
96        } else {
97            Style::default().fg(white).add_modifier(Modifier::BOLD)
98        };
99        spans.push(Span::styled(opt.label.clone(), label_style));
100        lines.push(Line::from(spans));
101        if let Some(desc) = &opt.description {
102            lines.push(Line::from(Span::styled(
103                format!("     {}", desc),
104                Style::default().fg(dim),
105            )));
106        }
107    }
108    if end < n {
109        lines.push(Line::from(Span::styled(
110            format!("  ... {} more below", n - end),
111            Style::default().fg(dim),
112        )));
113    }
114
115    // "Other" free-text row.
116    let other_focused = sel.cursor == n;
117    let mut spans: Vec<Span<'static>> = vec![
118        Span::styled(
119            if other_focused { "> " } else { "  " },
120            Style::default().fg(brand),
121        ),
122        Span::styled(format!("{}. ", n + 1), Style::default().fg(dim)),
123    ];
124    if multi {
125        let checked = !sel.other_text.trim().is_empty();
126        spans.push(Span::styled(
127            if checked { "[x] " } else { "[ ] " },
128            Style::default().fg(if checked { brand } else { dim }),
129        ));
130    }
131    if sel.other_text.is_empty() {
132        spans.push(Span::styled("Type something", Style::default().fg(dim)));
133    } else {
134        spans.push(Span::styled(
135            sel.other_text.clone(),
136            Style::default().fg(white),
137        ));
138    }
139    lines.push(Line::from(spans));
140
141    // Submit row (multi-select only).
142    if multi {
143        let focused = sel.cursor == n + 1;
144        let style = if focused {
145            Style::default().fg(brand).add_modifier(Modifier::BOLD)
146        } else {
147            Style::default().fg(white).add_modifier(Modifier::BOLD)
148        };
149        lines.push(Line::from(vec![
150            Span::styled(
151                if focused { "> " } else { "  " },
152                Style::default().fg(brand),
153            ),
154            Span::styled("Submit", style),
155        ]));
156    }
157}
158
159/// Render an input-kind value field, an optional Number slider bar, and
160/// live validation feedback.
161fn push_input_lines(
162    lines: &mut Vec<Line<'static>>,
163    q: &Question,
164    sel: &QuestionSelection,
165    theme: &Theme,
166) {
167    let brand = theme.colors.brand.to_color();
168    let dim = theme.colors.text_disabled.to_color();
169    let white = theme.colors.text_primary.to_color();
170    let value = &sel.value;
171    let mut field: Vec<Span<'static>> = vec![Span::styled("> ", Style::default().fg(brand))];
172    if value.is_empty() {
173        field.push(Span::styled(
174            input_placeholder(&q.kind),
175            Style::default().fg(dim),
176        ));
177    } else {
178        field.push(Span::styled(value.clone(), Style::default().fg(white)));
179    }
180    field.push(Span::styled("_", Style::default().fg(brand)));
181    lines.push(Line::from(field));
182
183    if let crate::domain::QuestionKind::Number {
184        min: Some(lo),
185        max: Some(hi),
186        slider: true,
187        ..
188    } = &q.kind
189        && hi > lo
190    {
191        let cur: f64 = value.trim().parse().unwrap_or(*lo);
192        let frac = ((cur - lo) / (hi - lo)).clamp(0.0, 1.0);
193        let width = 20usize;
194        let filled = (frac * width as f64).round() as usize;
195        let bar = format!("[{}{}]", "#".repeat(filled), "-".repeat(width - filled));
196        lines.push(Line::from(Span::styled(
197            format!("  {bar}"),
198            Style::default().fg(brand),
199        )));
200    }
201
202    match crate::domain::validate_input(&q.kind, value) {
203        Err(e) => lines.push(Line::from(Span::styled(
204            format!("  {e}"),
205            Style::default().fg(theme.colors.error.to_color()),
206        ))),
207        Ok(()) => lines.push(Line::from(Span::styled(
208            "  Enter to submit",
209            Style::default().fg(dim),
210        ))),
211    }
212}
213
214/// Render a Rank question's options in their current order, with a grab marker.
215fn push_rank_lines(
216    lines: &mut Vec<Line<'static>>,
217    q: &Question,
218    sel: &QuestionSelection,
219    theme: &Theme,
220) {
221    let brand = theme.colors.brand.to_color();
222    let dim = theme.colors.text_disabled.to_color();
223    let white = theme.colors.text_primary.to_color();
224    for (pos, &opt_idx) in crate::domain::rank_order(q, sel).iter().enumerate() {
225        let focused = sel.cursor == pos;
226        let grabbed = focused && sel.grabbed;
227        let prefix = if grabbed {
228            ">>"
229        } else if focused {
230            "> "
231        } else {
232            "  "
233        };
234        let label_style = if focused {
235            Style::default().fg(brand).add_modifier(Modifier::BOLD)
236        } else {
237            Style::default().fg(white).add_modifier(Modifier::BOLD)
238        };
239        lines.push(Line::from(vec![
240            Span::styled(prefix, Style::default().fg(brand)),
241            Span::styled(format!("{}. ", pos + 1), Style::default().fg(dim)),
242            Span::styled(
243                q.options
244                    .get(opt_idx)
245                    .map(|o| o.label.clone())
246                    .unwrap_or_default(),
247                label_style,
248            ),
249        ]));
250    }
251}
252
253/// Build the modal's content lines. Shared by the widget and the height
254/// estimator so the reserved bottom-zone height always matches what's drawn.
255pub fn build_question_lines(set: &PendingQuestionSet, theme: &Theme) -> Vec<Line<'static>> {
256    let brand = theme.colors.brand.to_color();
257    let dim = theme.colors.text_disabled.to_color();
258    let white = theme.colors.text_primary.to_color();
259    let nq = set.questions.len();
260    let has_memory = set.questions.iter().any(|q| q.memory_key.is_some());
261    let mut lines: Vec<Line<'static>> = Vec::new();
262
263    // Tab strip for batched questions: chips for each question plus a trailing
264    // Submit; the active one is highlighted.
265    if nq > 1 {
266        let mut spans: Vec<Span<'static>> = vec![Span::styled("< ", Style::default().fg(dim))];
267        for (qi, q) in set.questions.iter().enumerate() {
268            let label = truncate_to_cells(&q.header, 12);
269            if qi == set.active {
270                spans.push(chip(&label, theme.colors.background.to_color(), brand));
271            } else {
272                spans.push(Span::styled(
273                    format!(" {} ", label),
274                    Style::default().fg(dim),
275                ));
276            }
277            spans.push(Span::raw(" "));
278        }
279        if set.active >= nq {
280            spans.push(chip("Submit", theme.colors.background.to_color(), brand));
281        } else {
282            spans.push(Span::styled(" Submit ", Style::default().fg(dim)));
283        }
284        spans.push(Span::styled(" >", Style::default().fg(dim)));
285        lines.push(Line::from(spans));
286        lines.push(Line::from(""));
287    }
288
289    // Review screen.
290    if set.active >= nq {
291        lines.push(Line::from(Span::styled(
292            "Review your answers",
293            Style::default().fg(white).add_modifier(Modifier::BOLD),
294        )));
295        lines.push(Line::from(""));
296        for (q, ans) in set.questions.iter().zip(set.build_answers()) {
297            lines.push(Line::from(Span::styled(
298                format!("- {}", q.question),
299                Style::default().fg(white),
300            )));
301            let value = if ans.selected.is_empty() {
302                "(no selection)".to_string()
303            } else {
304                ans.selected.join(", ")
305            };
306            lines.push(Line::from(Span::styled(
307                format!("   -> {}", value),
308                Style::default().fg(brand),
309            )));
310        }
311        if has_memory {
312            let mark = if set.remember { "[x]" } else { "[ ]" };
313            lines.push(Line::from(Span::styled(
314                format!("{mark} Remember my answers across sessions (r)"),
315                Style::default().fg(if set.remember { brand } else { dim }),
316            )));
317        }
318        lines.push(Line::from(""));
319        lines.push(Line::from(Span::styled(
320            "Ready to submit your answers?",
321            Style::default().fg(dim),
322        )));
323        for (i, opt) in ["1. Submit answers", "2. Cancel"].iter().enumerate() {
324            let focused = set.review_cursor == i;
325            let style = if focused {
326                Style::default().fg(brand).add_modifier(Modifier::BOLD)
327            } else {
328                Style::default().fg(white)
329            };
330            lines.push(Line::from(vec![
331                Span::styled(
332                    if focused { "> " } else { "  " },
333                    Style::default().fg(brand),
334                ),
335                Span::styled((*opt).to_string(), style),
336            ]));
337        }
338        lines.push(Line::from(""));
339        let mut foot = String::from("Enter to select | Up/Down to navigate | c: chat");
340        if has_memory {
341            foot.push_str(" | r: remember");
342        }
343        foot.push_str(" | Esc to cancel");
344        lines.push(Line::from(Span::styled(foot, Style::default().fg(dim))));
345        return lines;
346    }
347
348    // A single question tab.
349    let q = &set.questions[set.active];
350    let sel = &set.selections[set.active];
351
352    // The batched tab strip already shows the header chip for the active
353    // question, so only render it here (above the title) when there's no tab
354    // strip — i.e. a single question. Otherwise it's a duplicate.
355    if nq == 1 {
356        lines.push(Line::from(chip(
357            &truncate_to_cells(&q.header, 12),
358            theme.colors.background.to_color(),
359            brand,
360        )));
361    }
362    lines.push(Line::from(Span::styled(
363        q.question.clone(),
364        Style::default().fg(white).add_modifier(Modifier::BOLD),
365    )));
366    lines.push(Line::from(""));
367
368    if q.is_input() {
369        push_input_lines(&mut lines, q, sel, theme);
370    } else if q.is_rank() {
371        push_rank_lines(&mut lines, q, sel, theme);
372    } else {
373        push_choice_lines(&mut lines, q, sel, theme);
374    }
375
376    // Notes line (choice kinds only — input kinds capture every keystroke).
377    if q.is_choice() {
378        lines.push(Line::from(""));
379        if set.editing_note {
380            lines.push(Line::from(vec![
381                Span::styled("Notes: ", Style::default().fg(brand)),
382                Span::styled(sel.note.clone(), Style::default().fg(white)),
383                Span::styled("_", Style::default().fg(brand)),
384            ]));
385        } else if !sel.note.trim().is_empty() {
386            lines.push(Line::from(vec![
387                Span::styled("Notes: ", Style::default().fg(dim)),
388                Span::styled(sel.note.clone(), Style::default().fg(white)),
389            ]));
390        } else {
391            lines.push(Line::from(Span::styled(
392                "Notes: press n to add notes",
393                Style::default().fg(dim),
394            )));
395        }
396    }
397
398    // Footer hints, tailored to the kind.
399    lines.push(Line::from(""));
400    let mut hint = if q.is_input() {
401        let mut h = String::from("Type to edit");
402        if matches!(q.kind, crate::domain::QuestionKind::Number { .. }) {
403            h.push_str(" | Up/Down to step");
404        }
405        h.push_str(" | Enter to submit");
406        h
407    } else if q.is_rank() {
408        String::from("Up/Down to move | Space to grab | Enter to submit")
409    } else {
410        String::from("Enter to select | Up/Down to navigate")
411    };
412    if nq > 1 {
413        hint.push_str(" | Tab to switch");
414    }
415    if q.is_choice() {
416        hint.push_str(" | n: notes | c: chat");
417        if has_memory {
418            hint.push_str(" | r: remember");
419        }
420    }
421    hint.push_str(" | Esc to cancel");
422    lines.push(Line::from(Span::styled(hint, Style::default().fg(dim))));
423
424    lines
425}
426
427/// The preview to show in the right pane: the focused option's preview on the
428/// active question tab (none on the review screen or on the Other/Submit rows).
429fn focused_preview(set: &PendingQuestionSet) -> Option<&OptionPreview> {
430    if set.active >= set.questions.len() {
431        return None;
432    }
433    let q = &set.questions[set.active];
434    let cursor = set.selections[set.active].cursor;
435    q.options.get(cursor).and_then(|o| o.preview.as_ref())
436}
437
438/// Whether the active question has any option with a preview — drives the
439/// side-by-side layout (kept stable as the cursor moves between options).
440fn active_question_has_preview(set: &PendingQuestionSet) -> bool {
441    set.active < set.questions.len()
442        && set.questions[set.active]
443            .options
444            .iter()
445            .any(|o| o.preview.is_some())
446}
447
448/// Tallest preview among the active question's options, so the modal height is
449/// stable while arrowing through options.
450fn max_preview_lines(set: &PendingQuestionSet) -> usize {
451    if set.active >= set.questions.len() {
452        return 0;
453    }
454    set.questions[set.active]
455        .options
456        .iter()
457        .filter_map(|o| o.preview.as_ref())
458        .map(|p| p.content.lines().count())
459        .max()
460        .unwrap_or(0)
461}
462
463/// Render a preview body: plain monospace, or a unified diff with `+` green,
464/// `-` red, and `@@` hunk headers cyan.
465fn build_preview_lines(preview: &OptionPreview, theme: &Theme) -> Vec<Line<'static>> {
466    let add = theme.colors.success.to_color();
467    let rem = theme.colors.error.to_color();
468    let hunk = theme.colors.info.to_color();
469    let base = theme.colors.code_foreground.to_color();
470    preview
471        .content
472        .lines()
473        .map(|raw| {
474            let style = if preview.diff {
475                match raw.chars().next() {
476                    Some('+') => Style::default().fg(add),
477                    Some('-') => Style::default().fg(rem),
478                    Some('@') => Style::default().fg(hunk),
479                    _ => Style::default().fg(base),
480                }
481            } else {
482                Style::default().fg(base)
483            };
484            Line::from(Span::styled(raw.to_string(), style))
485        })
486        .collect()
487}
488
489/// Total rendered height including the border, so `render::mod` can size the
490/// bottom zone to fit the modal — the taller of the option list and its preview.
491pub fn question_modal_height(set: &PendingQuestionSet, theme: &Theme) -> u16 {
492    let left = build_question_lines(set, theme).len();
493    let content = if active_question_has_preview(set) {
494        left.max(max_preview_lines(set))
495    } else {
496        left
497    };
498    (content as u16).saturating_add(2)
499}
500
501impl<'a> Widget for QuestionModalWidget<'a> {
502    fn render(self, area: Rect, buf: &mut Buffer) {
503        let brand = self.theme.colors.brand.to_color();
504        let dim = self.theme.colors.text_disabled.to_color();
505        let block = Block::default()
506            .borders(Borders::ALL)
507            .border_style(Style::default().fg(brand));
508        let inner = block.inner(area);
509        block.render(area, buf);
510
511        if active_question_has_preview(self.set) {
512            // Side-by-side: options on the left, focused option's preview right.
513            let cols = Layout::default()
514                .direction(Direction::Horizontal)
515                .constraints([Constraint::Percentage(48), Constraint::Percentage(52)])
516                .split(inner);
517            Paragraph::new(build_question_lines(self.set, self.theme)).render(cols[0], buf);
518            let preview_lines = focused_preview(self.set)
519                .map(|p| build_preview_lines(p, self.theme))
520                .unwrap_or_default();
521            Paragraph::new(preview_lines)
522                .block(
523                    Block::default()
524                        .borders(Borders::LEFT)
525                        .border_style(Style::default().fg(dim)),
526                )
527                .render(cols[1], buf);
528        } else {
529            Paragraph::new(build_question_lines(self.set, self.theme)).render(inner, buf);
530        }
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use crate::domain::{Question, QuestionOption, ToolCallId, TurnId};
538
539    fn opt_with_preview(label: &str, preview: Option<OptionPreview>) -> QuestionOption {
540        QuestionOption {
541            label: label.to_string(),
542            description: None,
543            recommended: false,
544            preview,
545        }
546    }
547
548    #[test]
549    fn diff_preview_colors_hunk_remove_and_add_lines() {
550        let theme = Theme::dark();
551        let preview = OptionPreview {
552            content: "@@ -1 +1 @@\n-old\n+new\n context".to_string(),
553            language: None,
554            diff: true,
555        };
556        let lines = build_preview_lines(&preview, &theme);
557        assert_eq!(lines.len(), 4);
558        assert_eq!(
559            lines[0].spans[0].style.fg,
560            Some(theme.colors.info.to_color())
561        );
562        assert_eq!(
563            lines[1].spans[0].style.fg,
564            Some(theme.colors.error.to_color())
565        );
566        assert_eq!(
567            lines[2].spans[0].style.fg,
568            Some(theme.colors.success.to_color())
569        );
570    }
571
572    #[test]
573    fn tall_preview_drives_modal_height() {
574        let body = std::iter::repeat_n("line", 20)
575            .collect::<Vec<_>>()
576            .join("\n");
577        let q = Question {
578            header: "H".to_string(),
579            question: "Q?".to_string(),
580            kind: crate::domain::QuestionKind::Select,
581            options: vec![
582                opt_with_preview(
583                    "A",
584                    Some(OptionPreview {
585                        content: body,
586                        language: None,
587                        diff: false,
588                    }),
589                ),
590                opt_with_preview("B", None),
591            ],
592            memory_key: None,
593        };
594        let set = PendingQuestionSet::new(TurnId(1), ToolCallId(1), vec![q]);
595        let theme = Theme::dark();
596        // Modal grows to fit the 20-line preview (plus the 2-line border).
597        assert!(
598            question_modal_height(&set, &theme) >= 22,
599            "expected height >= 22 to fit the preview"
600        );
601    }
602
603    fn q_with_header(header: &str) -> Question {
604        Question {
605            header: header.to_string(),
606            question: format!("Which {}?", header),
607            kind: crate::domain::QuestionKind::Select,
608            options: vec![opt_with_preview("A", None), opt_with_preview("B", None)],
609            memory_key: None,
610        }
611    }
612
613    /// Count lines that are a lone header chip (a single span whose text is
614    /// exactly `label`). The tab strip renders the header among several spans, so
615    /// it never matches; only the standalone chip above the title does.
616    fn lone_chip_lines(lines: &[Line<'static>], label: &str) -> usize {
617        // `chip()` pads the label to " label ", so trim before comparing.
618        lines
619            .iter()
620            .filter(|l| l.spans.len() == 1 && l.spans[0].content.as_ref().trim() == label)
621            .count()
622    }
623
624    #[test]
625    fn header_chip_shown_once_for_single_question() {
626        // No tab strip for a single question, so the header chip appears exactly
627        // once (above the title) — otherwise the header would vanish entirely.
628        let set = PendingQuestionSet::new(TurnId(1), ToolCallId(1), vec![q_with_header("HDR")]);
629        let lines = build_question_lines(&set, &Theme::dark());
630        assert_eq!(lone_chip_lines(&lines, "HDR"), 1);
631    }
632
633    #[test]
634    fn header_chip_not_duplicated_above_title_when_batched() {
635        // The batched tab strip already shows the active header, so there must be
636        // NO standalone header chip above the title (it used to be duplicated).
637        let set = PendingQuestionSet::new(
638            TurnId(1),
639            ToolCallId(1),
640            vec![q_with_header("HDR"), q_with_header("Other")],
641        );
642        let lines = build_question_lines(&set, &Theme::dark());
643        assert_eq!(lone_chip_lines(&lines, "HDR"), 0);
644    }
645}