use crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::{Alignment, Rect};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap};
use crate::palette;
use crate::tools::user_input::{
UserInputAnswer, UserInputQuestion, UserInputRequest, UserInputResponse,
};
use crate::tui::menu_style;
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent, render_modal_surface};
fn modal_block(title: &str) -> Block<'static> {
Block::default()
.title(Line::from(vec![Span::styled(
title.to_string(),
Style::default().fg(palette::WHALE_HUMAN).bold(),
)]))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default().bg(palette::WHALE_BG))
.padding(Padding::uniform(1))
}
fn render_modal_chrome(area: Rect, popup_area: Rect, buf: &mut Buffer) {
render_modal_surface(area, popup_area, buf);
}
fn push_option_lines(
lines: &mut Vec<Line<'static>>,
selected: bool,
number: usize,
label: String,
description: String,
ticked: bool,
) {
let row_style = if selected {
menu_style::selected_row_style()
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
let detail_style = if selected {
row_style
} else {
Style::default().fg(palette::TEXT_MUTED)
};
let prefix = crate::tui::glyphs::selection_marker(selected);
let mark = if ticked { "✔ " } else { " " };
lines.push(Line::from(Span::styled(
format!("{prefix}{mark}{number}) {label}"),
row_style,
)));
lines.push(Line::from(Span::styled(
format!(" {description}"),
detail_style,
)));
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InputMode {
Selecting,
OtherInput,
}
#[derive(Debug, Clone)]
pub struct UserInputView {
tool_id: String,
request: UserInputRequest,
question_index: usize,
selected: usize,
mode: InputMode,
other_input: String,
answers: Vec<UserInputAnswer>,
multi_pending: Vec<usize>,
}
impl UserInputView {
pub fn new(tool_id: impl Into<String>, request: UserInputRequest) -> Self {
Self {
tool_id: tool_id.into(),
request,
question_index: 0,
selected: 0,
mode: InputMode::Selecting,
other_input: String::new(),
answers: Vec::new(),
multi_pending: Vec::new(),
}
}
fn current_question(&self) -> &UserInputQuestion {
&self.request.questions[self.question_index]
}
fn offers_other(&self) -> bool {
true
}
fn option_count(&self) -> usize {
let mut count = self.current_question().options.len();
count += usize::from(self.offers_other());
count += usize::from(self.is_multi_select());
count
}
fn is_other_selected(&self) -> bool {
let other_last = !self.is_multi_select();
if other_last {
self.offers_other() && self.selected + 1 == self.option_count()
} else {
self.offers_other() && self.selected + 2 == self.option_count()
}
}
fn is_confirm_selected(&self) -> bool {
self.confirm_index() == Some(self.selected)
}
fn confirm_index(&self) -> Option<usize> {
self.is_multi_select()
.then(|| self.option_count().saturating_sub(1))
}
fn is_multi_select(&self) -> bool {
self.current_question().multi_select
}
fn content_line_count(&self) -> usize {
let question = self.current_question();
let mut count = 5;
count += question.options.len() * 2;
if self.offers_other() {
count += 2;
}
if self.is_multi_select() {
count += 2;
}
if self.mode == InputMode::OtherInput {
count += 2;
}
count += 2;
count
}
fn toggle_pending(&mut self, index: usize) {
if let Some(pos) = self.multi_pending.iter().position(|i| *i == index) {
self.multi_pending.remove(pos);
} else {
self.multi_pending.push(index);
}
}
fn answers_for_selection(&self, index: usize) -> Vec<UserInputAnswer> {
let question = self.current_question();
let option = &question.options[index];
vec![UserInputAnswer {
id: question.id.clone(),
label: option.label.clone(),
value: option.label.clone(),
}]
}
fn advance_question(&mut self, new_answers: Vec<UserInputAnswer>) -> ViewAction {
self.answers.extend(new_answers);
if self.question_index + 1 >= self.request.questions.len() {
let response = UserInputResponse {
answers: self.answers.clone(),
};
return ViewAction::EmitAndClose(ViewEvent::UserInputSubmitted {
tool_id: self.tool_id.clone(),
response,
});
}
self.question_index += 1;
self.selected = 0;
self.mode = InputMode::Selecting;
self.other_input.clear();
self.multi_pending.clear();
ViewAction::None
}
fn handle_selecting_key(&mut self, key: KeyEvent) -> ViewAction {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.selected = self.selected.saturating_sub(1);
ViewAction::None
}
KeyCode::Down | KeyCode::Char('j') => {
self.selected = (self.selected + 1).min(self.option_count().saturating_sub(1));
ViewAction::None
}
KeyCode::Char(ch) if ch.is_ascii_digit() => {
let Some(number) = ch.to_digit(10) else {
return ViewAction::None;
};
if number == 0 {
return ViewAction::None;
}
let index = usize::try_from(number - 1).unwrap_or(usize::MAX);
if index >= self.option_count() {
return ViewAction::None;
}
self.selected = index;
self.activate_or_confirm_selection()
}
KeyCode::Char(' ') if self.is_multi_select() => {
let is_confirm = self.confirm_index() == Some(self.selected);
if !self.is_other_selected() && !is_confirm {
self.toggle_pending(self.selected);
}
ViewAction::None
}
KeyCode::Enter => self.activate_or_confirm_selection(),
KeyCode::Esc => ViewAction::EmitAndClose(ViewEvent::UserInputCancelled {
tool_id: self.tool_id.clone(),
}),
_ => ViewAction::None,
}
}
fn activate_or_confirm_selection(&mut self) -> ViewAction {
if self.is_other_selected() {
self.mode = InputMode::OtherInput;
self.other_input.clear();
return ViewAction::None;
}
if self.is_multi_select() {
if self.is_confirm_selected() {
let question = self.current_question();
let answers: Vec<UserInputAnswer> = self
.multi_pending
.iter()
.filter_map(|i| question.options.get(*i))
.map(|opt| UserInputAnswer {
id: question.id.clone(),
label: opt.label.clone(),
value: opt.label.clone(),
})
.collect();
return self.advance_question(answers);
}
if !self.multi_pending.contains(&self.selected) {
self.multi_pending.push(self.selected);
}
if let Some(confirm) = self.confirm_index() {
self.selected = confirm;
}
return ViewAction::None;
}
let answers = self.answers_for_selection(self.selected);
self.advance_question(answers)
}
fn handle_other_input_key(&mut self, key: KeyEvent) -> ViewAction {
match key.code {
KeyCode::Esc => {
self.mode = InputMode::Selecting;
self.other_input.clear();
ViewAction::None
}
KeyCode::Enter => {
let question = self.current_question();
let answer = UserInputAnswer {
id: question.id.clone(),
label: "Other".to_string(),
value: self.other_input.trim().to_string(),
};
let mut answers: Vec<UserInputAnswer> = self
.multi_pending
.iter()
.filter_map(|i| question.options.get(*i))
.map(|opt| UserInputAnswer {
id: question.id.clone(),
label: opt.label.clone(),
value: opt.label.clone(),
})
.collect();
answers.push(answer);
self.advance_question(answers)
}
KeyCode::Backspace => {
self.other_input.pop();
ViewAction::None
}
KeyCode::Char('h')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
self.other_input.pop();
ViewAction::None
}
KeyCode::Char(ch) => {
if !ch.is_control() {
self.other_input.push(ch);
}
ViewAction::None
}
_ => ViewAction::None,
}
}
}
impl ModalView for UserInputView {
fn kind(&self) -> ModalKind {
ModalKind::UserInput
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
match self.mode {
InputMode::Selecting => self.handle_selecting_key(key),
InputMode::OtherInput => self.handle_other_input_key(key),
}
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let question = self.current_question();
let total = self.request.questions.len();
let header = format!(
" {} ({}/{}) ",
question.header,
self.question_index + 1,
total
);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(vec![Span::styled(
"Action required",
Style::default().fg(palette::WHALE_INFO).bold(),
)]));
lines.push(Line::from(vec![
Span::styled(
question.header.clone(),
Style::default().fg(palette::TEXT_PRIMARY).bold(),
),
Span::styled(
format!(" Question {} of {}", self.question_index + 1, total),
Style::default().fg(palette::TEXT_MUTED),
),
]));
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
question.question.clone(),
Style::default().fg(palette::TEXT_PRIMARY).bold(),
)]));
lines.push(Line::from(""));
for (idx, option) in question.options.iter().enumerate() {
let number = idx + 1;
let ticked = self.is_multi_select() && self.multi_pending.contains(&idx);
push_option_lines(
&mut lines,
self.selected == idx,
number,
option.label.clone(),
option.description.clone(),
ticked,
);
}
if self.offers_other() {
let other_index = question.options.len();
let other_number = other_index + 1;
push_option_lines(
&mut lines,
self.selected == other_index,
other_number,
"Other".to_string(),
"Type a custom response".to_string(),
false,
);
}
if let Some(confirm_index) = self.confirm_index() {
let confirm_number = confirm_index + 1;
push_option_lines(
&mut lines,
self.selected == confirm_index,
confirm_number,
"Confirm selection".to_string(),
format!("Submit {} selected", self.multi_pending.len()),
false,
);
}
if self.mode == InputMode::OtherInput {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(
"> Custom response:",
Style::default().fg(palette::TEXT_PRIMARY).bold(),
),
Span::raw(" "),
Span::styled(
if self.other_input.is_empty() {
"(type your response)".to_string()
} else {
self.other_input.clone()
},
Style::default().fg(palette::WHALE_HUMAN),
),
]));
}
lines.push(Line::from(""));
if self.mode == InputMode::OtherInput {
lines.push(Line::from(vec![
Span::styled("Enter", Style::default().fg(palette::WHALE_INFO).bold()),
Span::styled(" submit", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Esc", Style::default().fg(palette::WHALE_INFO).bold()),
Span::styled(" back", Style::default().fg(palette::TEXT_MUTED)),
]));
} else {
let opt_count = self.option_count();
let quick_pick_label = if opt_count <= 9 {
format!("1-{opt_count}")
} else {
"digit".to_string()
};
if self.is_multi_select() {
lines.push(Line::from(vec![
Span::styled(
quick_pick_label,
Style::default().fg(palette::WHALE_INFO).bold(),
),
Span::styled(" move", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Space", Style::default().fg(palette::WHALE_INFO).bold()),
Span::styled(" toggle", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Enter", Style::default().fg(palette::WHALE_INFO).bold()),
Span::styled(" select/confirm", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Esc", Style::default().fg(palette::WHALE_INFO).bold()),
Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)),
]));
} else {
lines.push(Line::from(vec![
Span::styled(
quick_pick_label,
Style::default().fg(palette::WHALE_INFO).bold(),
),
Span::styled(" quick pick", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("↑/↓", Style::default().fg(palette::WHALE_INFO).bold()),
Span::styled(" move", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Enter", Style::default().fg(palette::WHALE_INFO).bold()),
Span::styled(" confirm", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Esc", Style::default().fg(palette::WHALE_INFO).bold()),
Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)),
]));
}
}
let paragraph = Paragraph::new(lines)
.alignment(Alignment::Left)
.wrap(Wrap { trim: true })
.block(modal_block(&header));
let popup_area = compact_popup_rect(area, self.content_line_count());
render_modal_chrome(area, popup_area, buf);
paragraph.render(popup_area, buf);
}
fn occupied_region(&self, area: Rect) -> Rect {
let popup = compact_popup_rect(area, self.content_line_count());
Rect {
x: popup.x,
y: popup.y,
width: (popup.width.saturating_add(1)).min(area.right().saturating_sub(popup.x)),
height: (popup.height.saturating_add(1)).min(area.bottom().saturating_sub(popup.y)),
}
}
}
fn compact_popup_rect(r: Rect, content_lines: usize) -> Rect {
let width = r.width.min(110);
let desired = u16::try_from(content_lines)
.unwrap_or(u16::MAX)
.saturating_add(4);
let height = desired
.clamp(6, 22)
.min((r.height.saturating_mul(60) / 100).clamp(6, 22))
.min(r.height);
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0),
Constraint::Length(height),
Constraint::Min(0),
])
.split(r);
let horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(0),
Constraint::Length(width),
Constraint::Min(0),
])
.split(popup_layout[1]);
horizontal[1]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::user_input::{UserInputOption, UserInputQuestion, UserInputRequest};
fn render_view(view: &UserInputView, width: u16, height: u16) -> String {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
(0..height)
.map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
fn sample_view() -> UserInputView {
UserInputView::new(
"tool-1",
UserInputRequest {
questions: vec![UserInputQuestion {
header: "Confirm".to_string(),
id: "confirm".to_string(),
question: "What should happen next?".to_string(),
options: vec![
UserInputOption {
label: "Ship it".to_string(),
description: "Proceed with the current change set".to_string(),
},
UserInputOption {
label: "Revise it".to_string(),
description: "Return to editing before continuing".to_string(),
},
],
allow_free_text: true,
multi_select: false,
}],
},
)
}
#[test]
fn user_input_modal_calls_out_required_action_and_controls() {
let rendered = render_view(&sample_view(), 110, 36);
assert!(rendered.contains("Action required"));
assert!(rendered.contains("Question 1 of 1"));
assert!(rendered.contains("quick pick"));
assert!(rendered.contains("Other"));
}
#[test]
fn user_input_modal_renders_custom_response_state() {
let mut view = sample_view();
view.selected = 2;
view.mode = InputMode::OtherInput;
view.other_input = "Need one more pass".to_string();
let rendered = render_view(&view, 110, 36);
assert!(rendered.contains("Custom response"));
assert!(rendered.contains("Need one more pass"));
assert!(rendered.contains("Enter"));
assert!(rendered.contains("submit"));
}
#[test]
fn user_input_modal_keeps_other_row_when_free_text_disabled() {
let mut view = sample_view();
view.request.questions[0].allow_free_text = false;
view.selected = 0;
let rendered = render_view(&view, 110, 36);
assert!(
rendered.contains("Type a custom response"),
"Other row must stay reachable even when allow_free_text is false"
);
assert!(rendered.contains("Other"));
view.selected = view.option_count() - 1;
let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
assert!(matches!(action, ViewAction::None));
assert_eq!(view.mode, InputMode::OtherInput);
}
#[test]
fn user_input_modal_renders_multi_select_ticks_and_confirm() {
let mut view = sample_view();
view.request.questions[0].multi_select = true;
view.request.questions[0].allow_free_text = false;
view.multi_pending.push(0);
view.selected = view.option_count() - 1;
let rendered = render_view(&view, 120, 40);
assert!(rendered.contains("✔"), "toggled option shows a check mark");
assert!(
rendered.contains("Confirm selection"),
"multi-select renders a confirm row"
);
assert!(rendered.contains("Submit 1 selected"));
assert!(rendered.contains("toggle"));
assert!(
rendered.contains("▸ 4) Confirm selection"),
"confirm row should display selected focus at its real quick-pick index"
);
assert!(
!rendered.contains("5) Confirm selection"),
"confirm row must not advertise an unreachable quick-pick number"
);
}
#[test]
fn user_input_modal_space_toggles_and_enter_confirms_multi_select() {
let mut view = sample_view();
view.request.questions[0].multi_select = true;
view.selected = 0;
let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Char(' ')));
assert!(matches!(action, ViewAction::None));
assert_eq!(view.multi_pending, vec![0], "Space toggles option 0 in");
let _action = view.handle_selecting_key(KeyEvent::from(KeyCode::Char(' ')));
assert!(view.multi_pending.is_empty(), "Space toggles option 0 out");
view.selected = view.confirm_index().expect("confirm row present");
let before = view.multi_pending.clone();
let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Char(' ')));
assert!(matches!(action, ViewAction::None));
assert_eq!(view.multi_pending, before, "Space on confirm is a no-op");
view.multi_pending.push(0);
let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
assert!(
matches!(action, ViewAction::EmitAndClose(ViewEvent::UserInputSubmitted { tool_id, response })
if tool_id == "tool-1" && response.answers.first().is_some_and(|a| a.value == "Ship it")),
"Enter on confirm submits the toggled options"
);
}
#[test]
fn user_input_modal_double_enter_never_submits_empty_multi_select() {
let mut view = sample_view();
view.request.questions[0].multi_select = true;
view.selected = 0;
let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
assert!(matches!(action, ViewAction::None));
assert_eq!(
view.multi_pending,
vec![0],
"Enter selects the highlighted option"
);
assert!(
view.is_confirm_selected(),
"focus moves to the Confirm row after Enter"
);
let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
assert!(
matches!(action, ViewAction::EmitAndClose(ViewEvent::UserInputSubmitted { tool_id, response })
if tool_id == "tool-1"
&& response.answers.len() == 1
&& response.answers[0].value == "Ship it"),
"double-Enter must submit the highlighted option, not an empty set"
);
}
#[test]
fn user_input_modal_enter_never_deselects_multi_select_option() {
let mut view = sample_view();
view.request.questions[0].multi_select = true;
view.selected = 0;
let _ = view.handle_selecting_key(KeyEvent::from(KeyCode::Char(' ')));
assert_eq!(view.multi_pending, vec![0], "Space toggles option 0 in");
let action = view.handle_selecting_key(KeyEvent::from(KeyCode::Enter));
assert!(matches!(action, ViewAction::None));
assert_eq!(
view.multi_pending,
vec![0],
"Enter must not toggle the option back out"
);
view.selected = 0;
let _ = view.handle_selecting_key(KeyEvent::from(KeyCode::Char(' ')));
assert!(view.multi_pending.is_empty(), "Space toggles option 0 out");
}
#[test]
fn user_input_modal_popup_is_centered_and_sized_to_content() {
let area = Rect::new(0, 0, 120, 40);
let view = sample_view();
let content = view.content_line_count();
let popup = compact_popup_rect(area, content);
assert_eq!(popup.height, u16::try_from(content).unwrap() + 4);
assert!(popup.height < area.height / 2);
assert_eq!(popup.width, 110);
assert!(popup.y > 0);
assert!(popup.y + popup.height < area.height);
let capped = compact_popup_rect(area, 100);
assert_eq!(capped.height, 22);
}
#[test]
fn user_input_modal_occupied_region_matches_painted_card_plus_shadow() {
let area = Rect::new(0, 0, 120, 40);
let view = sample_view();
let popup = compact_popup_rect(area, view.content_line_count());
let occupied = view.occupied_region(area);
assert_eq!(occupied.x, popup.x);
assert_eq!(occupied.y, popup.y);
assert_eq!(occupied.width, popup.width + 1);
assert_eq!(occupied.height, popup.height + 1);
assert!(area.right() >= occupied.right());
assert!(area.bottom() >= occupied.bottom());
}
#[test]
fn user_input_modal_leaves_surrounding_frame_visible() {
use crate::tui::views::ViewStack;
let area = Rect::new(0, 0, 120, 40);
let mut buf = Buffer::empty(area);
for y in 0..area.height {
for x in 0..area.width {
buf[(x, y)].set_symbol("·");
}
}
let mut stack = ViewStack::default();
stack.push(sample_view());
stack.render(area, &mut buf);
assert_eq!(buf[(0, 0)].symbol(), "·");
assert_eq!(buf[(119, 0)].symbol(), "·");
assert_eq!(buf[(0, 39)].symbol(), "·");
assert_eq!(buf[(119, 39)].symbol(), "·");
assert_eq!(buf[(60, 0)].symbol(), "·");
assert_eq!(buf[(60, 39)].symbol(), "·");
assert_ne!(buf[(60, 20)].symbol(), "·");
}
#[test]
fn user_input_modal_numbers_confirm_after_other_row() {
let mut view = sample_view();
view.request.questions[0].multi_select = true;
view.request.questions[0].allow_free_text = true;
view.selected = view.option_count() - 1;
let rendered = render_view(&view, 120, 40);
assert!(rendered.contains("3) Other"));
assert!(
rendered.contains("▸ 4) Confirm selection"),
"confirm should follow the optional Other row with selected focus"
);
assert!(!rendered.contains("5) Confirm selection"));
}
}