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.
313#[expect(
314    clippy::too_many_lines,
315    reason = "predates the lint; see .github/baselines/expect_budget.txt"
316)]
317pub fn build_question_lines(
318    set: &PendingQuestionSet,
319    theme: &Theme,
320    width: usize,
321) -> Vec<Line<'static>> {
322    let brand = theme.colors.brand.to_color();
323    let dim = theme.colors.text_disabled.to_color();
324    let white = theme.colors.text_primary.to_color();
325    let nq = set.questions.len();
326    let has_memory = set.questions.iter().any(|q| q.memory_key.is_some());
327    let mut lines: Vec<Line<'static>> = Vec::new();
328
329    // Tab strip for batched questions: chips for each question plus a trailing
330    // Submit; the active one is highlighted.
331    if nq > 1 {
332        let mut spans: Vec<Span<'static>> = vec![Span::styled("< ", Style::default().fg(dim))];
333        for (qi, q) in set.questions.iter().enumerate() {
334            let label = truncate_to_cells(&q.header, 12);
335            if qi == set.active {
336                spans.push(chip(&label, theme.colors.background.to_color(), brand));
337            } else {
338                spans.push(Span::styled(format!(" {label} "), Style::default().fg(dim)));
339            }
340            spans.push(Span::raw(" "));
341        }
342        if set.active >= nq {
343            spans.push(chip("Submit", theme.colors.background.to_color(), brand));
344        } else {
345            spans.push(Span::styled(" Submit ", Style::default().fg(dim)));
346        }
347        spans.push(Span::styled(" >", Style::default().fg(dim)));
348        lines.push(Line::from(spans));
349        lines.push(Line::from(""));
350    }
351
352    // Review screen.
353    if set.active >= nq {
354        lines.push(Line::from(Span::styled(
355            "Review your answers",
356            Style::default().fg(white).add_modifier(Modifier::BOLD),
357        )));
358        lines.push(Line::from(""));
359        for (q, ans) in set.questions.iter().zip(set.build_answers()) {
360            push_wrapped(
361                &mut lines,
362                Line::from(Span::styled(
363                    format!("- {}", q.question),
364                    Style::default().fg(white),
365                )),
366                width,
367                2,
368            );
369            let value = if ans.selected.is_empty() {
370                "(no selection)".to_string()
371            } else {
372                ans.selected.join(", ")
373            };
374            push_wrapped(
375                &mut lines,
376                Line::from(Span::styled(
377                    format!("   -> {value}"),
378                    Style::default().fg(brand),
379                )),
380                width,
381                6,
382            );
383        }
384        if has_memory {
385            let mark = if set.remember { "[x]" } else { "[ ]" };
386            lines.push(Line::from(Span::styled(
387                format!("{mark} Remember my answers across sessions (r)"),
388                Style::default().fg(if set.remember { brand } else { dim }),
389            )));
390        }
391        lines.push(Line::from(""));
392        lines.push(Line::from(Span::styled(
393            "Ready to submit your answers?",
394            Style::default().fg(dim),
395        )));
396        for (i, opt) in ["1. Submit answers", "2. Cancel"].iter().enumerate() {
397            let focused = set.review_cursor == i;
398            let style = if focused {
399                Style::default().fg(brand).add_modifier(Modifier::BOLD)
400            } else {
401                Style::default().fg(white)
402            };
403            lines.push(Line::from(vec![
404                Span::styled(
405                    if focused { "> " } else { "  " },
406                    Style::default().fg(brand),
407                ),
408                Span::styled((*opt).to_string(), style),
409            ]));
410        }
411        lines.push(Line::from(""));
412        let mut foot = String::from("Enter to select | Up/Down to navigate | c: chat");
413        if has_memory {
414            foot.push_str(" | r: remember");
415        }
416        foot.push_str(" | Esc to cancel");
417        push_wrapped(
418            &mut lines,
419            Line::from(Span::styled(foot, Style::default().fg(dim))),
420            width,
421            0,
422        );
423        return lines;
424    }
425
426    // A single question tab.
427    let q = &set.questions[set.active];
428    let sel = &set.selections[set.active];
429
430    // The batched tab strip already shows the header chip for the active
431    // question, so only render it here (above the title) when there's no tab
432    // strip — i.e. a single question. Otherwise it's a duplicate.
433    if nq == 1 {
434        lines.push(Line::from(chip(
435            &truncate_to_cells(&q.header, 12),
436            theme.colors.background.to_color(),
437            brand,
438        )));
439    }
440    push_wrapped(
441        &mut lines,
442        Line::from(Span::styled(
443            q.question.clone(),
444            Style::default().fg(white).add_modifier(Modifier::BOLD),
445        )),
446        width,
447        0,
448    );
449    lines.push(Line::from(""));
450
451    if q.is_input() {
452        push_input_lines(&mut lines, q, sel, theme, width);
453    } else if q.is_rank() {
454        push_rank_lines(&mut lines, q, sel, theme, width);
455    } else {
456        push_choice_lines(&mut lines, q, sel, theme, width);
457    }
458
459    // Notes line (choice kinds only — input kinds capture every keystroke).
460    if q.is_choice() {
461        lines.push(Line::from(""));
462        if set.editing_note {
463            push_wrapped(
464                &mut lines,
465                Line::from(vec![
466                    Span::styled("Notes: ", Style::default().fg(brand)),
467                    Span::styled(sel.note.clone(), Style::default().fg(white)),
468                    Span::styled("_", Style::default().fg(brand)),
469                ]),
470                width,
471                7,
472            );
473        } else if !sel.note.trim().is_empty() {
474            push_wrapped(
475                &mut lines,
476                Line::from(vec![
477                    Span::styled("Notes: ", Style::default().fg(dim)),
478                    Span::styled(sel.note.clone(), Style::default().fg(white)),
479                ]),
480                width,
481                7,
482            );
483        } else {
484            lines.push(Line::from(Span::styled(
485                "Notes: press n to add notes",
486                Style::default().fg(dim),
487            )));
488        }
489    }
490
491    // Footer hints, tailored to the kind.
492    lines.push(Line::from(""));
493    let mut hint = if q.is_input() {
494        let mut h = String::from("Type to edit");
495        if matches!(q.kind, mermaid_domain::QuestionKind::Number { .. }) {
496            h.push_str(" | Up/Down to step");
497        }
498        h.push_str(" | Enter to submit");
499        h
500    } else if q.is_rank() {
501        String::from("Up/Down to move | Space to grab | Enter to submit")
502    } else {
503        String::from("Enter to select | Up/Down to navigate")
504    };
505    if nq > 1 {
506        hint.push_str(" | Tab to switch");
507    }
508    if q.is_choice() {
509        hint.push_str(" | n: notes | c: chat");
510        if has_memory {
511            hint.push_str(" | r: remember");
512        }
513    }
514    hint.push_str(" | Esc to cancel");
515    push_wrapped(
516        &mut lines,
517        Line::from(Span::styled(hint, Style::default().fg(dim))),
518        width,
519        0,
520    );
521
522    lines
523}
524
525/// The preview to show in the right pane: the focused option's preview on the
526/// active question tab (none on the review screen or on the Other/Submit rows).
527fn focused_preview(set: &PendingQuestionSet) -> Option<&OptionPreview> {
528    if set.active >= set.questions.len() {
529        return None;
530    }
531    let q = &set.questions[set.active];
532    let cursor = set.selections[set.active].cursor;
533    q.options.get(cursor).and_then(|o| o.preview.as_ref())
534}
535
536/// Whether the active question has any option with a preview — drives the
537/// side-by-side layout (kept stable as the cursor moves between options).
538fn active_question_has_preview(set: &PendingQuestionSet) -> bool {
539    set.active < set.questions.len()
540        && set.questions[set.active]
541            .options
542            .iter()
543            .any(|o| o.preview.is_some())
544}
545
546/// Tallest preview among the active question's options, so the modal height is
547/// stable while arrowing through options.
548fn max_preview_lines(set: &PendingQuestionSet) -> usize {
549    if set.active >= set.questions.len() {
550        return 0;
551    }
552    set.questions[set.active]
553        .options
554        .iter()
555        .filter_map(|o| o.preview.as_ref())
556        .map(|p| p.content.lines().count())
557        .max()
558        .unwrap_or(0)
559}
560
561/// Render a preview body: plain monospace, or a unified diff with `+` green,
562/// `-` red, and `@@` hunk headers cyan.
563fn build_preview_lines(preview: &OptionPreview, theme: &Theme) -> Vec<Line<'static>> {
564    let add = theme.colors.success.to_color();
565    let rem = theme.colors.error.to_color();
566    let hunk = theme.colors.info.to_color();
567    let base = theme.colors.code_foreground.to_color();
568    preview
569        .content
570        .lines()
571        .map(|raw| {
572            let style = if preview.diff {
573                match raw.chars().next() {
574                    Some('+') => Style::default().fg(add),
575                    Some('-') => Style::default().fg(rem),
576                    Some('@') => Style::default().fg(hunk),
577                    _ => Style::default().fg(base),
578                }
579            } else {
580                Style::default().fg(base)
581            };
582            Line::from(Span::styled(raw.to_string(), style))
583        })
584        .collect()
585}
586
587/// Total rendered height including the border, so `render::mod` can size the
588/// bottom zone to fit the modal — the taller of the option list and its preview.
589#[must_use]
590pub fn question_modal_height(set: &PendingQuestionSet, theme: &Theme, total_width: u16) -> u16 {
591    let left = build_question_lines(set, theme, question_column_width(set, total_width)).len();
592    let content = if active_question_has_preview(set) {
593        left.max(max_preview_lines(set))
594    } else {
595        left
596    };
597    (content as u16).saturating_add(2)
598}
599
600impl<'a> Widget for QuestionModalWidget<'a> {
601    fn render(self, area: Rect, buf: &mut Buffer) {
602        let brand = self.theme.colors.brand.to_color();
603        let dim = self.theme.colors.text_disabled.to_color();
604        let block = Block::default()
605            .borders(Borders::ALL)
606            .border_style(Style::default().fg(brand));
607        let inner = block.inner(area);
608        block.render(area, buf);
609
610        if active_question_has_preview(self.set) {
611            // Side-by-side: options on the left, focused option's preview right.
612            let cols = Layout::default()
613                .direction(Direction::Horizontal)
614                .constraints([Constraint::Percentage(48), Constraint::Percentage(52)])
615                .split(inner);
616            Paragraph::new(build_question_lines(
617                self.set,
618                self.theme,
619                question_column_width(self.set, self.width),
620            ))
621            .render(cols[0], buf);
622            let preview_lines = focused_preview(self.set)
623                .map(|p| build_preview_lines(p, self.theme))
624                .unwrap_or_default();
625            Paragraph::new(preview_lines)
626                .block(
627                    Block::default()
628                        .borders(Borders::LEFT)
629                        .border_style(Style::default().fg(dim)),
630                )
631                .render(cols[1], buf);
632        } else {
633            Paragraph::new(build_question_lines(
634                self.set,
635                self.theme,
636                question_column_width(self.set, self.width),
637            ))
638            .render(inner, buf);
639        }
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use mermaid_domain::{Question, QuestionOption, ToolCallId, TurnId};
647
648    fn opt_with_preview(label: &str, preview: Option<OptionPreview>) -> QuestionOption {
649        QuestionOption {
650            label: label.to_string(),
651            description: None,
652            recommended: false,
653            preview,
654        }
655    }
656
657    #[test]
658    fn diff_preview_colors_hunk_remove_and_add_lines() {
659        let theme = Theme::dark();
660        let preview = OptionPreview {
661            content: "@@ -1 +1 @@\n-old\n+new\n context".to_string(),
662            language: None,
663            diff: true,
664        };
665        let lines = build_preview_lines(&preview, &theme);
666        assert_eq!(lines.len(), 4);
667        assert_eq!(
668            lines[0].spans[0].style.fg,
669            Some(theme.colors.info.to_color())
670        );
671        assert_eq!(
672            lines[1].spans[0].style.fg,
673            Some(theme.colors.error.to_color())
674        );
675        assert_eq!(
676            lines[2].spans[0].style.fg,
677            Some(theme.colors.success.to_color())
678        );
679    }
680
681    #[test]
682    fn tall_preview_drives_modal_height() {
683        let body = std::iter::repeat_n("line", 20)
684            .collect::<Vec<_>>()
685            .join("\n");
686        let q = Question {
687            header: "H".to_string(),
688            question: "Q?".to_string(),
689            kind: mermaid_domain::QuestionKind::Select,
690            options: vec![
691                opt_with_preview(
692                    "A",
693                    Some(OptionPreview {
694                        content: body,
695                        language: None,
696                        diff: false,
697                    }),
698                ),
699                opt_with_preview("B", None),
700            ],
701            memory_key: None,
702        };
703        let set = PendingQuestionSet::new(TurnId(1), ToolCallId(1), vec![q]);
704        let theme = Theme::dark();
705        // Modal grows to fit the 20-line preview (plus the 2-line border).
706        assert!(
707            question_modal_height(&set, &theme, 120) >= 22,
708            "expected height >= 22 to fit the preview"
709        );
710    }
711
712    fn q_with_header(header: &str) -> Question {
713        Question {
714            header: header.to_string(),
715            question: format!("Which {header}?"),
716            kind: mermaid_domain::QuestionKind::Select,
717            options: vec![opt_with_preview("A", None), opt_with_preview("B", None)],
718            memory_key: None,
719        }
720    }
721
722    /// Count lines that are a lone header chip (a single span whose text is
723    /// exactly `label`). The tab strip renders the header among several spans, so
724    /// it never matches; only the standalone chip above the title does.
725    fn lone_chip_lines(lines: &[Line<'static>], label: &str) -> usize {
726        // `chip()` pads the label to " label ", so trim before comparing.
727        lines
728            .iter()
729            .filter(|l| l.spans.len() == 1 && l.spans[0].content.as_ref().trim() == label)
730            .count()
731    }
732
733    #[test]
734    fn header_chip_shown_once_for_single_question() {
735        // No tab strip for a single question, so the header chip appears exactly
736        // once (above the title) — otherwise the header would vanish entirely.
737        let set = PendingQuestionSet::new(TurnId(1), ToolCallId(1), vec![q_with_header("HDR")]);
738        let lines = build_question_lines(&set, &Theme::dark(), 120);
739        assert_eq!(lone_chip_lines(&lines, "HDR"), 1);
740    }
741
742    #[test]
743    fn header_chip_not_duplicated_above_title_when_batched() {
744        // The batched tab strip already shows the active header, so there must be
745        // NO standalone header chip above the title (it used to be duplicated).
746        let set = PendingQuestionSet::new(
747            TurnId(1),
748            ToolCallId(1),
749            vec![q_with_header("HDR"), q_with_header("Other")],
750        );
751        let lines = build_question_lines(&set, &Theme::dark(), 120);
752        assert_eq!(lone_chip_lines(&lines, "HDR"), 0);
753    }
754
755    /// Regression: the modal built its lines width-blind, so a long option
756    /// description ran under the right border and was clipped mid-word
757    /// ("…screenshots + file dro"). A modal is a fixed box — every line it
758    /// draws must fit, and the height estimator must count the wrapped lines.
759    #[test]
760    fn long_option_text_wraps_inside_the_modal_instead_of_being_clipped() {
761        let desc = "Press Shift+Tab or run /safety ask so I can run PowerShell diagnostics and \
762                    patch clipboard.rs to handle screenshots + file drops";
763        let q = Question {
764            header: "Safety".to_string(),
765            question: "I'm in read_only mode and can't run diagnostics or patch the clipboard \
766                       code. How should I proceed?"
767                .to_string(),
768            kind: mermaid_domain::QuestionKind::Select,
769            options: vec![QuestionOption {
770                label: "Switch to ask/full_access (Recommended)".to_string(),
771                description: Some(desc.to_string()),
772                preview: None,
773                recommended: true,
774            }],
775            memory_key: None,
776        };
777        let set = PendingQuestionSet::new(TurnId(1), ToolCallId(1), vec![q]);
778        let theme = Theme::dark();
779        let width = 60usize;
780        let lines = build_question_lines(&set, &theme, width);
781        for line in &lines {
782            let w: usize = line.spans.iter().map(|s| s.content.as_ref().width()).sum();
783            assert!(
784                w <= width,
785                "line overflows the modal ({w} > {width}): {:?}",
786                line.spans
787                    .iter()
788                    .map(|s| s.content.as_ref())
789                    .collect::<String>()
790            );
791        }
792        // Nothing is lost to the wrap: the description's tail still renders.
793        let all: String = lines
794            .iter()
795            .flat_map(|l| l.spans.iter())
796            .map(|s| s.content.as_ref())
797            .collect();
798        assert!(
799            all.contains("file drops"),
800            "the clipped tail must survive wrapping: {all}"
801        );
802        // The reserved height counts the wrapped lines, not the unwrapped ones.
803        assert!(
804            question_modal_height(&set, &theme, (width + 2) as u16) as usize >= lines.len() + 2,
805            "the estimator must reserve room for every wrapped line"
806        );
807    }
808}