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};
15
16use super::truncate_to_cells;
17use crate::domain::{OptionPreview, PendingQuestionSet, Question, QuestionSelection};
18use crate::render::theme::Theme;
19
20pub struct QuestionModalWidget<'a> {
21 pub theme: &'a Theme,
22 pub set: &'a PendingQuestionSet,
23}
24
25fn chip(label: &str, fg: Color, bg: Color) -> Span<'static> {
28 Span::styled(
29 format!(" {} ", label),
30 Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD),
31 )
32}
33
34fn option_window(cursor: usize, n: usize, max: usize) -> (usize, usize) {
37 if n <= max {
38 return (0, n);
39 }
40 let c = cursor.min(n - 1);
41 let start = c.saturating_sub(max / 2).min(n - max);
42 (start, start + max)
43}
44
45fn input_placeholder(kind: &crate::domain::QuestionKind) -> &'static str {
46 match kind {
47 crate::domain::QuestionKind::Number { .. } => "a number",
48 crate::domain::QuestionKind::Date => "YYYY-MM-DD",
49 crate::domain::QuestionKind::Path { .. } => "a path",
50 _ => "type a value",
51 }
52}
53
54fn push_choice_lines(
57 lines: &mut Vec<Line<'static>>,
58 q: &Question,
59 sel: &QuestionSelection,
60 theme: &Theme,
61) {
62 let brand = theme.colors.brand.to_color();
63 let dim = theme.colors.text_disabled.to_color();
64 let white = theme.colors.text_primary.to_color();
65 let n = q.options.len();
66 let multi = q.is_multi();
67 const MAX_VISIBLE: usize = 8;
68 let (start, end) = option_window(sel.cursor, n, MAX_VISIBLE);
69 if start > 0 {
70 lines.push(Line::from(Span::styled(
71 format!(" ... {start} more above"),
72 Style::default().fg(dim),
73 )));
74 }
75 for i in start..end {
76 let opt = &q.options[i];
77 let focused = sel.cursor == i;
78 let checked = sel.chosen.contains(&i);
79 let mut spans: Vec<Span<'static>> = vec![
80 Span::styled(
81 if focused { "> " } else { " " },
82 Style::default().fg(brand),
83 ),
84 Span::styled(format!("{}. ", i + 1), Style::default().fg(dim)),
85 ];
86 if multi {
87 spans.push(Span::styled(
88 if checked { "[x] " } else { "[ ] " },
89 Style::default().fg(if checked { brand } else { dim }),
90 ));
91 }
92 let label_style = if focused {
93 Style::default().fg(brand).add_modifier(Modifier::BOLD)
94 } else if !multi && checked {
95 Style::default().fg(brand)
96 } else {
97 Style::default().fg(white).add_modifier(Modifier::BOLD)
98 };
99 spans.push(Span::styled(opt.label.clone(), label_style));
100 lines.push(Line::from(spans));
101 if let Some(desc) = &opt.description {
102 lines.push(Line::from(Span::styled(
103 format!(" {}", desc),
104 Style::default().fg(dim),
105 )));
106 }
107 }
108 if end < n {
109 lines.push(Line::from(Span::styled(
110 format!(" ... {} more below", n - end),
111 Style::default().fg(dim),
112 )));
113 }
114
115 let other_focused = sel.cursor == n;
117 let mut spans: Vec<Span<'static>> = vec![
118 Span::styled(
119 if other_focused { "> " } else { " " },
120 Style::default().fg(brand),
121 ),
122 Span::styled(format!("{}. ", n + 1), Style::default().fg(dim)),
123 ];
124 if multi {
125 let checked = !sel.other_text.trim().is_empty();
126 spans.push(Span::styled(
127 if checked { "[x] " } else { "[ ] " },
128 Style::default().fg(if checked { brand } else { dim }),
129 ));
130 }
131 if sel.other_text.is_empty() {
132 spans.push(Span::styled("Type something", Style::default().fg(dim)));
133 } else {
134 spans.push(Span::styled(
135 sel.other_text.clone(),
136 Style::default().fg(white),
137 ));
138 }
139 lines.push(Line::from(spans));
140
141 if multi {
143 let focused = sel.cursor == n + 1;
144 let style = if focused {
145 Style::default().fg(brand).add_modifier(Modifier::BOLD)
146 } else {
147 Style::default().fg(white).add_modifier(Modifier::BOLD)
148 };
149 lines.push(Line::from(vec![
150 Span::styled(
151 if focused { "> " } else { " " },
152 Style::default().fg(brand),
153 ),
154 Span::styled("Submit", style),
155 ]));
156 }
157}
158
159fn push_input_lines(
162 lines: &mut Vec<Line<'static>>,
163 q: &Question,
164 sel: &QuestionSelection,
165 theme: &Theme,
166) {
167 let brand = theme.colors.brand.to_color();
168 let dim = theme.colors.text_disabled.to_color();
169 let white = theme.colors.text_primary.to_color();
170 let value = &sel.value;
171 let mut field: Vec<Span<'static>> = vec![Span::styled("> ", Style::default().fg(brand))];
172 if value.is_empty() {
173 field.push(Span::styled(
174 input_placeholder(&q.kind),
175 Style::default().fg(dim),
176 ));
177 } else {
178 field.push(Span::styled(value.clone(), Style::default().fg(white)));
179 }
180 field.push(Span::styled("_", Style::default().fg(brand)));
181 lines.push(Line::from(field));
182
183 if let crate::domain::QuestionKind::Number {
184 min: Some(lo),
185 max: Some(hi),
186 slider: true,
187 ..
188 } = &q.kind
189 && hi > lo
190 {
191 let cur: f64 = value.trim().parse().unwrap_or(*lo);
192 let frac = ((cur - lo) / (hi - lo)).clamp(0.0, 1.0);
193 let width = 20usize;
194 let filled = (frac * width as f64).round() as usize;
195 let bar = format!("[{}{}]", "#".repeat(filled), "-".repeat(width - filled));
196 lines.push(Line::from(Span::styled(
197 format!(" {bar}"),
198 Style::default().fg(brand),
199 )));
200 }
201
202 match crate::domain::validate_input(&q.kind, value) {
203 Err(e) => lines.push(Line::from(Span::styled(
204 format!(" {e}"),
205 Style::default().fg(theme.colors.error.to_color()),
206 ))),
207 Ok(()) => lines.push(Line::from(Span::styled(
208 " Enter to submit",
209 Style::default().fg(dim),
210 ))),
211 }
212}
213
214fn push_rank_lines(
216 lines: &mut Vec<Line<'static>>,
217 q: &Question,
218 sel: &QuestionSelection,
219 theme: &Theme,
220) {
221 let brand = theme.colors.brand.to_color();
222 let dim = theme.colors.text_disabled.to_color();
223 let white = theme.colors.text_primary.to_color();
224 for (pos, &opt_idx) in crate::domain::rank_order(q, sel).iter().enumerate() {
225 let focused = sel.cursor == pos;
226 let grabbed = focused && sel.grabbed;
227 let prefix = if grabbed {
228 ">>"
229 } else if focused {
230 "> "
231 } else {
232 " "
233 };
234 let label_style = if focused {
235 Style::default().fg(brand).add_modifier(Modifier::BOLD)
236 } else {
237 Style::default().fg(white).add_modifier(Modifier::BOLD)
238 };
239 lines.push(Line::from(vec![
240 Span::styled(prefix, Style::default().fg(brand)),
241 Span::styled(format!("{}. ", pos + 1), Style::default().fg(dim)),
242 Span::styled(
243 q.options
244 .get(opt_idx)
245 .map(|o| o.label.clone())
246 .unwrap_or_default(),
247 label_style,
248 ),
249 ]));
250 }
251}
252
253pub fn build_question_lines(set: &PendingQuestionSet, theme: &Theme) -> Vec<Line<'static>> {
256 let brand = theme.colors.brand.to_color();
257 let dim = theme.colors.text_disabled.to_color();
258 let white = theme.colors.text_primary.to_color();
259 let nq = set.questions.len();
260 let has_memory = set.questions.iter().any(|q| q.memory_key.is_some());
261 let mut lines: Vec<Line<'static>> = Vec::new();
262
263 if nq > 1 {
266 let mut spans: Vec<Span<'static>> = vec![Span::styled("< ", Style::default().fg(dim))];
267 for (qi, q) in set.questions.iter().enumerate() {
268 let label = truncate_to_cells(&q.header, 12);
269 if qi == set.active {
270 spans.push(chip(&label, theme.colors.background.to_color(), brand));
271 } else {
272 spans.push(Span::styled(
273 format!(" {} ", label),
274 Style::default().fg(dim),
275 ));
276 }
277 spans.push(Span::raw(" "));
278 }
279 if set.active >= nq {
280 spans.push(chip("Submit", theme.colors.background.to_color(), brand));
281 } else {
282 spans.push(Span::styled(" Submit ", Style::default().fg(dim)));
283 }
284 spans.push(Span::styled(" >", Style::default().fg(dim)));
285 lines.push(Line::from(spans));
286 lines.push(Line::from(""));
287 }
288
289 if set.active >= nq {
291 lines.push(Line::from(Span::styled(
292 "Review your answers",
293 Style::default().fg(white).add_modifier(Modifier::BOLD),
294 )));
295 lines.push(Line::from(""));
296 for (q, ans) in set.questions.iter().zip(set.build_answers()) {
297 lines.push(Line::from(Span::styled(
298 format!("- {}", q.question),
299 Style::default().fg(white),
300 )));
301 let value = if ans.selected.is_empty() {
302 "(no selection)".to_string()
303 } else {
304 ans.selected.join(", ")
305 };
306 lines.push(Line::from(Span::styled(
307 format!(" -> {}", value),
308 Style::default().fg(brand),
309 )));
310 }
311 if has_memory {
312 let mark = if set.remember { "[x]" } else { "[ ]" };
313 lines.push(Line::from(Span::styled(
314 format!("{mark} Remember my answers across sessions (r)"),
315 Style::default().fg(if set.remember { brand } else { dim }),
316 )));
317 }
318 lines.push(Line::from(""));
319 lines.push(Line::from(Span::styled(
320 "Ready to submit your answers?",
321 Style::default().fg(dim),
322 )));
323 for (i, opt) in ["1. Submit answers", "2. Cancel"].iter().enumerate() {
324 let focused = set.review_cursor == i;
325 let style = if focused {
326 Style::default().fg(brand).add_modifier(Modifier::BOLD)
327 } else {
328 Style::default().fg(white)
329 };
330 lines.push(Line::from(vec![
331 Span::styled(
332 if focused { "> " } else { " " },
333 Style::default().fg(brand),
334 ),
335 Span::styled((*opt).to_string(), style),
336 ]));
337 }
338 lines.push(Line::from(""));
339 let mut foot = String::from("Enter to select | Up/Down to navigate | c: chat");
340 if has_memory {
341 foot.push_str(" | r: remember");
342 }
343 foot.push_str(" | Esc to cancel");
344 lines.push(Line::from(Span::styled(foot, Style::default().fg(dim))));
345 return lines;
346 }
347
348 let q = &set.questions[set.active];
350 let sel = &set.selections[set.active];
351
352 if nq == 1 {
356 lines.push(Line::from(chip(
357 &truncate_to_cells(&q.header, 12),
358 theme.colors.background.to_color(),
359 brand,
360 )));
361 }
362 lines.push(Line::from(Span::styled(
363 q.question.clone(),
364 Style::default().fg(white).add_modifier(Modifier::BOLD),
365 )));
366 lines.push(Line::from(""));
367
368 if q.is_input() {
369 push_input_lines(&mut lines, q, sel, theme);
370 } else if q.is_rank() {
371 push_rank_lines(&mut lines, q, sel, theme);
372 } else {
373 push_choice_lines(&mut lines, q, sel, theme);
374 }
375
376 if q.is_choice() {
378 lines.push(Line::from(""));
379 if set.editing_note {
380 lines.push(Line::from(vec![
381 Span::styled("Notes: ", Style::default().fg(brand)),
382 Span::styled(sel.note.clone(), Style::default().fg(white)),
383 Span::styled("_", Style::default().fg(brand)),
384 ]));
385 } else if !sel.note.trim().is_empty() {
386 lines.push(Line::from(vec![
387 Span::styled("Notes: ", Style::default().fg(dim)),
388 Span::styled(sel.note.clone(), Style::default().fg(white)),
389 ]));
390 } else {
391 lines.push(Line::from(Span::styled(
392 "Notes: press n to add notes",
393 Style::default().fg(dim),
394 )));
395 }
396 }
397
398 lines.push(Line::from(""));
400 let mut hint = if q.is_input() {
401 let mut h = String::from("Type to edit");
402 if matches!(q.kind, crate::domain::QuestionKind::Number { .. }) {
403 h.push_str(" | Up/Down to step");
404 }
405 h.push_str(" | Enter to submit");
406 h
407 } else if q.is_rank() {
408 String::from("Up/Down to move | Space to grab | Enter to submit")
409 } else {
410 String::from("Enter to select | Up/Down to navigate")
411 };
412 if nq > 1 {
413 hint.push_str(" | Tab to switch");
414 }
415 if q.is_choice() {
416 hint.push_str(" | n: notes | c: chat");
417 if has_memory {
418 hint.push_str(" | r: remember");
419 }
420 }
421 hint.push_str(" | Esc to cancel");
422 lines.push(Line::from(Span::styled(hint, Style::default().fg(dim))));
423
424 lines
425}
426
427fn focused_preview(set: &PendingQuestionSet) -> Option<&OptionPreview> {
430 if set.active >= set.questions.len() {
431 return None;
432 }
433 let q = &set.questions[set.active];
434 let cursor = set.selections[set.active].cursor;
435 q.options.get(cursor).and_then(|o| o.preview.as_ref())
436}
437
438fn active_question_has_preview(set: &PendingQuestionSet) -> bool {
441 set.active < set.questions.len()
442 && set.questions[set.active]
443 .options
444 .iter()
445 .any(|o| o.preview.is_some())
446}
447
448fn max_preview_lines(set: &PendingQuestionSet) -> usize {
451 if set.active >= set.questions.len() {
452 return 0;
453 }
454 set.questions[set.active]
455 .options
456 .iter()
457 .filter_map(|o| o.preview.as_ref())
458 .map(|p| p.content.lines().count())
459 .max()
460 .unwrap_or(0)
461}
462
463fn build_preview_lines(preview: &OptionPreview, theme: &Theme) -> Vec<Line<'static>> {
466 let add = theme.colors.success.to_color();
467 let rem = theme.colors.error.to_color();
468 let hunk = theme.colors.info.to_color();
469 let base = theme.colors.code_foreground.to_color();
470 preview
471 .content
472 .lines()
473 .map(|raw| {
474 let style = if preview.diff {
475 match raw.chars().next() {
476 Some('+') => Style::default().fg(add),
477 Some('-') => Style::default().fg(rem),
478 Some('@') => Style::default().fg(hunk),
479 _ => Style::default().fg(base),
480 }
481 } else {
482 Style::default().fg(base)
483 };
484 Line::from(Span::styled(raw.to_string(), style))
485 })
486 .collect()
487}
488
489pub fn question_modal_height(set: &PendingQuestionSet, theme: &Theme) -> u16 {
492 let left = build_question_lines(set, theme).len();
493 let content = if active_question_has_preview(set) {
494 left.max(max_preview_lines(set))
495 } else {
496 left
497 };
498 (content as u16).saturating_add(2)
499}
500
501impl<'a> Widget for QuestionModalWidget<'a> {
502 fn render(self, area: Rect, buf: &mut Buffer) {
503 let brand = self.theme.colors.brand.to_color();
504 let dim = self.theme.colors.text_disabled.to_color();
505 let block = Block::default()
506 .borders(Borders::ALL)
507 .border_style(Style::default().fg(brand));
508 let inner = block.inner(area);
509 block.render(area, buf);
510
511 if active_question_has_preview(self.set) {
512 let cols = Layout::default()
514 .direction(Direction::Horizontal)
515 .constraints([Constraint::Percentage(48), Constraint::Percentage(52)])
516 .split(inner);
517 Paragraph::new(build_question_lines(self.set, self.theme)).render(cols[0], buf);
518 let preview_lines = focused_preview(self.set)
519 .map(|p| build_preview_lines(p, self.theme))
520 .unwrap_or_default();
521 Paragraph::new(preview_lines)
522 .block(
523 Block::default()
524 .borders(Borders::LEFT)
525 .border_style(Style::default().fg(dim)),
526 )
527 .render(cols[1], buf);
528 } else {
529 Paragraph::new(build_question_lines(self.set, self.theme)).render(inner, buf);
530 }
531 }
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537 use crate::domain::{Question, QuestionOption, ToolCallId, TurnId};
538
539 fn opt_with_preview(label: &str, preview: Option<OptionPreview>) -> QuestionOption {
540 QuestionOption {
541 label: label.to_string(),
542 description: None,
543 recommended: false,
544 preview,
545 }
546 }
547
548 #[test]
549 fn diff_preview_colors_hunk_remove_and_add_lines() {
550 let theme = Theme::dark();
551 let preview = OptionPreview {
552 content: "@@ -1 +1 @@\n-old\n+new\n context".to_string(),
553 language: None,
554 diff: true,
555 };
556 let lines = build_preview_lines(&preview, &theme);
557 assert_eq!(lines.len(), 4);
558 assert_eq!(
559 lines[0].spans[0].style.fg,
560 Some(theme.colors.info.to_color())
561 );
562 assert_eq!(
563 lines[1].spans[0].style.fg,
564 Some(theme.colors.error.to_color())
565 );
566 assert_eq!(
567 lines[2].spans[0].style.fg,
568 Some(theme.colors.success.to_color())
569 );
570 }
571
572 #[test]
573 fn tall_preview_drives_modal_height() {
574 let body = std::iter::repeat_n("line", 20)
575 .collect::<Vec<_>>()
576 .join("\n");
577 let q = Question {
578 header: "H".to_string(),
579 question: "Q?".to_string(),
580 kind: crate::domain::QuestionKind::Select,
581 options: vec![
582 opt_with_preview(
583 "A",
584 Some(OptionPreview {
585 content: body,
586 language: None,
587 diff: false,
588 }),
589 ),
590 opt_with_preview("B", None),
591 ],
592 memory_key: None,
593 };
594 let set = PendingQuestionSet::new(TurnId(1), ToolCallId(1), vec![q]);
595 let theme = Theme::dark();
596 assert!(
598 question_modal_height(&set, &theme) >= 22,
599 "expected height >= 22 to fit the preview"
600 );
601 }
602
603 fn q_with_header(header: &str) -> Question {
604 Question {
605 header: header.to_string(),
606 question: format!("Which {}?", header),
607 kind: crate::domain::QuestionKind::Select,
608 options: vec![opt_with_preview("A", None), opt_with_preview("B", None)],
609 memory_key: None,
610 }
611 }
612
613 fn lone_chip_lines(lines: &[Line<'static>], label: &str) -> usize {
617 lines
619 .iter()
620 .filter(|l| l.spans.len() == 1 && l.spans[0].content.as_ref().trim() == label)
621 .count()
622 }
623
624 #[test]
625 fn header_chip_shown_once_for_single_question() {
626 let set = PendingQuestionSet::new(TurnId(1), ToolCallId(1), vec![q_with_header("HDR")]);
629 let lines = build_question_lines(&set, &Theme::dark());
630 assert_eq!(lone_chip_lines(&lines, "HDR"), 1);
631 }
632
633 #[test]
634 fn header_chip_not_duplicated_above_title_when_batched() {
635 let set = PendingQuestionSet::new(
638 TurnId(1),
639 ToolCallId(1),
640 vec![q_with_header("HDR"), q_with_header("Other")],
641 );
642 let lines = build_question_lines(&set, &Theme::dark());
643 assert_eq!(lone_chip_lines(&lines, "HDR"), 0);
644 }
645}