1use 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 pub width: u16,
28}
29
30fn 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
48fn 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
60fn 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
69fn 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
89fn 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 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 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 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
203fn 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
264fn 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#[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 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 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 let q = &set.questions[set.active];
428 let sel = &set.selections[set.active];
429
430 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 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 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
525fn 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
536fn 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
546fn 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
561fn 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#[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 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 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 fn lone_chip_lines(lines: &[Line<'static>], label: &str) -> usize {
726 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 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 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 #[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 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 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}