返回 CodeWhale
user_input.rs
根目录 / crates / tui / src / tui / user_input.rs
1 //! Modal for request_user_input tool prompts.
2
3 use crossterm::event::{KeyCode, KeyEvent};
4 use ratatui::layout::{Alignment, Rect};
5 use ratatui::prelude::*;
6 use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap};
7
8 use crate::palette;
9 use crate::tools::user_input::{
10 UserInputAnswer, UserInputQuestion, UserInputRequest, UserInputResponse,
11 };
12 use crate::tui::menu_style;
13 use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent, render_modal_surface};
14
15 fn modal_block(title: &str) -> Block<'static> {
16 Block::default()
17 .title(Line::from(vec![Span::styled(
18 title.to_string(),
19 Style::default().fg(palette::WHALE_HUMAN).bold(),
20 )]))
21 .borders(Borders::ALL)
22 .border_style(Style::default().fg(palette::BORDER_COLOR))
23 .style(Style::default().bg(palette::WHALE_BG))
24 .padding(Padding::uniform(1))
25 }
26
27 fn render_modal_chrome(area: Rect, popup_area: Rect, buf: &mut Buffer) {
28 render_modal_surface(area, popup_area, buf);
29 }
30
31 fn push_option_lines(
32 lines: &mut Vec<Line<'static>>,
33 selected: bool,
34 number: usize,
35 label: String,
36 description: String,
37 ticked: bool,
38 ) {
39 let row_style = if selected {
40 menu_style::selected_row_style()
41 } else {
42 Style::default().fg(palette::TEXT_PRIMARY)
43 };
44 let detail_style = if selected {
45 row_style
46 } else {
47 Style::default().fg(palette::TEXT_MUTED)
48 };
49 let prefix = crate::tui::glyphs::selection_marker(selected);
50 // Multi-select rows get a check-mark gutter when toggled into the pending
51 // set, mirroring the affordance used in other multi-option pickers.
52 let mark = if ticked { "✔ " } else { " " };
53
54 lines.push(Line::from(Span::styled(
55 format!("{prefix}{mark}{number}) {label}"),
56 row_style,
57 )));
58 lines.push(Line::from(Span::styled(
59 format!(" {description}"),
60 detail_style,
61 )));
62 }
63
64 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
65 enum InputMode {
66 Selecting,
67 OtherInput,
68 }
69
70 #[derive(Debug, Clone)]
71 pub struct UserInputView {
72 tool_id: String,
73 request: UserInputRequest,
74 question_index: usize,
75 selected: usize,
76 mode: InputMode,
77 other_input: String,
78 answers: Vec<UserInputAnswer>,
79 /// Indices toggled into the pending multi-select set for the current
80 /// question. Only used when `question.multi_select` is true.
81 multi_pending: Vec<usize>,
82 }
83
84 impl UserInputView {
85 pub fn new(tool_id: impl Into<String>, request: UserInputRequest) -> Self {
86 Self {
87 tool_id: tool_id.into(),
88 request,
89 question_index: 0,
90 selected: 0,
91 mode: InputMode::Selecting,
92 other_input: String::new(),
93 answers: Vec::new(),
94 multi_pending: Vec::new(),
95 }
96 }
97
98 fn current_question(&self) -> &UserInputQuestion {
99 &self.request.questions[self.question_index]
100 }
101
102 /// Whether the "Other" free-text row is offered for the current question.
103 /// Free text is ALWAYS available so the user can answer with their own
104 /// words even when the model did not offer it. `allow_free_text` remains
105 /// part of the wire request (backward-compatible) but no longer gates the
106 /// row: a custom response must always be reachable alongside the options.
107 fn offers_other(&self) -> bool {
108 true
109 }
110
111 fn option_count(&self) -> usize {
112 // Options + conditional "Other" row + conditional "Confirm" row.
113 let mut count = self.current_question().options.len();
114 count += usize::from(self.offers_other());
115 count += usize::from(self.is_multi_select());
116 count
117 }
118
119 fn is_other_selected(&self) -> bool {
120 // "Other" sits immediately before the Confirm row when both exist, and
121 // is last otherwise.
122 let other_last = !self.is_multi_select();
123 if other_last {
124 self.offers_other() && self.selected + 1 == self.option_count()
125 } else {
126 self.offers_other() && self.selected + 2 == self.option_count()
127 }
128 }
129
130 /// True when the multi-select "Confirm selection" row is highlighted.
131 fn is_confirm_selected(&self) -> bool {
132 self.confirm_index() == Some(self.selected)
133 }
134
135 fn confirm_index(&self) -> Option<usize> {
136 self.is_multi_select()
137 .then(|| self.option_count().saturating_sub(1))
138 }
139
140 fn is_multi_select(&self) -> bool {
141 self.current_question().multi_select
142 }
143
144 /// Number of content lines the render path emits for the current state.
145 /// Drives the content-sized popup height so the dialog hugs what it
146 /// shows instead of claiming a fixed share of the screen.
147 fn content_line_count(&self) -> usize {
148 let question = self.current_question();
149 // "Action required" banner, header line, blank, question, blank.
150 let mut count = 5;
151 count += question.options.len() * 2;
152 if self.offers_other() {
153 count += 2;
154 }
155 if self.is_multi_select() {
156 count += 2;
157 }
158 if self.mode == InputMode::OtherInput {
159 count += 2;
160 }
161 // Trailing blank line + controls hint.
162 count += 2;
163 count
164 }
165
166 fn toggle_pending(&mut self, index: usize) {
167 if let Some(pos) = self.multi_pending.iter().position(|i| *i == index) {
168 self.multi_pending.remove(pos);
169 } else {
170 self.multi_pending.push(index);
171 }
172 }
173
174 /// Build the answer(s) for the current question from a single selected
175 /// option index (single-select and the confirm step of multi-select).
176 fn answers_for_selection(&self, index: usize) -> Vec<UserInputAnswer> {
177 let question = self.current_question();
178 let option = &question.options[index];
179 vec![UserInputAnswer {
180 id: question.id.clone(),
181 label: option.label.clone(),
182 value: option.label.clone(),
183 }]
184 }
185
186 fn advance_question(&mut self, new_answers: Vec<UserInputAnswer>) -> ViewAction {
187 self.answers.extend(new_answers);
188 if self.question_index + 1 >= self.request.questions.len() {
189 let response = UserInputResponse {
190 answers: self.answers.clone(),
191 };
192 return ViewAction::EmitAndClose(ViewEvent::UserInputSubmitted {
193 tool_id: self.tool_id.clone(),
194 response,
195 });
196 }
197 self.question_index += 1;
198 self.selected = 0;
199 self.mode = InputMode::Selecting;
200 self.other_input.clear();
201 self.multi_pending.clear();
202 ViewAction::None
203 }
204
205 fn handle_selecting_key(&mut self, key: KeyEvent) -> ViewAction {
206 match key.code {
207 KeyCode::Up | KeyCode::Char('k') => {
208 self.selected = self.selected.saturating_sub(1);
209 ViewAction::None
210 }
211 KeyCode::Down | KeyCode::Char('j') => {
212 self.selected = (self.selected + 1).min(self.option_count().saturating_sub(1));
213 ViewAction::None
214 }
215 KeyCode::Char(ch) if ch.is_ascii_digit() => {
216 let Some(number) = ch.to_digit(10) else {
217 return ViewAction::None;
218 };
219 if number == 0 {
220 return ViewAction::None;
221 }
222 let index = usize::try_from(number - 1).unwrap_or(usize::MAX);
223 if index >= self.option_count() {
224 return ViewAction::None;
225 }
226 self.selected = index;
227 self.activate_or_confirm_selection()
228 }
229 KeyCode::Char(' ') if self.is_multi_select() => {
230 // Space toggles the highlighted option in the pending set
231 // without leaving the picker (standard multi-select affordance).
232 // The Other row and the Confirm row are not options: toggling
233 // them would corrupt the pending set.
234 let is_confirm = self.confirm_index() == Some(self.selected);
235 if !self.is_other_selected() && !is_confirm {
236 self.toggle_pending(self.selected);
237 }
238 ViewAction::None
239 }
240 KeyCode::Enter => self.activate_or_confirm_selection(),
241 KeyCode::Esc => ViewAction::EmitAndClose(ViewEvent::UserInputCancelled {
242 tool_id: self.tool_id.clone(),
243 }),
244 _ => ViewAction::None,
245 }
246 }
247
248 /// Resolve a digit/Enter activation for the currently highlighted row.
249 ///
250 /// - "Other" row → enter free-text input mode.
251 /// - multi-select option → add to the pending set (never remove — that is
252 /// Space's job) and move focus to the Confirm row, so the single-select
253 /// muscle memory of Enter-then-Enter submits the highlighted option
254 /// instead of toggling it back out and submitting an empty set.
255 /// - multi-select Confirm row → submit the pending set.
256 /// - single-select option → submit immediately (legacy behavior).
257 fn activate_or_confirm_selection(&mut self) -> ViewAction {
258 if self.is_other_selected() {
259 self.mode = InputMode::OtherInput;
260 self.other_input.clear();
261 return ViewAction::None;
262 }
263 if self.is_multi_select() {
264 if self.is_confirm_selected() {
265 // Flush the pending set as this question's answers. An empty
266 // set is allowed (skip-like) — the model is expected to offer a
267 // sensible default, but we don't deadlock.
268 let question = self.current_question();
269 let answers: Vec<UserInputAnswer> = self
270 .multi_pending
271 .iter()
272 .filter_map(|i| question.options.get(*i))
273 .map(|opt| UserInputAnswer {
274 id: question.id.clone(),
275 label: opt.label.clone(),
276 value: opt.label.clone(),
277 })
278 .collect();
279 return self.advance_question(answers);
280 }
281 // Enter on a real option selects it and moves to Confirm. It
282 // never toggles out: double-Enter must submit the highlighted
283 // option, matching single-select on the same view.
284 if !self.multi_pending.contains(&self.selected) {
285 self.multi_pending.push(self.selected);
286 }
287 if let Some(confirm) = self.confirm_index() {
288 self.selected = confirm;
289 }
290 return ViewAction::None;
291 }
292 // Single-select: submit immediately.
293 let answers = self.answers_for_selection(self.selected);
294 self.advance_question(answers)
295 }
296
297 fn handle_other_input_key(&mut self, key: KeyEvent) -> ViewAction {
298 match key.code {
299 KeyCode::Esc => {
300 self.mode = InputMode::Selecting;
301 self.other_input.clear();
302 ViewAction::None
303 }
304 KeyCode::Enter => {
305 let question = self.current_question();
306 let answer = UserInputAnswer {
307 id: question.id.clone(),
308 label: "Other".to_string(),
309 value: self.other_input.trim().to_string(),
310 };
311 // In multi-select mode a free-text "Other" is still a single
312 // answer appended to whatever options were toggled.
313 let mut answers: Vec<UserInputAnswer> = self
314 .multi_pending
315 .iter()
316 .filter_map(|i| question.options.get(*i))
317 .map(|opt| UserInputAnswer {
318 id: question.id.clone(),
319 label: opt.label.clone(),
320 value: opt.label.clone(),
321 })
322 .collect();
323 answers.push(answer);
324 self.advance_question(answers)
325 }
326 KeyCode::Backspace => {
327 self.other_input.pop();
328 ViewAction::None
329 }
330 KeyCode::Char('h')
331 if key
332 .modifiers
333 .contains(crossterm::event::KeyModifiers::CONTROL) =>
334 {
335 self.other_input.pop();
336 ViewAction::None
337 }
338 KeyCode::Char(ch) => {
339 if !ch.is_control() {
340 self.other_input.push(ch);
341 }
342 ViewAction::None
343 }
344 _ => ViewAction::None,
345 }
346 }
347 }
348
349 impl ModalView for UserInputView {
350 fn kind(&self) -> ModalKind {
351 ModalKind::UserInput
352 }
353
354 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
355 self
356 }
357
358 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
359 match self.mode {
360 InputMode::Selecting => self.handle_selecting_key(key),
361 InputMode::OtherInput => self.handle_other_input_key(key),
362 }
363 }
364
365 fn render(&self, area: Rect, buf: &mut Buffer) {
366 let question = self.current_question();
367 let total = self.request.questions.len();
368 let header = format!(
369 " {} ({}/{}) ",
370 question.header,
371 self.question_index + 1,
372 total
373 );
374
375 let mut lines: Vec<Line> = Vec::new();
376 lines.push(Line::from(vec![Span::styled(
377 "Action required",
378 Style::default().fg(palette::WHALE_INFO).bold(),
379 )]));
380 lines.push(Line::from(vec![
381 Span::styled(
382 question.header.clone(),
383 Style::default().fg(palette::TEXT_PRIMARY).bold(),
384 ),
385 Span::styled(
386 format!(" Question {} of {}", self.question_index + 1, total),
387 Style::default().fg(palette::TEXT_MUTED),
388 ),
389 ]));
390 lines.push(Line::from(""));
391 lines.push(Line::from(vec![Span::styled(
392 question.question.clone(),
393 Style::default().fg(palette::TEXT_PRIMARY).bold(),
394 )]));
395 lines.push(Line::from(""));
396
397 for (idx, option) in question.options.iter().enumerate() {
398 let number = idx + 1;
399 let ticked = self.is_multi_select() && self.multi_pending.contains(&idx);
400 push_option_lines(
401 &mut lines,
402 self.selected == idx,
403 number,
404 option.label.clone(),
405 option.description.clone(),
406 ticked,
407 );
408 }
409
410 // The free-text "Other" row is now conditional on allow_free_text.
411 if self.offers_other() {
412 let other_index = question.options.len();
413 let other_number = other_index + 1;
414 push_option_lines(
415 &mut lines,
416 self.selected == other_index,
417 other_number,
418 "Other".to_string(),
419 "Type a custom response".to_string(),
420 false,
421 );
422 }
423
424 // Multi-select gets a dedicated "Confirm selection" row after the
425 // options (and after "Other" when present). Selecting and pressing
426 // Enter on it flushes the pending set as the question's answers.
427 if let Some(confirm_index) = self.confirm_index() {
428 let confirm_number = confirm_index + 1;
429 push_option_lines(
430 &mut lines,
431 self.selected == confirm_index,
432 confirm_number,
433 "Confirm selection".to_string(),
434 format!("Submit {} selected", self.multi_pending.len()),
435 false,
436 );
437 }
438
439 if self.mode == InputMode::OtherInput {
440 lines.push(Line::from(""));
441 lines.push(Line::from(vec![
442 Span::styled(
443 "> Custom response:",
444 Style::default().fg(palette::TEXT_PRIMARY).bold(),
445 ),
446 Span::raw(" "),
447 Span::styled(
448 if self.other_input.is_empty() {
449 "(type your response)".to_string()
450 } else {
451 self.other_input.clone()
452 },
453 Style::default().fg(palette::WHALE_HUMAN),
454 ),
455 ]));
456 }
457
458 lines.push(Line::from(""));
459 if self.mode == InputMode::OtherInput {
460 lines.push(Line::from(vec![
461 Span::styled("Enter", Style::default().fg(palette::WHALE_INFO).bold()),
462 Span::styled(" submit", Style::default().fg(palette::TEXT_MUTED)),
463 Span::raw(" "),
464 Span::styled("Esc", Style::default().fg(palette::WHALE_INFO).bold()),
465 Span::styled(" back", Style::default().fg(palette::TEXT_MUTED)),
466 ]));
467 } else {
468 let opt_count = self.option_count();
469 let quick_pick_label = if opt_count <= 9 {
470 format!("1-{opt_count}")
471 } else {
472 "digit".to_string()
473 };
474 if self.is_multi_select() {
475 lines.push(Line::from(vec![
476 Span::styled(
477 quick_pick_label,
478 Style::default().fg(palette::WHALE_INFO).bold(),
479 ),
480 Span::styled(" move", Style::default().fg(palette::TEXT_MUTED)),
481 Span::raw(" "),
482 Span::styled("Space", Style::default().fg(palette::WHALE_INFO).bold()),
483 Span::styled(" toggle", Style::default().fg(palette::TEXT_MUTED)),
484 Span::raw(" "),
485 Span::styled("Enter", Style::default().fg(palette::WHALE_INFO).bold()),
486 Span::styled(" select/confirm", Style::default().fg(palette::TEXT_MUTED)),
487 Span::raw(" "),
488 Span::styled("Esc", Style::default().fg(palette::WHALE_INFO).bold()),
489 Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)),
490 ]));
491 } else {
492 lines.push(Line::from(vec![
493 Span::styled(
494 quick_pick_label,
495 Style::default().fg(palette::WHALE_INFO).bold(),
496 ),
497 Span::styled(" quick pick", Style::default().fg(palette::TEXT_MUTED)),
498 Span::raw(" "),
499 Span::styled("↑/↓", Style::default().fg(palette::WHALE_INFO).bold()),
500 Span::styled(" move", Style::default().fg(palette::TEXT_MUTED)),
501 Span::raw(" "),
502 Span::styled("Enter", Style::default().fg(palette::WHALE_INFO).bold()),
503 Span::styled(" confirm", Style::default().fg(palette::TEXT_MUTED)),
504 Span::raw(" "),
505 Span::styled("Esc", Style::default().fg(palette::WHALE_INFO).bold()),
506 Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)),
507 ]));
508 }
509 }
510
511 let paragraph = Paragraph::new(lines)
512 .alignment(Alignment::Left)
513 .wrap(Wrap { trim: true })
514 .block(modal_block(&header));
515
516 let popup_area = compact_popup_rect(area, self.content_line_count());
517 render_modal_chrome(area, popup_area, buf);
518 paragraph.render(popup_area, buf);
519 }
520
521 fn occupied_region(&self, area: Rect) -> Rect {
522 // The dialog only occupies its compact centered card; blanking the
523 // whole frame (the default) hid the live conversation the user is
524 // being asked about (v0.9.4, FINISH-0.9.4 #13). Cover the card plus
525 // the one-cell drop shadow `render_modal_surface` draws at +1/+1.
526 let popup = compact_popup_rect(area, self.content_line_count());
527 Rect {
528 x: popup.x,
529 y: popup.y,
530 width: (popup.width.saturating_add(1)).min(area.right().saturating_sub(popup.x)),
531 height: (popup.height.saturating_add(1)).min(area.bottom().saturating_sub(popup.y)),
532 }
533 }
534 }
535
536 /// Compact centered overlay: bounded width (max 110 columns) and a height
537 /// sized to the content (border + padding around `content_lines`, never more
538 /// than 22 rows or 60% of the screen) so the live conversation stays visible
539 /// behind the modal instead of being covered edge-to-edge.
540 fn compact_popup_rect(r: Rect, content_lines: usize) -> Rect {
541 let width = r.width.min(110);
542 // Border (2 rows) + uniform padding (2 rows) around the content lines.
543 let desired = u16::try_from(content_lines)
544 .unwrap_or(u16::MAX)
545 .saturating_add(4);
546 let height = desired
547 .clamp(6, 22)
548 .min((r.height.saturating_mul(60) / 100).clamp(6, 22))
549 .min(r.height);
550 let popup_layout = Layout::default()
551 .direction(Direction::Vertical)
552 .constraints([
553 Constraint::Min(0),
554 Constraint::Length(height),
555 Constraint::Min(0),
556 ])
557 .split(r);
558 let horizontal = Layout::default()
559 .direction(Direction::Horizontal)
560 .constraints([
561 Constraint::Min(0),
562 Constraint::Length(width),
563 Constraint::Min(0),
564 ])
565 .split(popup_layout[1]);
566 horizontal[1]
567 }
568
569 #[cfg(test)]
570 mod tests {
571 use super::*;
572 use crate::tools::user_input::{UserInputOption, UserInputQuestion, UserInputRequest};
573
574 fn render_view(view: &UserInputView, width: u16, height: u16) -> String {
575 let area = Rect::new(0, 0, width, height);
576 let mut buf = Buffer::empty(area);
577 view.render(area, &mut buf);
578
579 (0..height)
580 .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
581 .collect::<Vec<_>>()
582 .join("\n")
583 }
584
585 fn sample_view() -> UserInputView {
586 UserInputView::new(
587 "tool-1",
588 UserInputRequest {
589 questions: vec![UserInputQuestion {
590 header: "Confirm".to_string(),
591 id: "confirm".to_string(),
592 question: "What should happen next?".to_string(),
593 options: vec![
594 UserInputOption {
595 label: "Ship it".to_string(),
596 description: "Proceed with the current change set".to_string(),
597 },
598 UserInputOption {
599 label: "Revise it".to_string(),
600 description: "Return to editing before continuing".to_string(),
601 },
602 ],
603 allow_free_text: true,
604 multi_select: false,
605 }],
606 },
607 )
608 }
609
610 #[test]
611 fn user_input_modal_calls_out_required_action_and_controls() {
612 let rendered = render_view(&sample_view(), 110, 36);
613
614 assert!(rendered.contains("Action required"));
615 assert!(rendered.contains("Question 1 of 1"));
616 assert!(rendered.contains("quick pick"));
617 // allow_free_text=true surfaces the Other row.
618 assert!(rendered.contains("Other"));
619 }
620
621 #[test]
622 fn user_input_modal_renders_custom_response_state() {
623 let mut view = sample_view();
624 view.selected = 2;
625 view.mode = InputMode::OtherInput;
626 view.other_input = "Need one more pass".to_string();
627
628 let rendered = render_view(&view, 110, 36);
629
630 assert!(rendered.contains("Custom response"));
631 assert!(rendered.contains("Need one more pass"));
632 assert!(rendered.contains("Enter"));
633 assert!(rendered.contains("submit"));
634 }
635
636 #[test]
637 fn user_input_modal_keeps_other_row_when_free_text_disabled() {
638 // v0.9.4: a custom free-text response is ALWAYS available alongside
639 // the options, even when the model did not offer it. The wire field
640 // `allow_free_text` stays for backward compatibility but no longer
641 // gates the row (#3102 originally hid it).
642 let mut view = sample_view();
643 view.request.questions[0].allow_free_text = false;
644 view.selected = 0;
645
646 let rendered = render_view(&view, 110, 36);
647 assert!(
648 rendered.contains("Type a custom response"),
649 "Other row must stay reachable even when allow_free_text is false"
650 );
651 assert!(rendered.contains("Other"));
652
653 // Entering the row switches to free-text input mode regardless.
654 view.selected = view.option_count() - 1;
655 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
656 assert!(matches!(action, ViewAction::None));
657 assert_eq!(view.mode, InputMode::OtherInput);
658 }
659
660 #[test]
661 fn user_input_modal_renders_multi_select_ticks_and_confirm() {
662 // Issue #3102: multi_select=true renders a check-mark gutter on
663 // toggled options plus a trailing "Confirm selection" row, and the
664 // controls hint advertises Space/Enter toggle semantics. With the
665 // v0.9.4 always-available Other row, confirm sits at index 3.
666 let mut view = sample_view();
667 view.request.questions[0].multi_select = true;
668 view.request.questions[0].allow_free_text = false;
669 // Toggle the first option into the pending set.
670 view.multi_pending.push(0);
671 // Highlight the confirm row (last selectable row).
672 view.selected = view.option_count() - 1;
673
674 let rendered = render_view(&view, 120, 40);
675 assert!(rendered.contains("✔"), "toggled option shows a check mark");
676 assert!(
677 rendered.contains("Confirm selection"),
678 "multi-select renders a confirm row"
679 );
680 assert!(rendered.contains("Submit 1 selected"));
681 assert!(rendered.contains("toggle"));
682 assert!(
683 rendered.contains("▸ 4) Confirm selection"),
684 "confirm row should display selected focus at its real quick-pick index"
685 );
686 assert!(
687 !rendered.contains("5) Confirm selection"),
688 "confirm row must not advertise an unreachable quick-pick number"
689 );
690 }
691
692 #[test]
693 fn user_input_modal_space_toggles_and_enter_confirms_multi_select() {
694 // Keyboard-first multi-select: Space toggles the highlighted option
695 // into the pending set without leaving the picker; Enter on the
696 // confirm row flushes the set.
697 let mut view = sample_view();
698 view.request.questions[0].multi_select = true;
699 view.selected = 0;
700
701 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Char(' ')));
702 assert!(matches!(action, ViewAction::None));
703 assert_eq!(view.multi_pending, vec![0], "Space toggles option 0 in");
704
705 let _action = view.handle_selecting_key(KeyEvent::from(KeyCode::Char(' ')));
706 assert!(view.multi_pending.is_empty(), "Space toggles option 0 out");
707
708 // Space on the confirm row must not toggle it (it is not an option).
709 view.selected = view.confirm_index().expect("confirm row present");
710 let before = view.multi_pending.clone();
711 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Char(' ')));
712 assert!(matches!(action, ViewAction::None));
713 assert_eq!(view.multi_pending, before, "Space on confirm is a no-op");
714
715 // Enter on the confirm row flushes the pending set as answers.
716 view.multi_pending.push(0);
717 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
718 assert!(
719 matches!(action, ViewAction::EmitAndClose(ViewEvent::UserInputSubmitted { tool_id, response })
720 if tool_id == "tool-1" && response.answers.first().is_some_and(|a| a.value == "Ship it")),
721 "Enter on confirm submits the toggled options"
722 );
723 }
724
725 #[test]
726 fn user_input_modal_double_enter_never_submits_empty_multi_select() {
727 // Enter on a multi-select option used to toggle it into the pending
728 // set, so a second Enter (single-select muscle memory) toggled it
729 // back out — and Confirm then submitted an empty answer set.
730 let mut view = sample_view();
731 view.request.questions[0].multi_select = true;
732 view.selected = 0;
733
734 // First Enter: option 0 joins the pending set, focus moves to Confirm.
735 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
736 assert!(matches!(action, ViewAction::None));
737 assert_eq!(
738 view.multi_pending,
739 vec![0],
740 "Enter selects the highlighted option"
741 );
742 assert!(
743 view.is_confirm_selected(),
744 "focus moves to the Confirm row after Enter"
745 );
746
747 // Second Enter submits exactly that option — never an empty set.
748 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
749 assert!(
750 matches!(action, ViewAction::EmitAndClose(ViewEvent::UserInputSubmitted { tool_id, response })
751 if tool_id == "tool-1"
752 && response.answers.len() == 1
753 && response.answers[0].value == "Ship it"),
754 "double-Enter must submit the highlighted option, not an empty set"
755 );
756 }
757
758 #[test]
759 fn user_input_modal_enter_never_deselects_multi_select_option() {
760 // Deselecting remains Space's job: Enter on an already-toggled option
761 // keeps it in the pending set.
762 let mut view = sample_view();
763 view.request.questions[0].multi_select = true;
764 view.selected = 0;
765
766 let _ = view.handle_selecting_key(KeyEvent::from(KeyCode::Char(' ')));
767 assert_eq!(view.multi_pending, vec![0], "Space toggles option 0 in");
768
769 let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
770 assert!(matches!(action, ViewAction::None));
771 assert_eq!(
772 view.multi_pending,
773 vec![0],
774 "Enter must not toggle the option back out"
775 );
776
777 // Space still toggles both ways.
778 view.selected = 0;
779 let _ = view.handle_selecting_key(KeyEvent::from(KeyCode::Char(' ')));
780 assert!(view.multi_pending.is_empty(), "Space toggles option 0 out");
781 }
782
783 #[test]
784 fn user_input_modal_popup_is_centered_and_sized_to_content() {
785 let area = Rect::new(0, 0, 120, 40);
786 let view = sample_view();
787 let content = view.content_line_count();
788 let popup = compact_popup_rect(area, content);
789
790 // Height hugs the content (border + padding = 4 chrome rows), well
791 // under the 22-row / 60% cap for a 40-row screen.
792 assert_eq!(popup.height, u16::try_from(content).unwrap() + 4);
793 assert!(popup.height < area.height / 2);
794 assert_eq!(popup.width, 110);
795 // Centered: breathing room above and below.
796 assert!(popup.y > 0);
797 assert!(popup.y + popup.height < area.height);
798
799 // Long content is still bounded by the 22-row cap.
800 let capped = compact_popup_rect(area, 100);
801 assert_eq!(capped.height, 22);
802 }
803
804 #[test]
805 fn user_input_modal_occupied_region_matches_painted_card_plus_shadow() {
806 let area = Rect::new(0, 0, 120, 40);
807 let view = sample_view();
808 let popup = compact_popup_rect(area, view.content_line_count());
809 let occupied = view.occupied_region(area);
810
811 assert_eq!(occupied.x, popup.x);
812 assert_eq!(occupied.y, popup.y);
813 assert_eq!(occupied.width, popup.width + 1);
814 assert_eq!(occupied.height, popup.height + 1);
815 assert!(area.right() >= occupied.right());
816 assert!(area.bottom() >= occupied.bottom());
817 }
818
819 #[test]
820 fn user_input_modal_leaves_surrounding_frame_visible() {
821 use crate::tui::views::ViewStack;
822
823 let area = Rect::new(0, 0, 120, 40);
824 let mut buf = Buffer::empty(area);
825 // Pre-fill the frame as if the live transcript had painted it.
826 for y in 0..area.height {
827 for x in 0..area.width {
828 buf[(x, y)].set_symbol("·");
829 }
830 }
831
832 let mut stack = ViewStack::default();
833 stack.push(sample_view());
834 stack.render(area, &mut buf);
835
836 // Cells outside the compact card survive untouched: the conversation
837 // stays visible around the dialog (FINISH-0.9.4 #13).
838 assert_eq!(buf[(0, 0)].symbol(), "·");
839 assert_eq!(buf[(119, 0)].symbol(), "·");
840 assert_eq!(buf[(0, 39)].symbol(), "·");
841 assert_eq!(buf[(119, 39)].symbol(), "·");
842 assert_eq!(buf[(60, 0)].symbol(), "·");
843 assert_eq!(buf[(60, 39)].symbol(), "·");
844 // The card itself is blanked + repainted by the modal surface.
845 assert_ne!(buf[(60, 20)].symbol(), "·");
846 }
847
848 #[test]
849 fn user_input_modal_numbers_confirm_after_other_row() {
850 let mut view = sample_view();
851 view.request.questions[0].multi_select = true;
852 view.request.questions[0].allow_free_text = true;
853 view.selected = view.option_count() - 1;
854
855 let rendered = render_view(&view, 120, 40);
856 assert!(rendered.contains("3) Other"));
857 assert!(
858 rendered.contains("▸ 4) Confirm selection"),
859 "confirm should follow the optional Other row with selected focus"
860 );
861 assert!(!rendered.contains("5) Confirm selection"));
862 }
863 }
864
864 lines RUST