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};
15use unicode_width::UnicodeWidthStr;
16
17use super::truncate_to_cells;
18use crate::render::theme::Theme;
19use crate::render::wrap::wrap_styled_line;
20use mermaid_model::question::{OptionPreview, PendingQuestionSet, Question, QuestionSelection};
21
22pub struct QuestionModalWidget<'a> {
23    pub theme: &'a Theme,
24    pub set: &'a PendingQuestionSet,
25    /// Total width of the modal INCLUDING its border, so the content wraps to
26    /// the same width the height estimator assumed.
27    pub width: u16,
28}
29
30/// Content width available to the question column, derived from the modal's
31/// total width. The estimator and the renderer must agree or the reserved
32/// bottom zone won't match what's drawn, so both go through here.
33///
34/// With a preview open the questions get the left 48% split; the estimator's
35/// integer share is never wider than the layout solver's column, so any drift
36/// makes the estimate wrap MORE and the modal a line too tall rather than a
37/// line too short (which would clip).
38fn question_column_width(set: &PendingQuestionSet, total_width: u16) -> usize {
39    let inner = total_width.saturating_sub(2) as usize;
40    if active_question_has_preview(set) {
41        inner * 48 / 100
42    } else {
43        inner
44    }
45    .max(8)
46}
47
48/// Push a line, wrapping it to `width` with `hang` cells of hanging indent so
49/// a long option description continues under its own text instead of running
50/// off the border. Everything the modal draws goes through this — a modal is
51/// a fixed box, so nothing may rely on the terminal to clip it.
52fn push_wrapped(lines: &mut Vec<Line<'static>>, line: Line<'static>, width: usize, hang: usize) {
53    lines.extend(wrap_styled_line(
54        line,
55        width,
56        hang.min(width.saturating_sub(1)),
57    ));
58}
59
60/// A header "chip": label on the brand accent, like Claude Code's blue tag.
61/// `fg` is the theme background so the label stays readable on the accent.
62fn chip(label: &str, fg: Color, bg: Color) -> Span<'static> {
63    Span::styled(
64        format!(" {label} "),
65        Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD),
66    )
67}
68
69/// Scroll window for a long option list: `(start, end)` indices to render,
70/// keeping the cursor visible. Rows beyond the window get "N more" markers.
71fn option_window(cursor: usize, n: usize, max: usize) -> (usize, usize) {
72    if n <= max {
73        return (0, n);
74    }
75    let c = cursor.min(n - 1);
76    let start = c.saturating_sub(max / 2).min(n - max);
77    (start, start + max)
78}
79
80fn input_placeholder(kind: &mermaid_domain::QuestionKind) -> &'static str {
81    match kind {
82        mermaid_domain::QuestionKind::Number { .. } => "a number",
83        mermaid_domain::QuestionKind::Date => "YYYY-MM-DD",
84        mermaid_domain::QuestionKind::Path { .. } => "a path",
85        _ => "type a value",
86    }
87}
88
89/// Render Select/MultiSelect options (with a scroll window for long lists), the
90/// Other row, and the Submit row (multi-select only).
91fn push_choice_lines(
92    lines: &mut Vec<Line<'static>>,
93    q: &Question,
94    sel: &QuestionSelection,
95    theme: &Theme,
96    width: usize,
97) {
98    let brand = theme.colors.brand.to_color();
99    let dim = theme.colors.text_disabled.to_color();
100    let white = theme.colors.text_primary.to_color();
101    let n = q.options.len();
102    let multi = q.is_multi();
103    const MAX_VISIBLE: usize = 8;
104    let (start, end) = option_window(sel.cursor, n, MAX_VISIBLE);
105    if start > 0 {
106        lines.push(Line::from(Span::styled(
107            format!("  ... {start} more above"),
108            Style::default().fg(dim),
109        )));
110    }
111    for i in start..end {
112        let opt = &q.options[i];
113        let focused = sel.cursor == i;
114        let checked = sel.chosen.contains(&i);
115        let mut spans: Vec<Span<'static>> = vec![
116            Span::styled(
117                if focused { "> " } else { "  " },
118                Style::default().fg(brand),
119            ),
120            Span::styled(format!("{}. ", i + 1), Style::default().fg(dim)),
121        ];
122        if multi {
123            spans.push(Span::styled(
124                if checked { "[x] " } else { "[ ] " },
125                Style::default().fg(if checked { brand } else { dim }),
126            ));
127        }
128        let label_style = if focused {
129            Style::default().fg(brand).add_modifier(Modifier::BOLD)
130        } else if !multi && checked {
131            Style::default().fg(brand)
132        } else {
133            Style::default().fg(white).add_modifier(Modifier::BOLD)
134        };
135        // Everything before the label is gutter: "> " + "N. " (+ "[x] ").
136        let hang: usize = spans.iter().map(|s| s.content.width()).sum();
137        spans.push(Span::styled(opt.label.clone(), label_style));
138        push_wrapped(lines, Line::from(spans), width, hang);
139        if let Some(desc) = &opt.description {
140            push_wrapped(
141                lines,
142                Line::from(Span::styled(
143                    format!("     {desc}"),
144                    Style::default().fg(dim),
145                )),
146                width,
147                5,
148            );
149        }
150    }
151    if end < n {
152        lines.push(Line::from(Span::styled(
153            format!("  ... {} more below", n - end),
154            Style::default().fg(dim),
155        )));
156    }
157
158    // "Other" free-text row.
159    let other_focused = sel.cursor == n;
160    let mut spans: Vec<Span<'static>> = vec![
161        Span::styled(
162            if other_focused { "> " } else { "  " },
163            Style::default().fg(brand),
164        ),
165        Span::styled(format!("{}. ", n + 1), Style::default().fg(dim)),
166    ];
167    if multi {
168        let checked = !sel.other_text.trim().is_empty();
169        spans.push(Span::styled(
170            if checked { "[x] " } else { "[ ] " },
171            Style::default().fg(if checked { brand } else { dim }),
172        ));
173    }
174    let hang: usize = spans.iter().map(|s| s.content.width()).sum();
175    if sel.other_text.is_empty() {
176        spans.push(Span::styled("Type something", Style::default().fg(dim)));
177    } else {
178        spans.push(Span::styled(
179            sel.other_text.clone(),
180            Style::default().fg(white),
181        ));
182    }
183    push_wrapped(lines, Line::from(spans), width, hang);
184
185    // Submit row (multi-select only).
186    if multi {
187        let focused = sel.cursor == n + 1;
188        let style = if focused {
189            Style::default().fg(brand).add_modifier(Modifier::BOLD)
190        } else {
191            Style::default().fg(white).add_modifier(Modifier::BOLD)
192        };
193        lines.push(Line::from(vec![
194            Span::styled(
195                if focused { "> " } else { "  " },
196                Style::default().fg(brand),
197            ),
198            Span::styled("Submit", style),
199        ]));
200    }
201}
202
203/// Render an input-kind value field, an optional Number slider bar, and
204/// live validation feedback.
205fn push_input_lines(
206    lines: &mut Vec<Line<'static>>,
207    q: &Question,
208    sel: &QuestionSelection,
209    theme: &Theme,
210    max_width: usize,
211) {
212    let brand = theme.colors.brand.to_color();
213    let dim = theme.colors.text_disabled.to_color();
214    let white = theme.colors.text_primary.to_color();
215    let value = &sel.value;
216    let mut field: Vec<Span<'static>> = vec![Span::styled("> ", Style::default().fg(brand))];
217    if value.is_empty() {
218        field.push(Span::styled(
219            input_placeholder(&q.kind),
220            Style::default().fg(dim),
221        ));
222    } else {
223        field.push(Span::styled(value.clone(), Style::default().fg(white)));
224    }
225    field.push(Span::styled("_", Style::default().fg(brand)));
226    push_wrapped(lines, Line::from(field), max_width, 2);
227
228    if let mermaid_domain::QuestionKind::Number {
229        min: Some(lo),
230        max: Some(hi),
231        slider: true,
232        ..
233    } = &q.kind
234        && hi > lo
235    {
236        let cur: f64 = value.trim().parse().unwrap_or(*lo);
237        let frac = ((cur - lo) / (hi - lo)).clamp(0.0, 1.0);
238        let width = 20usize;
239        let filled = (frac * width as f64).round() as usize;
240        let bar = format!("[{}{}]", "#".repeat(filled), "-".repeat(width - filled));
241        lines.push(Line::from(Span::styled(
242            format!("  {bar}"),
243            Style::default().fg(brand),
244        )));
245    }
246
247    match mermaid_domain::validate_input(&q.kind, value) {
248        Err(e) => push_wrapped(
249            lines,
250            Line::from(Span::styled(
251                format!("  {e}"),
252                Style::default().fg(theme.colors.error.to_color()),
253            )),
254            max_width,
255            2,
256        ),
257        Ok(()) => lines.push(Line::from(Span::styled(
258            "  Enter to submit",
259            Style::default().fg(dim),
260        ))),
261    }
262}
263
264/// Render a Rank question's options in their current order, with a grab marker.
265fn push_rank_lines(
266    lines: &mut Vec<Line<'static>>,
267    q: &Question,
268    sel: &QuestionSelection,
269    theme: &Theme,
270    width: usize,
271) {
272    let brand = theme.colors.brand.to_color();
273    let dim = theme.colors.text_disabled.to_color();
274    let white = theme.colors.text_primary.to_color();
275    for (pos, &opt_idx) in mermaid_domain::rank_order(q, sel).iter().enumerate() {
276        let focused = sel.cursor == pos;
277        let grabbed = focused && sel.grabbed;
278        let prefix = if grabbed {
279            ">>"
280        } else if focused {
281            "> "
282        } else {
283            "  "
284        };
285        let label_style = if focused {
286            Style::default().fg(brand).add_modifier(Modifier::BOLD)
287        } else {
288            Style::default().fg(white).add_modifier(Modifier::BOLD)
289        };
290        let marker = format!("{}. ", pos + 1);
291        let hang = prefix.width() + marker.width();
292        push_wrapped(
293            lines,
294            Line::from(vec![
295                Span::styled(prefix, Style::default().fg(brand)),
296                Span::styled(marker, Style::default().fg(dim)),
297                Span::styled(
298                    q.options
299                        .get(opt_idx)
300                        .map(|o| o.label.clone())
301                        .unwrap_or_default(),
302                    label_style,
303                ),
304            ]),
305            width,
306            hang,
307        );
308    }
309}
310
311/// Build the modal's content lines. Shared by the widget and the height
312/// estimator so the reserved bottom-zone height always matches what's drawn.
313pub fn build_question_lines(
314    set: &PendingQuestionSet,
315    theme: &Theme,
316    width: usize,
317) -> Vec<Line<'static>> {
318    let brand = theme.colors.brand.to_color();
319    let dim = theme.colors.text_disabled.to_color();
320    let white = theme.colors.text_primary.to_color();
321    let nq = set.questions.len();
322    let has_memory = set.questions.iter().any(|q| q.memory_key.is_some());
323    let mut lines: Vec<Line<'static>> = Vec::new();
324
325    if nq > 1 {
326        push_tab_strip(&mut lines, set, theme);
327    }
328
329    if set.active >= nq {
330        push_review_lines(&mut lines, set, theme, width);
331        return lines;
332    }
333
334    // A single question tab.
335    let q = &set.questions[set.active];
336    let sel = &set.selections[set.active];
337
338    // The batched tab strip already shows the header chip for the active
339    // question, so only render it here (above the title) when there's no tab
340    // strip — i.e. a single question. Otherwise it's a duplicate.
341    if nq == 1 {
342        lines.push(Line::from(chip(
343            &truncate_to_cells(&q.header, 12),
344            theme.colors.background.to_color(),
345            brand,
346        )));
347    }
348    push_wrapped(
349        &mut lines,
350        Line::from(Span::styled(
351            q.question.clone(),
352            Style::default().fg(white).add_modifier(Modifier::BOLD),
353        )),
354        width,
355        0,
356    );
357    lines.push(Line::from(""));
358
359    if q.is_input() {
360        push_input_lines(&mut lines, q, sel, theme, width);
361    } else if q.is_rank() {
362        push_rank_lines(&mut lines, q, sel, theme, width);
363    } else {
364        push_choice_lines(&mut lines, q, sel, theme, width);
365    }
366
367    // Notes line (choice kinds only — input kinds capture every keystroke).
368    if q.is_choice() {
369        push_notes_line(&mut lines, set, theme, width);
370    }
371
372    // Footer hints, tailored to the kind.
373    lines.push(Line::from(""));
374    push_wrapped(
375        &mut lines,
376        Line::from(Span::styled(
377            footer_hint(q, nq > 1, has_memory),
378            Style::default().fg(dim),
379        )),
380        width,
381        0,
382    );
383
384    lines
385}
386
387/// Tab strip for batched questions: chips for each question plus a trailing
388/// Submit; the active one is highlighted.
389fn push_tab_strip(lines: &mut Vec<Line<'static>>, set: &PendingQuestionSet, theme: &Theme) {
390    let brand = theme.colors.brand.to_color();
391    let dim = theme.colors.text_disabled.to_color();
392    let nq = set.questions.len();
393    let mut spans: Vec<Span<'static>> = vec![Span::styled("< ", Style::default().fg(dim))];
394    for (qi, q) in set.questions.iter().enumerate() {
395        let label = truncate_to_cells(&q.header, 12);
396        if qi == set.active {
397            spans.push(chip(&label, theme.colors.background.to_color(), brand));
398        } else {
399            spans.push(Span::styled(format!(" {label} "), Style::default().fg(dim)));
400        }
401        spans.push(Span::raw(" "));
402    }
403    if set.active >= nq {
404        spans.push(chip("Submit", theme.colors.background.to_color(), brand));
405    } else {
406        spans.push(Span::styled(" Submit ", Style::default().fg(dim)));
407    }
408    spans.push(Span::styled(" >", Style::default().fg(dim)));
409    lines.push(Line::from(spans));
410    lines.push(Line::from(""));
411}
412
413/// The review screen: every question with its answer, the remember toggle
414/// when any question persists, and the Submit/Cancel rows.
415fn push_review_lines(
416    lines: &mut Vec<Line<'static>>,
417    set: &PendingQuestionSet,
418    theme: &Theme,
419    width: usize,
420) {
421    let brand = theme.colors.brand.to_color();
422    let dim = theme.colors.text_disabled.to_color();
423    let white = theme.colors.text_primary.to_color();
424    let has_memory = set.questions.iter().any(|q| q.memory_key.is_some());
425    lines.push(Line::from(Span::styled(
426        "Review your answers",
427        Style::default().fg(white).add_modifier(Modifier::BOLD),
428    )));
429    lines.push(Line::from(""));
430    for (q, ans) in set.questions.iter().zip(set.build_answers()) {
431        push_wrapped(
432            lines,
433            Line::from(Span::styled(
434                format!("- {}", q.question),
435                Style::default().fg(white),
436            )),
437            width,
438            2,
439        );
440        let value = if ans.selected.is_empty() {
441            "(no selection)".to_string()
442        } else {
443            ans.selected.join(", ")
444        };
445        push_wrapped(
446            lines,
447            Line::from(Span::styled(
448                format!("   -> {value}"),
449                Style::default().fg(brand),
450            )),
451            width,
452            6,
453        );
454    }
455    if has_memory {
456        let mark = if set.remember { "[x]" } else { "[ ]" };
457        lines.push(Line::from(Span::styled(
458            format!("{mark} Remember my answers across sessions (r)"),
459            Style::default().fg(if set.remember { brand } else { dim }),
460        )));
461    }
462    lines.push(Line::from(""));
463    lines.push(Line::from(Span::styled(
464        "Ready to submit your answers?",
465        Style::default().fg(dim),
466    )));
467    for (i, opt) in ["1. Submit answers", "2. Cancel"].iter().enumerate() {
468        let focused = set.review_cursor == i;
469        let style = if focused {
470            Style::default().fg(brand).add_modifier(Modifier::BOLD)
471        } else {
472            Style::default().fg(white)
473        };
474        lines.push(Line::from(vec![
475            Span::styled(
476                if focused { "> " } else { "  " },
477                Style::default().fg(brand),
478            ),
479            Span::styled((*opt).to_string(), style),
480        ]));
481    }
482    lines.push(Line::from(""));
483    let mut foot = String::from("Enter to select | Up/Down to navigate | c: chat");
484    if has_memory {
485        foot.push_str(" | r: remember");
486    }
487    foot.push_str(" | Esc to cancel");
488    push_wrapped(
489        lines,
490        Line::from(Span::styled(foot, Style::default().fg(dim))),
491        width,
492        0,
493    );
494}
495
496/// The Notes row under a choice question: the note being edited (with a
497/// caret), a saved note, or the hint to add one.
498fn push_notes_line(
499    lines: &mut Vec<Line<'static>>,
500    set: &PendingQuestionSet,
501    theme: &Theme,
502    width: usize,
503) {
504    let brand = theme.colors.brand.to_color();
505    let dim = theme.colors.text_disabled.to_color();
506    let white = theme.colors.text_primary.to_color();
507    let sel = &set.selections[set.active];
508    lines.push(Line::from(""));
509    if set.editing_note {
510        push_wrapped(
511            lines,
512            Line::from(vec![
513                Span::styled("Notes: ", Style::default().fg(brand)),
514                Span::styled(sel.note.clone(), Style::default().fg(white)),
515                Span::styled("_", Style::default().fg(brand)),
516            ]),
517            width,
518            7,
519        );
520    } else if !sel.note.trim().is_empty() {
521        push_wrapped(
522            lines,
523            Line::from(vec![
524                Span::styled("Notes: ", Style::default().fg(dim)),
525                Span::styled(sel.note.clone(), Style::default().fg(white)),
526            ]),
527            width,
528            7,
529        );
530    } else {
531        lines.push(Line::from(Span::styled(
532            "Notes: press n to add notes",
533            Style::default().fg(dim),
534        )));
535    }
536}
537
538/// The key-hint footer for one question tab, tailored to its kind.
539fn footer_hint(q: &mermaid_domain::Question, batched: bool, has_memory: bool) -> String {
540    let mut hint = if q.is_input() {
541        let mut h = String::from("Type to edit");
542        if matches!(q.kind, mermaid_domain::QuestionKind::Number { .. }) {
543            h.push_str(" | Up/Down to step");
544        }
545        h.push_str(" | Enter to submit");
546        h
547    } else if q.is_rank() {
548        String::from("Up/Down to move | Space to grab | Enter to submit")
549    } else {
550        String::from("Enter to select | Up/Down to navigate")
551    };
552    if batched {
553        hint.push_str(" | Tab to switch");
554    }
555    if q.is_choice() {
556        hint.push_str(" | n: notes | c: chat");
557        if has_memory {
558            hint.push_str(" | r: remember");
559        }
560    }
561    hint.push_str(" | Esc to cancel");
562    hint
563}
564
565/// The preview to show in the right pane: the focused option's preview on the
566/// active question tab (none on the review screen or on the Other/Submit rows).
567fn focused_preview(set: &PendingQuestionSet) -> Option<&OptionPreview> {
568    if set.active >= set.questions.len() {
569        return None;
570    }
571    let q = &set.questions[set.active];
572    let cursor = set.selections[set.active].cursor;
573    q.options.get(cursor).and_then(|o| o.preview.as_ref())
574}
575
576/// Whether the active question has any option with a preview — drives the
577/// side-by-side layout (kept stable as the cursor moves between options).
578fn active_question_has_preview(set: &PendingQuestionSet) -> bool {
579    set.active < set.questions.len()
580        && set.questions[set.active]
581            .options
582            .iter()
583            .any(|o| o.preview.is_some())
584}
585
586/// Tallest preview among the active question's options, so the modal height is
587/// stable while arrowing through options.
588fn max_preview_lines(set: &PendingQuestionSet) -> usize {
589    if set.active >= set.questions.len() {
590        return 0;
591    }
592    set.questions[set.active]
593        .options
594        .iter()
595        .filter_map(|o| o.preview.as_ref())
596        .map(|p| p.content.lines().count())
597        .max()
598        .unwrap_or(0)
599}
600
601/// Render a preview body: plain monospace, or a unified diff with `+` green,
602/// `-` red, and `@@` hunk headers cyan.
603fn build_preview_lines(preview: &OptionPreview, theme: &Theme) -> Vec<Line<'static>> {
604    let add = theme.colors.success.to_color();
605    let rem = theme.colors.error.to_color();
606    let hunk = theme.colors.info.to_color();
607    let base = theme.colors.code_foreground.to_color();
608    preview
609        .content
610        .lines()
611        .map(|raw| {
612            let style = if preview.diff {
613                match raw.chars().next() {
614                    Some('+') => Style::default().fg(add),
615                    Some('-') => Style::default().fg(rem),
616                    Some('@') => Style::default().fg(hunk),
617                    _ => Style::default().fg(base),
618                }
619            } else {
620                Style::default().fg(base)
621            };
622            Line::from(Span::styled(raw.to_string(), style))
623        })
624        .collect()
625}
626
627/// Total rendered height including the border, so `render::mod` can size the
628/// bottom zone to fit the modal — the taller of the option list and its preview.
629#[must_use]
630pub fn question_modal_height(set: &PendingQuestionSet, theme: &Theme, total_width: u16) -> u16 {
631    let left = build_question_lines(set, theme, question_column_width(set, total_width)).len();
632    let content = if active_question_has_preview(set) {
633        left.max(max_preview_lines(set))
634    } else {
635        left
636    };
637    (content as u16).saturating_add(2)
638}
639
640impl<'a> Widget for QuestionModalWidget<'a> {
641    fn render(self, area: Rect, buf: &mut Buffer) {
642        let brand = self.theme.colors.brand.to_color();
643        let dim = self.theme.colors.text_disabled.to_color();
644        let block = Block::default()
645            .borders(Borders::ALL)
646            .border_style(Style::default().fg(brand));
647        let inner = block.inner(area);
648        block.render(area, buf);
649
650        if active_question_has_preview(self.set) {
651            // Side-by-side: options on the left, focused option's preview right.
652            let cols = Layout::default()
653                .direction(Direction::Horizontal)
654                .constraints([Constraint::Percentage(48), Constraint::Percentage(52)])
655                .split(inner);
656            Paragraph::new(build_question_lines(
657                self.set,
658                self.theme,
659                question_column_width(self.set, self.width),
660            ))
661            .render(cols[0], buf);
662            let preview_lines = focused_preview(self.set)
663                .map(|p| build_preview_lines(p, self.theme))
664                .unwrap_or_default();
665            Paragraph::new(preview_lines)
666                .block(
667                    Block::default()
668                        .borders(Borders::LEFT)
669                        .border_style(Style::default().fg(dim)),
670                )
671                .render(cols[1], buf);
672        } else {
673            Paragraph::new(build_question_lines(
674                self.set,
675                self.theme,
676                question_column_width(self.set, self.width),
677            ))
678            .render(inner, buf);
679        }
680    }
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686    use mermaid_domain::{Question, QuestionOption, ToolCallId, TurnId};
687
688    fn opt_with_preview(label: &str, preview: Option<OptionPreview>) -> QuestionOption {
689        QuestionOption {
690            label: label.to_string(),
691            description: None,
692            recommended: false,
693            preview,
694        }
695    }
696
697    #[test]
698    fn diff_preview_colors_hunk_remove_and_add_lines() {
699        let theme = Theme::dark();
700        let preview = OptionPreview {
701            content: "@@ -1 +1 @@\n-old\n+new\n context".to_string(),
702            language: None,
703            diff: true,
704        };
705        let lines = build_preview_lines(&preview, &theme);
706        assert_eq!(lines.len(), 4);
707        assert_eq!(
708            lines[0].spans[0].style.fg,
709            Some(theme.colors.info.to_color())
710        );
711        assert_eq!(
712            lines[1].spans[0].style.fg,
713            Some(theme.colors.error.to_color())
714        );
715        assert_eq!(
716            lines[2].spans[0].style.fg,
717            Some(theme.colors.success.to_color())
718        );
719    }
720
721    #[test]
722    fn tall_preview_drives_modal_height() {
723        let body = std::iter::repeat_n("line", 20)
724            .collect::<Vec<_>>()
725            .join("\n");
726        let q = Question {
727            header: "H".to_string(),
728            question: "Q?".to_string(),
729            kind: mermaid_domain::QuestionKind::Select,
730            options: vec![
731                opt_with_preview(
732                    "A",
733                    Some(OptionPreview {
734                        content: body,
735                        language: None,
736                        diff: false,
737                    }),
738                ),
739                opt_with_preview("B", None),
740            ],
741            memory_key: None,
742        };
743        let set = PendingQuestionSet::new(TurnId(1), ToolCallId(1), vec![q]);
744        let theme = Theme::dark();
745        // Modal grows to fit the 20-line preview (plus the 2-line border).
746        assert!(
747            question_modal_height(&set, &theme, 120) >= 22,
748            "expected height >= 22 to fit the preview"
749        );
750    }
751
752    fn q_with_header(header: &str) -> Question {
753        Question {
754            header: header.to_string(),
755            question: format!("Which {header}?"),
756            kind: mermaid_domain::QuestionKind::Select,
757            options: vec![opt_with_preview("A", None), opt_with_preview("B", None)],
758            memory_key: None,
759        }
760    }
761
762    /// Count lines that are a lone header chip (a single span whose text is
763    /// exactly `label`). The tab strip renders the header among several spans, so
764    /// it never matches; only the standalone chip above the title does.
765    fn lone_chip_lines(lines: &[Line<'static>], label: &str) -> usize {
766        // `chip()` pads the label to " label ", so trim before comparing.
767        lines
768            .iter()
769            .filter(|l| l.spans.len() == 1 && l.spans[0].content.as_ref().trim() == label)
770            .count()
771    }
772
773    #[test]
774    fn header_chip_shown_once_for_single_question() {
775        // No tab strip for a single question, so the header chip appears exactly
776        // once (above the title) — otherwise the header would vanish entirely.
777        let set = PendingQuestionSet::new(TurnId(1), ToolCallId(1), vec![q_with_header("HDR")]);
778        let lines = build_question_lines(&set, &Theme::dark(), 120);
779        assert_eq!(lone_chip_lines(&lines, "HDR"), 1);
780    }
781
782    #[test]
783    fn header_chip_not_duplicated_above_title_when_batched() {
784        // The batched tab strip already shows the active header, so there must be
785        // NO standalone header chip above the title (it used to be duplicated).
786        let set = PendingQuestionSet::new(
787            TurnId(1),
788            ToolCallId(1),
789            vec![q_with_header("HDR"), q_with_header("Other")],
790        );
791        let lines = build_question_lines(&set, &Theme::dark(), 120);
792        assert_eq!(lone_chip_lines(&lines, "HDR"), 0);
793    }
794
795    /// Regression: the modal built its lines width-blind, so a long option
796    /// description ran under the right border and was clipped mid-word
797    /// ("…screenshots + file dro"). A modal is a fixed box — every line it
798    /// draws must fit, and the height estimator must count the wrapped lines.
799    #[test]
800    fn long_option_text_wraps_inside_the_modal_instead_of_being_clipped() {
801        let desc = "Press Shift+Tab or run /safety ask so I can run PowerShell diagnostics and \
802                    patch clipboard.rs to handle screenshots + file drops";
803        let q = Question {
804            header: "Safety".to_string(),
805            question: "I'm in read_only mode and can't run diagnostics or patch the clipboard \
806                       code. How should I proceed?"
807                .to_string(),
808            kind: mermaid_domain::QuestionKind::Select,
809            options: vec![QuestionOption {
810                label: "Switch to ask/full_access (Recommended)".to_string(),
811                description: Some(desc.to_string()),
812                preview: None,
813                recommended: true,
814            }],
815            memory_key: None,
816        };
817        let set = PendingQuestionSet::new(TurnId(1), ToolCallId(1), vec![q]);
818        let theme = Theme::dark();
819        let width = 60usize;
820        let lines = build_question_lines(&set, &theme, width);
821        for line in &lines {
822            let w: usize = line.spans.iter().map(|s| s.content.as_ref().width()).sum();
823            assert!(
824                w <= width,
825                "line overflows the modal ({w} > {width}): {:?}",
826                line.spans
827                    .iter()
828                    .map(|s| s.content.as_ref())
829                    .collect::<String>()
830            );
831        }
832        // Nothing is lost to the wrap: the description's tail still renders.
833        let all: String = lines
834            .iter()
835            .flat_map(|l| l.spans.iter())
836            .map(|s| s.content.as_ref())
837            .collect();
838        assert!(
839            all.contains("file drops"),
840            "the clipped tail must survive wrapping: {all}"
841        );
842        // The reserved height counts the wrapped lines, not the unwrapped ones.
843        assert!(
844            question_modal_height(&set, &theme, (width + 2) as u16) as usize >= lines.len() + 2,
845            "the estimator must reserve room for every wrapped line"
846        );
847    }
848}