返回 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 /// A pasted custom answer lands in the free-text field (U08-m1); while
408 /// choosing among options the paste is not consumed. Line breaks become
409 /// spaces because Enter submits this single-line answer.
410 fn handle_paste(&mut self, text: &str) -> bool {
411 if self.mode != InputMode::OtherInput {
412 return false;
413 }
414 self.other_input.extend(
415 text.chars()
416 .map(|ch| {
417 if matches!(ch, '\n' | '\r' | '\t') {
418 ' '
419 } else {
420 ch
421 }
422 })
423 .filter(|ch| !ch.is_control()),
424 );
425 true
426 }
427
428 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
429 let scroll = match mouse.kind {
430 MouseEventKind::ScrollUp => self.scroll_offset.get().saturating_sub(3),
431 MouseEventKind::ScrollDown => self.scroll_offset.get().saturating_add(3),
432 _ => return ViewAction::None,
433 };
434 self.manual_scroll = true;
435 self.scroll_offset.set(scroll.min(self.max_scroll.get()));
436 ViewAction::None
437 }
438
439 fn render(&self, area: Rect, buf: &mut Buffer) {
440 let question = self.current_question();
441 let total = self.request.questions.len();
442 let header = format!(
443 " {} ({}/{}) ",
444 question.header,
445 self.question_index + 1,
446 total
447 );
448
449 let mut lines: Vec<Line> = Vec::new();
450 lines.push(Line::from(vec![Span::styled(
451 "Action required",
452 Style::default().fg(palette::WHALE_ACTION).bold(),
453 )]));
454 lines.push(Line::from(vec![
455 Span::styled(
456 question.header.clone(),
457 Style::default().fg(palette::TEXT_PRIMARY).bold(),
458 ),
459 Span::styled(
460 format!(" Question {} of {}", self.question_index + 1, total),
461 Style::default().fg(palette::TEXT_MUTED),
462 ),
463 ]));
464 lines.push(Line::from(""));
465 lines.push(Line::from(vec![Span::styled(
466 question.question.clone(),
467 Style::default().fg(palette::TEXT_PRIMARY).bold(),
468 )]));
469 lines.push(Line::from(""));
470
471 for (idx, option) in question.options.iter().enumerate() {
472 let number = idx + 1;
473 let ticked = self.is_multi_select() && self.multi_pending.contains(&idx);
474 push_option_lines(
475 &mut lines,
476 self.selected == idx,
477 number,
478 option.label.clone(),
479 option.description.clone(),
480 ticked,
481 );
482 }
483
484 // A custom response stays available alongside the suggested options.
485 if self.offers_other() {
486 let other_index = question.options.len();
487 let other_number = other_index + 1;
488 push_option_lines(
489 &mut lines,
490 self.selected == other_index,
491 other_number,
492 "Other".to_string(),
493 "Type a custom response".to_string(),
494 false,
495 );
496 }
497
498 // Multi-select gets a dedicated "Confirm selection" row after the
499 // options (and after "Other" when present). Selecting and pressing
500 // Enter on it flushes the pending set as the question's answers.
501 if let Some(confirm_index) = self.confirm_index() {
502 let confirm_number = confirm_index + 1;
503 push_option_lines(
504 &mut lines,
505 self.selected == confirm_index,
506 confirm_number,
507 "Confirm selection".to_string(),
508 format!("Submit {} selected", self.multi_pending.len()),
509 false,
510 );
511 }
512
513 if self.mode == InputMode::OtherInput {
514 lines.push(Line::from(""));
515 lines.push(Line::from(vec![
516 Span::styled(
517 "> Custom response:",
518 Style::default().fg(palette::TEXT_PRIMARY).bold(),
519 ),
520 Span::raw(" "),
521 Span::styled(
522 if self.other_input.is_empty() {
523 "(type your response)".to_string()
524 } else {
525 self.other_input.clone()
526 },
527 Style::default().fg(palette::WHALE_HUMAN),
528 ),
529 ]));
530 }
531
532 lines.push(Line::from(""));
533 if self.mode == InputMode::OtherInput {
534 lines.push(Line::from(vec![
535 Span::styled("Enter", Style::default().fg(palette::WHALE_ACTION).bold()),
536 Span::styled(" submit", Style::default().fg(palette::TEXT_MUTED)),
537 Span::raw(" "),
538 Span::styled("Esc", Style::default().fg(palette::WHALE_ACTION).bold()),
539 Span::styled(" back", Style::default().fg(palette::TEXT_MUTED)),
540 ]));
541 } else {
542 let opt_count = self.option_count();
543 let quick_pick_label = if opt_count <= 9 {
544 format!("1-{opt_count}")
545 } else {
546 "digit".to_string()
547 };
548 if self.is_multi_select() {
549 lines.push(Line::from(vec![
550 Span::styled(
551 quick_pick_label,
552 Style::default().fg(palette::WHALE_ACTION).bold(),
553 ),
554 Span::styled(" move", Style::default().fg(palette::TEXT_MUTED)),
555 Span::raw(" "),
556 Span::styled("Space", Style::default().fg(palette::WHALE_ACTION).bold()),
557 Span::styled(" toggle", Style::default().fg(palette::TEXT_MUTED)),
558 Span::raw(" "),
559 Span::styled("Enter", Style::default().fg(palette::WHALE_ACTION).bold()),
560 Span::styled(" select/confirm", Style::default().fg(palette::TEXT_MUTED)),
561 Span::raw(" "),
562 Span::styled("←/h", Style::default().fg(palette::WHALE_ACTION).bold()),
563 Span::styled(" back", Style::default().fg(palette::TEXT_MUTED)),
564 Span::raw(" "),
565 Span::styled("Esc", Style::default().fg(palette::WHALE_ACTION).bold()),
566 Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)),
567 ]));
568 } else {
569 lines.push(Line::from(vec![
570 Span::styled(
571 quick_pick_label,
572 Style::default().fg(palette::WHALE_ACTION).bold(),
573 ),
574 Span::styled(" quick pick", Style::default().fg(palette::TEXT_MUTED)),
575 Span::raw(" "),
576 Span::styled("↑/↓", Style::default().fg(palette::WHALE_ACTION).bold()),
577 Span::styled(" move", Style::default().fg(palette::TEXT_MUTED)),
578 Span::raw(" "),
579 Span::styled("Enter", Style::default().fg(palette::WHALE_ACTION).bold()),
580 Span::styled(" confirm", Style::default().fg(palette::TEXT_MUTED)),
581 Span::raw(" "),
582 Span::styled("←/h", Style::default().fg(palette::WHALE_ACTION).bold()),
583 Span::styled(" back", Style::default().fg(palette::TEXT_MUTED)),
584 Span::raw(" "),
585 Span::styled("Esc", Style::default().fg(palette::WHALE_ACTION).bold()),
586 Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)),
587 ]));
588 }
589 }
590
591 let popup_area = sheet_rect(area, self.content_line_count());
592 let inner_h = popup_area.height.saturating_sub(4) as usize;
593 // Paragraph scroll offsets count wrapped rows, not the source Lines.
594 // Use the same wrapper for focus measurement and painting so long
595 // questions/descriptions cannot hide the highlighted choice (#6045).
596 let width = modal_block(&header).inner(popup_area).width.max(1);
597 let heights: Vec<usize> = lines
598 .iter()
599 .map(|line| {
600 Paragraph::new(line.clone())
601 .wrap(Wrap { trim: true })
602 .line_count(width)
603 })
604 .collect();
605 let (start, end) = self.focused_line_range();
606 let focus_end = heights[..=end].iter().sum::<usize>().saturating_sub(1);
607 let focus_start = if self.mode == InputMode::OtherInput {
608 // Keep the typing end visible when a custom answer wraps.
609 focus_end
610 } else {
611 heights[..start].iter().sum()
612 };
613 let total_rows = heights.iter().sum::<usize>();
614 let max_scroll = u16::try_from(total_rows.saturating_sub(inner_h)).unwrap_or(u16::MAX);
615 let scroll = if self.manual_scroll {
616 self.scroll_offset.get().min(max_scroll)
617 } else {
618 scroll_to_keep_range_visible((focus_start, focus_end), total_rows, inner_h)
619 };
620 self.scroll_offset.set(scroll);
621 self.max_scroll.set(max_scroll);
622 let paragraph = Paragraph::new(lines)
623 .alignment(Alignment::Left)
624 .wrap(Wrap { trim: true })
625 .scroll((scroll, 0))
626 .block(modal_block(&header));
627
628 render_modal_chrome(area, popup_area, buf);
629 paragraph.render(popup_area, buf);
630 }
631
632 fn occupied_region(&self, area: Rect) -> Rect {
633 // Bottom sheet plus the one-cell drop shadow `render_modal_surface`
634 // draws at +1/+1. Transcript above the sheet stays undimmed.
635 let popup = sheet_rect(area, self.content_line_count());
636 Rect {
637 x: popup.x,
638 y: popup.y,
639 width: (popup.width.saturating_add(1)).min(area.right().saturating_sub(popup.x)),
640 height: (popup.height.saturating_add(1)).min(area.bottom().saturating_sub(popup.y)),
641 }
642 }
643 }
644
645 /// Bottom-anchored sheet: grows with content, leaves a transcript strip
646 /// above when the frame is tall enough, and never uses a fixed 22-row cap
647 /// that clips options on every terminal ≥ 37 rows (#6045).
648 fn sheet_rect(r: Rect, content_lines: usize) -> Rect {
649 if r.width == 0 || r.height == 0 {
650 return Rect {
651 x: r.x,
652 y: r.y.saturating_add(r.height),
653 width: 0,
654 height: 0,
655 };
656 }
657 let desired = u16::try_from(content_lines)
658 .unwrap_or(u16::MAX)
659 .saturating_add(4);
660 let transcript_reserve = if r.height >= 16 {
661 4u16.min(r.height.saturating_sub(8))
662 } else {
663 0
664 };
665 let max_height = r
666 .height
667 .saturating_sub(transcript_reserve)
668 .max(6.min(r.height));
669 let height = desired.min(max_height).max(6.min(r.height)).min(r.height);
670 Rect {
671 x: r.x,
672 y: r.y.saturating_add(r.height.saturating_sub(height)),
673 width: r.width,
674 height,
675 }
676 }
677
678 fn scroll_to_keep_range_visible(
679 (focus_start, focus_end): (usize, usize),
680 total_lines: usize,
681 inner_h: usize,
682 ) -> u16 {
683 if inner_h == 0 || total_lines <= inner_h {
684 return 0;
685 }
686 let max_scroll = total_lines.saturating_sub(inner_h);
687 let mut scroll = 0usize;
688 if focus_end >= inner_h {
689 scroll = focus_end.saturating_add(1).saturating_sub(inner_h);
690 }
691 if focus_start < scroll {
692 scroll = focus_start;
693 }
694 u16::try_from(scroll.min(max_scroll)).unwrap_or(u16::MAX)
695 }
696
697 #[cfg(test)]
698 mod tests {
699 use super::*;
700 use crate::tools::user_input::{UserInputOption, UserInputQuestion, UserInputRequest};
701
702 fn render_view(view: &UserInputView, width: u16, height: u16) -> String {
703 let area = Rect::new(0, 0, width, height);
704 let mut buf = Buffer::empty(area);
705 view.render(area, &mut buf);
706
707 (0..height)
708 .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
709 .collect::<Vec<_>>()
710 .join("\n")
711 }
712
713 fn sample_view() -> UserInputView {
714 UserInputView::new(
715 "tool-1",
716 UserInputRequest {
717 questions: vec![UserInputQuestion {
718 header: "Confirm".to_string(),
719 id: "confirm".to_string(),
720 question: "What should happen next?".to_string(),
721 options: vec![
722 UserInputOption {
723 label: "Ship it".to_string(),
724 description: "Proceed with the current change set".to_string(),
725 },
726 UserInputOption {
727 label: "Revise it".to_string(),
728 description: "Return to editing before continuing".to_string(),
729 },
730 ],
731 allow_free_text: true,
732 multi_select: false,
733 }],
734 },
735 )
736 }
737
738 #[test]
739 fn user_input_modal_calls_out_required_action_and_controls() {
740 let rendered = render_view(&sample_view(), 110, 36);
741
742 assert!(rendered.contains("Action required"));
743 assert!(rendered.contains("Question 1 of 1"));
744 assert!(rendered.contains("quick pick"));
745 // allow_free_text=true surfaces the Other row.
746 assert!(rendered.contains("Other"));
747 }
748
749 #[test]
750 fn user_input_modal_renders_custom_response_state() {
751 let mut view = sample_view();
752 view.selected = 2;
753 view.mode = InputMode::OtherInput;
754 view.other_input = "Need one more pass".to_string();
755
756 let rendered = render_view(&view, 110, 36);
757
758 assert!(rendered.contains("Custom response"));
759 assert!(rendered.contains("Need one more pass"));
760 assert!(rendered.contains("Enter"));
761 assert!(rendered.contains("submit"));
762 }
763
764 #[test]
765 fn user_input_modal_keeps_other_row_when_free_text_disabled() {
766 // v0.9.4: a custom free-text response is ALWAYS available alongside
767 // the options, even when the model did not offer it. The wire field
768 // `allow_free_text` stays for backward compatibility but no longer
769 // gates the row (#3102 originally hid it).
770 let mut view = sample_view();
771 view.request.questions[0].allow_free_text = false;
772 view.selected = 0;
773
774 let rendered = render_view(&view, 110, 36);
775 assert!(
776 rendered.contains("Type a custom response"),
777 "Other row must stay reachable even when allow_free_text is false"
778 );
779 assert!(rendered.contains("Other"));
780
781 // Entering the row switches to free-text input mode regardless.
782 view.selected = view.option_count() - 1;
783 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
784 assert!(matches!(action, ViewAction::None));
785 assert_eq!(view.mode, InputMode::OtherInput);
786 }
787
788 #[test]
789 fn user_input_modal_renders_multi_select_ticks_and_confirm() {
790 // Issue #3102: multi_select=true renders a check-mark gutter on
791 // toggled options plus a trailing "Confirm selection" row, and the
792 // controls hint advertises Space/Enter toggle semantics. With the
793 // v0.9.4 always-available Other row, confirm sits at index 3.
794 let mut view = sample_view();
795 view.request.questions[0].multi_select = true;
796 view.request.questions[0].allow_free_text = false;
797 // Toggle the first option into the pending set.
798 view.multi_pending.push(0);
799 // Highlight the confirm row (last selectable row).
800 view.selected = view.option_count() - 1;
801
802 let rendered = render_view(&view, 120, 40);
803 assert!(rendered.contains("✔"), "toggled option shows a check mark");
804 assert!(
805 rendered.contains("Confirm selection"),
806 "multi-select renders a confirm row"
807 );
808 assert!(rendered.contains("Submit 1 selected"));
809 assert!(rendered.contains("toggle"));
810 assert!(
811 rendered.contains("▸ 4) Confirm selection"),
812 "confirm row should display selected focus at its real quick-pick index"
813 );
814 assert!(
815 !rendered.contains("5) Confirm selection"),
816 "confirm row must not advertise an unreachable quick-pick number"
817 );
818 }
819
820 #[test]
821 fn user_input_modal_space_toggles_and_enter_confirms_multi_select() {
822 // Keyboard-first multi-select: Space toggles the highlighted option
823 // into the pending set without leaving the picker; Enter on the
824 // confirm row flushes the set.
825 let mut view = sample_view();
826 view.request.questions[0].multi_select = true;
827 view.selected = 0;
828
829 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Char(' ')));
830 assert!(matches!(action, ViewAction::None));
831 assert_eq!(view.multi_pending, vec![0], "Space toggles option 0 in");
832
833 let _action = view.handle_selecting_key(KeyEvent::from(KeyCode::Char(' ')));
834 assert!(view.multi_pending.is_empty(), "Space toggles option 0 out");
835
836 // Space on the confirm row must not toggle it (it is not an option).
837 view.selected = view.confirm_index().expect("confirm row present");
838 let before = view.multi_pending.clone();
839 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Char(' ')));
840 assert!(matches!(action, ViewAction::None));
841 assert_eq!(view.multi_pending, before, "Space on confirm is a no-op");
842
843 // Enter on the confirm row flushes the pending set as answers.
844 view.multi_pending.push(0);
845 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
846 assert!(
847 matches!(action, ViewAction::EmitAndClose(ViewEvent::UserInputSubmitted { tool_id, response })
848 if tool_id == "tool-1" && response.answers.first().is_some_and(|a| a.value == "Ship it")),
849 "Enter on confirm submits the toggled options"
850 );
851 }
852
853 #[test]
854 fn user_input_modal_double_enter_never_submits_empty_multi_select() {
855 // Enter on a multi-select option used to toggle it into the pending
856 // set, so a second Enter (single-select muscle memory) toggled it
857 // back out — and Confirm then submitted an empty answer set.
858 let mut view = sample_view();
859 view.request.questions[0].multi_select = true;
860 view.selected = 0;
861
862 // First Enter: option 0 joins the pending set, focus moves to Confirm.
863 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
864 assert!(matches!(action, ViewAction::None));
865 assert_eq!(
866 view.multi_pending,
867 vec![0],
868 "Enter selects the highlighted option"
869 );
870 assert!(
871 view.is_confirm_selected(),
872 "focus moves to the Confirm row after Enter"
873 );
874
875 // Second Enter submits exactly that option — never an empty set.
876 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
877 assert!(
878 matches!(action, ViewAction::EmitAndClose(ViewEvent::UserInputSubmitted { tool_id, response })
879 if tool_id == "tool-1"
880 && response.answers.len() == 1
881 && response.answers[0].value == "Ship it"),
882 "double-Enter must submit the highlighted option, not an empty set"
883 );
884 }
885
886 #[test]
887 fn user_input_modal_enter_never_deselects_multi_select_option() {
888 // Deselecting remains Space's job: Enter on an already-toggled option
889 // keeps it in the pending set.
890 let mut view = sample_view();
891 view.request.questions[0].multi_select = true;
892 view.selected = 0;
893
894 let _ = view.handle_selecting_key(KeyEvent::from(KeyCode::Char(' ')));
895 assert_eq!(view.multi_pending, vec![0], "Space toggles option 0 in");
896
897 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
898 assert!(matches!(action, ViewAction::None));
899 assert_eq!(
900 view.multi_pending,
901 vec![0],
902 "Enter must not toggle the option back out"
903 );
904
905 // Space still toggles both ways.
906 view.selected = 0;
907 let _ = view.handle_selecting_key(KeyEvent::from(KeyCode::Char(' ')));
908 assert!(view.multi_pending.is_empty(), "Space toggles option 0 out");
909 }
910
911 fn many_option_view() -> UserInputView {
912 UserInputView::new(
913 "tool-1",
914 UserInputRequest {
915 questions: vec![
916 UserInputQuestion {
917 header: "Choose".to_string(),
918 id: "q1".to_string(),
919 question: "Which path?".to_string(),
920 options: (1..=8)
921 .map(|n| UserInputOption {
922 label: format!("Option {n}"),
923 description: format!(
924 "A longer description for option {n} that wraps on a narrow terminal"
925 ),
926 })
927 .collect(),
928 allow_free_text: true,
929 multi_select: false,
930 },
931 UserInputQuestion {
932 header: "Confirm".to_string(),
933 id: "q2".to_string(),
934 question: "Second question after the first.".to_string(),
935 options: vec![UserInputOption {
936 label: "Yes".to_string(),
937 description: "Proceed".to_string(),
938 }],
939 allow_free_text: true,
940 multi_select: false,
941 },
942 ],
943 },
944 )
945 }
946
947 #[test]
948 fn user_input_sheet_is_bottom_anchored_and_not_capped_at_22() {
949 let area = Rect::new(0, 0, 141, 38);
950 let view = many_option_view();
951 let content = view.content_line_count();
952 let popup = sheet_rect(area, content);
953
954 assert_eq!(
955 popup.bottom(),
956 area.bottom(),
957 "sheet must sit on the bottom"
958 );
959 assert!(popup.y > 0, "transcript strip remains above the sheet");
960 assert_eq!(popup.width, area.width);
961 assert!(
962 popup.height > 22,
963 "141×38 must not be stuck at the old 22-row cap, got {}",
964 popup.height
965 );
966
967 let capped = sheet_rect(area, 100);
968 assert!(
969 capped.height > 22,
970 "long content may grow past 22 rows; got {}",
971 capped.height
972 );
973 assert_eq!(capped.bottom(), area.bottom());
974 }
975
976 fn wheel(view: &mut UserInputView, kind: MouseEventKind) {
977 assert!(matches!(
978 view.handle_mouse(MouseEvent {
979 kind,
980 column: 1,
981 row: 1,
982 modifiers: crossterm::event::KeyModifiers::NONE,
983 }),
984 ViewAction::None
985 ));
986 }
987
988 #[test]
989 fn user_input_wheel_browses_wrapped_rows_without_changing_answers() {
990 for (width, height) in [(40, 12), (80, 24), (100, 32), (141, 38)] {
991 let mut view = many_option_view();
992 view.request.questions[0].multi_select = true;
993 view.multi_pending.push(0);
994 let before = render_view(&view, width, height);
995 let initial = view.scroll_offset.get();
996 wheel(&mut view, MouseEventKind::ScrollDown);
997 let after = render_view(&view, width, height);
998 assert_eq!(
999 view.scroll_offset.get(),
1000 (initial + 3).min(view.max_scroll.get())
1001 );
1002 if view.max_scroll.get() == 0 {
1003 assert_eq!(before, after, "fully visible content must stay still");
1004 } else {
1005 assert_ne!(
1006 before, after,
1007 "overflowing question content must scroll at {width}x{height}"
1008 );
1009 }
1010 assert_eq!(view.selected, 0);
1011 assert_eq!(view.multi_pending, [0]);
1012 assert!(view.answered.is_empty());
1013
1014 for _ in 0..100 {
1015 wheel(&mut view, MouseEventKind::ScrollDown);
1016 }
1017 assert_eq!(view.scroll_offset.get(), view.max_scroll.get());
1018 view.handle_key(KeyEvent::from(KeyCode::Down));
1019 let focused = render_view(&view, width, height);
1020 assert!(focused.contains("▸ 2) Option 2"), "{focused}");
1021 assert_eq!(view.multi_pending, [0]);
1022 for _ in 0..100 {
1023 wheel(&mut view, MouseEventKind::ScrollUp);
1024 }
1025 assert_eq!(view.scroll_offset.get(), 0);
1026 }
1027 }
1028
1029 #[test]
1030 fn user_input_wheel_resize_and_typing_restore_custom_answer_visibility() {
1031 let mut view = many_option_view();
1032 view.selected = view.option_count() - 1;
1033 view.handle_key(KeyEvent::from(KeyCode::Enter));
1034 for ch in "retained custom answer".chars() {
1035 view.handle_key(KeyEvent::from(KeyCode::Char(ch)));
1036 }
1037 render_view(&view, 40, 12);
1038 wheel(&mut view, MouseEventKind::ScrollDown);
1039 let narrow_offset = view.scroll_offset.get();
1040 render_view(&view, 141, 38);
1041 assert!(view.max_scroll.get() < narrow_offset);
1042 assert_eq!(view.scroll_offset.get(), view.max_scroll.get());
1043
1044 render_view(&view, 40, 12);
1045 for _ in 0..100 {
1046 wheel(&mut view, MouseEventKind::ScrollUp);
1047 }
1048 let browsing = render_view(&view, 40, 12);
1049 assert!(!browsing.contains("retained custom answer"));
1050 view.handle_key(KeyEvent::from(KeyCode::Char('!')));
1051 let editing = render_view(&view, 40, 12);
1052 assert!(editing.contains("answer!"), "{editing}");
1053 assert_eq!(view.other_input, "retained custom answer!");
1054 view.handle_key(KeyEvent::from(KeyCode::Enter));
1055 assert_eq!(view.answered[0][0].value, "retained custom answer!");
1056 assert_eq!(view.question_index, 1);
1057 view.handle_key(KeyEvent::from(KeyCode::Left));
1058 assert_eq!(view.question_index, 0);
1059 assert!(
1060 view.answered.is_empty(),
1061 "back navigation permits correction"
1062 );
1063 }
1064
1065 #[test]
1066 fn user_input_sheet_fits_80x24_and_keeps_selection_visible() {
1067 let mut view = many_option_view();
1068 view.selected = 7;
1069 let rendered = render_view(&view, 80, 24);
1070 assert!(
1071 rendered.contains("Option 8") || rendered.contains("8) Option"),
1072 "selected last option must be scrolled into view on 80×24:\n{rendered}"
1073 );
1074 let popup = sheet_rect(Rect::new(0, 0, 80, 24), view.content_line_count());
1075 assert_eq!(popup.bottom(), 24);
1076 assert!(popup.height <= 24);
1077 }
1078
1079 #[test]
1080 fn wrapped_questions_keep_choices_and_custom_typing_visible() {
1081 let mut view = sample_view();
1082 let question = &mut view.request.questions[0];
1083 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();
1084 question.options = (1..=4)
1085 .map(|n| UserInputOption {
1086 label: format!("Option {n}"),
1087 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(),
1088 })
1089 .collect();
1090 question.multi_select = true;
1091
1092 for (width, height) in [(40, 12), (60, 16), (80, 24), (100, 32), (140, 40)] {
1093 view.mode = InputMode::Selecting;
1094 for selected in 0..view.option_count() {
1095 view.selected = selected;
1096 let label = match selected {
1097 4 => "Other".to_string(),
1098 5 => "Confirm selection".to_string(),
1099 _ => format!("Option {}", selected + 1),
1100 };
1101 let focused = format!(
1102 "{} {}) {label}",
1103 crate::tui::glyphs::selection_marker(true),
1104 selected + 1
1105 );
1106 let rendered = render_view(&view, width, height);
1107 assert!(
1108 rendered.contains(&focused),
1109 "highlighted choice must remain visible at {width}x{height}:\n{rendered}"
1110 );
1111 }
1112 view.mode = InputMode::OtherInput;
1113 view.other_input = format!("{}TAIL_SENTINEL", "輸入 text ".repeat(40));
1114 let rendered = render_view(&view, width, height);
1115 assert!(
1116 rendered.contains("TAIL_SENTINEL"),
1117 "custom-response typing end must remain visible at {width}x{height}:\n{rendered}"
1118 );
1119 }
1120 }
1121
1122 #[test]
1123 fn user_input_custom_response_stays_visible_while_typing() {
1124 let mut view = many_option_view();
1125 view.selected = view.option_count() - 1;
1126 view.mode = InputMode::OtherInput;
1127 view.other_input = "Need one more pass on the last option".to_string();
1128 for (width, height) in [(141, 38), (80, 24)] {
1129 let rendered = render_view(&view, width, height);
1130 assert!(
1131 rendered.contains("Need one more pass"),
1132 "typed custom response must stay visible at {width}×{height}:\n{rendered}"
1133 );
1134 }
1135 }
1136
1137 #[test]
1138 fn user_input_left_goes_back_to_previous_question() {
1139 let mut view = many_option_view();
1140 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
1141 assert!(matches!(action, ViewAction::None));
1142 assert_eq!(view.question_index, 1);
1143 assert_eq!(view.answered.len(), 1);
1144
1145 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Left));
1146 assert!(matches!(action, ViewAction::None));
1147 assert_eq!(view.question_index, 0);
1148 assert!(view.answered.is_empty());
1149
1150 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Char('h')));
1151 assert!(matches!(action, ViewAction::None));
1152 assert_eq!(
1153 view.question_index, 0,
1154 "back on the first question is a no-op"
1155 );
1156 }
1157
1158 #[test]
1159 fn user_input_modal_occupied_region_matches_painted_card_plus_shadow() {
1160 let area = Rect::new(0, 0, 120, 40);
1161 let view = sample_view();
1162 let popup = sheet_rect(area, view.content_line_count());
1163 let occupied = view.occupied_region(area);
1164
1165 assert_eq!(occupied.x, popup.x);
1166 assert_eq!(occupied.y, popup.y);
1167 assert_eq!(
1168 occupied.width,
1169 (popup.width.saturating_add(1)).min(area.right().saturating_sub(popup.x))
1170 );
1171 assert_eq!(
1172 occupied.height,
1173 (popup.height.saturating_add(1)).min(area.bottom().saturating_sub(popup.y))
1174 );
1175 assert!(area.right() >= occupied.right());
1176 assert!(area.bottom() >= occupied.bottom());
1177 }
1178
1179 #[test]
1180 fn user_input_modal_leaves_surrounding_frame_visible() {
1181 use crate::tui::views::ViewStack;
1182
1183 let area = Rect::new(0, 0, 120, 40);
1184 let mut buf = Buffer::empty(area);
1185 // Pre-fill the frame as if the live transcript had painted it.
1186 for y in 0..area.height {
1187 for x in 0..area.width {
1188 buf[(x, y)].set_symbol("·");
1189 }
1190 }
1191
1192 let mut stack = ViewStack::default();
1193 stack.push(sample_view());
1194 stack.render(area, &mut buf);
1195
1196 // Transcript above the bottom sheet survives untouched.
1197 assert_eq!(buf[(0, 0)].symbol(), "·");
1198 assert_eq!(buf[(119, 0)].symbol(), "·");
1199 assert_eq!(buf[(60, 0)].symbol(), "·");
1200 assert_eq!(buf[(60, 4)].symbol(), "·");
1201 // The sheet itself is blanked + repainted by the modal surface.
1202 assert_ne!(buf[(60, 39)].symbol(), "·");
1203 assert_ne!(buf[(0, 39)].symbol(), "·");
1204 assert_ne!(buf[(119, 39)].symbol(), "·");
1205 }
1206
1207 #[test]
1208 fn user_input_modal_numbers_confirm_after_other_row() {
1209 let mut view = sample_view();
1210 view.request.questions[0].multi_select = true;
1211 view.request.questions[0].allow_free_text = true;
1212 view.selected = view.option_count() - 1;
1213
1214 let rendered = render_view(&view, 120, 40);
1215 assert!(rendered.contains("3) Other"));
1216 assert!(
1217 rendered.contains("▸ 4) Confirm selection"),
1218 "confirm should follow the optional Other row with selected focus"
1219 );
1220 assert!(!rendered.contains("5) Confirm selection"));
1221 }
1222 }
1223
1223 lines RUST