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