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::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 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: &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
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 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
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 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
311pub 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 {
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 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 let q = &set.questions[set.active];
427 let sel = &set.selections[set.active];
428
429 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 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 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
524fn 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
535fn 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
545fn 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
560fn 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
586pub 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 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 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 fn lone_chip_lines(lines: &[Line<'static>], label: &str) -> usize {
724 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 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 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 #[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 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 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}