返回 CodeWhale
user_input.rs
根目录 / crates / tui / src / tui / user_input.rs
1 //! Modal for request_user_input tool prompts.
2
3 use std::cell::Cell;
4
5 use crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind};
6 use ratatui::layout::{Alignment, Rect};
7 use ratatui::prelude::*;
8 use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap};
9
10 use crate::tools::user_input::{
11 UserInputAnswer, UserInputQuestion, UserInputRequest, UserInputResponse,
12 };
13 use crate::tui::menu_style;
14 use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent, render_modal_surface};
15 use codewhale_palette as palette;
16
17 fn modal_block(title: &str) -> Block<'static> {
18 Block::default()
19 .title(Line::from(vec![Span::styled(
20 title.to_string(),
21 Style::default().fg(palette::WHALE_HUMAN).bold(),
22 )]))
23 .borders(Borders::ALL)
24 .border_style(Style::default().fg(palette::BORDER_COLOR))
25 .style(Style::default().bg(palette::WHALE_BG))
26 .padding(Padding::uniform(1))
27 }
28
29 fn render_modal_chrome(area: Rect, popup_area: Rect, buf: &mut Buffer) {
30 render_modal_surface(area, popup_area, buf);
31 }
32
33 fn push_option_lines(
34 lines: &mut Vec<Line<'static>>,
35 selected: bool,
36 number: usize,
37 label: String,
38 description: String,
39 ticked: bool,
40 ) {
41 let row_style = if selected {
42 menu_style::selected_row_style()
43 } else {
44 Style::default().fg(palette::TEXT_PRIMARY)
45 };
46 let detail_style = if selected {
47 row_style
48 } else {
49 Style::default().fg(palette::TEXT_MUTED)
50 };
51 let prefix = crate::tui::glyphs::selection_marker(selected);
52 // Multi-select rows get a check-mark gutter when toggled into the pending
53 // set, mirroring the affordance used in other multi-option pickers.
54 let mark = if ticked { "✔ " } else { " " };
55
56 lines.push(Line::from(Span::styled(
57 format!("{prefix}{mark}{number}) {label}"),
58 row_style,
59 )));
60 lines.push(Line::from(Span::styled(
61 format!(" {description}"),
62 detail_style,
63 )));
64 }
65
66 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
67 enum InputMode {
68 Selecting,
69 OtherInput,
70 }
71
72 #[derive(Debug, Clone)]
73 pub struct UserInputView {
74 tool_id: String,
75 request: UserInputRequest,
76 question_index: usize,
77 selected: usize,
78 mode: InputMode,
79 other_input: String,
80 /// Answers committed for previous questions. Going back pops the last
81 /// batch so an accidental Enter is reversible.
82 answered: Vec<Vec<UserInputAnswer>>,
83 /// Indices toggled into the pending multi-select set for the current
84 /// question. Only used when `question.multi_select` is true.
85 multi_pending: Vec<usize>,
86 /// Wheel browsing uses wrapped rows from the last paint. Option navigation
87 /// or typing returns to following focus so the next edit stays visible.
88 manual_scroll: bool,
89 scroll_offset: Cell<u16>,
90 max_scroll: Cell<u16>,
91 }
92
93 impl UserInputView {
94 pub fn new(tool_id: impl Into<String>, request: UserInputRequest) -> Self {
95 Self {
96 tool_id: tool_id.into(),
97 request,
98 question_index: 0,
99 selected: 0,
100 mode: InputMode::Selecting,
101 other_input: String::new(),
102 answered: Vec::new(),
103 multi_pending: Vec::new(),
104 manual_scroll: false,
105 scroll_offset: Cell::new(0),
106 max_scroll: Cell::new(0),
107 }
108 }
109
110 fn current_question(&self) -> &UserInputQuestion {
111 &self.request.questions[self.question_index]
112 }
113
114 /// Whether the "Other" free-text row is offered for the current question.
115 /// Free text is ALWAYS available so the user can answer with their own
116 /// words even when the model did not offer it. `allow_free_text` remains
117 /// part of the wire request (backward-compatible) but no longer gates the
118 /// row: a custom response must always be reachable alongside the options.
119 fn offers_other(&self) -> bool {
120 true
121 }
122
123 fn option_count(&self) -> usize {
124 // Options + conditional "Other" row + conditional "Confirm" row.
125 let mut count = self.current_question().options.len();
126 count += usize::from(self.offers_other());
127 count += usize::from(self.is_multi_select());
128 count
129 }
130
131 fn is_other_selected(&self) -> bool {
132 // "Other" sits immediately before the Confirm row when both exist, and
133 // is last otherwise.
134 let other_last = !self.is_multi_select();
135 if other_last {
136 self.offers_other() && self.selected + 1 == self.option_count()
137 } else {
138 self.offers_other() && self.selected + 2 == self.option_count()
139 }
140 }
141
142 /// True when the multi-select "Confirm selection" row is highlighted.
143 fn is_confirm_selected(&self) -> bool {
144 self.confirm_index() == Some(self.selected)
145 }
146
147 fn confirm_index(&self) -> Option<usize> {
148 self.is_multi_select()
149 .then(|| self.option_count().saturating_sub(1))
150 }
151
152 fn is_multi_select(&self) -> bool {
153 self.current_question().multi_select
154 }
155
156 /// Number of content lines the render path emits for the current state.
157 /// Drives the content-sized popup height so the dialog hugs what it
158 /// shows instead of claiming a fixed share of the screen.
159 fn content_line_count(&self) -> usize {
160 let question = self.current_question();
161 // "Action required" banner, header line, blank, question, blank.
162 let mut count = 5;
163 count += question.options.len() * 2;
164 if self.offers_other() {
165 count += 2;
166 }
167 if self.is_multi_select() {
168 count += 2;
169 }
170 if self.mode == InputMode::OtherInput {
171 count += 2;
172 }
173 // Trailing blank line + controls hint.
174 count += 2;
175 count
176 }
177
178 fn toggle_pending(&mut self, index: usize) {
179 if let Some(pos) = self.multi_pending.iter().position(|i| *i == index) {
180 self.multi_pending.remove(pos);
181 } else {
182 self.multi_pending.push(index);
183 }
184 }
185
186 /// Build the answer(s) for the current question from a single selected
187 /// option index (single-select and the confirm step of multi-select).
188 fn answers_for_selection(&self, index: usize) -> Vec<UserInputAnswer> {
189 let question = self.current_question();
190 let option = &question.options[index];
191 vec![UserInputAnswer {
192 id: question.id.clone(),
193 label: option.label.clone(),
194 value: option.label.clone(),
195 }]
196 }
197
198 fn committed_answers(&self) -> Vec<UserInputAnswer> {
199 self.answered.iter().flatten().cloned().collect()
200 }
201
202 fn advance_question(&mut self, new_answers: Vec<UserInputAnswer>) -> ViewAction {
203 self.answered.push(new_answers);
204 if self.question_index + 1 >= self.request.questions.len() {
205 let response = UserInputResponse {
206 answers: self.committed_answers(),
207 };
208 return ViewAction::EmitAndClose(ViewEvent::UserInputSubmitted {
209 tool_id: self.tool_id.clone(),
210 response,
211 });
212 }
213 self.question_index += 1;
214 self.selected = 0;
215 self.mode = InputMode::Selecting;
216 self.other_input.clear();
217 self.multi_pending.clear();
218 ViewAction::None
219 }
220
221 fn go_back(&mut self) -> ViewAction {
222 if self.answered.is_empty() {
223 return ViewAction::None;
224 }
225 self.answered.pop();
226 self.question_index = self.question_index.saturating_sub(1);
227 self.selected = 0;
228 self.mode = InputMode::Selecting;
229 self.other_input.clear();
230 self.multi_pending.clear();
231 ViewAction::None
232 }
233
234 /// Content-line range that must stay on screen: the highlighted option,
235 /// or the typed custom-response row while editing it.
236 fn focused_line_range(&self) -> (usize, usize) {
237 if self.mode == InputMode::OtherInput {
238 let start = self.content_line_count().saturating_sub(4);
239 return (start, start.saturating_add(1));
240 }
241 let start = 5 + self.selected.saturating_mul(2);
242 (start, start.saturating_add(1))
243 }
244
245 fn handle_selecting_key(&mut self, key: KeyEvent) -> ViewAction {
246 match key.code {
247 KeyCode::Up | KeyCode::Char('k') => {
248 self.selected = self.selected.saturating_sub(1);
249 ViewAction::None
250 }
251 KeyCode::Down | KeyCode::Char('j') => {
252 self.selected = (self.selected + 1).min(self.option_count().saturating_sub(1));
253 ViewAction::None
254 }
255 KeyCode::Left | KeyCode::Char('h') => self.go_back(),
256 KeyCode::Char(ch) if ch.is_ascii_digit() => {
257 let Some(number) = ch.to_digit(10) else {
258 return ViewAction::None;
259 };
260 if number == 0 {
261 return ViewAction::None;
262 }
263 let index = usize::try_from(number - 1).unwrap_or(usize::MAX);
264 if index >= self.option_count() {
265 return ViewAction::None;
266 }
267 self.selected = index;
268 self.activate_or_confirm_selection()
269 }
270 KeyCode::Char(' ') if self.is_multi_select() => {
271 // Space toggles the highlighted option in the pending set
272 // without leaving the picker (standard multi-select affordance).
273 // The Other row and the Confirm row are not options: toggling
274 // them would corrupt the pending set.
275 let is_confirm = self.confirm_index() == Some(self.selected);
276 if !self.is_other_selected() && !is_confirm {
277 self.toggle_pending(self.selected);
278 }
279 ViewAction::None
280 }
281 KeyCode::Enter => self.activate_or_confirm_selection(),
282 KeyCode::Esc => ViewAction::EmitAndClose(ViewEvent::UserInputCancelled {
283 tool_id: self.tool_id.clone(),
284 }),
285 _ => ViewAction::None,
286 }
287 }
288
289 /// Resolve a digit/Enter activation for the currently highlighted row.
290 ///
291 /// - "Other" row → enter free-text input mode.
292 /// - multi-select option → add to the pending set (never remove — that is
293 /// Space's job) and move focus to the Confirm row, so the single-select
294 /// muscle memory of Enter-then-Enter submits the highlighted option
295 /// instead of toggling it back out and submitting an empty set.
296 /// - multi-select Confirm row → submit the pending set.
297 /// - single-select option → submit immediately (legacy behavior).
298 fn activate_or_confirm_selection(&mut self) -> ViewAction {
299 if self.is_other_selected() {
300 self.mode = InputMode::OtherInput;
301 self.other_input.clear();
302 return ViewAction::None;
303 }
304 if self.is_multi_select() {
305 if self.is_confirm_selected() {
306 // Flush the pending set as this question's answers. An empty
307 // set is allowed (skip-like) — the model is expected to offer a
308 // sensible default, but we don't deadlock.
309 let question = self.current_question();
310 let answers: Vec<UserInputAnswer> = self
311 .multi_pending
312 .iter()
313 .filter_map(|i| question.options.get(*i))
314 .map(|opt| UserInputAnswer {
315 id: question.id.clone(),
316 label: opt.label.clone(),
317 value: opt.label.clone(),
318 })
319 .collect();
320 return self.advance_question(answers);
321 }
322 // Enter on a real option selects it and moves to Confirm. It
323 // never toggles out: double-Enter must submit the highlighted
324 // option, matching single-select on the same view.
325 if !self.multi_pending.contains(&self.selected) {
326 self.multi_pending.push(self.selected);
327 }
328 if let Some(confirm) = self.confirm_index() {
329 self.selected = confirm;
330 }
331 return ViewAction::None;
332 }
333 // Single-select: submit immediately.
334 let answers = self.answers_for_selection(self.selected);
335 self.advance_question(answers)
336 }
337
338 fn handle_other_input_key(&mut self, key: KeyEvent) -> ViewAction {
339 match key.code {
340 KeyCode::Esc => {
341 self.mode = InputMode::Selecting;
342 self.other_input.clear();
343 ViewAction::None
344 }
345 KeyCode::Enter => {
346 let question = self.current_question();
347 let answer = UserInputAnswer {
348 id: question.id.clone(),
349 label: "Other".to_string(),
350 value: self.other_input.trim().to_string(),
351 };
352 // In multi-select mode a free-text "Other" is still a single
353 // answer appended to whatever options were toggled.
354 let mut answers: Vec<UserInputAnswer> = self
355 .multi_pending
356 .iter()
357 .filter_map(|i| question.options.get(*i))
358 .map(|opt| UserInputAnswer {
359 id: question.id.clone(),
360 label: opt.label.clone(),
361 value: opt.label.clone(),
362 })
363 .collect();
364 answers.push(answer);
365 self.advance_question(answers)
366 }
367 KeyCode::Backspace => {
368 self.other_input.pop();
369 ViewAction::None
370 }
371 KeyCode::Char('h')
372 if key
373 .modifiers
374 .contains(crossterm::event::KeyModifiers::CONTROL) =>
375 {
376 self.other_input.pop();
377 ViewAction::None
378 }
379 KeyCode::Char(ch) => {
380 if !ch.is_control() {
381 self.other_input.push(ch);
382 }
383 ViewAction::None
384 }
385 _ => ViewAction::None,
386 }
387 }
388 }
389
390 impl ModalView for UserInputView {
391 fn kind(&self) -> ModalKind {
392 ModalKind::UserInput
393 }
394
395 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
396 self
397 }
398
399 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
400 self.manual_scroll = false;
401 match self.mode {
402 InputMode::Selecting => self.handle_selecting_key(key),
403 InputMode::OtherInput => self.handle_other_input_key(key),
404 }
405 }
406
407 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
408 let scroll = match mouse.kind {
409 MouseEventKind::ScrollUp => self.scroll_offset.get().saturating_sub(3),
410 MouseEventKind::ScrollDown => self.scroll_offset.get().saturating_add(3),
411 _ => return ViewAction::None,
412 };
413 self.manual_scroll = true;
414 self.scroll_offset.set(scroll.min(self.max_scroll.get()));
415 ViewAction::None
416 }
417
418 fn render(&self, area: Rect, buf: &mut Buffer) {
419 let question = self.current_question();
420 let total = self.request.questions.len();
421 let header = format!(
422 " {} ({}/{}) ",
423 question.header,
424 self.question_index + 1,
425 total
426 );
427
428 let mut lines: Vec<Line> = Vec::new();
429 lines.push(Line::from(vec![Span::styled(
430 "Action required",
431 Style::default().fg(palette::WHALE_ACTION).bold(),
432 )]));
433 lines.push(Line::from(vec![
434 Span::styled(
435 question.header.clone(),
436 Style::default().fg(palette::TEXT_PRIMARY).bold(),
437 ),
438 Span::styled(
439 format!(" Question {} of {}", self.question_index + 1, total),
440 Style::default().fg(palette::TEXT_MUTED),
441 ),
442 ]));
443 lines.push(Line::from(""));
444 lines.push(Line::from(vec![Span::styled(
445 question.question.clone(),
446 Style::default().fg(palette::TEXT_PRIMARY).bold(),
447 )]));
448 lines.push(Line::from(""));
449
450 for (idx, option) in question.options.iter().enumerate() {
451 let number = idx + 1;
452 let ticked = self.is_multi_select() && self.multi_pending.contains(&idx);
453 push_option_lines(
454 &mut lines,
455 self.selected == idx,
456 number,
457 option.label.clone(),
458 option.description.clone(),
459 ticked,
460 );
461 }
462
463 // A custom response stays available alongside the suggested options.
464 if self.offers_other() {
465 let other_index = question.options.len();
466 let other_number = other_index + 1;
467 push_option_lines(
468 &mut lines,
469 self.selected == other_index,
470 other_number,
471 "Other".to_string(),
472 "Type a custom response".to_string(),
473 false,
474 );
475 }
476
477 // Multi-select gets a dedicated "Confirm selection" row after the
478 // options (and after "Other" when present). Selecting and pressing
479 // Enter on it flushes the pending set as the question's answers.
480 if let Some(confirm_index) = self.confirm_index() {
481 let confirm_number = confirm_index + 1;
482 push_option_lines(
483 &mut lines,
484 self.selected == confirm_index,
485 confirm_number,
486 "Confirm selection".to_string(),
487 format!("Submit {} selected", self.multi_pending.len()),
488 false,
489 );
490 }
491
492 if self.mode == InputMode::OtherInput {
493 lines.push(Line::from(""));
494 lines.push(Line::from(vec![
495 Span::styled(
496 "> Custom response:",
497 Style::default().fg(palette::TEXT_PRIMARY).bold(),
498 ),
499 Span::raw(" "),
500 Span::styled(
501 if self.other_input.is_empty() {
502 "(type your response)".to_string()
503 } else {
504 self.other_input.clone()
505 },
506 Style::default().fg(palette::WHALE_HUMAN),
507 ),
508 ]));
509 }
510
511 lines.push(Line::from(""));
512 if self.mode == InputMode::OtherInput {
513 lines.push(Line::from(vec![
514 Span::styled("Enter", Style::default().fg(palette::WHALE_ACTION).bold()),
515 Span::styled(" submit", Style::default().fg(palette::TEXT_MUTED)),
516 Span::raw(" "),
517 Span::styled("Esc", Style::default().fg(palette::WHALE_ACTION).bold()),
518 Span::styled(" back", Style::default().fg(palette::TEXT_MUTED)),
519 ]));
520 } else {
521 let opt_count = self.option_count();
522 let quick_pick_label = if opt_count <= 9 {
523 format!("1-{opt_count}")
524 } else {
525 "digit".to_string()
526 };
527 if self.is_multi_select() {
528 lines.push(Line::from(vec![
529 Span::styled(
530 quick_pick_label,
531 Style::default().fg(palette::WHALE_ACTION).bold(),
532 ),
533 Span::styled(" move", Style::default().fg(palette::TEXT_MUTED)),
534 Span::raw(" "),
535 Span::styled("Space", Style::default().fg(palette::WHALE_ACTION).bold()),
536 Span::styled(" toggle", Style::default().fg(palette::TEXT_MUTED)),
537 Span::raw(" "),
538 Span::styled("Enter", Style::default().fg(palette::WHALE_ACTION).bold()),
539 Span::styled(" select/confirm", Style::default().fg(palette::TEXT_MUTED)),
540 Span::raw(" "),
541 Span::styled("←/h", Style::default().fg(palette::WHALE_ACTION).bold()),
542 Span::styled(" back", Style::default().fg(palette::TEXT_MUTED)),
543 Span::raw(" "),
544 Span::styled("Esc", Style::default().fg(palette::WHALE_ACTION).bold()),
545 Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)),
546 ]));
547 } else {
548 lines.push(Line::from(vec![
549 Span::styled(
550 quick_pick_label,
551 Style::default().fg(palette::WHALE_ACTION).bold(),
552 ),
553 Span::styled(" quick pick", Style::default().fg(palette::TEXT_MUTED)),
554 Span::raw(" "),
555 Span::styled("↑/↓", Style::default().fg(palette::WHALE_ACTION).bold()),
556 Span::styled(" move", Style::default().fg(palette::TEXT_MUTED)),
557 Span::raw(" "),
558 Span::styled("Enter", Style::default().fg(palette::WHALE_ACTION).bold()),
559 Span::styled(" confirm", Style::default().fg(palette::TEXT_MUTED)),
560 Span::raw(" "),
561 Span::styled("←/h", Style::default().fg(palette::WHALE_ACTION).bold()),
562 Span::styled(" back", Style::default().fg(palette::TEXT_MUTED)),
563 Span::raw(" "),
564 Span::styled("Esc", Style::default().fg(palette::WHALE_ACTION).bold()),
565 Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)),
566 ]));
567 }
568 }
569
570 let popup_area = sheet_rect(area, self.content_line_count());
571 let inner_h = popup_area.height.saturating_sub(4) as usize;
572 // Paragraph scroll offsets count wrapped rows, not the source Lines.
573 // Use the same wrapper for focus measurement and painting so long
574 // questions/descriptions cannot hide the highlighted choice (#6045).
575 let width = modal_block(&header).inner(popup_area).width.max(1);
576 let heights: Vec<usize> = lines
577 .iter()
578 .map(|line| {
579 Paragraph::new(line.clone())
580 .wrap(Wrap { trim: true })
581 .line_count(width)
582 })
583 .collect();
584 let (start, end) = self.focused_line_range();
585 let focus_end = heights[..=end].iter().sum::<usize>().saturating_sub(1);
586 let focus_start = if self.mode == InputMode::OtherInput {
587 // Keep the typing end visible when a custom answer wraps.
588 focus_end
589 } else {
590 heights[..start].iter().sum()
591 };
592 let total_rows = heights.iter().sum::<usize>();
593 let max_scroll = u16::try_from(total_rows.saturating_sub(inner_h)).unwrap_or(u16::MAX);
594 let scroll = if self.manual_scroll {
595 self.scroll_offset.get().min(max_scroll)
596 } else {
597 scroll_to_keep_range_visible((focus_start, focus_end), total_rows, inner_h)
598 };
599 self.scroll_offset.set(scroll);
600 self.max_scroll.set(max_scroll);
601 let paragraph = Paragraph::new(lines)
602 .alignment(Alignment::Left)
603 .wrap(Wrap { trim: true })
604 .scroll((scroll, 0))
605 .block(modal_block(&header));
606
607 render_modal_chrome(area, popup_area, buf);
608 paragraph.render(popup_area, buf);
609 }
610
611 fn occupied_region(&self, area: Rect) -> Rect {
612 // Bottom sheet plus the one-cell drop shadow `render_modal_surface`
613 // draws at +1/+1. Transcript above the sheet stays undimmed.
614 let popup = sheet_rect(area, self.content_line_count());
615 Rect {
616 x: popup.x,
617 y: popup.y,
618 width: (popup.width.saturating_add(1)).min(area.right().saturating_sub(popup.x)),
619 height: (popup.height.saturating_add(1)).min(area.bottom().saturating_sub(popup.y)),
620 }
621 }
622 }
623
624 /// Bottom-anchored sheet: grows with content, leaves a transcript strip
625 /// above when the frame is tall enough, and never uses a fixed 22-row cap
626 /// that clips options on every terminal ≥ 37 rows (#6045).
627 fn sheet_rect(r: Rect, content_lines: usize) -> Rect {
628 if r.width == 0 || r.height == 0 {
629 return Rect {
630 x: r.x,
631 y: r.y.saturating_add(r.height),
632 width: 0,
633 height: 0,
634 };
635 }
636 let desired = u16::try_from(content_lines)
637 .unwrap_or(u16::MAX)
638 .saturating_add(4);
639 let transcript_reserve = if r.height >= 16 {
640 4u16.min(r.height.saturating_sub(8))
641 } else {
642 0
643 };
644 let max_height = r
645 .height
646 .saturating_sub(transcript_reserve)
647 .max(6.min(r.height));
648 let height = desired.min(max_height).max(6.min(r.height)).min(r.height);
649 Rect {
650 x: r.x,
651 y: r.y.saturating_add(r.height.saturating_sub(height)),
652 width: r.width,
653 height,
654 }
655 }
656
657 fn scroll_to_keep_range_visible(
658 (focus_start, focus_end): (usize, usize),
659 total_lines: usize,
660 inner_h: usize,
661 ) -> u16 {
662 if inner_h == 0 || total_lines <= inner_h {
663 return 0;
664 }
665 let max_scroll = total_lines.saturating_sub(inner_h);
666 let mut scroll = 0usize;
667 if focus_end >= inner_h {
668 scroll = focus_end.saturating_add(1).saturating_sub(inner_h);
669 }
670 if focus_start < scroll {
671 scroll = focus_start;
672 }
673 u16::try_from(scroll.min(max_scroll)).unwrap_or(u16::MAX)
674 }
675
676 #[cfg(test)]
677 mod tests {
678 use super::*;
679 use crate::tools::user_input::{UserInputOption, UserInputQuestion, UserInputRequest};
680
681 fn render_view(view: &UserInputView, width: u16, height: u16) -> String {
682 let area = Rect::new(0, 0, width, height);
683 let mut buf = Buffer::empty(area);
684 view.render(area, &mut buf);
685
686 (0..height)
687 .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
688 .collect::<Vec<_>>()
689 .join("\n")
690 }
691
692 fn sample_view() -> UserInputView {
693 UserInputView::new(
694 "tool-1",
695 UserInputRequest {
696 questions: vec![UserInputQuestion {
697 header: "Confirm".to_string(),
698 id: "confirm".to_string(),
699 question: "What should happen next?".to_string(),
700 options: vec![
701 UserInputOption {
702 label: "Ship it".to_string(),
703 description: "Proceed with the current change set".to_string(),
704 },
705 UserInputOption {
706 label: "Revise it".to_string(),
707 description: "Return to editing before continuing".to_string(),
708 },
709 ],
710 allow_free_text: true,
711 multi_select: false,
712 }],
713 },
714 )
715 }
716
717 #[test]
718 fn user_input_modal_calls_out_required_action_and_controls() {
719 let rendered = render_view(&sample_view(), 110, 36);
720
721 assert!(rendered.contains("Action required"));
722 assert!(rendered.contains("Question 1 of 1"));
723 assert!(rendered.contains("quick pick"));
724 // allow_free_text=true surfaces the Other row.
725 assert!(rendered.contains("Other"));
726 }
727
728 #[test]
729 fn user_input_modal_renders_custom_response_state() {
730 let mut view = sample_view();
731 view.selected = 2;
732 view.mode = InputMode::OtherInput;
733 view.other_input = "Need one more pass".to_string();
734
735 let rendered = render_view(&view, 110, 36);
736
737 assert!(rendered.contains("Custom response"));
738 assert!(rendered.contains("Need one more pass"));
739 assert!(rendered.contains("Enter"));
740 assert!(rendered.contains("submit"));
741 }
742
743 #[test]
744 fn user_input_modal_keeps_other_row_when_free_text_disabled() {
745 // v0.9.4: a custom free-text response is ALWAYS available alongside
746 // the options, even when the model did not offer it. The wire field
747 // `allow_free_text` stays for backward compatibility but no longer
748 // gates the row (#3102 originally hid it).
749 let mut view = sample_view();
750 view.request.questions[0].allow_free_text = false;
751 view.selected = 0;
752
753 let rendered = render_view(&view, 110, 36);
754 assert!(
755 rendered.contains("Type a custom response"),
756 "Other row must stay reachable even when allow_free_text is false"
757 );
758 assert!(rendered.contains("Other"));
759
760 // Entering the row switches to free-text input mode regardless.
761 view.selected = view.option_count() - 1;
762 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
763 assert!(matches!(action, ViewAction::None));
764 assert_eq!(view.mode, InputMode::OtherInput);
765 }
766
767 #[test]
768 fn user_input_modal_renders_multi_select_ticks_and_confirm() {
769 // Issue #3102: multi_select=true renders a check-mark gutter on
770 // toggled options plus a trailing "Confirm selection" row, and the
771 // controls hint advertises Space/Enter toggle semantics. With the
772 // v0.9.4 always-available Other row, confirm sits at index 3.
773 let mut view = sample_view();
774 view.request.questions[0].multi_select = true;
775 view.request.questions[0].allow_free_text = false;
776 // Toggle the first option into the pending set.
777 view.multi_pending.push(0);
778 // Highlight the confirm row (last selectable row).
779 view.selected = view.option_count() - 1;
780
781 let rendered = render_view(&view, 120, 40);
782 assert!(rendered.contains("✔"), "toggled option shows a check mark");
783 assert!(
784 rendered.contains("Confirm selection"),
785 "multi-select renders a confirm row"
786 );
787 assert!(rendered.contains("Submit 1 selected"));
788 assert!(rendered.contains("toggle"));
789 assert!(
790 rendered.contains("▸ 4) Confirm selection"),
791 "confirm row should display selected focus at its real quick-pick index"
792 );
793 assert!(
794 !rendered.contains("5) Confirm selection"),
795 "confirm row must not advertise an unreachable quick-pick number"
796 );
797 }
798
799 #[test]
800 fn user_input_modal_space_toggles_and_enter_confirms_multi_select() {
801 // Keyboard-first multi-select: Space toggles the highlighted option
802 // into the pending set without leaving the picker; Enter on the
803 // confirm row flushes the set.
804 let mut view = sample_view();
805 view.request.questions[0].multi_select = true;
806 view.selected = 0;
807
808 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Char(' ')));
809 assert!(matches!(action, ViewAction::None));
810 assert_eq!(view.multi_pending, vec![0], "Space toggles option 0 in");
811
812 let _action = view.handle_selecting_key(KeyEvent::from(KeyCode::Char(' ')));
813 assert!(view.multi_pending.is_empty(), "Space toggles option 0 out");
814
815 // Space on the confirm row must not toggle it (it is not an option).
816 view.selected = view.confirm_index().expect("confirm row present");
817 let before = view.multi_pending.clone();
818 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Char(' ')));
819 assert!(matches!(action, ViewAction::None));
820 assert_eq!(view.multi_pending, before, "Space on confirm is a no-op");
821
822 // Enter on the confirm row flushes the pending set as answers.
823 view.multi_pending.push(0);
824 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
825 assert!(
826 matches!(action, ViewAction::EmitAndClose(ViewEvent::UserInputSubmitted { tool_id, response })
827 if tool_id == "tool-1" && response.answers.first().is_some_and(|a| a.value == "Ship it")),
828 "Enter on confirm submits the toggled options"
829 );
830 }
831
832 #[test]
833 fn user_input_modal_double_enter_never_submits_empty_multi_select() {
834 // Enter on a multi-select option used to toggle it into the pending
835 // set, so a second Enter (single-select muscle memory) toggled it
836 // back out — and Confirm then submitted an empty answer set.
837 let mut view = sample_view();
838 view.request.questions[0].multi_select = true;
839 view.selected = 0;
840
841 // First Enter: option 0 joins the pending set, focus moves to Confirm.
842 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
843 assert!(matches!(action, ViewAction::None));
844 assert_eq!(
845 view.multi_pending,
846 vec![0],
847 "Enter selects the highlighted option"
848 );
849 assert!(
850 view.is_confirm_selected(),
851 "focus moves to the Confirm row after Enter"
852 );
853
854 // Second Enter submits exactly that option — never an empty set.
855 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
856 assert!(
857 matches!(action, ViewAction::EmitAndClose(ViewEvent::UserInputSubmitted { tool_id, response })
858 if tool_id == "tool-1"
859 && response.answers.len() == 1
860 && response.answers[0].value == "Ship it"),
861 "double-Enter must submit the highlighted option, not an empty set"
862 );
863 }
864
865 #[test]
866 fn user_input_modal_enter_never_deselects_multi_select_option() {
867 // Deselecting remains Space's job: Enter on an already-toggled option
868 // keeps it in the pending set.
869 let mut view = sample_view();
870 view.request.questions[0].multi_select = true;
871 view.selected = 0;
872
873 let _ = view.handle_selecting_key(KeyEvent::from(KeyCode::Char(' ')));
874 assert_eq!(view.multi_pending, vec![0], "Space toggles option 0 in");
875
876 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
877 assert!(matches!(action, ViewAction::None));
878 assert_eq!(
879 view.multi_pending,
880 vec![0],
881 "Enter must not toggle the option back out"
882 );
883
884 // Space still toggles both ways.
885 view.selected = 0;
886 let _ = view.handle_selecting_key(KeyEvent::from(KeyCode::Char(' ')));
887 assert!(view.multi_pending.is_empty(), "Space toggles option 0 out");
888 }
889
890 fn many_option_view() -> UserInputView {
891 UserInputView::new(
892 "tool-1",
893 UserInputRequest {
894 questions: vec![
895 UserInputQuestion {
896 header: "Choose".to_string(),
897 id: "q1".to_string(),
898 question: "Which path?".to_string(),
899 options: (1..=8)
900 .map(|n| UserInputOption {
901 label: format!("Option {n}"),
902 description: format!(
903 "A longer description for option {n} that wraps on a narrow terminal"
904 ),
905 })
906 .collect(),
907 allow_free_text: true,
908 multi_select: false,
909 },
910 UserInputQuestion {
911 header: "Confirm".to_string(),
912 id: "q2".to_string(),
913 question: "Second question after the first.".to_string(),
914 options: vec![UserInputOption {
915 label: "Yes".to_string(),
916 description: "Proceed".to_string(),
917 }],
918 allow_free_text: true,
919 multi_select: false,
920 },
921 ],
922 },
923 )
924 }
925
926 #[test]
927 fn user_input_sheet_is_bottom_anchored_and_not_capped_at_22() {
928 let area = Rect::new(0, 0, 141, 38);
929 let view = many_option_view();
930 let content = view.content_line_count();
931 let popup = sheet_rect(area, content);
932
933 assert_eq!(
934 popup.bottom(),
935 area.bottom(),
936 "sheet must sit on the bottom"
937 );
938 assert!(popup.y > 0, "transcript strip remains above the sheet");
939 assert_eq!(popup.width, area.width);
940 assert!(
941 popup.height > 22,
942 "141×38 must not be stuck at the old 22-row cap, got {}",
943 popup.height
944 );
945
946 let capped = sheet_rect(area, 100);
947 assert!(
948 capped.height > 22,
949 "long content may grow past 22 rows; got {}",
950 capped.height
951 );
952 assert_eq!(capped.bottom(), area.bottom());
953 }
954
955 fn wheel(view: &mut UserInputView, kind: MouseEventKind) {
956 assert!(matches!(
957 view.handle_mouse(MouseEvent {
958 kind,
959 column: 1,
960 row: 1,
961 modifiers: crossterm::event::KeyModifiers::NONE,
962 }),
963 ViewAction::None
964 ));
965 }
966
967 #[test]
968 fn user_input_wheel_browses_wrapped_rows_without_changing_answers() {
969 for (width, height) in [(40, 12), (80, 24), (100, 32), (141, 38)] {
970 let mut view = many_option_view();
971 view.request.questions[0].multi_select = true;
972 view.multi_pending.push(0);
973 let before = render_view(&view, width, height);
974 let initial = view.scroll_offset.get();
975 wheel(&mut view, MouseEventKind::ScrollDown);
976 let after = render_view(&view, width, height);
977 assert_eq!(
978 view.scroll_offset.get(),
979 (initial + 3).min(view.max_scroll.get())
980 );
981 if view.max_scroll.get() == 0 {
982 assert_eq!(before, after, "fully visible content must stay still");
983 } else {
984 assert_ne!(
985 before, after,
986 "overflowing question content must scroll at {width}x{height}"
987 );
988 }
989 assert_eq!(view.selected, 0);
990 assert_eq!(view.multi_pending, [0]);
991 assert!(view.answered.is_empty());
992
993 for _ in 0..100 {
994 wheel(&mut view, MouseEventKind::ScrollDown);
995 }
996 assert_eq!(view.scroll_offset.get(), view.max_scroll.get());
997 view.handle_key(KeyEvent::from(KeyCode::Down));
998 let focused = render_view(&view, width, height);
999 assert!(focused.contains("▸ 2) Option 2"), "{focused}");
1000 assert_eq!(view.multi_pending, [0]);
1001 for _ in 0..100 {
1002 wheel(&mut view, MouseEventKind::ScrollUp);
1003 }
1004 assert_eq!(view.scroll_offset.get(), 0);
1005 }
1006 }
1007
1008 #[test]
1009 fn user_input_wheel_resize_and_typing_restore_custom_answer_visibility() {
1010 let mut view = many_option_view();
1011 view.selected = view.option_count() - 1;
1012 view.handle_key(KeyEvent::from(KeyCode::Enter));
1013 for ch in "retained custom answer".chars() {
1014 view.handle_key(KeyEvent::from(KeyCode::Char(ch)));
1015 }
1016 render_view(&view, 40, 12);
1017 wheel(&mut view, MouseEventKind::ScrollDown);
1018 let narrow_offset = view.scroll_offset.get();
1019 render_view(&view, 141, 38);
1020 assert!(view.max_scroll.get() < narrow_offset);
1021 assert_eq!(view.scroll_offset.get(), view.max_scroll.get());
1022
1023 render_view(&view, 40, 12);
1024 for _ in 0..100 {
1025 wheel(&mut view, MouseEventKind::ScrollUp);
1026 }
1027 let browsing = render_view(&view, 40, 12);
1028 assert!(!browsing.contains("retained custom answer"));
1029 view.handle_key(KeyEvent::from(KeyCode::Char('!')));
1030 let editing = render_view(&view, 40, 12);
1031 assert!(editing.contains("answer!"), "{editing}");
1032 assert_eq!(view.other_input, "retained custom answer!");
1033 view.handle_key(KeyEvent::from(KeyCode::Enter));
1034 assert_eq!(view.answered[0][0].value, "retained custom answer!");
1035 assert_eq!(view.question_index, 1);
1036 view.handle_key(KeyEvent::from(KeyCode::Left));
1037 assert_eq!(view.question_index, 0);
1038 assert!(
1039 view.answered.is_empty(),
1040 "back navigation permits correction"
1041 );
1042 }
1043
1044 #[test]
1045 fn user_input_sheet_fits_80x24_and_keeps_selection_visible() {
1046 let mut view = many_option_view();
1047 view.selected = 7;
1048 let rendered = render_view(&view, 80, 24);
1049 assert!(
1050 rendered.contains("Option 8") || rendered.contains("8) Option"),
1051 "selected last option must be scrolled into view on 80×24:\n{rendered}"
1052 );
1053 let popup = sheet_rect(Rect::new(0, 0, 80, 24), view.content_line_count());
1054 assert_eq!(popup.bottom(), 24);
1055 assert!(popup.height <= 24);
1056 }
1057
1058 #[test]
1059 fn wrapped_questions_keep_choices_and_custom_typing_visible() {
1060 let mut view = sample_view();
1061 let question = &mut view.request.questions[0];
1062 question.question = "Choose a synthetic option to verify the question sheet, its scrolling, and visible selection when both the question and option descriptions wrap across several terminal rows.".into();
1063 question.options = (1..=4)
1064 .map(|n| UserInputOption {
1065 label: format!("Option {n}"),
1066 description: "This synthetic option has a long description that wraps across several rows; choosing it writes no external state and triggers no provider charge.".into(),
1067 })
1068 .collect();
1069 question.multi_select = true;
1070
1071 for (width, height) in [(40, 12), (60, 16), (80, 24), (100, 32), (140, 40)] {
1072 view.mode = InputMode::Selecting;
1073 for selected in 0..view.option_count() {
1074 view.selected = selected;
1075 let label = match selected {
1076 4 => "Other".to_string(),
1077 5 => "Confirm selection".to_string(),
1078 _ => format!("Option {}", selected + 1),
1079 };
1080 let focused = format!(
1081 "{} {}) {label}",
1082 crate::tui::glyphs::selection_marker(true),
1083 selected + 1
1084 );
1085 let rendered = render_view(&view, width, height);
1086 assert!(
1087 rendered.contains(&focused),
1088 "highlighted choice must remain visible at {width}x{height}:\n{rendered}"
1089 );
1090 }
1091 view.mode = InputMode::OtherInput;
1092 view.other_input = format!("{}TAIL_SENTINEL", "輸入 text ".repeat(40));
1093 let rendered = render_view(&view, width, height);
1094 assert!(
1095 rendered.contains("TAIL_SENTINEL"),
1096 "custom-response typing end must remain visible at {width}x{height}:\n{rendered}"
1097 );
1098 }
1099 }
1100
1101 #[test]
1102 fn user_input_custom_response_stays_visible_while_typing() {
1103 let mut view = many_option_view();
1104 view.selected = view.option_count() - 1;
1105 view.mode = InputMode::OtherInput;
1106 view.other_input = "Need one more pass on the last option".to_string();
1107 for (width, height) in [(141, 38), (80, 24)] {
1108 let rendered = render_view(&view, width, height);
1109 assert!(
1110 rendered.contains("Need one more pass"),
1111 "typed custom response must stay visible at {width}×{height}:\n{rendered}"
1112 );
1113 }
1114 }
1115
1116 #[test]
1117 fn user_input_left_goes_back_to_previous_question() {
1118 let mut view = many_option_view();
1119 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
1120 assert!(matches!(action, ViewAction::None));
1121 assert_eq!(view.question_index, 1);
1122 assert_eq!(view.answered.len(), 1);
1123
1124 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Left));
1125 assert!(matches!(action, ViewAction::None));
1126 assert_eq!(view.question_index, 0);
1127 assert!(view.answered.is_empty());
1128
1129 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Char('h')));
1130 assert!(matches!(action, ViewAction::None));
1131 assert_eq!(
1132 view.question_index, 0,
1133 "back on the first question is a no-op"
1134 );
1135 }
1136
1137 #[test]
1138 fn user_input_modal_occupied_region_matches_painted_card_plus_shadow() {
1139 let area = Rect::new(0, 0, 120, 40);
1140 let view = sample_view();
1141 let popup = sheet_rect(area, view.content_line_count());
1142 let occupied = view.occupied_region(area);
1143
1144 assert_eq!(occupied.x, popup.x);
1145 assert_eq!(occupied.y, popup.y);
1146 assert_eq!(
1147 occupied.width,
1148 (popup.width.saturating_add(1)).min(area.right().saturating_sub(popup.x))
1149 );
1150 assert_eq!(
1151 occupied.height,
1152 (popup.height.saturating_add(1)).min(area.bottom().saturating_sub(popup.y))
1153 );
1154 assert!(area.right() >= occupied.right());
1155 assert!(area.bottom() >= occupied.bottom());
1156 }
1157
1158 #[test]
1159 fn user_input_modal_leaves_surrounding_frame_visible() {
1160 use crate::tui::views::ViewStack;
1161
1162 let area = Rect::new(0, 0, 120, 40);
1163 let mut buf = Buffer::empty(area);
1164 // Pre-fill the frame as if the live transcript had painted it.
1165 for y in 0..area.height {
1166 for x in 0..area.width {
1167 buf[(x, y)].set_symbol("·");
1168 }
1169 }
1170
1171 let mut stack = ViewStack::default();
1172 stack.push(sample_view());
1173 stack.render(area, &mut buf);
1174
1175 // Transcript above the bottom sheet survives untouched.
1176 assert_eq!(buf[(0, 0)].symbol(), "·");
1177 assert_eq!(buf[(119, 0)].symbol(), "·");
1178 assert_eq!(buf[(60, 0)].symbol(), "·");
1179 assert_eq!(buf[(60, 4)].symbol(), "·");
1180 // The sheet itself is blanked + repainted by the modal surface.
1181 assert_ne!(buf[(60, 39)].symbol(), "·");
1182 assert_ne!(buf[(0, 39)].symbol(), "·");
1183 assert_ne!(buf[(119, 39)].symbol(), "·");
1184 }
1185
1186 #[test]
1187 fn user_input_modal_numbers_confirm_after_other_row() {
1188 let mut view = sample_view();
1189 view.request.questions[0].multi_select = true;
1190 view.request.questions[0].allow_free_text = true;
1191 view.selected = view.option_count() - 1;
1192
1193 let rendered = render_view(&view, 120, 40);
1194 assert!(rendered.contains("3) Other"));
1195 assert!(
1196 rendered.contains("▸ 4) Confirm selection"),
1197 "confirm should follow the optional Other row with selected focus"
1198 );
1199 assert!(!rendered.contains("5) Confirm selection"));
1200 }
1201 }
1202
1202 lines RUST