//! Request-user-input overlay state machine.
//!
//! Core behaviors:
//! - Each question can be answered by selecting one option and/or providing notes.
//! - Notes are stored per question and appended as extra answers.
//! - Typing while focused on options jumps into notes to keep freeform input fast.
//! - Enter advances to the next question; the last question submits all answers.
//! - Freeform-only questions submit an empty answer list when empty.
use std::collections::HashMap;
use std::collections::VecDeque;
use std::path::PathBuf;

use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
mod layout;
mod render;

use crate::product::tui_app::app_event::AppEvent;
use crate::product::tui_app::app_event_sender::AppEventSender;
use crate::product::tui_app::bottom_pane::CancellationEvent;
use crate::product::tui_app::bottom_pane::ChatComposer;
use crate::product::tui_app::bottom_pane::ChatComposerConfig;
use crate::product::tui_app::bottom_pane::InputResult;
use crate::product::tui_app::bottom_pane::bottom_pane_view::BottomPaneView;
use crate::product::tui_app::bottom_pane::scroll_state::ScrollState;
use crate::product::tui_app::bottom_pane::selection_popup_common::GenericDisplayRow;
use crate::product::tui_app::bottom_pane::selection_popup_common::measure_rows_height;
use crate::product::tui_app::render::renderable::Renderable;

use crate::product::agent::protocol::Op;
use crate::product::protocol::request_user_input::RequestUserInputAnswer;
use crate::product::protocol::request_user_input::RequestUserInputEvent;
use crate::product::protocol::request_user_input::RequestUserInputResponse;
use crate::product::protocol::user_input::TextElement;
use unicode_width::UnicodeWidthStr;

const NOTES_PLACEHOLDER: &str = "Add notes";
const ANSWER_PLACEHOLDER: &str = "Type your answer (optional)";
// Keep in sync with ChatComposer's minimum composer height.
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";

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Focus {
    Options,
    Notes,
}

#[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 {
    // Scrollable cursor state for option navigation/highlight.
    options_state: ScrollState,
    // Per-question notes draft.
    draft: ComposerDraft,
    // Whether the answer for this question has been explicitly submitted.
    answer_committed: bool,
    // Whether the notes UI has been explicitly opened for this question.
    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: RequestUserInputEvent,
    // Queue of incoming requests to process after the current one.
    queue: VecDeque<RequestUserInputEvent>,
    // Reuse the shared chat composer so notes/freeform answers match the
    // primary input styling and behavior.
    composer: ChatComposer,
    // One entry per question: selection state plus a stored notes draft.
    answers: Vec<AnswerState>,
    current_idx: usize,
    focus: Focus,
    done: bool,
    pending_submission_draft: Option<ComposerDraft>,
    confirm_unanswered: Option<ScrollState>,
}

impl RequestUserInputOverlay {
    pub(crate) fn new(
        request: RequestUserInputEvent,
        app_event_tx: AppEventSender,
        has_input_focus: bool,
        enhanced_keys_supported: bool,
        disable_paste_burst: bool,
    ) -> Self {
        // Use the same composer widget, but disable popups/slash-commands and
        // image-path attachment so it behaves like a focused notes field.
        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(),
        );
        // The overlay renders its own footer hints, so keep the composer footer empty.
        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,
        };
        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<&crate::product::protocol::request_user_input::RequestUserInputQuestion> {
        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 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;
                        GenericDisplayRow {
                            name: format!("{prefix} {number}. {label}"),
                            description: Some(opt.description.clone()),
                            ..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;
                    rows.push(GenericDisplayRow {
                        name: format!("{prefix} {number}. {OTHER_OPTION_LABEL}"),
                        description: Some(OTHER_OPTION_DESCRIPTION.to_string()),
                        ..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 && self.focus_is_notes() {
                tips.push(FooterTip::new("tab to clear notes"));
            }
        }

        let question_count = self.question_count();
        let enter_tip = if question_count == 1 {
            FooterTip::highlighted("enter to submit answer")
        } else if self.current_index().saturating_add(1) >= question_count {
            FooterTip::highlighted("enter to submit all")
        } else {
            FooterTip::new("enter to submit answer")
        };
        tips.push(enter_tip);
        if question_count > 1 {
            tips.push(FooterTip::new("ctrl + p/n questions"));
        }
        tips.push(FooterTip::new("esc to interrupt"));
        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
    }

    /// Ensure the focus mode is valid for the current question.
    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();
        }
    }

    /// Rebuild local answer state from the current request.
    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: &crate::product::protocol::request_user_input::RequestUserInputQuestion,
    ) -> 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: &crate::product::protocol::request_user_input::RequestUserInputQuestion,
    ) -> bool {
        question.is_other
            && question
                .options
                .as_ref()
                .is_some_and(|options| !options.is_empty())
    }

    fn option_label_for_index(
        question: &crate::product::protocol::request_user_input::RequestUserInputQuestion,
        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
    }

    /// Move to the next/previous question, wrapping in either direction.
    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();
    }

    /// Synchronize selection state to the currently focused option.
    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();
        }
    }

    /// Clear the current option selection and hide notes when empty.
    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();
    }

    /// Ensure there is a selection before allowing notes entry.
    fn ensure_selected_for_notes(&mut self) {
        if let Some(answer) = self.current_answer_mut() {
            answer.notes_visible = true;
        }
        self.sync_composer_placeholder();
    }

    /// Advance to next question, or submit when on the last one.
    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);
        }
    }

    /// Build the response payload and dispatch it to the app.
    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();
            // For option questions we may still produce no selection.
            let selected_idx =
                if options.is_some_and(|opts| !opts.is_empty()) && answer_state.answer_committed {
                    answer_state.options_state.selected_idx
                } else {
                    None
                };
            // Notes are appended as extra answers. For freeform questions, only submit when
            // the user explicitly committed the draft.
            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(),
                RequestUserInputAnswer {
                    answers: answer_list,
                },
            );
        }
        self.app_event_tx
            .send(AppEvent::CodexOp(Op::UserInputAnswer {
                id: self.request.turn_id.clone(),
                response: RequestUserInputResponse { answers },
            }));
        if let Some(next) = self.queue.pop_front() {
            self.request = next;
            self.reset_for_request();
            self.ensure_focus_available();
            self.restore_current_draft();
        } else {
            self.done = true;
        }
    }

    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, &current_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
        }
    }

    /// Count questions that would submit an empty answer list.
    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, &current_text))
            .count()
    }

    /// Compute the preferred notes input height for the current question.
    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 allow_background_transcript_interaction(&self) -> bool {
        true
    }

    fn handle_key_event(&mut self, key_event: KeyEvent) {
        if key_event.kind == KeyEventKind::Release {
            return;
        }

        if self.confirm_unanswered_active() {
            self.handle_confirm_unanswered_key_event(key_event);
            return;
        }

        if matches!(key_event.code, KeyCode::Esc) {
            self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
            self.done = true;
            return;
        }

        // Question navigation is always available.
        match key_event {
            KeyEvent {
                code: KeyCode::Char('p'),
                modifiers: KeyModifiers::CONTROL,
                ..
            } => {
                self.move_question(false);
                return;
            }
            KeyEvent {
                code: KeyCode::Char('n'),
                modifiers: KeyModifiers::CONTROL,
                ..
            } => {
                self.move_question(true);
                return;
            }
            KeyEvent {
                code: KeyCode::Char('h'),
                modifiers: KeyModifiers::NONE,
                ..
            } if self.has_options() && matches!(self.focus, Focus::Options) => {
                self.move_question(false);
                return;
            }
            KeyEvent {
                code: KeyCode::Char('l'),
                modifiers: KeyModifiers::NONE,
                ..
            } if self.has_options() && matches!(self.focus, Focus::Options) => {
                self.move_question(true);
                return;
            }
            _ => {}
        }

        match self.focus {
            Focus::Options => {
                let options_len = self.options_len();
                // Keep selection synchronized as the user moves.
                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) {
                    if let Some(answer) = self.current_answer_mut() {
                        answer.draft = ComposerDraft::default();
                        answer.answer_committed = false;
                        answer.notes_visible = false;
                    }
                    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();
                    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 matches!(key_event.code, KeyCode::Enter) {
                    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;
                }
                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 on_ctrl_c(&mut self) -> CancellationEvent {
        if self.confirm_unanswered_active() {
            self.close_unanswered_confirmation();
            self.app_event_tx.send(AppEvent::CodexOp(Op::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.send(AppEvent::CodexOp(Op::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;
        }
        if matches!(self.focus, Focus::Options) {
            // Treat pastes the same as typing: switch into notes.
            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 try_consume_user_input_request(
        &mut self,
        request: RequestUserInputEvent,
    ) -> Option<RequestUserInputEvent> {
        self.queue.push_back(request);
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::product::protocol::request_user_input::RequestUserInputQuestion;
    use crate::product::protocol::request_user_input::RequestUserInputQuestionOption;
    use crate::product::tui_app::app_event::AppEvent;
    use crate::product::tui_app::bottom_pane::selection_popup_common::menu_surface_inset;
    use crate::product::tui_app::render::renderable::Renderable;
    use pretty_assertions::assert_eq;
    use ratatui::buffer::Buffer;
    use ratatui::layout::Rect;
    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 question_with_options(id: &str, header: &str) -> RequestUserInputQuestion {
        RequestUserInputQuestion {
            id: id.to_string(),
            header: header.to_string(),
            question: "Choose an option.".to_string(),
            is_other: false,
            is_secret: false,
            options: Some(vec![
                RequestUserInputQuestionOption {
                    label: "Option 1".to_string(),
                    description: "First choice.".to_string(),
                },
                RequestUserInputQuestionOption {
                    label: "Option 2".to_string(),
                    description: "Second choice.".to_string(),
                },
                RequestUserInputQuestionOption {
                    label: "Option 3".to_string(),
                    description: "Third choice.".to_string(),
                },
            ]),
        }
    }

    fn question_with_options_and_other(id: &str, header: &str) -> RequestUserInputQuestion {
        RequestUserInputQuestion {
            id: id.to_string(),
            header: header.to_string(),
            question: "Choose an option.".to_string(),
            is_other: true,
            is_secret: false,
            options: Some(vec![
                RequestUserInputQuestionOption {
                    label: "Option 1".to_string(),
                    description: "First choice.".to_string(),
                },
                RequestUserInputQuestionOption {
                    label: "Option 2".to_string(),
                    description: "Second choice.".to_string(),
                },
                RequestUserInputQuestionOption {
                    label: "Option 3".to_string(),
                    description: "Third choice.".to_string(),
                },
            ]),
        }
    }

    fn question_with_wrapped_options(id: &str, header: &str) -> RequestUserInputQuestion {
        RequestUserInputQuestion {
            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![
                RequestUserInputQuestionOption {
                    label: "Discuss a code change".to_string(),
                    description:
                        "Walk through a plan, then implement it together with careful checks."
                            .to_string(),
                },
                RequestUserInputQuestionOption {
                    label: "Run targeted tests".to_string(),
                    description:
                        "Pick the most relevant crate and validate the current behavior first."
                            .to_string(),
                },
                RequestUserInputQuestionOption {
                    label: "Review the diff".to_string(),
                    description:
                        "Summarize the changes and highlight the most important risks and gaps."
                            .to_string(),
                },
            ]),
        }
    }

    fn question_without_options(id: &str, header: &str) -> RequestUserInputQuestion {
        RequestUserInputQuestion {
            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<RequestUserInputQuestion>,
    ) -> RequestUserInputEvent {
        RequestUserInputEvent {
            call_id: "call-1".to_string(),
            turn_id: turn_id.to_string(),
            questions,
        }
    }

    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 {
        let mut buf = Buffer::empty(area);
        overlay.render(area, &mut buf);
        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 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());

        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 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 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());
        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);
        let event = rx.try_recv().expect("expected AppEvent");
        let AppEvent::CodexOp(op) = event else {
            panic!("expected CodexOp");
        };
        assert_eq!(op, Op::Interrupt);
    }

    #[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);
        let event = rx.try_recv().expect("expected AppEvent");
        let AppEvent::CodexOp(op) = event else {
            panic!("expected CodexOp");
        };
        assert_eq!(op, Op::Interrupt);
    }

    #[test]
    fn esc_in_notes_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,
        );
        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.handle_key_event(KeyEvent::from(KeyCode::Esc));

        assert_eq!(overlay.done, true);
        let event = rx.try_recv().expect("expected AppEvent");
        let AppEvent::CodexOp(op) = event else {
            panic!("expected CodexOp");
        };
        assert_eq!(op, Op::Interrupt);
    }

    #[test]
    fn esc_in_notes_mode_interrupts_with_notes_visible() {
        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.handle_key_event(KeyEvent::from(KeyCode::Char('a')));
        overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));

        assert_eq!(overlay.done, true);
        let event = rx.try_recv().expect("expected AppEvent");
        let AppEvent::CodexOp(op) = event else {
            panic!("expected CodexOp");
        };
        assert_eq!(op, Op::Interrupt);
    }

    #[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_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);

        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 // progress
            .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_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![RequestUserInputQuestion {
                    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![
                        RequestUserInputQuestionOption {
                            label: "Discuss a code change (Recommended)".to_string(),
                            description: "Walk through a plan and edit code together.".to_string(),
                        },
                        RequestUserInputQuestionOption {
                            label: "Run tests".to_string(),
                            description: "Pick a crate and run its tests.".to_string(),
                        },
                        RequestUserInputQuestionOption {
                            label: "Review a diff".to_string(),
                            description: "Summarize or review current changes.".to_string(),
                        },
                        RequestUserInputQuestionOption {
                            label: "Refactor".to_string(),
                            description: "Tighten structure and remove dead code.".to_string(),
                        },
                        RequestUserInputQuestionOption {
                            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![RequestUserInputQuestion {
                    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![
                        RequestUserInputQuestionOption {
                            label: "Discuss a code change (Recommended)".to_string(),
                            description: "Walk through a plan and edit code together.".to_string(),
                        },
                        RequestUserInputQuestionOption {
                            label: "Run tests".to_string(),
                            description: "Pick a crate and run its tests.".to_string(),
                        },
                        RequestUserInputQuestionOption {
                            label: "Review a diff".to_string(),
                            description: "Summarize or review current changes.".to_string(),
                        },
                        RequestUserInputQuestionOption {
                            label: "Refactor".to_string(),
                            description: "Tighten structure and remove dead code.".to_string(),
                        },
                        RequestUserInputQuestionOption {
                            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_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);
    }

    #[test]
    fn page_keys_do_not_move_questions_but_ctrl_p_n_still_do() {
        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::PageDown));
        assert_eq!(overlay.current_index(), 0);

        overlay.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
        assert_eq!(overlay.current_index(), 1);

        overlay.handle_key_event(KeyEvent::from(KeyCode::PageUp));
        assert_eq!(overlay.current_index(), 1);

        overlay.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
        assert_eq!(overlay.current_index(), 0);
    }
}
