use std::collections::HashMap;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
use crate::tui_internal::app::app_server_requests::ResolvedAppServerRequest;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
mod layout;
mod render;
use crate::tui_internal::app_event::AppEvent;
use crate::tui_internal::app_event_sender::AppEventSender;
use crate::tui_internal::bottom_pane::CancellationEvent;
use crate::tui_internal::bottom_pane::ChatComposer;
use crate::tui_internal::bottom_pane::ChatComposerConfig;
use crate::tui_internal::bottom_pane::InputResult;
use crate::tui_internal::bottom_pane::bottom_pane_view::BottomPaneView;
use crate::tui_internal::bottom_pane::scroll_state::ScrollState;
use crate::tui_internal::bottom_pane::selection_popup_common::GenericDisplayRow;
use crate::tui_internal::bottom_pane::selection_popup_common::measure_rows_height;
use crate::tui_internal::history_cell;
use crate::tui_internal::key_hint::KeyBinding;
use crate::tui_internal::key_hint::KeyBindingListExt;
use crate::tui_internal::keymap::ListKeymap;
use crate::tui_internal::keymap::RuntimeKeymap;
use crate::tui_internal::render::renderable::Renderable;
#[cfg(test)]
use crate::tui_internal::app_command::AppCommand as Op;
use lemurclaw_core::app_server_protocol::ToolRequestUserInputAnswer;
#[cfg(test)]
use lemurclaw_core::app_server_protocol::ToolRequestUserInputOption;
use lemurclaw_core::app_server_protocol::ToolRequestUserInputParams;
use lemurclaw_core::app_server_protocol::ToolRequestUserInputQuestion;
use lemurclaw_core::app_server_protocol::ToolRequestUserInputResponse;
use lemurclaw_core::protocol::user_input::TextElement;
use unicode_width::UnicodeWidthStr;
const NOTES_PLACEHOLDER: &str = "Add notes";
const ANSWER_PLACEHOLDER: &str = "Type your answer (optional)";
const MIN_COMPOSER_HEIGHT: u16 = 3;
const SELECT_OPTION_PLACEHOLDER: &str = "Select an option to add notes";
pub(super) const TIP_SEPARATOR: &str = " | ";
pub(super) const DESIRED_SPACERS_BETWEEN_SECTIONS: u16 = 2;
const OTHER_OPTION_LABEL: &str = "None of the above";
const OTHER_OPTION_DESCRIPTION: &str = "Optionally, add details in notes (tab).";
const UNANSWERED_CONFIRM_TITLE: &str = "Submit with unanswered questions?";
const UNANSWERED_CONFIRM_GO_BACK: &str = "Go back";
const UNANSWERED_CONFIRM_GO_BACK_DESC: &str = "Return to the first unanswered question.";
const UNANSWERED_CONFIRM_SUBMIT: &str = "Proceed";
const UNANSWERED_CONFIRM_SUBMIT_DESC_SINGULAR: &str = "question";
const UNANSWERED_CONFIRM_SUBMIT_DESC_PLURAL: &str = "questions";
const AUTO_RESOLUTION_HIDDEN_GRACE: Duration = Duration::from_secs( 60);
const AUTO_RESOLUTION_VISIBLE_COUNTDOWN: Duration = Duration::from_secs( 60);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Focus {
Options,
Notes,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AutoResolutionTiming {
Disabled,
HiddenGrace { remaining: Duration },
VisibleCountdown { remaining: Duration },
Due,
}
fn format_auto_resolution_remaining(remaining: Duration) -> String {
let mut seconds = remaining.as_secs();
if remaining.subsec_nanos() > 0 {
seconds = seconds.saturating_add(1);
}
if seconds < 60 {
return format!("{seconds}s");
}
let minutes = seconds / 60;
let seconds = seconds % 60;
format!("{minutes}m {seconds:02}s")
}
#[derive(Default, Clone, PartialEq)]
struct ComposerDraft {
text: String,
text_elements: Vec<TextElement>,
local_image_paths: Vec<PathBuf>,
pending_pastes: Vec<(String, String)>,
}
impl ComposerDraft {
fn text_with_pending(&self) -> String {
if self.pending_pastes.is_empty() {
return self.text.clone();
}
debug_assert!(
!self.text_elements.is_empty(),
"pending pastes should always have matching text elements"
);
let (expanded, _) = ChatComposer::expand_pending_pastes(
&self.text,
self.text_elements.clone(),
&self.pending_pastes,
);
expanded
}
}
struct AnswerState {
options_state: ScrollState,
draft: ComposerDraft,
answer_committed: bool,
notes_visible: bool,
}
#[derive(Clone, Debug)]
pub(super) struct FooterTip {
pub(super) text: String,
pub(super) highlight: bool,
}
impl FooterTip {
fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
highlight: false,
}
}
fn highlighted(text: impl Into<String>) -> Self {
Self {
text: text.into(),
highlight: true,
}
}
}
pub(crate) struct RequestUserInputOverlay {
app_event_tx: AppEventSender,
request: ToolRequestUserInputParams,
queue: VecDeque<ToolRequestUserInputParams>,
composer: ChatComposer,
answers: Vec<AnswerState>,
current_idx: usize,
focus: Focus,
done: bool,
pending_submission_draft: Option<ComposerDraft>,
confirm_unanswered: Option<ScrollState>,
request_started_at: Instant,
auto_resolution_snoozed: bool,
composer_submit_keys: Vec<KeyBinding>,
interrupt_turn_keys: Vec<KeyBinding>,
list_keymap: ListKeymap,
}
impl RequestUserInputOverlay {
#[cfg(test)]
pub(crate) fn new(
request: ToolRequestUserInputParams,
app_event_tx: AppEventSender,
has_input_focus: bool,
enhanced_keys_supported: bool,
disable_paste_burst: bool,
) -> Self {
Self::new_with_keymap(
request,
app_event_tx,
has_input_focus,
enhanced_keys_supported,
disable_paste_burst,
RuntimeKeymap::defaults(),
)
}
pub(crate) fn new_with_keymap(
request: ToolRequestUserInputParams,
app_event_tx: AppEventSender,
has_input_focus: bool,
enhanced_keys_supported: bool,
disable_paste_burst: bool,
keymap: RuntimeKeymap,
) -> Self {
let mut composer = ChatComposer::new_with_config(
has_input_focus,
app_event_tx.clone(),
enhanced_keys_supported,
ANSWER_PLACEHOLDER.to_string(),
disable_paste_burst,
ChatComposerConfig::plain_text(),
);
composer.set_keymap_bindings(&keymap);
composer.set_footer_hint_override(Some(Vec::new()));
let mut overlay = Self {
app_event_tx,
request,
queue: VecDeque::new(),
composer,
answers: Vec::new(),
current_idx: 0,
focus: Focus::Options,
done: false,
pending_submission_draft: None,
confirm_unanswered: None,
request_started_at: Instant::now(),
auto_resolution_snoozed: false,
composer_submit_keys: keymap.composer.submit.clone(),
interrupt_turn_keys: keymap.chat.interrupt_turn.clone(),
list_keymap: keymap.list,
};
overlay.reset_for_request();
overlay.ensure_focus_available();
overlay.restore_current_draft();
overlay
}
fn current_index(&self) -> usize {
self.current_idx
}
fn current_question(&self) -> Option<&ToolRequestUserInputQuestion> {
self.request.questions.get(self.current_index())
}
fn current_answer_mut(&mut self) -> Option<&mut AnswerState> {
let idx = self.current_index();
self.answers.get_mut(idx)
}
fn current_answer(&self) -> Option<&AnswerState> {
let idx = self.current_index();
self.answers.get(idx)
}
fn question_count(&self) -> usize {
self.request.questions.len()
}
fn advance_queue_or_complete_at(&mut self, now: Instant) {
if let Some(next) = self.queue.pop_front() {
self.request = next;
self.request_started_at = now;
self.auto_resolution_snoozed = false;
self.reset_for_request();
self.ensure_focus_available();
self.restore_current_draft();
} else {
self.done = true;
}
}
fn snooze_auto_resolution(&mut self) {
if self.request.auto_resolution_ms.is_some() {
self.auto_resolution_snoozed = true;
}
}
fn auto_resolution_timing_at(&self, now: Instant) -> AutoResolutionTiming {
if self.request.auto_resolution_ms.is_none() || self.auto_resolution_snoozed {
return AutoResolutionTiming::Disabled;
}
let elapsed = now.saturating_duration_since(self.request_started_at);
if elapsed < AUTO_RESOLUTION_HIDDEN_GRACE {
return AutoResolutionTiming::HiddenGrace {
remaining: AUTO_RESOLUTION_HIDDEN_GRACE.saturating_sub(elapsed),
};
}
let visible_elapsed = elapsed.saturating_sub(AUTO_RESOLUTION_HIDDEN_GRACE);
if visible_elapsed < AUTO_RESOLUTION_VISIBLE_COUNTDOWN {
return AutoResolutionTiming::VisibleCountdown {
remaining: AUTO_RESOLUTION_VISIBLE_COUNTDOWN.saturating_sub(visible_elapsed),
};
}
AutoResolutionTiming::Due
}
fn auto_resolution_next_frame_delay_at(&self, now: Instant) -> Option<Duration> {
match self.auto_resolution_timing_at(now) {
AutoResolutionTiming::Disabled => None,
AutoResolutionTiming::HiddenGrace { remaining } => Some(remaining),
AutoResolutionTiming::VisibleCountdown { remaining } => {
Some(remaining.min(Duration::from_secs( 1)))
}
AutoResolutionTiming::Due => Some(Duration::ZERO),
}
}
fn maybe_auto_resolve_at(&mut self, now: Instant) -> bool {
if !matches!(
self.auto_resolution_timing_at(now),
AutoResolutionTiming::Due
) {
return false;
}
self.submit_empty_auto_resolution(now);
true
}
fn auto_resolution_countdown_text_at(&self, now: Instant) -> Option<String> {
match self.auto_resolution_timing_at(now) {
AutoResolutionTiming::VisibleCountdown { remaining } => Some(format!(
"auto-resolves in {}",
format_auto_resolution_remaining(remaining)
)),
AutoResolutionTiming::Disabled
| AutoResolutionTiming::HiddenGrace { .. }
| AutoResolutionTiming::Due => None,
}
}
pub(super) fn progress_prefix_text(&self) -> String {
if self.question_count() > 0 {
let idx = self.current_index() + 1;
let total = self.question_count();
let base = format!("Question {idx}/{total}");
let unanswered = self.unanswered_count();
if unanswered > 0 {
format!("{base} ({unanswered} unanswered)")
} else {
base
}
} else {
"No questions".to_string()
}
}
fn has_options(&self) -> bool {
self.current_question()
.and_then(|question| question.options.as_ref())
.is_some_and(|options| !options.is_empty())
}
fn options_len(&self) -> usize {
self.current_question()
.map(Self::options_len_for_question)
.unwrap_or(0)
}
fn option_index_for_digit(&self, ch: char) -> Option<usize> {
if !self.has_options() {
return None;
}
let digit = ch.to_digit(10)?;
if digit == 0 {
return None;
}
let idx = (digit - 1) as usize;
(idx < self.options_len()).then_some(idx)
}
fn selected_option_index(&self) -> Option<usize> {
if !self.has_options() {
return None;
}
self.current_answer()
.and_then(|answer| answer.options_state.selected_idx)
}
fn notes_has_content(&self, idx: usize) -> bool {
if idx == self.current_index() {
!self.composer.current_text_with_pending().trim().is_empty()
} else {
!self.answers[idx].draft.text.trim().is_empty()
}
}
pub(super) fn notes_ui_visible(&self) -> bool {
if !self.has_options() {
return true;
}
let idx = self.current_index();
self.current_answer()
.is_some_and(|answer| answer.notes_visible || self.notes_has_content(idx))
}
pub(super) fn wrapped_question_lines(&self, width: u16) -> Vec<String> {
self.current_question()
.map(|q| {
textwrap::wrap(&q.question, width.max(1) as usize)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn focus_is_notes(&self) -> bool {
matches!(self.focus, Focus::Notes)
}
fn confirm_unanswered_active(&self) -> bool {
self.confirm_unanswered.is_some()
}
pub(super) fn option_rows(&self) -> Vec<GenericDisplayRow> {
self.current_question()
.and_then(|question| question.options.as_ref().map(|options| (question, options)))
.map(|(question, options)| {
let selected_idx = self
.current_answer()
.and_then(|answer| answer.options_state.selected_idx);
let mut rows = options
.iter()
.enumerate()
.map(|(idx, opt)| {
let selected = selected_idx.is_some_and(|sel| sel == idx);
let prefix = if selected { '›' } else { ' ' };
let label = opt.label.as_str();
let number = idx + 1;
let prefix_label = format!("{prefix} {number}. ");
let wrap_indent = UnicodeWidthStr::width(prefix_label.as_str());
GenericDisplayRow {
name: format!("{prefix_label}{label}"),
description: Some(opt.description.clone()),
wrap_indent: Some(wrap_indent),
..Default::default()
}
})
.collect::<Vec<_>>();
if Self::other_option_enabled_for_question(question) {
let idx = options.len();
let selected = selected_idx.is_some_and(|sel| sel == idx);
let prefix = if selected { '›' } else { ' ' };
let number = idx + 1;
let prefix_label = format!("{prefix} {number}. ");
let wrap_indent = UnicodeWidthStr::width(prefix_label.as_str());
rows.push(GenericDisplayRow {
name: format!("{prefix_label}{OTHER_OPTION_LABEL}"),
description: Some(OTHER_OPTION_DESCRIPTION.to_string()),
wrap_indent: Some(wrap_indent),
..Default::default()
});
}
rows
})
.unwrap_or_default()
}
pub(super) fn options_required_height(&self, width: u16) -> u16 {
if !self.has_options() {
return 0;
}
let rows = self.option_rows();
if rows.is_empty() {
return 1;
}
let mut state = self
.current_answer()
.map(|answer| answer.options_state)
.unwrap_or_default();
if state.selected_idx.is_none() {
state.selected_idx = Some(0);
}
measure_rows_height(&rows, &state, rows.len(), width.max(1))
}
pub(super) fn options_preferred_height(&self, width: u16) -> u16 {
if !self.has_options() {
return 0;
}
let rows = self.option_rows();
if rows.is_empty() {
return 1;
}
let mut state = self
.current_answer()
.map(|answer| answer.options_state)
.unwrap_or_default();
if state.selected_idx.is_none() {
state.selected_idx = Some(0);
}
measure_rows_height(&rows, &state, rows.len(), width.max(1))
}
fn capture_composer_draft(&self) -> ComposerDraft {
ComposerDraft {
text: self.composer.current_text(),
text_elements: self.composer.text_elements(),
local_image_paths: self
.composer
.local_images()
.into_iter()
.map(|img| img.path)
.collect(),
pending_pastes: self.composer.pending_pastes(),
}
}
fn save_current_draft(&mut self) {
let draft = self.capture_composer_draft();
let notes_empty = draft.text.trim().is_empty();
if let Some(answer) = self.current_answer_mut() {
if answer.answer_committed && answer.draft != draft {
answer.answer_committed = false;
}
answer.draft = draft;
if !notes_empty {
answer.notes_visible = true;
}
}
}
fn restore_current_draft(&mut self) {
self.composer
.set_placeholder_text(self.notes_placeholder().to_string());
self.composer.set_footer_hint_override(Some(Vec::new()));
let Some(answer) = self.current_answer() else {
self.composer
.set_text_content(String::new(), Vec::new(), Vec::new());
self.composer.move_cursor_to_end();
return;
};
let draft = answer.draft.clone();
self.composer
.set_text_content(draft.text, draft.text_elements, draft.local_image_paths);
self.composer.set_pending_pastes(draft.pending_pastes);
self.composer.move_cursor_to_end();
}
fn notes_placeholder(&self) -> &'static str {
if self.has_options() && self.selected_option_index().is_none() {
SELECT_OPTION_PLACEHOLDER
} else if self.has_options() {
NOTES_PLACEHOLDER
} else {
ANSWER_PLACEHOLDER
}
}
fn sync_composer_placeholder(&mut self) {
self.composer
.set_placeholder_text(self.notes_placeholder().to_string());
}
fn clear_notes_draft(&mut self) {
if let Some(answer) = self.current_answer_mut() {
answer.draft = ComposerDraft::default();
answer.answer_committed = false;
answer.notes_visible = true;
}
self.pending_submission_draft = None;
self.composer
.set_text_content(String::new(), Vec::new(), Vec::new());
self.composer.move_cursor_to_end();
self.sync_composer_placeholder();
}
fn footer_tips(&self) -> Vec<FooterTip> {
let mut tips = Vec::new();
let notes_visible = self.notes_ui_visible();
if self.has_options() {
if self.selected_option_index().is_some() && !notes_visible {
tips.push(FooterTip::highlighted("tab to add notes"));
}
if self.selected_option_index().is_some() && notes_visible {
tips.push(FooterTip::new("tab or esc to clear notes"));
}
}
let question_count = self.question_count();
let is_last_question = self.current_index().saturating_add(1) >= question_count;
let submit_key = if self.focus_is_notes() || !self.has_options() {
self.composer_submit_keys
.first()
.map(KeyBinding::display_label)
} else {
Some("enter".to_string())
};
if let Some(submit_key) = submit_key {
let submit_tip = if question_count == 1 {
FooterTip::highlighted(format!("{submit_key} to submit answer"))
} else if is_last_question {
FooterTip::highlighted(format!("{submit_key} to submit all"))
} else {
FooterTip::new(format!("{submit_key} to submit answer"))
};
tips.push(submit_tip);
}
if question_count > 1 {
if self.has_options() && !self.focus_is_notes() {
tips.push(FooterTip::new("←/→ to navigate questions"));
} else if !self.has_options() {
tips.push(FooterTip::new("ctrl + p / ctrl + n change question"));
}
}
if let Some(interrupt_key) = self.interrupt_turn_keys.first()
&& !(self.has_options()
&& notes_visible
&& *interrupt_key == crate::tui_internal::key_hint::plain(KeyCode::Esc))
{
tips.push(FooterTip::new(format!(
"{} to interrupt",
interrupt_key.display_label()
)));
}
tips
}
pub(super) fn footer_tip_lines(&self, width: u16) -> Vec<Vec<FooterTip>> {
self.wrap_footer_tips(width, self.footer_tips())
}
pub(super) fn footer_tip_lines_with_prefix(
&self,
width: u16,
prefix: Option<FooterTip>,
) -> Vec<Vec<FooterTip>> {
let mut tips = Vec::new();
if let Some(prefix) = prefix {
tips.push(prefix);
}
tips.extend(self.footer_tips());
self.wrap_footer_tips(width, tips)
}
fn wrap_footer_tips(&self, width: u16, tips: Vec<FooterTip>) -> Vec<Vec<FooterTip>> {
let max_width = width.max(1) as usize;
let separator_width = UnicodeWidthStr::width(TIP_SEPARATOR);
if tips.is_empty() {
return vec![Vec::new()];
}
let mut lines: Vec<Vec<FooterTip>> = Vec::new();
let mut current: Vec<FooterTip> = Vec::new();
let mut used = 0usize;
for tip in tips {
let tip_width = UnicodeWidthStr::width(tip.text.as_str()).min(max_width);
let extra = if current.is_empty() {
tip_width
} else {
separator_width.saturating_add(tip_width)
};
if !current.is_empty() && used.saturating_add(extra) > max_width {
lines.push(current);
current = Vec::new();
used = 0;
}
if current.is_empty() {
used = tip_width;
} else {
used = used
.saturating_add(separator_width)
.saturating_add(tip_width);
}
current.push(tip);
}
if current.is_empty() {
lines.push(Vec::new());
} else {
lines.push(current);
}
lines
}
pub(super) fn footer_required_height(&self, width: u16) -> u16 {
self.footer_tip_lines(width).len() as u16
}
fn ensure_focus_available(&mut self) {
if self.question_count() == 0 {
return;
}
if !self.has_options() {
self.focus = Focus::Notes;
if let Some(answer) = self.current_answer_mut() {
answer.notes_visible = true;
}
return;
}
if matches!(self.focus, Focus::Notes) && !self.notes_ui_visible() {
self.focus = Focus::Options;
self.sync_composer_placeholder();
}
}
fn reset_for_request(&mut self) {
self.answers = self
.request
.questions
.iter()
.map(|question| {
let has_options = question
.options
.as_ref()
.is_some_and(|options| !options.is_empty());
let mut options_state = ScrollState::new();
if has_options {
options_state.selected_idx = Some(0);
}
AnswerState {
options_state,
draft: ComposerDraft::default(),
answer_committed: false,
notes_visible: !has_options,
}
})
.collect();
self.current_idx = 0;
self.focus = Focus::Options;
self.composer
.set_text_content(String::new(), Vec::new(), Vec::new());
self.confirm_unanswered = None;
self.pending_submission_draft = None;
}
fn options_len_for_question(question: &ToolRequestUserInputQuestion) -> usize {
let options_len = question
.options
.as_ref()
.map(std::vec::Vec::len)
.unwrap_or(0);
if Self::other_option_enabled_for_question(question) {
options_len + 1
} else {
options_len
}
}
fn other_option_enabled_for_question(question: &ToolRequestUserInputQuestion) -> bool {
question.is_other
&& question
.options
.as_ref()
.is_some_and(|options| !options.is_empty())
}
fn option_label_for_index(
question: &ToolRequestUserInputQuestion,
idx: usize,
) -> Option<String> {
let options = question.options.as_ref()?;
if idx < options.len() {
return options.get(idx).map(|opt| opt.label.clone());
}
if idx == options.len() && Self::other_option_enabled_for_question(question) {
return Some(OTHER_OPTION_LABEL.to_string());
}
None
}
fn move_question(&mut self, next: bool) {
let len = self.question_count();
if len == 0 {
return;
}
self.save_current_draft();
let offset = if next { 1 } else { len.saturating_sub(1) };
self.current_idx = (self.current_idx + offset) % len;
self.restore_current_draft();
self.ensure_focus_available();
}
fn jump_to_question(&mut self, idx: usize) {
if idx >= self.question_count() {
return;
}
self.save_current_draft();
self.current_idx = idx;
self.restore_current_draft();
self.ensure_focus_available();
}
fn select_current_option(&mut self, committed: bool) {
if !self.has_options() {
return;
}
let options_len = self.options_len();
let updated = if let Some(answer) = self.current_answer_mut() {
answer.options_state.clamp_selection(options_len);
answer.answer_committed = committed;
true
} else {
false
};
if updated {
self.sync_composer_placeholder();
}
}
fn clear_selection(&mut self) {
if !self.has_options() {
return;
}
if let Some(answer) = self.current_answer_mut() {
answer.options_state.reset();
answer.draft = ComposerDraft::default();
answer.answer_committed = false;
answer.notes_visible = false;
}
self.pending_submission_draft = None;
self.composer
.set_text_content(String::new(), Vec::new(), Vec::new());
self.composer.move_cursor_to_end();
self.sync_composer_placeholder();
}
fn clear_notes_and_focus_options(&mut self) {
if !self.has_options() {
return;
}
if let Some(answer) = self.current_answer_mut() {
answer.draft = ComposerDraft::default();
answer.answer_committed = false;
answer.notes_visible = false;
}
self.pending_submission_draft = None;
self.composer
.set_text_content(String::new(), Vec::new(), Vec::new());
self.composer.move_cursor_to_end();
self.focus = Focus::Options;
self.sync_composer_placeholder();
}
fn ensure_selected_for_notes(&mut self) {
if let Some(answer) = self.current_answer_mut() {
answer.notes_visible = true;
}
self.sync_composer_placeholder();
}
fn go_next_or_submit(&mut self) {
if self.current_index() + 1 >= self.question_count() {
self.save_current_draft();
if self.unanswered_count() > 0 {
self.open_unanswered_confirmation();
} else {
self.submit_answers();
}
} else {
self.move_question( true);
}
}
fn submit_answers(&mut self) {
self.confirm_unanswered = None;
self.save_current_draft();
let mut answers = HashMap::new();
for (idx, question) in self.request.questions.iter().enumerate() {
let answer_state = &self.answers[idx];
let options = question.options.as_ref();
let selected_idx =
if options.is_some_and(|opts| !opts.is_empty()) && answer_state.answer_committed {
answer_state.options_state.selected_idx
} else {
None
};
let notes = if answer_state.answer_committed {
answer_state.draft.text_with_pending().trim().to_string()
} else {
String::new()
};
let selected_label = selected_idx
.and_then(|selected_idx| Self::option_label_for_index(question, selected_idx));
let mut answer_list = selected_label.into_iter().collect::<Vec<_>>();
if !notes.is_empty() {
answer_list.push(format!("user_note: {notes}"));
}
answers.insert(
question.id.clone(),
ToolRequestUserInputAnswer {
answers: answer_list,
},
);
}
self.app_event_tx.user_input_answer(
self.request.turn_id.clone(),
ToolRequestUserInputResponse {
answers: answers.clone(),
},
);
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::RequestUserInputResultCell {
questions: self.request.questions.clone(),
answers,
interrupted: false,
},
)));
self.advance_queue_or_complete_at(Instant::now());
}
fn submit_empty_auto_resolution(&mut self, now: Instant) {
self.confirm_unanswered = None;
let answers: HashMap<String, ToolRequestUserInputAnswer> = HashMap::new();
self.app_event_tx.user_input_answer(
self.request.turn_id.clone(),
ToolRequestUserInputResponse {
answers: answers.clone(),
},
);
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::RequestUserInputResultCell {
questions: self.request.questions.clone(),
answers,
interrupted: false,
},
)));
self.advance_queue_or_complete_at(now);
}
fn dismiss_resolved_request(&mut self, request: &ResolvedAppServerRequest) -> bool {
let ResolvedAppServerRequest::UserInput { call_id } = request else {
return false;
};
let queue_len = self.queue.len();
self.queue
.retain(|queued_request| queued_request.item_id != *call_id);
if self.request.item_id == *call_id {
self.advance_queue_or_complete_at(Instant::now());
return true;
}
self.queue.len() != queue_len
}
fn open_unanswered_confirmation(&mut self) {
let mut state = ScrollState::new();
state.selected_idx = Some(0);
self.confirm_unanswered = Some(state);
}
fn close_unanswered_confirmation(&mut self) {
self.confirm_unanswered = None;
}
fn unanswered_question_count(&self) -> usize {
self.unanswered_count()
}
fn unanswered_submit_description(&self) -> String {
let count = self.unanswered_question_count();
let suffix = if count == 1 {
UNANSWERED_CONFIRM_SUBMIT_DESC_SINGULAR
} else {
UNANSWERED_CONFIRM_SUBMIT_DESC_PLURAL
};
format!("Submit with {count} unanswered {suffix}.")
}
fn first_unanswered_index(&self) -> Option<usize> {
let current_text = self.composer.current_text();
self.request
.questions
.iter()
.enumerate()
.find(|(idx, _)| !self.is_question_answered(*idx, ¤t_text))
.map(|(idx, _)| idx)
}
fn unanswered_confirmation_rows(&self) -> Vec<GenericDisplayRow> {
let selected = self
.confirm_unanswered
.as_ref()
.and_then(|state| state.selected_idx)
.unwrap_or(0);
let entries = [
(
UNANSWERED_CONFIRM_SUBMIT,
self.unanswered_submit_description(),
),
(
UNANSWERED_CONFIRM_GO_BACK,
UNANSWERED_CONFIRM_GO_BACK_DESC.to_string(),
),
];
entries
.iter()
.enumerate()
.map(|(idx, (label, description))| {
let prefix = if idx == selected { '›' } else { ' ' };
let number = idx + 1;
GenericDisplayRow {
name: format!("{prefix} {number}. {label}"),
description: Some(description.clone()),
..Default::default()
}
})
.collect()
}
fn is_question_answered(&self, idx: usize, _current_text: &str) -> bool {
let Some(question) = self.request.questions.get(idx) else {
return false;
};
let Some(answer) = self.answers.get(idx) else {
return false;
};
let has_options = question
.options
.as_ref()
.is_some_and(|options| !options.is_empty());
if has_options {
answer.options_state.selected_idx.is_some() && answer.answer_committed
} else {
answer.answer_committed
}
}
fn unanswered_count(&self) -> usize {
let current_text = self.composer.current_text();
self.request
.questions
.iter()
.enumerate()
.filter(|(idx, _question)| !self.is_question_answered(*idx, ¤t_text))
.count()
}
fn notes_input_height(&self, width: u16) -> u16 {
let min_height = MIN_COMPOSER_HEIGHT;
self.composer
.desired_height(width.max(1))
.clamp(min_height, min_height.saturating_add(5))
}
fn apply_submission_to_draft(&mut self, text: String, text_elements: Vec<TextElement>) {
let local_image_paths = self
.composer
.local_images()
.into_iter()
.map(|img| img.path)
.collect::<Vec<_>>();
if let Some(answer) = self.current_answer_mut() {
answer.draft = ComposerDraft {
text: text.clone(),
text_elements: text_elements.clone(),
local_image_paths: local_image_paths.clone(),
pending_pastes: Vec::new(),
};
}
self.composer
.set_text_content(text, text_elements, local_image_paths);
self.composer.move_cursor_to_end();
self.composer.set_footer_hint_override(Some(Vec::new()));
}
fn apply_submission_draft(&mut self, draft: ComposerDraft) {
if let Some(answer) = self.current_answer_mut() {
answer.draft = draft.clone();
}
self.composer
.set_text_content(draft.text, draft.text_elements, draft.local_image_paths);
self.composer.set_pending_pastes(draft.pending_pastes);
self.composer.move_cursor_to_end();
self.composer.set_footer_hint_override(Some(Vec::new()));
}
fn handle_composer_input_result(&mut self, result: InputResult) -> bool {
match result {
InputResult::Submitted {
text,
text_elements,
}
| InputResult::Queued {
text,
text_elements,
..
} => {
if self.has_options()
&& matches!(self.focus, Focus::Notes)
&& !text.trim().is_empty()
{
let options_len = self.options_len();
if let Some(answer) = self.current_answer_mut() {
answer.options_state.clamp_selection(options_len);
}
}
if self.has_options() {
if let Some(answer) = self.current_answer_mut() {
answer.answer_committed = true;
}
} else if let Some(answer) = self.current_answer_mut() {
answer.answer_committed = !text.trim().is_empty();
}
let draft_override = self.pending_submission_draft.take();
if let Some(draft) = draft_override {
self.apply_submission_draft(draft);
} else {
self.apply_submission_to_draft(text, text_elements);
}
self.go_next_or_submit();
true
}
_ => false,
}
}
fn handle_confirm_unanswered_key_event(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Release {
return;
}
let Some(state) = self.confirm_unanswered.as_mut() else {
return;
};
match key_event.code {
KeyCode::Esc | KeyCode::Backspace => {
self.close_unanswered_confirmation();
if let Some(idx) = self.first_unanswered_index() {
self.jump_to_question(idx);
}
}
KeyCode::Up | KeyCode::Char('k') => {
state.move_up_wrap( 2);
}
KeyCode::Down | KeyCode::Char('j') => {
state.move_down_wrap( 2);
}
KeyCode::Enter => {
let selected = state.selected_idx.unwrap_or(0);
self.close_unanswered_confirmation();
if selected == 0 {
self.submit_answers();
} else if let Some(idx) = self.first_unanswered_index() {
self.jump_to_question(idx);
}
}
KeyCode::Char('1') | KeyCode::Char('2') => {
let idx = if matches!(key_event.code, KeyCode::Char('1')) {
0
} else {
1
};
state.selected_idx = Some(idx);
}
_ => {}
}
}
}
impl BottomPaneView for RequestUserInputOverlay {
fn prefer_esc_to_handle_key_event(&self) -> bool {
true
}
fn will_interrupt_turn_on_key_event(&self, key_event: KeyEvent) -> bool {
if KeyBinding::new(KeyCode::Char('c'), KeyModifiers::CONTROL).is_press(key_event) {
return self.confirm_unanswered_active()
|| !self.focus_is_notes()
|| self.composer.current_text_with_pending().is_empty();
}
key_event.kind != KeyEventKind::Release
&& !self.confirm_unanswered_active()
&& !(matches!(key_event.code, KeyCode::Esc)
&& self.has_options()
&& self.notes_ui_visible())
&& self.interrupt_turn_keys.is_pressed(key_event)
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Release {
return;
}
self.snooze_auto_resolution();
if self.confirm_unanswered_active() {
self.handle_confirm_unanswered_key_event(key_event);
return;
}
if matches!(key_event.code, KeyCode::Esc) && self.has_options() && self.notes_ui_visible() {
self.clear_notes_and_focus_options();
return;
}
if self.interrupt_turn_keys.is_pressed(key_event) {
self.app_event_tx.interrupt();
self.done = true;
return;
}
if self.focus_is_notes() && self.composer_submit_keys.is_pressed(key_event) {
self.ensure_selected_for_notes();
self.pending_submission_draft = Some(self.capture_composer_draft());
let (result, _) = self.composer.handle_key_event(key_event);
if !self.handle_composer_input_result(result) {
self.pending_submission_draft = None;
if self.has_options() {
self.select_current_option( true);
}
self.go_next_or_submit();
}
return;
}
match key_event {
KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::PageUp,
modifiers: KeyModifiers::NONE,
..
} => {
self.move_question( false);
return;
}
KeyEvent {
code: KeyCode::PageDown,
modifiers: KeyModifiers::NONE,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.move_question( true);
return;
}
KeyEvent {
code: KeyCode::Char('h'),
modifiers: KeyModifiers::NONE,
..
}
| KeyEvent {
code: KeyCode::Left,
modifiers: KeyModifiers::NONE,
..
} if self.has_options() && matches!(self.focus, Focus::Options) => {
self.move_question( false);
return;
}
_ if self.has_options()
&& matches!(self.focus, Focus::Options)
&& self.list_keymap.move_left.is_pressed(key_event) =>
{
self.move_question( false);
return;
}
KeyEvent {
code: KeyCode::Char('l'),
modifiers: KeyModifiers::NONE,
..
}
| KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::NONE,
..
} if self.has_options() && matches!(self.focus, Focus::Options) => {
self.move_question( true);
return;
}
_ if self.has_options()
&& matches!(self.focus, Focus::Options)
&& self.list_keymap.move_right.is_pressed(key_event) =>
{
self.move_question( true);
return;
}
_ => {}
}
match self.focus {
Focus::Options => {
let options_len = self.options_len();
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
let moved = if let Some(answer) = self.current_answer_mut() {
answer.options_state.move_up_wrap(options_len);
answer.answer_committed = false;
true
} else {
false
};
if moved {
self.sync_composer_placeholder();
}
}
KeyCode::Down | KeyCode::Char('j') => {
let moved = if let Some(answer) = self.current_answer_mut() {
answer.options_state.move_down_wrap(options_len);
answer.answer_committed = false;
true
} else {
false
};
if moved {
self.sync_composer_placeholder();
}
}
KeyCode::Char(' ') => {
self.select_current_option( true);
}
KeyCode::Backspace | KeyCode::Delete => {
self.clear_selection();
}
KeyCode::Tab if self.selected_option_index().is_some() => {
self.focus = Focus::Notes;
self.ensure_selected_for_notes();
}
KeyCode::Enter => {
let has_selection = self.selected_option_index().is_some();
if has_selection {
self.select_current_option( true);
}
self.go_next_or_submit();
}
KeyCode::Char(ch) => {
if let Some(option_idx) = self.option_index_for_digit(ch) {
if let Some(answer) = self.current_answer_mut() {
answer.options_state.selected_idx = Some(option_idx);
}
self.select_current_option( true);
self.go_next_or_submit();
}
}
_ => {}
}
}
Focus::Notes => {
let notes_empty = self.composer.current_text_with_pending().trim().is_empty();
if self.has_options() && matches!(key_event.code, KeyCode::Tab) {
self.clear_notes_and_focus_options();
return;
}
if self.has_options() && matches!(key_event.code, KeyCode::Backspace) && notes_empty
{
self.save_current_draft();
if let Some(answer) = self.current_answer_mut() {
answer.notes_visible = false;
}
self.focus = Focus::Options;
self.sync_composer_placeholder();
return;
}
if self.has_options() && matches!(key_event.code, KeyCode::Up | KeyCode::Down) {
let options_len = self.options_len();
match key_event.code {
KeyCode::Up => {
let moved = if let Some(answer) = self.current_answer_mut() {
answer.options_state.move_up_wrap(options_len);
answer.answer_committed = false;
true
} else {
false
};
if moved {
self.sync_composer_placeholder();
}
}
KeyCode::Down => {
let moved = if let Some(answer) = self.current_answer_mut() {
answer.options_state.move_down_wrap(options_len);
answer.answer_committed = false;
true
} else {
false
};
if moved {
self.sync_composer_placeholder();
}
}
_ => {}
}
return;
}
self.ensure_selected_for_notes();
if matches!(
key_event.code,
KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete
) && let Some(answer) = self.current_answer_mut()
{
answer.answer_committed = false;
}
let before = self.capture_composer_draft();
let (result, _) = self.composer.handle_key_event(key_event);
let submitted = self.handle_composer_input_result(result);
if !submitted {
let after = self.capture_composer_draft();
if before != after
&& let Some(answer) = self.current_answer_mut()
{
answer.answer_committed = false;
}
}
}
}
}
fn terminal_title_requires_action(&self) -> bool {
true
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
if self.confirm_unanswered_active() {
self.close_unanswered_confirmation();
self.app_event_tx.interrupt();
self.done = true;
return CancellationEvent::Handled;
}
if self.focus_is_notes() && !self.composer.current_text_with_pending().is_empty() {
self.clear_notes_draft();
return CancellationEvent::Handled;
}
self.app_event_tx.interrupt();
self.done = true;
CancellationEvent::Handled
}
fn is_complete(&self) -> bool {
self.done
}
fn handle_paste(&mut self, pasted: String) -> bool {
if pasted.is_empty() {
return false;
}
self.snooze_auto_resolution();
if matches!(self.focus, Focus::Options) {
self.focus = Focus::Notes;
}
self.ensure_selected_for_notes();
if let Some(answer) = self.current_answer_mut() {
answer.answer_committed = false;
}
self.composer.handle_paste(pasted)
}
fn flush_paste_burst_if_due(&mut self) -> bool {
self.composer.flush_paste_burst_if_due()
}
fn is_in_paste_burst(&self) -> bool {
self.composer.is_in_paste_burst()
}
fn pre_draw_tick(&mut self, now: Instant) -> bool {
self.maybe_auto_resolve_at(now)
}
fn next_frame_delay(&self) -> Option<Duration> {
self.auto_resolution_next_frame_delay_at(Instant::now())
}
fn try_consume_user_input_request(
&mut self,
request: ToolRequestUserInputParams,
) -> Option<ToolRequestUserInputParams> {
self.queue.push_back(request);
None
}
fn dismiss_app_server_request(&mut self, request: &ResolvedAppServerRequest) -> bool {
self.dismiss_resolved_request(request)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui_internal::app_event::AppEvent;
use crate::tui_internal::bottom_pane::selection_popup_common::menu_surface_inset;
use crate::tui_internal::render::renderable::Renderable;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use std::collections::HashMap;
use std::time::Instant;
use tokio::sync::mpsc::unbounded_channel;
use unicode_width::UnicodeWidthStr;
fn test_sender() -> (
AppEventSender,
tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
) {
let (tx_raw, rx) = unbounded_channel::<AppEvent>();
(AppEventSender::new(tx_raw), rx)
}
fn expect_interrupt_only(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) {
let event = rx.try_recv().expect("expected interrupt AppEvent");
let AppEvent::CodexOp(op) = event else {
panic!("expected CodexOp");
};
assert_eq!(op, Op::interrupt());
assert!(
rx.try_recv().is_err(),
"unexpected AppEvents before interrupt completion"
);
}
fn question_with_options(id: &str, header: &str) -> ToolRequestUserInputQuestion {
ToolRequestUserInputQuestion {
id: id.to_string(),
header: header.to_string(),
question: "Choose an option.".to_string(),
is_other: false,
is_secret: false,
options: Some(vec![
ToolRequestUserInputOption {
label: "Option 1".to_string(),
description: "First choice.".to_string(),
},
ToolRequestUserInputOption {
label: "Option 2".to_string(),
description: "Second choice.".to_string(),
},
ToolRequestUserInputOption {
label: "Option 3".to_string(),
description: "Third choice.".to_string(),
},
]),
}
}
fn question_with_options_and_other(id: &str, header: &str) -> ToolRequestUserInputQuestion {
ToolRequestUserInputQuestion {
id: id.to_string(),
header: header.to_string(),
question: "Choose an option.".to_string(),
is_other: true,
is_secret: false,
options: Some(vec![
ToolRequestUserInputOption {
label: "Option 1".to_string(),
description: "First choice.".to_string(),
},
ToolRequestUserInputOption {
label: "Option 2".to_string(),
description: "Second choice.".to_string(),
},
ToolRequestUserInputOption {
label: "Option 3".to_string(),
description: "Third choice.".to_string(),
},
]),
}
}
fn question_with_wrapped_options(id: &str, header: &str) -> ToolRequestUserInputQuestion {
ToolRequestUserInputQuestion {
id: id.to_string(),
header: header.to_string(),
question: "Choose the next step for this task.".to_string(),
is_other: false,
is_secret: false,
options: Some(vec![
ToolRequestUserInputOption {
label: "Discuss a code change".to_string(),
description:
"Walk through a plan, then implement it together with careful checks."
.to_string(),
},
ToolRequestUserInputOption {
label: "Run targeted tests".to_string(),
description:
"Pick the most relevant crate and validate the current behavior first."
.to_string(),
},
ToolRequestUserInputOption {
label: "Review the diff".to_string(),
description:
"Summarize the changes and highlight the most important risks and gaps."
.to_string(),
},
]),
}
}
fn question_with_very_long_option_text(id: &str, header: &str) -> ToolRequestUserInputQuestion {
ToolRequestUserInputQuestion {
id: id.to_string(),
header: header.to_string(),
question: "Choose one option.".to_string(),
is_other: false,
is_secret: false,
options: Some(vec![
ToolRequestUserInputOption {
label: "Job: running/completed/failed/expired; Run/Experiment: succeeded/failed/unknown (Recommended when triaging long-running background work and status transitions)".to_string(),
description: "Keep async job statuses for progress tracking and include enough context for debugging retries, stale workers, and unexpected expiration paths.".to_string(),
},
ToolRequestUserInputOption {
label: "Add a short status model".to_string(),
description: "Simpler labels with less detail for quick rollouts.".to_string(),
},
]),
}
}
fn question_with_long_scroll_options(id: &str, header: &str) -> ToolRequestUserInputQuestion {
ToolRequestUserInputQuestion {
id: id.to_string(),
header: header.to_string(),
question:
"Choose one option; each hint is intentionally very long to test wrapped scrolling."
.to_string(),
is_other: false,
is_secret: false,
options: Some(vec![
ToolRequestUserInputOption {
label: "Use Detailed Hint A (Recommended)".to_string(),
description: "Select this if you want a deliberately overextended explanatory hint that reads like a miniature specification, including context, rationale, expected behavior, and an explicit statement that this choice is mainly for testing how gracefully the interface wraps, truncates, and preserves readability under unusually verbose helper text conditions.".to_string(),
},
ToolRequestUserInputOption {
label: "Use Detailed Hint B".to_string(),
description: "Select this if you want an equally verbose but differently phrased guidance block that emphasizes user-facing clarity, spacing tolerance, multiline wrapping, visual hierarchy interactions, and whether long descriptive metadata remains understandable when scanned quickly in a constrained layout where cognitive load is already high.".to_string(),
},
ToolRequestUserInputOption {
label: "Use Detailed Hint C".to_string(),
description: "Select this when you specifically want to verify that navigating downward will keep the currently highlighted option visible, even when previous options consume many wrapped lines and would otherwise push the selection out of the viewport.".to_string(),
},
ToolRequestUserInputOption {
label: "None of the above".to_string(),
description:
"Use this only if the previous long-form options do not apply.".to_string(),
},
]),
}
}
fn question_without_options(id: &str, header: &str) -> ToolRequestUserInputQuestion {
ToolRequestUserInputQuestion {
id: id.to_string(),
header: header.to_string(),
question: "Share details.".to_string(),
is_other: false,
is_secret: false,
options: None,
}
}
fn request_event(
turn_id: &str,
questions: Vec<ToolRequestUserInputQuestion>,
) -> ToolRequestUserInputParams {
ToolRequestUserInputParams {
thread_id: "thread-1".to_string(),
item_id: "call-1".to_string(),
turn_id: turn_id.to_string(),
questions,
auto_resolution_ms: None,
}
}
fn request_event_with_auto_resolution(
turn_id: &str,
questions: Vec<ToolRequestUserInputQuestion>,
) -> ToolRequestUserInputParams {
let mut request = request_event(turn_id, questions);
request.auto_resolution_ms = Some(60_000);
request
}
fn snapshot_buffer(buf: &Buffer) -> String {
let mut lines = Vec::new();
for y in 0..buf.area().height {
let mut row = String::new();
for x in 0..buf.area().width {
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
lines.push(row);
}
lines.join("\n")
}
fn render_snapshot(overlay: &RequestUserInputOverlay, area: Rect) -> String {
render_snapshot_at(overlay, area, Instant::now())
}
fn render_snapshot_at(overlay: &RequestUserInputOverlay, area: Rect, now: Instant) -> String {
let mut buf = Buffer::empty(area);
overlay.render_ui_at(area, &mut buf, now);
snapshot_buffer(&buf)
}
#[test]
fn queued_requests_are_fifo() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "First")]),
tx,
true,
false,
false,
);
overlay.try_consume_user_input_request(request_event(
"turn-2",
vec![question_with_options("q2", "Second")],
));
overlay.try_consume_user_input_request(request_event(
"turn-3",
vec![question_with_options("q3", "Third")],
));
overlay.submit_answers();
assert_eq!(overlay.request.turn_id, "turn-2");
overlay.submit_answers();
assert_eq!(overlay.request.turn_id, "turn-3");
}
#[test]
fn interrupt_discards_queued_requests_and_emits_interrupt() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "First")]),
tx,
true,
false,
false,
);
overlay.try_consume_user_input_request(ToolRequestUserInputParams {
thread_id: "thread-1".to_string(),
item_id: "call-2".to_string(),
turn_id: "turn-2".to_string(),
questions: vec![question_with_options("q2", "Second")],
auto_resolution_ms: None,
});
overlay.try_consume_user_input_request(ToolRequestUserInputParams {
thread_id: "thread-1".to_string(),
item_id: "call-3".to_string(),
turn_id: "turn-3".to_string(),
questions: vec![question_with_options("q3", "Third")],
auto_resolution_ms: None,
});
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
assert!(overlay.done, "expected overlay to be done");
expect_interrupt_only(&mut rx);
}
#[test]
fn auto_resolution_absent_has_no_timer() {
let (tx, _rx) = test_sender();
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "First")]),
tx,
true,
false,
false,
);
let now = Instant::now();
assert_eq!(
overlay.auto_resolution_timing_at(now),
AutoResolutionTiming::Disabled
);
assert_eq!(overlay.auto_resolution_next_frame_delay_at(now), None);
assert_eq!(overlay.auto_resolution_countdown_text_at(now), None);
}
#[test]
fn auto_resolution_hides_timer_during_grace_period() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event_with_auto_resolution(
"turn-1",
vec![question_with_options("q1", "First")],
),
tx,
true,
false,
false,
);
let now = Instant::now();
overlay.request_started_at = now;
assert_eq!(
overlay.auto_resolution_timing_at(now),
AutoResolutionTiming::HiddenGrace {
remaining: AUTO_RESOLUTION_HIDDEN_GRACE
}
);
assert_eq!(
overlay.auto_resolution_next_frame_delay_at(now),
Some(AUTO_RESOLUTION_HIDDEN_GRACE)
);
assert!(
!render_snapshot_at(&overlay, Rect::new(0, 0, 120, 16), now).contains("auto-resolves")
);
}
#[test]
fn auto_resolution_visible_countdown_snapshot() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event_with_auto_resolution(
"turn-1",
vec![
question_with_options("q1", "First"),
question_with_options("q2", "Second"),
question_with_options("q3", "Third"),
],
),
tx,
true,
false,
false,
);
let now = Instant::now();
overlay.request_started_at = now - AUTO_RESOLUTION_HIDDEN_GRACE;
insta::assert_snapshot!(
"request_user_input_auto_resolution_countdown",
render_snapshot_at(&overlay, Rect::new(0, 0, 120, 16), now)
);
}
#[test]
fn auto_resolution_visible_countdown_is_red() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event_with_auto_resolution(
"turn-1",
vec![question_with_options("q1", "First")],
),
tx,
true,
false,
false,
);
let now = Instant::now();
overlay.request_started_at = now - AUTO_RESOLUTION_HIDDEN_GRACE;
let area = Rect::new(0, 0, 120, 16);
let mut buf = Buffer::empty(area);
overlay.render_ui_at(area, &mut buf, now);
let rendered = snapshot_buffer(&buf);
let progress_line = rendered.lines().nth(1).expect("expected progress line");
let countdown = "auto-resolves in 1m 00s";
let countdown_byte_idx = progress_line
.find(countdown)
.expect("expected countdown in progress line");
let countdown_x = progress_line[..countdown_byte_idx].width();
for offset in 0..countdown.width() {
assert_eq!(
buf[((countdown_x + offset) as u16, 1)].style().fg,
Some(Color::Red)
);
}
let prefix_byte_idx = progress_line
.find("Question")
.expect("expected question prefix in progress line");
let prefix_x = progress_line[..prefix_byte_idx].width();
assert_ne!(buf[(prefix_x as u16, 1)].style().fg, Some(Color::Red));
}
#[test]
fn auto_resolution_expiry_emits_empty_answer() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event_with_auto_resolution(
"turn-1",
vec![question_with_options("q1", "First")],
),
tx,
true,
false,
false,
);
let now = Instant::now();
let total_timeout = AUTO_RESOLUTION_HIDDEN_GRACE + AUTO_RESOLUTION_VISIBLE_COUNTDOWN;
overlay.request_started_at = now - total_timeout;
assert!(overlay.pre_draw_tick(now));
assert!(overlay.done);
let event = rx.try_recv().expect("expected UserInputAnswer event");
let AppEvent::CodexOp(Op::UserInputAnswer { id, response }) = event else {
panic!("expected UserInputAnswer event");
};
assert_eq!(id, "turn-1");
assert_eq!(response.answers, HashMap::new());
let event = rx.try_recv().expect("expected history cell event");
assert!(
matches!(event, AppEvent::InsertHistoryCell(_)),
"expected history cell event"
);
}
#[test]
fn auto_resolution_key_interaction_snoozes_timer() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event_with_auto_resolution(
"turn-1",
vec![question_with_options("q1", "First")],
),
tx,
true,
false,
false,
);
let now = Instant::now();
let total_timeout = AUTO_RESOLUTION_HIDDEN_GRACE + AUTO_RESOLUTION_VISIBLE_COUNTDOWN;
overlay.request_started_at = now - AUTO_RESOLUTION_HIDDEN_GRACE;
overlay.handle_key_event(KeyEvent::from(KeyCode::Down));
assert_eq!(
overlay.auto_resolution_timing_at(now + total_timeout),
AutoResolutionTiming::Disabled
);
assert!(!overlay.pre_draw_tick(now + total_timeout));
assert!(rx.try_recv().is_err());
}
#[test]
fn auto_resolution_paste_interaction_snoozes_timer() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event_with_auto_resolution(
"turn-1",
vec![question_with_options("q1", "First")],
),
tx,
true,
false,
false,
);
let now = Instant::now();
let total_timeout = AUTO_RESOLUTION_HIDDEN_GRACE + AUTO_RESOLUTION_VISIBLE_COUNTDOWN;
overlay.request_started_at = now - AUTO_RESOLUTION_HIDDEN_GRACE;
assert!(overlay.handle_paste("notes".to_string()));
assert_eq!(
overlay.auto_resolution_timing_at(now + total_timeout),
AutoResolutionTiming::Disabled
);
assert!(!overlay.pre_draw_tick(now + total_timeout));
assert!(rx.try_recv().is_err());
}
#[test]
fn auto_resolution_resets_for_queued_request() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event_with_auto_resolution(
"turn-1",
vec![question_with_options("q1", "First")],
),
tx,
true,
false,
false,
);
overlay.try_consume_user_input_request(request_event_with_auto_resolution(
"turn-2",
vec![question_with_options("q2", "Second")],
));
let now = Instant::now();
let total_timeout = AUTO_RESOLUTION_HIDDEN_GRACE + AUTO_RESOLUTION_VISIBLE_COUNTDOWN;
overlay.request_started_at = now - total_timeout;
assert!(overlay.pre_draw_tick(now));
assert_eq!(overlay.request.turn_id, "turn-2");
assert!(!overlay.auto_resolution_snoozed);
assert_eq!(
overlay.auto_resolution_timing_at(now),
AutoResolutionTiming::HiddenGrace {
remaining: AUTO_RESOLUTION_HIDDEN_GRACE
}
);
assert!(!overlay.done);
assert!(rx.try_recv().is_ok());
}
#[test]
fn resolved_request_dismisses_overlay_without_emitting_events() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
ToolRequestUserInputParams {
thread_id: "thread-1".to_string(),
item_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
questions: vec![question_with_options("q1", "First")],
auto_resolution_ms: None,
},
tx,
true,
false,
false,
);
assert!(
overlay.dismiss_app_server_request(&ResolvedAppServerRequest::UserInput {
call_id: "call-1".to_string(),
})
);
assert!(overlay.done, "resolved request should close the overlay");
assert!(
rx.try_recv().is_err(),
"dismissing a stale request should not emit an interrupt or answer"
);
}
#[test]
fn resolved_current_request_advances_to_next_same_turn_prompt() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
ToolRequestUserInputParams {
thread_id: "thread-1".to_string(),
item_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
questions: vec![question_with_options("q1", "First")],
auto_resolution_ms: None,
},
tx,
true,
false,
false,
);
overlay.try_consume_user_input_request(ToolRequestUserInputParams {
thread_id: "thread-1".to_string(),
item_id: "call-2".to_string(),
turn_id: "turn-1".to_string(),
questions: vec![question_with_options("q2", "Second")],
auto_resolution_ms: None,
});
assert!(
overlay.dismiss_app_server_request(&ResolvedAppServerRequest::UserInput {
call_id: "call-1".to_string(),
})
);
assert!(!overlay.done, "newer same-turn prompt should stay pending");
assert_eq!(overlay.request.item_id, "call-2");
assert_eq!(overlay.request.turn_id, "turn-1");
assert_eq!(overlay.request.questions[0].id, "q2");
assert!(
rx.try_recv().is_err(),
"dismissing a stale request should not emit an interrupt or answer"
);
}
#[test]
fn resolved_queued_request_removes_only_that_prompt() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
ToolRequestUserInputParams {
thread_id: "thread-1".to_string(),
item_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
questions: vec![question_with_options("q1", "First")],
auto_resolution_ms: None,
},
tx,
true,
false,
false,
);
overlay.try_consume_user_input_request(ToolRequestUserInputParams {
thread_id: "thread-1".to_string(),
item_id: "call-2".to_string(),
turn_id: "turn-1".to_string(),
questions: vec![question_with_options("q2", "Second")],
auto_resolution_ms: None,
});
overlay.try_consume_user_input_request(ToolRequestUserInputParams {
thread_id: "thread-1".to_string(),
item_id: "call-3".to_string(),
turn_id: "turn-1".to_string(),
questions: vec![question_with_options("q3", "Third")],
auto_resolution_ms: None,
});
assert!(
overlay.dismiss_app_server_request(&ResolvedAppServerRequest::UserInput {
call_id: "call-2".to_string(),
})
);
assert_eq!(overlay.request.item_id, "call-1");
assert!(
rx.try_recv().is_err(),
"dismissing a stale queued request should not emit an event"
);
overlay.submit_answers();
assert_eq!(overlay.request.item_id, "call-3");
assert_eq!(overlay.request.questions[0].id, "q3");
assert!(
rx.try_recv().is_ok(),
"submitting the still-current prompt should emit an answer"
);
assert!(
rx.try_recv().is_ok(),
"submitting the still-current prompt should emit a history cell"
);
}
#[test]
fn options_can_submit_empty_when_unanswered() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
);
overlay.submit_answers();
let event = rx.try_recv().expect("expected AppEvent");
let AppEvent::CodexOp(Op::UserInputAnswer { id, response, .. }) = event else {
panic!("expected UserInputAnswer");
};
assert_eq!(id, "turn-1");
let answer = response.answers.get("q1").expect("answer missing");
assert_eq!(answer.answers, Vec::<String>::new());
}
#[test]
fn enter_commits_default_selection_on_last_option_question() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
);
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
let event = rx.try_recv().expect("expected AppEvent");
let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else {
panic!("expected UserInputAnswer");
};
let answer = response.answers.get("q1").expect("answer missing");
assert_eq!(answer.answers, vec!["Option 1".to_string()]);
}
#[test]
fn enter_commits_default_selection_on_non_last_option_question() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_with_options("q1", "Pick one"),
question_with_options("q2", "Pick two"),
],
),
tx,
true,
false,
false,
);
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_eq!(overlay.current_index(), 1);
let first_answer = &overlay.answers[0];
assert!(first_answer.answer_committed);
assert_eq!(first_answer.options_state.selected_idx, Some(0));
assert!(
rx.try_recv().is_err(),
"unexpected AppEvent before full submission"
);
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
let event = rx.try_recv().expect("expected AppEvent");
let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else {
panic!("expected UserInputAnswer");
};
let mut expected = HashMap::new();
expected.insert(
"q1".to_string(),
ToolRequestUserInputAnswer {
answers: vec!["Option 1".to_string()],
},
);
expected.insert(
"q2".to_string(),
ToolRequestUserInputAnswer {
answers: vec!["Option 1".to_string()],
},
);
assert_eq!(response.answers, expected);
}
#[test]
fn number_keys_select_and_submit_options() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
);
overlay.handle_key_event(KeyEvent::from(KeyCode::Char('2')));
let event = rx.try_recv().expect("expected AppEvent");
let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else {
panic!("expected UserInputAnswer");
};
let answer = response.answers.get("q1").expect("answer missing");
assert_eq!(answer.answers, vec!["Option 2".to_string()]);
}
#[test]
fn vim_keys_move_option_selection() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
);
let answer = overlay.current_answer().expect("answer missing");
assert_eq!(answer.options_state.selected_idx, Some(0));
overlay.handle_key_event(KeyEvent::from(KeyCode::Char('j')));
let answer = overlay.current_answer().expect("answer missing");
assert_eq!(answer.options_state.selected_idx, Some(1));
overlay.handle_key_event(KeyEvent::from(KeyCode::Char('k')));
let answer = overlay.current_answer().expect("answer missing");
assert_eq!(answer.options_state.selected_idx, Some(0));
}
#[test]
fn typing_in_options_does_not_open_notes() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_with_options("q1", "Pick one"),
question_with_options("q2", "Pick two"),
],
),
tx,
true,
false,
false,
);
assert_eq!(overlay.current_index(), 0);
assert_eq!(overlay.notes_ui_visible(), false);
overlay.handle_key_event(KeyEvent::from(KeyCode::Char('x')));
assert_eq!(overlay.current_index(), 0);
assert_eq!(overlay.notes_ui_visible(), false);
assert!(matches!(overlay.focus, Focus::Options));
assert_eq!(overlay.composer.current_text_with_pending(), "");
}
#[test]
fn h_l_move_between_questions_in_options() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_with_options("q1", "Pick one"),
question_with_options("q2", "Pick two"),
],
),
tx,
true,
false,
false,
);
assert_eq!(overlay.current_index(), 0);
overlay.handle_key_event(KeyEvent::from(KeyCode::Char('l')));
assert_eq!(overlay.current_index(), 1);
overlay.handle_key_event(KeyEvent::from(KeyCode::Char('h')));
assert_eq!(overlay.current_index(), 0);
}
#[test]
fn left_right_move_between_questions_in_options() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_with_options("q1", "Pick one"),
question_with_options("q2", "Pick two"),
],
),
tx,
true,
false,
false,
);
assert_eq!(overlay.current_index(), 0);
overlay.handle_key_event(KeyEvent::from(KeyCode::Right));
assert_eq!(overlay.current_index(), 1);
overlay.handle_key_event(KeyEvent::from(KeyCode::Left));
assert_eq!(overlay.current_index(), 0);
}
#[test]
fn horizontal_list_keys_move_between_questions_in_options() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_with_options("q1", "Pick one"),
question_with_options("q2", "Pick two"),
],
),
tx,
true,
false,
false,
);
assert_eq!(overlay.current_index(), 0);
overlay.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL));
assert_eq!(overlay.current_index(), 1);
overlay.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
assert_eq!(overlay.current_index(), 0);
}
#[test]
fn options_notes_focus_hides_question_navigation_tip() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_with_options("q1", "Pick one"),
question_with_options("q2", "Pick two"),
],
),
tx,
true,
false,
false,
);
let tips = overlay.footer_tips();
let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::<Vec<_>>();
assert_eq!(
tip_texts,
vec![
"tab to add notes",
"enter to submit answer",
"←/→ to navigate questions",
"esc to interrupt",
]
);
overlay.handle_key_event(KeyEvent::from(KeyCode::Tab));
let tips = overlay.footer_tips();
let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::<Vec<_>>();
assert_eq!(
tip_texts,
vec!["tab or esc to clear notes", "enter to submit answer",]
);
}
#[test]
fn freeform_shows_ctrl_p_and_ctrl_n_question_navigation_tip() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_with_options("q1", "Area"),
question_without_options("q2", "Goal"),
],
),
tx,
true,
false,
false,
);
overlay.move_question( true);
let tips = overlay.footer_tips();
let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::<Vec<_>>();
assert_eq!(
tip_texts,
vec![
"enter to submit all",
"ctrl + p / ctrl + n change question",
"esc to interrupt",
]
);
}
#[test]
fn freeform_footer_shows_configured_submit_binding() {
let (tx, _rx) = test_sender();
let mut keymap = RuntimeKeymap::defaults();
keymap.composer.submit = vec![crate::tui_internal::key_hint::ctrl(KeyCode::Char('j'))];
let overlay = RequestUserInputOverlay::new_with_keymap(
request_event("turn-1", vec![question_without_options("q1", "Notes")]),
tx,
true,
false,
false,
keymap,
);
let tips = overlay.footer_tips();
let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::<Vec<_>>();
assert_eq!(
tip_texts,
vec!["ctrl + j to submit answer", "esc to interrupt"]
);
}
#[test]
fn request_user_input_uses_remapped_interrupt_binding_while_notes_are_visible() {
let (tx, mut rx) = test_sender();
let mut keymap = RuntimeKeymap::defaults();
keymap.chat.interrupt_turn = vec![crate::tui_internal::key_hint::plain(KeyCode::F(12))];
let mut overlay = RequestUserInputOverlay::new_with_keymap(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
keymap,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(0);
overlay.handle_key_event(KeyEvent::from(KeyCode::Tab));
let tips = overlay.footer_tips();
let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::<Vec<_>>();
assert_eq!(
tip_texts,
vec![
"tab or esc to clear notes",
"enter to submit answer",
"f12 to interrupt",
]
);
overlay.handle_key_event(KeyEvent::from(KeyCode::F(12)));
assert_eq!(overlay.done, true);
expect_interrupt_only(&mut rx);
}
#[test]
fn tab_opens_notes_when_option_selected() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(1);
assert_eq!(overlay.notes_ui_visible(), false);
overlay.handle_key_event(KeyEvent::from(KeyCode::Tab));
assert_eq!(overlay.notes_ui_visible(), true);
assert!(matches!(overlay.focus, Focus::Notes));
}
#[test]
fn switching_to_options_resets_notes_focus_when_notes_hidden() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_without_options("q1", "Notes"),
question_with_options("q2", "Pick one"),
],
),
tx,
true,
false,
false,
);
assert!(matches!(overlay.focus, Focus::Notes));
overlay.move_question( true);
assert!(matches!(overlay.focus, Focus::Options));
assert_eq!(overlay.notes_ui_visible(), false);
}
#[test]
fn switching_from_freeform_with_text_resets_focus_and_keeps_last_option_empty() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_without_options("q1", "Notes"),
question_with_options("q2", "Pick one"),
],
),
tx,
true,
false,
false,
);
overlay
.composer
.set_text_content("freeform notes".to_string(), Vec::new(), Vec::new());
overlay.composer.move_cursor_to_end();
overlay.move_question( true);
assert!(matches!(overlay.focus, Focus::Options));
assert_eq!(overlay.notes_ui_visible(), false);
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert!(overlay.confirm_unanswered_active());
assert!(
rx.try_recv().is_err(),
"unexpected AppEvent before confirmation submit"
);
overlay.handle_key_event(KeyEvent::from(KeyCode::Char('1')));
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
let event = rx.try_recv().expect("expected AppEvent");
let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else {
panic!("expected UserInputAnswer");
};
let answer = response.answers.get("q1").expect("answer missing");
assert_eq!(answer.answers, Vec::<String>::new());
let answer = response.answers.get("q2").expect("answer missing");
assert_eq!(answer.answers, vec!["Option 1".to_string()]);
}
#[test]
fn esc_in_notes_mode_without_options_interrupts() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_without_options("q1", "Notes")]),
tx,
true,
false,
false,
);
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
assert_eq!(overlay.done, true);
expect_interrupt_only(&mut rx);
}
#[test]
fn esc_in_options_mode_interrupts() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
);
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
assert_eq!(overlay.done, true);
expect_interrupt_only(&mut rx);
}
#[test]
fn esc_in_notes_mode_clears_notes_and_hides_ui() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(0);
answer.answer_committed = true;
overlay.handle_key_event(KeyEvent::from(KeyCode::Tab));
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
let answer = overlay.current_answer().expect("answer missing");
assert_eq!(overlay.done, false);
assert!(matches!(overlay.focus, Focus::Options));
assert_eq!(overlay.notes_ui_visible(), false);
assert_eq!(overlay.composer.current_text_with_pending(), "");
assert_eq!(answer.draft.text, "");
assert_eq!(answer.options_state.selected_idx, Some(0));
assert_eq!(answer.answer_committed, false);
assert!(rx.try_recv().is_err());
}
#[test]
fn esc_in_notes_mode_with_text_clears_notes_and_hides_ui() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(0);
answer.answer_committed = true;
overlay.handle_key_event(KeyEvent::from(KeyCode::Tab));
overlay.handle_key_event(KeyEvent::from(KeyCode::Char('a')));
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
let answer = overlay.current_answer().expect("answer missing");
assert_eq!(overlay.done, false);
assert!(matches!(overlay.focus, Focus::Options));
assert_eq!(overlay.notes_ui_visible(), false);
assert_eq!(overlay.composer.current_text_with_pending(), "");
assert_eq!(answer.draft.text, "");
assert_eq!(answer.options_state.selected_idx, Some(0));
assert_eq!(answer.answer_committed, false);
assert!(rx.try_recv().is_err());
}
#[test]
fn esc_drops_committed_answers() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_with_options("q1", "First"),
question_without_options("q2", "Second"),
],
),
tx,
true,
false,
false,
);
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert!(
rx.try_recv().is_err(),
"unexpected AppEvent before interruption"
);
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
expect_interrupt_only(&mut rx);
}
#[test]
fn backspace_in_options_clears_selection() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(1);
overlay.handle_key_event(KeyEvent::from(KeyCode::Backspace));
let answer = overlay.current_answer().expect("answer missing");
assert_eq!(answer.options_state.selected_idx, None);
assert_eq!(overlay.notes_ui_visible(), false);
assert!(rx.try_recv().is_err());
}
#[test]
fn backspace_on_empty_notes_closes_notes_ui() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(0);
overlay.handle_key_event(KeyEvent::from(KeyCode::Tab));
assert!(matches!(overlay.focus, Focus::Notes));
assert_eq!(overlay.notes_ui_visible(), true);
overlay.handle_key_event(KeyEvent::from(KeyCode::Backspace));
let answer = overlay.current_answer().expect("answer missing");
assert!(matches!(overlay.focus, Focus::Options));
assert_eq!(overlay.notes_ui_visible(), false);
assert_eq!(answer.options_state.selected_idx, Some(0));
assert!(rx.try_recv().is_err());
}
#[test]
fn tab_in_notes_clears_notes_and_hides_ui() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(0);
overlay.handle_key_event(KeyEvent::from(KeyCode::Tab));
overlay
.composer
.set_text_content("Some notes".to_string(), Vec::new(), Vec::new());
overlay.handle_key_event(KeyEvent::from(KeyCode::Tab));
let answer = overlay.current_answer().expect("answer missing");
assert!(matches!(overlay.focus, Focus::Options));
assert_eq!(overlay.notes_ui_visible(), false);
assert_eq!(overlay.composer.current_text_with_pending(), "");
assert_eq!(answer.draft.text, "");
assert_eq!(answer.options_state.selected_idx, Some(0));
assert!(rx.try_recv().is_err());
}
#[test]
fn skipped_option_questions_count_as_unanswered() {
let (tx, _rx) = test_sender();
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
);
assert_eq!(overlay.unanswered_count(), 1);
}
#[test]
fn highlighted_option_questions_are_unanswered() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(0);
assert_eq!(overlay.unanswered_count(), 1);
}
#[test]
fn freeform_requires_enter_with_text_to_mark_answered() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_without_options("q1", "Notes"),
question_without_options("q2", "More"),
],
),
tx,
true,
false,
false,
);
overlay
.composer
.set_text_content("Draft".to_string(), Vec::new(), Vec::new());
overlay.composer.move_cursor_to_end();
assert_eq!(overlay.unanswered_count(), 2);
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_eq!(overlay.answers[0].answer_committed, true);
assert_eq!(overlay.unanswered_count(), 1);
}
#[test]
fn freeform_enter_with_empty_text_is_unanswered() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_without_options("q1", "Notes"),
question_without_options("q2", "More"),
],
),
tx,
true,
false,
false,
);
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_eq!(overlay.answers[0].answer_committed, false);
assert_eq!(overlay.unanswered_count(), 2);
}
#[test]
fn freeform_shift_enter_inserts_newline_without_advancing() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_without_options("q1", "Notes"),
question_without_options("q2", "More"),
],
),
tx,
true,
true,
false,
);
overlay
.composer
.set_text_content("Draft".to_string(), Vec::new(), Vec::new());
overlay.composer.move_cursor_to_end();
overlay.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT));
assert_eq!(overlay.current_index(), 0);
assert_eq!(overlay.composer.current_text_with_pending(), "Draft\n");
assert_eq!(overlay.answers[0].answer_committed, false);
}
#[test]
fn freeform_uses_configured_composer_submit_binding() {
let (tx, _rx) = test_sender();
let mut keymap = RuntimeKeymap::defaults();
keymap.composer.submit = vec![crate::tui_internal::key_hint::ctrl(KeyCode::Char('j'))];
let mut overlay = RequestUserInputOverlay::new_with_keymap(
request_event(
"turn-1",
vec![
question_without_options("q1", "Notes"),
question_without_options("q2", "More"),
],
),
tx,
true,
false,
false,
keymap,
);
overlay
.composer
.set_text_content("Draft".to_string(), Vec::new(), Vec::new());
overlay.composer.move_cursor_to_end();
overlay.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL));
assert_eq!(overlay.current_index(), 1);
assert_eq!(overlay.answers[0].answer_committed, true);
}
#[test]
fn freeform_submit_binding_wins_over_question_navigation() {
let (tx, _rx) = test_sender();
let mut keymap = RuntimeKeymap::defaults();
keymap.composer.submit = vec![crate::tui_internal::key_hint::ctrl(KeyCode::Char('n'))];
let mut overlay = RequestUserInputOverlay::new_with_keymap(
request_event(
"turn-1",
vec![
question_without_options("q1", "Notes"),
question_without_options("q2", "More"),
],
),
tx,
true,
false,
false,
keymap,
);
overlay
.composer
.set_text_content("Draft".to_string(), Vec::new(), Vec::new());
overlay.composer.move_cursor_to_end();
overlay.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
assert_eq!(overlay.current_index(), 1);
assert_eq!(overlay.answers[0].answer_committed, true);
}
#[test]
fn freeform_questions_submit_empty_when_empty() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_without_options("q1", "Notes")]),
tx,
true,
false,
false,
);
overlay.submit_answers();
let event = rx.try_recv().expect("expected AppEvent");
let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else {
panic!("expected UserInputAnswer");
};
let answer = response.answers.get("q1").expect("answer missing");
assert_eq!(answer.answers, Vec::<String>::new());
}
#[test]
fn freeform_draft_is_not_submitted_without_enter() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_without_options("q1", "Notes")]),
tx,
true,
false,
false,
);
overlay
.composer
.set_text_content("Draft text".to_string(), Vec::new(), Vec::new());
overlay.composer.move_cursor_to_end();
overlay.submit_answers();
let event = rx.try_recv().expect("expected AppEvent");
let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else {
panic!("expected UserInputAnswer");
};
let answer = response.answers.get("q1").expect("answer missing");
assert_eq!(answer.answers, Vec::<String>::new());
}
#[test]
fn freeform_commit_resets_when_draft_changes() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_without_options("q1", "Notes"),
question_without_options("q2", "More"),
],
),
tx,
true,
false,
false,
);
overlay
.composer
.set_text_content("Committed".to_string(), Vec::new(), Vec::new());
overlay.composer.move_cursor_to_end();
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_eq!(overlay.answers[0].answer_committed, true);
let _ = rx.try_recv();
overlay.move_question( false);
overlay
.composer
.set_text_content("Edited".to_string(), Vec::new(), Vec::new());
overlay.composer.move_cursor_to_end();
overlay.move_question( true);
assert_eq!(overlay.answers[0].answer_committed, false);
overlay.submit_answers();
let event = rx.try_recv().expect("expected AppEvent");
let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else {
panic!("expected UserInputAnswer");
};
let answer = response.answers.get("q1").expect("answer missing");
assert_eq!(answer.answers, Vec::<String>::new());
}
#[test]
fn notes_are_captured_for_selected_option() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
);
{
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(1);
}
overlay.select_current_option( false);
overlay
.composer
.set_text_content("Notes for option 2".to_string(), Vec::new(), Vec::new());
overlay.composer.move_cursor_to_end();
let draft = overlay.capture_composer_draft();
if let Some(answer) = overlay.current_answer_mut() {
answer.draft = draft;
answer.answer_committed = true;
}
overlay.submit_answers();
let event = rx.try_recv().expect("expected AppEvent");
let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else {
panic!("expected UserInputAnswer");
};
let answer = response.answers.get("q1").expect("answer missing");
assert_eq!(
answer.answers,
vec![
"Option 2".to_string(),
"user_note: Notes for option 2".to_string(),
]
);
}
#[test]
fn notes_submission_commits_selected_option() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_with_options("q1", "Pick one"),
question_with_options("q2", "Pick two"),
],
),
tx,
true,
false,
false,
);
overlay.handle_key_event(KeyEvent::from(KeyCode::Down));
overlay.handle_key_event(KeyEvent::from(KeyCode::Tab));
overlay
.composer
.set_text_content("Notes".to_string(), Vec::new(), Vec::new());
overlay.composer.move_cursor_to_end();
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_eq!(overlay.current_index(), 1);
let answer = overlay.answers.first().expect("answer missing");
assert_eq!(answer.options_state.selected_idx, Some(1));
assert!(answer.answer_committed);
}
#[test]
fn is_other_adds_none_of_the_above_and_submits_it() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![question_with_options_and_other("q1", "Pick one")],
),
tx,
true,
false,
false,
);
let rows = overlay.option_rows();
let other_row = rows.last().expect("expected none-of-the-above row");
assert_eq!(other_row.name, " 4. None of the above");
assert_eq!(
other_row.description.as_deref(),
Some(OTHER_OPTION_DESCRIPTION)
);
let other_idx = overlay.options_len().saturating_sub(1);
{
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(other_idx);
}
overlay
.composer
.set_text_content("Custom answer".to_string(), Vec::new(), Vec::new());
overlay.composer.move_cursor_to_end();
let draft = overlay.capture_composer_draft();
if let Some(answer) = overlay.current_answer_mut() {
answer.draft = draft;
answer.answer_committed = true;
}
overlay.submit_answers();
let event = rx.try_recv().expect("expected AppEvent");
let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else {
panic!("expected UserInputAnswer");
};
let answer = response.answers.get("q1").expect("answer missing");
assert_eq!(
answer.answers,
vec![
OTHER_OPTION_LABEL.to_string(),
"user_note: Custom answer".to_string(),
]
);
}
#[test]
fn large_paste_is_preserved_when_switching_questions() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_without_options("q1", "First"),
question_without_options("q2", "Second"),
],
),
tx,
true,
false,
false,
);
let large = "x".repeat(1_500);
overlay.composer.handle_paste(large.clone());
overlay.move_question( true);
let draft = &overlay.answers[0].draft;
assert_eq!(draft.pending_pastes.len(), 1);
assert_eq!(draft.pending_pastes[0].1, large);
assert!(draft.text.contains(&draft.pending_pastes[0].0));
assert_eq!(draft.text_with_pending(), large);
}
#[test]
fn pending_paste_placeholder_survives_submission_and_back_navigation() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_with_options("q1", "First"),
question_with_options("q2", "Second"),
],
),
tx,
true,
false,
false,
);
let large = "x".repeat(1_200);
overlay.focus = Focus::Notes;
overlay.ensure_selected_for_notes();
overlay.composer.handle_paste(large.clone());
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
overlay.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
let draft = &overlay.answers[0].draft;
assert_eq!(draft.pending_pastes.len(), 1);
assert!(draft.text.contains(&draft.pending_pastes[0].0));
assert_eq!(draft.text_with_pending(), large);
}
#[test]
fn request_user_input_options_snapshot() {
let (tx, _rx) = test_sender();
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Area")]),
tx,
true,
false,
false,
);
let area = Rect::new(0, 0, 120, 16);
insta::assert_snapshot!(
"request_user_input_options",
render_snapshot(&overlay, area)
);
}
#[test]
fn request_user_input_options_notes_visible_snapshot() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Area")]),
tx,
true,
false,
false,
);
{
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(0);
}
overlay.handle_key_event(KeyEvent::from(KeyCode::Tab));
let area = Rect::new(0, 0, 120, 16);
insta::assert_snapshot!(
"request_user_input_options_notes_visible",
render_snapshot(&overlay, area)
);
}
#[test]
fn request_user_input_tight_height_snapshot() {
let (tx, _rx) = test_sender();
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Area")]),
tx,
true,
false,
false,
);
let area = Rect::new(0, 0, 120, 10);
insta::assert_snapshot!(
"request_user_input_tight_height",
render_snapshot(&overlay, area)
);
}
#[test]
fn layout_allocates_all_wrapped_options_when_space_allows() {
let (tx, _rx) = test_sender();
let overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![question_with_wrapped_options("q1", "Next Step")],
),
tx,
true,
false,
false,
);
let width = 48u16;
let question_height = overlay.wrapped_question_lines(width).len() as u16;
let options_height = overlay.options_required_height(width);
let extras = 1u16 .saturating_add(DESIRED_SPACERS_BETWEEN_SECTIONS)
.saturating_add(overlay.footer_required_height(width));
let height = question_height
.saturating_add(options_height)
.saturating_add(extras);
let sections = overlay.layout_sections(Rect::new(0, 0, width, height));
assert_eq!(sections.options_area.height, options_height);
}
#[test]
fn desired_height_keeps_spacers_and_preferred_options_visible() {
let (tx, _rx) = test_sender();
let overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![question_with_wrapped_options("q1", "Next Step")],
),
tx,
true,
false,
false,
);
let width = 110u16;
let height = overlay.desired_height(width);
let content_area = menu_surface_inset(Rect::new(0, 0, width, height));
let sections = overlay.layout_sections(content_area);
let preferred = overlay.options_preferred_height(content_area.width);
assert_eq!(sections.options_area.height, preferred);
let question_bottom = sections.question_area.y + sections.question_area.height;
let options_bottom = sections.options_area.y + sections.options_area.height;
let spacer_after_question = sections.options_area.y.saturating_sub(question_bottom);
let spacer_after_options = sections.notes_area.y.saturating_sub(options_bottom);
assert_eq!(spacer_after_question, 1);
assert_eq!(spacer_after_options, 1);
}
#[test]
fn footer_wraps_tips_without_splitting_individual_tips() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_with_options("q1", "Pick one"),
question_with_options("q2", "Pick two"),
],
),
tx,
true,
false,
false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(0);
let width = 36u16;
let lines = overlay.footer_tip_lines(width);
assert!(lines.len() > 1);
let separator_width = UnicodeWidthStr::width(TIP_SEPARATOR);
for tips in lines {
let used = tips.iter().enumerate().fold(0usize, |acc, (idx, tip)| {
let tip_width = UnicodeWidthStr::width(tip.text.as_str()).min(width as usize);
let extra = if idx == 0 {
tip_width
} else {
separator_width.saturating_add(tip_width)
};
acc.saturating_add(extra)
});
assert!(used <= width as usize);
}
}
#[test]
fn request_user_input_wrapped_options_snapshot() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![question_with_wrapped_options("q1", "Next Step")],
),
tx,
true,
false,
false,
);
{
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(0);
}
let width = 110u16;
let question_height = overlay.wrapped_question_lines(width).len() as u16;
let options_height = overlay.options_required_height(width);
let height = 1u16
.saturating_add(question_height)
.saturating_add(options_height)
.saturating_add(8);
let area = Rect::new(0, 0, width, height);
insta::assert_snapshot!(
"request_user_input_wrapped_options",
render_snapshot(&overlay, area)
);
}
#[test]
fn request_user_input_long_option_text_snapshot() {
let (tx, _rx) = test_sender();
let overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![question_with_very_long_option_text("q1", "Status")],
),
tx,
true,
false,
false,
);
let area = Rect::new(0, 0, 120, 18);
insta::assert_snapshot!(
"request_user_input_long_option_text",
render_snapshot(&overlay, area)
);
}
#[test]
fn selected_long_wrapped_option_stays_visible() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![question_with_long_scroll_options("q1", "Scroll")],
),
tx,
true,
false,
false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(2);
let rendered = render_snapshot(&overlay, Rect::new(0, 0, 80, 20));
assert!(
rendered.contains("› 3. Use Detailed Hint C"),
"expected selected option to be visible in viewport\n{rendered}"
);
}
#[test]
fn request_user_input_footer_wrap_snapshot() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_with_options("q1", "Pick one"),
question_with_options("q2", "Pick two"),
],
),
tx,
true,
false,
false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(1);
let width = 52u16;
let height = overlay.desired_height(width);
let area = Rect::new(0, 0, width, height);
insta::assert_snapshot!(
"request_user_input_footer_wrap",
render_snapshot(&overlay, area)
);
}
#[test]
fn request_user_input_scroll_options_snapshot() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![ToolRequestUserInputQuestion {
id: "q1".to_string(),
header: "Next Step".to_string(),
question: "What would you like to do next?".to_string(),
is_other: false,
is_secret: false,
options: Some(vec![
ToolRequestUserInputOption {
label: "Discuss a code change (Recommended)".to_string(),
description: "Walk through a plan and edit code together.".to_string(),
},
ToolRequestUserInputOption {
label: "Run tests".to_string(),
description: "Pick a crate and run its tests.".to_string(),
},
ToolRequestUserInputOption {
label: "Review a diff".to_string(),
description: "Summarize or review current changes.".to_string(),
},
ToolRequestUserInputOption {
label: "Refactor".to_string(),
description: "Tighten structure and remove dead code.".to_string(),
},
ToolRequestUserInputOption {
label: "Ship it".to_string(),
description: "Finalize and open a PR.".to_string(),
},
]),
}],
),
tx,
true,
false,
false,
);
{
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(3);
}
let area = Rect::new(0, 0, 120, 12);
insta::assert_snapshot!(
"request_user_input_scrolling_options",
render_snapshot(&overlay, area)
);
}
#[test]
fn request_user_input_hidden_options_footer_snapshot() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![ToolRequestUserInputQuestion {
id: "q1".to_string(),
header: "Next Step".to_string(),
question: "What would you like to do next?".to_string(),
is_other: false,
is_secret: false,
options: Some(vec![
ToolRequestUserInputOption {
label: "Discuss a code change (Recommended)".to_string(),
description: "Walk through a plan and edit code together.".to_string(),
},
ToolRequestUserInputOption {
label: "Run tests".to_string(),
description: "Pick a crate and run its tests.".to_string(),
},
ToolRequestUserInputOption {
label: "Review a diff".to_string(),
description: "Summarize or review current changes.".to_string(),
},
ToolRequestUserInputOption {
label: "Refactor".to_string(),
description: "Tighten structure and remove dead code.".to_string(),
},
ToolRequestUserInputOption {
label: "Ship it".to_string(),
description: "Finalize and open a PR.".to_string(),
},
]),
}],
),
tx,
true,
false,
false,
);
{
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(3);
}
let area = Rect::new(0, 0, 80, 10);
insta::assert_snapshot!(
"request_user_input_hidden_options_footer",
render_snapshot(&overlay, area)
);
}
#[test]
fn request_user_input_freeform_snapshot() {
let (tx, _rx) = test_sender();
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_without_options("q1", "Goal")]),
tx,
true,
false,
false,
);
let area = Rect::new(0, 0, 120, 10);
insta::assert_snapshot!(
"request_user_input_freeform",
render_snapshot(&overlay, area)
);
}
#[test]
fn request_user_input_freeform_remapped_submit_snapshot() {
let (tx, _rx) = test_sender();
let mut keymap = RuntimeKeymap::defaults();
keymap.composer.submit = vec![crate::tui_internal::key_hint::ctrl(KeyCode::Char('j'))];
let overlay = RequestUserInputOverlay::new_with_keymap(
request_event("turn-1", vec![question_without_options("q1", "Goal")]),
tx,
true,
false,
false,
keymap,
);
let area = Rect::new(0, 0, 120, 10);
insta::assert_snapshot!(
"request_user_input_freeform_remapped_submit",
render_snapshot(&overlay, area)
);
}
#[test]
fn request_user_input_freeform_remapped_interrupt_snapshot() {
let (tx, _rx) = test_sender();
let mut keymap = RuntimeKeymap::defaults();
keymap.chat.interrupt_turn = vec![crate::tui_internal::key_hint::plain(KeyCode::F(12))];
let overlay = RequestUserInputOverlay::new_with_keymap(
request_event("turn-1", vec![question_without_options("q1", "Goal")]),
tx,
true,
false,
false,
keymap,
);
let area = Rect::new(0, 0, 120, 10);
insta::assert_snapshot!(
"request_user_input_freeform_remapped_interrupt",
render_snapshot(&overlay, area)
);
}
#[test]
fn request_user_input_multi_question_first_snapshot() {
let (tx, _rx) = test_sender();
let overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_with_options("q1", "Area"),
question_without_options("q2", "Goal"),
],
),
tx,
true,
false,
false,
);
let area = Rect::new(0, 0, 120, 15);
insta::assert_snapshot!(
"request_user_input_multi_question_first",
render_snapshot(&overlay, area)
);
}
#[test]
fn request_user_input_multi_question_last_snapshot() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_with_options("q1", "Area"),
question_without_options("q2", "Goal"),
],
),
tx,
true,
false,
false,
);
overlay.move_question( true);
let area = Rect::new(0, 0, 120, 12);
insta::assert_snapshot!(
"request_user_input_multi_question_last",
render_snapshot(&overlay, area)
);
}
#[test]
fn request_user_input_unanswered_confirmation_snapshot() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_with_options("q1", "Area"),
question_without_options("q2", "Goal"),
],
),
tx,
true,
false,
false,
);
overlay.open_unanswered_confirmation();
let area = Rect::new(0, 0, 80, 12);
insta::assert_snapshot!(
"request_user_input_unanswered_confirmation",
render_snapshot(&overlay, area)
);
}
#[test]
fn options_scroll_while_editing_notes() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
);
overlay.select_current_option( false);
overlay.focus = Focus::Notes;
overlay
.composer
.set_text_content("Notes".to_string(), Vec::new(), Vec::new());
overlay.composer.move_cursor_to_end();
overlay.handle_key_event(KeyEvent::from(KeyCode::Down));
let answer = overlay.current_answer().expect("answer missing");
assert_eq!(answer.options_state.selected_idx, Some(1));
assert!(!answer.answer_committed);
}
}