diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index e6a3266d6..d101e042e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -232,7 +232,6 @@ impl ChatComposerConfig { } } } - pub(crate) struct ChatComposer { textarea: TextArea, textarea_state: RefCell, @@ -394,7 +393,6 @@ impl ChatComposer { pub fn set_personality_command_enabled(&mut self, enabled: bool) { self.personality_command_enabled = enabled; } - /// Centralized feature gating keeps config checks out of call sites. fn popups_enabled(&self) -> bool { self.config.popups_enabled diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs index 4a1817da8..11856bd1d 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs @@ -1,12 +1,12 @@ use ratatui::layout::Rect; +use super::DESIRED_SPACERS_WHEN_NOTES_HIDDEN; use super::RequestUserInputOverlay; pub(super) struct LayoutSections { pub(super) progress_area: Rect, pub(super) header_area: Rect, pub(super) question_area: Rect, - pub(super) answer_title_area: Rect, // Wrapped question text lines to render in the question area. pub(super) question_lines: Vec, pub(super) options_area: Rect, @@ -20,7 +20,8 @@ impl RequestUserInputOverlay { /// Compute layout sections, collapsing notes and hints as space shrinks. pub(super) fn layout_sections(&self, area: Rect) -> LayoutSections { let has_options = self.has_options(); - let footer_pref = if self.unanswered_count() > 0 { 2 } else { 1 }; + let notes_visible = !has_options || self.notes_ui_visible(); + let footer_pref = self.footer_required_height(area.width); let notes_pref_height = self.notes_input_height(area.width); let mut question_lines = self.wrapped_question_lines(area.width); let question_height = question_lines.len() as u16; @@ -28,18 +29,22 @@ impl RequestUserInputOverlay { let ( question_height, progress_height, - answer_title_height, + spacer_after_question, + options_height, + spacer_after_options, notes_title_height, notes_height, - options_height, footer_lines, ) = if has_options { self.layout_with_options( - area.height, - area.width, - question_height, - notes_pref_height, - footer_pref, + OptionsLayoutArgs { + available_height: area.height, + width: area.width, + question_height, + notes_pref_height, + footer_pref, + notes_visible, + }, &mut question_lines, ) } else { @@ -52,31 +57,24 @@ impl RequestUserInputOverlay { ) }; - let ( - progress_area, - header_area, - question_area, - answer_title_area, - options_area, - notes_title_area, - notes_area, - ) = self.build_layout_areas( - area, - LayoutHeights { - progress_height, - question_height, - answer_title_height, - options_height, - notes_title_height, - notes_height, - }, - ); + let (progress_area, header_area, question_area, options_area, notes_title_area, notes_area) = + self.build_layout_areas( + area, + LayoutHeights { + progress_height, + question_height, + spacer_after_question, + options_height, + spacer_after_options, + notes_title_height, + notes_height, + }, + ); LayoutSections { progress_area, header_area, question_area, - answer_title_area, question_lines, options_area, notes_title_area, @@ -90,21 +88,28 @@ impl RequestUserInputOverlay { /// Handles both tight layout (when space is constrained) and normal layout /// (when there's sufficient space for all elements). /// - /// Returns: (question_height, progress_height, answer_title_height, notes_title_height, notes_height, options_height, footer_lines) + /// Returns: (question_height, progress_height, spacer_after_question, options_height, spacer_after_options, notes_title_height, notes_height, footer_lines) fn layout_with_options( &self, - available_height: u16, - width: u16, - question_height: u16, - notes_pref_height: u16, - footer_pref: u16, + args: OptionsLayoutArgs, question_lines: &mut Vec, - ) -> (u16, u16, u16, u16, u16, u16, u16) { - let options_required_height = self.options_required_height(width); + ) -> (u16, u16, u16, u16, u16, u16, u16, u16) { + let OptionsLayoutArgs { + available_height, + width, + question_height, + notes_pref_height, + footer_pref, + notes_visible, + } = args; + let options_heights = OptionsHeights { + preferred: self.options_preferred_height(width), + full: self.options_required_height(width), + }; let min_options_height = 1u16; let required = 1u16 .saturating_add(question_height) - .saturating_add(options_required_height); + .saturating_add(options_heights.preferred); if required > available_height { self.layout_with_options_tight( @@ -115,11 +120,14 @@ impl RequestUserInputOverlay { ) } else { self.layout_with_options_normal( - available_height, - question_height, - options_required_height, - notes_pref_height, - footer_pref, + OptionsNormalArgs { + available_height, + question_height, + notes_pref_height, + footer_pref, + notes_visible, + }, + options_heights, ) } } @@ -132,7 +140,7 @@ impl RequestUserInputOverlay { question_height: u16, min_options_height: u16, question_lines: &mut Vec, - ) -> (u16, u16, u16, u16, u16, u16, u16) { + ) -> (u16, u16, u16, u16, u16, u16, u16, u16) { let max_question_height = available_height.saturating_sub(1u16.saturating_add(min_options_height)); let adjusted_question_height = question_height.min(max_question_height); @@ -140,42 +148,46 @@ impl RequestUserInputOverlay { let options_height = available_height.saturating_sub(1u16.saturating_add(adjusted_question_height)); - (adjusted_question_height, 0, 0, 0, 0, options_height, 0) + (adjusted_question_height, 0, 0, options_height, 0, 0, 0, 0) } - /// Normal layout for options case: allocate space for all elements with - /// preference order: notes, footer, labels, then progress. + /// Normal layout for options case: allocate footer + progress first, and + /// only allocate notes (and its label) when explicitly visible. fn layout_with_options_normal( &self, - available_height: u16, - question_height: u16, - options_required_height: u16, - notes_pref_height: u16, - footer_pref: u16, - ) -> (u16, u16, u16, u16, u16, u16, u16) { - let options_height = options_required_height; + args: OptionsNormalArgs, + options: OptionsHeights, + ) -> (u16, u16, u16, u16, u16, u16, u16, u16) { + let OptionsNormalArgs { + available_height, + question_height, + notes_pref_height, + footer_pref, + notes_visible, + } = args; + let min_options_height = 1u16; + let mut options_height = options.preferred.max(min_options_height); let used = 1u16 .saturating_add(question_height) .saturating_add(options_height); let mut remaining = available_height.saturating_sub(used); - // Prefer notes next, then footer, then labels, with progress last. - let mut notes_height = notes_pref_height.min(remaining); - remaining = remaining.saturating_sub(notes_height); - - let footer_lines = footer_pref.min(remaining); - remaining = remaining.saturating_sub(footer_lines); - - let mut answer_title_height = 0; - if remaining > 0 { - answer_title_height = 1; - remaining = remaining.saturating_sub(1); - } - - let mut notes_title_height = 0; - if remaining > 0 { - notes_title_height = 1; - remaining = remaining.saturating_sub(1); + // When notes are hidden, prefer to reserve room for progress, footer, + // and spacers by shrinking the options window if needed. + let desired_spacers = if notes_visible { + 0 + } else { + DESIRED_SPACERS_WHEN_NOTES_HIDDEN + }; + let required_extra = footer_pref + .saturating_add(1) // progress line + .saturating_add(desired_spacers); + if remaining < required_extra { + let deficit = required_extra.saturating_sub(remaining); + let reducible = options_height.saturating_sub(min_options_height); + let reduce_by = deficit.min(reducible); + options_height = options_height.saturating_sub(reduce_by); + remaining = remaining.saturating_add(reduce_by); } let mut progress_height = 0; @@ -184,16 +196,58 @@ impl RequestUserInputOverlay { remaining = remaining.saturating_sub(1); } - // Expand the notes composer with any leftover rows. + if !notes_visible { + let mut spacer_after_options = 0; + if remaining > footer_pref { + spacer_after_options = 1; + remaining = remaining.saturating_sub(1); + } + let footer_lines = footer_pref.min(remaining); + remaining = remaining.saturating_sub(footer_lines); + let mut spacer_after_question = 0; + if remaining > 0 { + spacer_after_question = 1; + remaining = remaining.saturating_sub(1); + } + let grow_by = remaining.min(options.full.saturating_sub(options_height)); + options_height = options_height.saturating_add(grow_by); + return ( + question_height, + progress_height, + spacer_after_question, + options_height, + spacer_after_options, + 0, + 0, + footer_lines, + ); + } + + let footer_lines = footer_pref.min(remaining); + remaining = remaining.saturating_sub(footer_lines); + + // Prefer notes next, then labels, with any leftover rows expanding notes. + let spacer_after_question = 0; + let spacer_after_options = 0; + let mut notes_height = notes_pref_height.min(remaining); + remaining = remaining.saturating_sub(notes_height); + + let mut notes_title_height = 0; + if remaining > 0 { + notes_title_height = 1; + remaining = remaining.saturating_sub(1); + } + notes_height = notes_height.saturating_add(remaining); ( question_height, progress_height, - answer_title_height, + spacer_after_question, + options_height, + spacer_after_options, notes_title_height, notes_height, - options_height, footer_lines, ) } @@ -203,7 +257,7 @@ impl RequestUserInputOverlay { /// Handles both tight layout (when space is constrained) and normal layout /// (when there's sufficient space for all elements). /// - /// Returns: (question_height, progress_height, answer_title_height, notes_title_height, notes_height, options_height, footer_lines) + /// Returns: (question_height, progress_height, spacer_after_question, options_height, spacer_after_options, notes_title_height, notes_height, footer_lines) fn layout_without_options( &self, available_height: u16, @@ -211,7 +265,7 @@ impl RequestUserInputOverlay { notes_pref_height: u16, footer_pref: u16, question_lines: &mut Vec, - ) -> (u16, u16, u16, u16, u16, u16, u16) { + ) -> (u16, u16, u16, u16, u16, u16, u16, u16) { let required = 1u16.saturating_add(question_height); if required > available_height { self.layout_without_options_tight(available_height, question_height, question_lines) @@ -231,12 +285,12 @@ impl RequestUserInputOverlay { available_height: u16, question_height: u16, question_lines: &mut Vec, - ) -> (u16, u16, u16, u16, u16, u16, u16) { + ) -> (u16, u16, u16, u16, u16, u16, u16, u16) { let max_question_height = available_height.saturating_sub(1); let adjusted_question_height = question_height.min(max_question_height); question_lines.truncate(adjusted_question_height as usize); - (adjusted_question_height, 0, 0, 0, 0, 0, 0) + (adjusted_question_height, 0, 0, 0, 0, 0, 0, 0) } /// Normal layout for no-options case: allocate space for notes, footer, and progress. @@ -246,7 +300,7 @@ impl RequestUserInputOverlay { question_height: u16, notes_pref_height: u16, footer_pref: u16, - ) -> (u16, u16, u16, u16, u16, u16, u16) { + ) -> (u16, u16, u16, u16, u16, u16, u16, u16) { let required = 1u16.saturating_add(question_height); let mut remaining = available_height.saturating_sub(required); let mut notes_height = notes_pref_height.min(remaining); @@ -268,8 +322,9 @@ impl RequestUserInputOverlay { progress_height, 0, 0, - notes_height, 0, + 0, + notes_height, footer_lines, ) } @@ -283,7 +338,6 @@ impl RequestUserInputOverlay { Rect, // progress_area Rect, // header_area Rect, // question_area - Rect, // answer_title_area Rect, // options_area Rect, // notes_title_area Rect, // notes_area @@ -311,14 +365,8 @@ impl RequestUserInputOverlay { height: heights.question_height, }; cursor_y = cursor_y.saturating_add(heights.question_height); + cursor_y = cursor_y.saturating_add(heights.spacer_after_question); - let answer_title_area = Rect { - x: area.x, - y: cursor_y, - width: area.width, - height: heights.answer_title_height, - }; - cursor_y = cursor_y.saturating_add(heights.answer_title_height); let options_area = Rect { x: area.x, y: cursor_y, @@ -326,6 +374,7 @@ impl RequestUserInputOverlay { height: heights.options_height, }; cursor_y = cursor_y.saturating_add(heights.options_height); + cursor_y = cursor_y.saturating_add(heights.spacer_after_options); let notes_title_area = Rect { x: area.x, @@ -345,7 +394,6 @@ impl RequestUserInputOverlay { progress_area, header_area, question_area, - answer_title_area, options_area, notes_title_area, notes_area, @@ -357,8 +405,34 @@ impl RequestUserInputOverlay { struct LayoutHeights { progress_height: u16, question_height: u16, - answer_title_height: u16, + spacer_after_question: u16, options_height: u16, + spacer_after_options: u16, notes_title_height: u16, notes_height: u16, } + +#[derive(Clone, Copy, Debug)] +struct OptionsLayoutArgs { + available_height: u16, + width: u16, + question_height: u16, + notes_pref_height: u16, + footer_pref: u16, + notes_visible: bool, +} + +#[derive(Clone, Copy, Debug)] +struct OptionsNormalArgs { + available_height: u16, + question_height: u16, + notes_pref_height: u16, + footer_pref: u16, + notes_visible: bool, +} + +#[derive(Clone, Copy, Debug)] +struct OptionsHeights { + preferred: u16, + full: u16, +} diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index 1da955ec3..3fe16c431 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -13,6 +13,7 @@ use std::path::PathBuf; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; mod layout; mod render; @@ -33,12 +34,16 @@ use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::TextElement; +use unicode_width::UnicodeWidthStr; -const NOTES_PLACEHOLDER: &str = "Add notes (optional)"; +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 (optional)"; +const SELECT_OPTION_PLACEHOLDER: &str = "Select an option to add notes"; +pub(super) const TIP_SEPARATOR: &str = " | "; +pub(super) const MAX_VISIBLE_OPTION_ROWS: usize = 4; +pub(super) const DESIRED_SPACERS_WHEN_NOTES_HIDDEN: u16 = 2; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Focus { @@ -54,12 +59,38 @@ struct ComposerDraft { } struct AnswerState { - // Final selection for the question (always set for option questions). - selected: Option, + // Committed selection for the question (may be None when unanswered). + committed_option_idx: Option, // Scrollable cursor state for option navigation/highlight. - option_state: ScrollState, + options_ui_state: ScrollState, // Per-question notes draft. draft: ComposerDraft, + // Whether a freeform answer has been explicitly submitted. + freeform_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) -> Self { + Self { + text: text.into(), + highlight: false, + } + } + + fn highlighted(text: impl Into) -> Self { + Self { + text: text.into(), + highlight: true, + } + } } pub(crate) struct RequestUserInputOverlay { @@ -154,8 +185,11 @@ impl RequestUserInputOverlay { if !self.has_options() { return None; } - self.current_answer() - .and_then(|answer| answer.selected.or(answer.option_state.selected_idx)) + self.current_answer().and_then(|answer| { + answer + .committed_option_idx + .or(answer.options_ui_state.selected_idx) + }) } fn current_option_label(&self) -> Option<&str> { @@ -166,6 +200,23 @@ impl RequestUserInputOverlay { .map(|option| option.label.as_str()) } + 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 { self.current_question() .map(|q| { @@ -177,6 +228,10 @@ impl RequestUserInputOverlay { .unwrap_or_default() } + fn focus_is_notes(&self) -> bool { + matches!(self.focus, Focus::Notes) + } + pub(super) fn option_rows(&self) -> Vec { self.current_question() .and_then(|question| question.options.as_ref()) @@ -187,7 +242,7 @@ impl RequestUserInputOverlay { .map(|(idx, opt)| { let selected = self .current_answer() - .and_then(|answer| answer.selected) + .and_then(|answer| answer.committed_option_idx) .is_some_and(|sel| sel == idx); let prefix = if selected { "(x)" } else { "( )" }; GenericDisplayRow { @@ -213,7 +268,7 @@ impl RequestUserInputOverlay { let mut state = self .current_answer() - .map(|answer| answer.option_state) + .map(|answer| answer.options_ui_state) .unwrap_or_default(); if state.selected_idx.is_none() { state.selected_idx = Some(0); @@ -222,6 +277,28 @@ impl RequestUserInputOverlay { 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_ui_state) + .unwrap_or_default(); + if state.selected_idx.is_none() { + state.selected_idx = Some(0); + } + + let visible_items = rows.len().min(MAX_VISIBLE_OPTION_ROWS); + measure_rows_height(&rows, &state, visible_items, width.max(1)) + } + fn capture_composer_draft(&self) -> ComposerDraft { ComposerDraft { text: self.composer.current_text_with_pending(), @@ -237,8 +314,12 @@ impl RequestUserInputOverlay { 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() { answer.draft = draft; + if !notes_empty { + answer.notes_visible = true; + } } } @@ -259,11 +340,7 @@ impl RequestUserInputOverlay { } fn notes_placeholder(&self) -> &'static str { - if self.has_options() - && self - .current_answer() - .is_some_and(|answer| answer.selected.is_none()) - { + if self.has_options() && self.selected_option_index().is_none() { SELECT_OPTION_PLACEHOLDER } else if self.has_options() { NOTES_PLACEHOLDER @@ -272,6 +349,94 @@ impl RequestUserInputOverlay { } } + fn sync_composer_placeholder(&mut self) { + self.composer + .set_placeholder_text(self.notes_placeholder().to_string()); + } + + fn footer_tips(&self) -> Vec { + let mut tips = Vec::new(); + let notes_visible = self.notes_ui_visible(); + if self.has_options() { + let options_len = self.options_len(); + if let Some(selected_idx) = self.selected_option_index() { + let option_index = selected_idx + 1; + tips.push(FooterTip::new(format!( + "Option {option_index} of {options_len}" + ))); + } else { + tips.push(FooterTip::new("No option selected")); + } + tips.push(FooterTip::new("\u{2191}/\u{2193} scroll")); + if self.selected_option_index().is_some() && !notes_visible { + tips.push(FooterTip::highlighted("Tab: add notes")); + } + if self.selected_option_index().is_some() && notes_visible && self.focus_is_notes() { + tips.push(FooterTip::new("Tab: clear notes")); + } + } + + let question_count = self.question_count(); + let is_last_question = question_count > 0 && self.current_index() + 1 >= question_count; + let enter_tip = if question_count > 1 && is_last_question { + "Enter: submit all answers" + } else { + "Enter: submit answer" + }; + tips.push(FooterTip::new(enter_tip)); + if question_count > 1 { + tips.push(FooterTip::new("Ctrl+n next")); + } + tips.push(FooterTip::new("Esc: interrupt")); + tips + } + + pub(super) fn footer_tip_lines(&self, width: u16) -> Vec> { + let max_width = width.max(1) as usize; + let separator_width = UnicodeWidthStr::width(TIP_SEPARATOR); + let tips = self.footer_tips(); + if tips.is_empty() { + return vec![Vec::new()]; + } + + let mut lines: Vec> = Vec::new(); + let mut current: Vec = 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 { @@ -279,6 +444,14 @@ impl RequestUserInputOverlay { } 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(); } } @@ -289,16 +462,20 @@ impl RequestUserInputOverlay { .questions .iter() .map(|question| { - let mut option_state = ScrollState::new(); - if let Some(options) = question.options.as_ref() - && !options.is_empty() - { - option_state.selected_idx = Some(0); + let has_options = question + .options + .as_ref() + .is_some_and(|options| !options.is_empty()); + let mut options_ui_state = ScrollState::new(); + if has_options { + options_ui_state.selected_idx = Some(0); } AnswerState { - selected: option_state.selected_idx, - option_state, + committed_option_idx: None, + options_ui_state, draft: ComposerDraft::default(), + freeform_committed: false, + notes_visible: !has_options, } }) .collect(); @@ -318,8 +495,8 @@ impl RequestUserInputOverlay { self.save_current_draft(); let offset = if next { 1 } else { len.saturating_sub(1) }; self.current_idx = (self.current_idx + offset) % len; - self.ensure_focus_available(); self.restore_current_draft(); + self.ensure_focus_available(); } /// Synchronize selection state to the currently focused option. @@ -328,22 +505,41 @@ impl RequestUserInputOverlay { return; } let options_len = self.options_len(); - let Some(answer) = self.current_answer_mut() else { - return; + let updated = if let Some(answer) = self.current_answer_mut() { + answer.options_ui_state.clamp_selection(options_len); + answer.committed_option_idx = answer.options_ui_state.selected_idx; + true + } else { + false }; - answer.option_state.clamp_selection(options_len); - answer.selected = answer.option_state.selected_idx; + 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; + } + self.save_current_draft(); + let notes_empty = self.composer.current_text_with_pending().trim().is_empty(); + if let Some(answer) = self.current_answer_mut() { + answer.committed_option_idx = None; + answer.options_ui_state.reset(); + if notes_empty { + answer.notes_visible = false; + } + } + self.sync_composer_placeholder(); } /// Ensure there is a selection before allowing notes entry. fn ensure_selected_for_notes(&mut self) { - if self.has_options() - && self - .current_answer() - .is_some_and(|answer| answer.selected.is_none()) - { - self.select_current_option(); + 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. @@ -362,16 +558,21 @@ impl RequestUserInputOverlay { 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 always produce a selection. + // For option questions we may still produce no selection. let selected_idx = if options.is_some_and(|opts| !opts.is_empty()) { - answer_state - .selected - .or(answer_state.option_state.selected_idx) + answer_state.committed_option_idx } else { - answer_state.selected + None + }; + // Notes are appended as extra answers. For freeform questions, only submit when + // the user explicitly committed the draft. + let notes = if options.is_some_and(|opts| !opts.is_empty()) + || answer_state.freeform_committed + { + answer_state.draft.text.trim().to_string() + } else { + String::new() }; - // Notes are appended as extra answers. - let notes = answer_state.draft.text.trim().to_string(); let selected_label = selected_idx.and_then(|selected_idx| { question .options @@ -405,27 +606,37 @@ impl RequestUserInputOverlay { } } - /// Count freeform-only questions that have no notes. + 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.committed_option_idx.is_some() + } else { + answer.freeform_committed + } + } + + fn current_question_answered(&self) -> bool { + let current_text = self.composer.current_text(); + self.is_question_answered(self.current_index(), ¤t_text) + } + + /// 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)| { - let answer = &self.answers[*idx]; - let options = question.options.as_ref(); - if options.is_some_and(|opts| !opts.is_empty()) { - false - } else { - let notes = if *idx == self.current_index() { - current_text.as_str() - } else { - answer.draft.text.as_str() - }; - notes.trim().is_empty() - } - }) + .filter(|(idx, _question)| !self.is_question_answered(*idx, ¤t_text)) .count() } @@ -467,6 +678,21 @@ impl RequestUserInputOverlay { 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_ui_state.clamp_selection(options_len); + answer.committed_option_idx = answer.options_ui_state.selected_idx; + } + } + if !self.has_options() + && let Some(answer) = self.current_answer_mut() + { + answer.freeform_committed = !text.trim().is_empty(); + } self.apply_submission_to_draft(text, text_elements); self.go_next_or_submit(); true @@ -489,12 +715,36 @@ impl BottomPaneView for RequestUserInputOverlay { } // Question navigation is always available. - match key_event.code { - KeyCode::PageUp => { + match key_event { + KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => { self.move_question(false); return; } - KeyCode::PageDown => { + 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; } @@ -506,36 +756,74 @@ impl BottomPaneView for RequestUserInputOverlay { let options_len = self.options_len(); // Keep selection synchronized as the user moves. match key_event.code { - KeyCode::Up => { - if let Some(answer) = self.current_answer_mut() { - answer.option_state.move_up_wrap(options_len); - answer.selected = answer.option_state.selected_idx; + KeyCode::Up | KeyCode::Char('k') => { + let moved = if let Some(answer) = self.current_answer_mut() { + answer.options_ui_state.move_up_wrap(options_len); + true + } else { + false + }; + if moved { + self.sync_composer_placeholder(); } } - KeyCode::Down => { - if let Some(answer) = self.current_answer_mut() { - answer.option_state.move_down_wrap(options_len); - answer.selected = answer.option_state.selected_idx; + KeyCode::Down | KeyCode::Char('j') => { + let moved = if let Some(answer) = self.current_answer_mut() { + answer.options_ui_state.move_down_wrap(options_len); + true + } else { + false + }; + if moved { + self.sync_composer_placeholder(); } } KeyCode::Char(' ') => { self.select_current_option(); } - KeyCode::Enter => { - self.select_current_option(); - self.go_next_or_submit(); + KeyCode::Backspace => { + self.clear_selection(); } - KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete => { - // Any typing while in options switches to notes for fast freeform input. - self.focus = Focus::Notes; - self.ensure_selected_for_notes(); - let (result, _) = self.composer.handle_key_event(key_event); - self.handle_composer_input_result(result); + 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(); + } + 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.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(); let (result, _) = self.composer.handle_key_event(key_event); @@ -548,15 +836,25 @@ impl BottomPaneView for RequestUserInputOverlay { let options_len = self.options_len(); match key_event.code { KeyCode::Up => { - if let Some(answer) = self.current_answer_mut() { - answer.option_state.move_up_wrap(options_len); - answer.selected = answer.option_state.selected_idx; + let moved = if let Some(answer) = self.current_answer_mut() { + answer.options_ui_state.move_up_wrap(options_len); + true + } else { + false + }; + if moved { + self.sync_composer_placeholder(); } } KeyCode::Down => { - if let Some(answer) = self.current_answer_mut() { - answer.option_state.move_down_wrap(options_len); - answer.selected = answer.option_state.selected_idx; + let moved = if let Some(answer) = self.current_answer_mut() { + answer.options_ui_state.move_down_wrap(options_len); + true + } else { + false + }; + if moved { + self.sync_composer_placeholder(); } } _ => {} @@ -613,6 +911,7 @@ impl BottomPaneView for RequestUserInputOverlay { mod tests { use super::*; use crate::app_event::AppEvent; + use crate::bottom_pane::selection_popup_common::menu_surface_inset; use crate::render::renderable::Renderable; use codex_protocol::request_user_input::RequestUserInputQuestion; use codex_protocol::request_user_input::RequestUserInputQuestionOption; @@ -620,6 +919,7 @@ mod tests { use ratatui::buffer::Buffer; use ratatui::layout::Rect; use tokio::sync::mpsc::unbounded_channel; + use unicode_width::UnicodeWidthStr; fn test_sender() -> ( AppEventSender, @@ -747,7 +1047,7 @@ mod tests { } #[test] - fn options_always_return_a_selection() { + 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")]), @@ -765,9 +1065,475 @@ mod tests { }; assert_eq!(id, "turn-1"); let answer = response.answers.get("q1").expect("answer missing"); + assert_eq!(answer.answers, Vec::::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_eq!(first_answer.committed_option_idx, Some(0)); + assert_eq!(first_answer.options_ui_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 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_ui_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_ui_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_ui_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_ui_state.selected_idx = Some(1); + answer.committed_option_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); + + let answer = overlay.current_answer().expect("answer missing"); + assert!(matches!(overlay.focus, Focus::Options)); + assert_eq!(answer.committed_option_idx, None); + 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); + + let answer = overlay.current_answer().expect("answer missing"); + assert!(matches!(overlay.focus, Focus::Options)); + assert_eq!(overlay.notes_ui_visible(), false); + assert_eq!(answer.committed_option_idx, None); + + 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("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_ui_state.selected_idx = Some(0); + answer.committed_option_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_ui_state.selected_idx = Some(0); + answer.committed_option_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_ui_state.selected_idx = Some(1); + answer.committed_option_idx = Some(1); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Backspace)); + + let answer = overlay.current_answer().expect("answer missing"); + assert_eq!(answer.committed_option_idx, None); + assert_eq!(answer.options_ui_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_ui_state.selected_idx = Some(0); + answer.committed_option_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.committed_option_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_ui_state.selected_idx = Some(0); + answer.committed_option_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.committed_option_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_ui_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].freeform_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].freeform_committed, false); + assert_eq!(overlay.unanswered_count(), 2); + } + #[test] fn freeform_questions_submit_empty_when_empty() { let (tx, mut rx) = test_sender(); @@ -789,6 +1555,31 @@ mod tests { assert_eq!(answer.answers, Vec::::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::::new()); + } + #[test] fn notes_are_captured_for_selected_option() { let (tx, mut rx) = test_sender(); @@ -802,7 +1593,7 @@ mod tests { { let answer = overlay.current_answer_mut().expect("answer missing"); - answer.option_state.selected_idx = Some(1); + answer.options_ui_state.selected_idx = Some(1); } overlay.select_current_option(); overlay @@ -826,6 +1617,37 @@ mod tests { ); } + #[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.committed_option_idx, Some(1)); + } + #[test] fn large_paste_is_preserved_when_switching_questions() { let (tx, _rx) = test_sender(); @@ -860,13 +1682,37 @@ mod tests { false, false, ); - let area = Rect::new(0, 0, 64, 16); + 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_ui_state.selected_idx = Some(0); + answer.committed_option_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(); @@ -877,7 +1723,7 @@ mod tests { false, false, ); - let area = Rect::new(0, 0, 60, 8); + let area = Rect::new(0, 0, 120, 10); insta::assert_snapshot!( "request_user_input_tight_height", render_snapshot(&overlay, area) @@ -901,17 +1747,20 @@ mod tests { let width = 48u16; 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) + let extras = 1u16 // header + .saturating_add(1) // progress + .saturating_add(DESIRED_SPACERS_WHEN_NOTES_HIDDEN) + .saturating_add(overlay.footer_required_height(width)); + let height = question_height .saturating_add(options_height) - .saturating_add(4); + .saturating_add(extras); let sections = overlay.layout_sections(Rect::new(0, 0, width, height)); assert_eq!(sections.options_area.height, options_height); } #[test] - fn request_user_input_wrapped_options_snapshot() { + fn desired_height_keeps_spacers_and_preferred_options_visible() { let (tx, _rx) = test_sender(); let overlay = RequestUserInputOverlay::new( request_event( @@ -924,13 +1773,86 @@ mod tests { false, ); - let width = 52u16; + 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_title_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_ui_state.selected_idx = Some(0); + answer.committed_option_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_ui_state.selected_idx = Some(0); + answer.committed_option_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(4); + .saturating_add(8); let area = Rect::new(0, 0, width, height); insta::assert_snapshot!( "request_user_input_wrapped_options", @@ -938,6 +1860,35 @@ mod tests { ); } + #[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_ui_state.selected_idx = Some(1); + answer.committed_option_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(); @@ -980,10 +1931,10 @@ mod tests { ); { let answer = overlay.current_answer_mut().expect("answer missing"); - answer.option_state.selected_idx = Some(3); - answer.selected = Some(3); + answer.options_ui_state.selected_idx = Some(3); + answer.committed_option_idx = Some(3); } - let area = Rect::new(0, 0, 68, 10); + let area = Rect::new(0, 0, 120, 12); insta::assert_snapshot!( "request_user_input_scrolling_options", render_snapshot(&overlay, area) @@ -1000,13 +1951,60 @@ mod tests { false, false, ); - let area = Rect::new(0, 0, 64, 10); + 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 options_scroll_while_editing_notes() { let (tx, _rx) = test_sender(); @@ -1017,6 +2015,7 @@ mod tests { false, false, ); + overlay.select_current_option(); overlay.focus = Focus::Notes; overlay .composer @@ -1026,6 +2025,7 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Down)); let answer = overlay.current_answer().expect("answer missing"); - assert_eq!(answer.selected, Some(1)); + assert_eq!(answer.committed_option_idx, Some(0)); + assert_eq!(answer.options_ui_state.selected_idx, Some(1)); } } diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs index b64f70434..0ab53fb84 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs @@ -1,41 +1,58 @@ -use crossterm::event::KeyCode; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; +use unicode_width::UnicodeWidthChar; +use unicode_width::UnicodeWidthStr; use crate::bottom_pane::selection_popup_common::menu_surface_inset; use crate::bottom_pane::selection_popup_common::menu_surface_padding_height; use crate::bottom_pane::selection_popup_common::render_menu_surface; use crate::bottom_pane::selection_popup_common::render_rows; -use crate::key_hint; use crate::render::renderable::Renderable; +use super::DESIRED_SPACERS_WHEN_NOTES_HIDDEN; use super::RequestUserInputOverlay; +use super::TIP_SEPARATOR; impl Renderable for RequestUserInputOverlay { fn desired_height(&self, width: u16) -> u16 { let outer = Rect::new(0, 0, width, u16::MAX); let inner = menu_surface_inset(outer); let inner_width = inner.width.max(1); + let has_options = self.has_options(); let question_height = self.wrapped_question_lines(inner_width).len(); - let options_height = self.options_required_height(inner_width) as usize; - let notes_height = self.notes_input_height(inner_width) as usize; - let footer_height = if self.unanswered_count() > 0 { 2 } else { 1 }; + let options_height = if has_options { + self.options_preferred_height(inner_width) as usize + } else { + 0 + }; + let notes_visible = !has_options || self.notes_ui_visible(); + let notes_height = if notes_visible { + self.notes_input_height(inner_width) as usize + } else { + 0 + }; + let spacer_rows = if has_options && !notes_visible { + DESIRED_SPACERS_WHEN_NOTES_HIDDEN as usize + } else { + 0 + }; + let footer_height = self.footer_required_height(inner_width) as usize; // Tight minimum height: progress + header + question + (optional) titles/options // + notes composer + footer + menu padding. let mut height = question_height .saturating_add(options_height) + .saturating_add(spacer_rows) .saturating_add(notes_height) .saturating_add(footer_height) .saturating_add(2); // progress + header - if self.has_options() { - height = height - .saturating_add(1) // answer title - .saturating_add(1); // notes title + if has_options && notes_visible { + height = height.saturating_add(1); // notes title } height = height.saturating_add(menu_surface_padding_height() as usize); height.max(8) as u16 @@ -63,12 +80,19 @@ impl RequestUserInputOverlay { return; } let sections = self.layout_sections(content_area); + let notes_visible = self.notes_ui_visible(); + let unanswered = self.unanswered_count(); // Progress header keeps the user oriented across multiple questions. let progress_line = if self.question_count() > 0 { let idx = self.current_index() + 1; let total = self.question_count(); - Line::from(format!("Question {idx}/{total}").dim()) + let base = format!("Question {idx}/{total}"); + if unanswered > 0 { + Line::from(format!("{base} ({unanswered} unanswered)").dim()) + } else { + Line::from(base.dim()) + } } else { Line::from("No questions".dim()) }; @@ -76,8 +100,13 @@ impl RequestUserInputOverlay { // Question title and wrapped prompt text. let question_header = self.current_question().map(|q| q.header.clone()); + let answered = self.current_question_answered(); let header_line = if let Some(header) = question_header { - Line::from(header.bold()) + if answered { + Line::from(header.bold()) + } else { + Line::from(header.cyan().bold()) + } } else { Line::from("No questions".dim()) }; @@ -101,54 +130,45 @@ impl RequestUserInputOverlay { ); } - if sections.answer_title_area.height > 0 { - let answer_label = "Answer"; - let answer_title = if self.focus_is_options() || self.focus_is_notes_without_options() { - answer_label.cyan().bold() - } else { - answer_label.dim() - }; - Paragraph::new(Line::from(answer_title)).render(sections.answer_title_area, buf); - } - // Build rows with selection markers for the shared selection renderer. let option_rows = self.option_rows(); if self.has_options() { - let mut option_state = self + let mut options_ui_state = self .current_answer() - .map(|answer| answer.option_state) + .map(|answer| answer.options_ui_state) .unwrap_or_default(); if sections.options_area.height > 0 { // Ensure the selected option is visible in the scroll window. - option_state + options_ui_state .ensure_visible(option_rows.len(), sections.options_area.height as usize); render_rows( sections.options_area, buf, &option_rows, - &option_state, + &options_ui_state, option_rows.len().max(1), "No options", ); } } - if sections.notes_title_area.height > 0 { + if notes_visible && sections.notes_title_area.height > 0 { let notes_label = if self.has_options() && self .current_answer() - .is_some_and(|answer| answer.selected.is_some()) + .is_some_and(|answer| answer.committed_option_idx.is_some()) { if let Some(label) = self.current_option_label() { - format!("Notes for {label} (optional)") + format!("Notes for {label}") } else { - "Notes (optional)".to_string() + "Notes".to_string() } } else { - "Notes (optional)".to_string() + "Notes".to_string() }; - let notes_title = if self.focus_is_notes() { + let notes_active = self.focus_is_notes(); + let notes_title = if notes_active { notes_label.as_str().cyan().bold() } else { notes_label.as_str().dim() @@ -156,7 +176,7 @@ impl RequestUserInputOverlay { Paragraph::new(Line::from(notes_title)).render(sections.notes_title_area, buf); } - if sections.notes_area.height > 0 { + if notes_visible && sections.notes_area.height > 0 { self.render_notes_input(sections.notes_area, buf); } @@ -164,69 +184,55 @@ impl RequestUserInputOverlay { .notes_area .y .saturating_add(sections.notes_area.height); - if sections.footer_lines == 2 { - // Status line for unanswered count when any question is empty. - let warning = format!( - "Unanswered: {} | Will submit as skipped", - self.unanswered_count() - ); - Paragraph::new(Line::from(warning.dim())).render( - Rect { - x: content_area.x, - y: footer_y, - width: content_area.width, - height: 1, - }, - buf, - ); + let footer_area = Rect { + x: content_area.x, + y: footer_y, + width: content_area.width, + height: sections.footer_lines, + }; + if footer_area.height == 0 { + return; } - let hint_y = footer_y.saturating_add(sections.footer_lines.saturating_sub(1)); - // Footer hints (selection index + navigation keys). - let mut hint_spans = Vec::new(); - if self.has_options() { - let options_len = self.options_len(); - let option_index = self.selected_option_index().map_or(0, |idx| idx + 1); - hint_spans.extend(vec![ - format!("Option {option_index} of {options_len}").into(), - " | ".into(), - ]); - } - hint_spans.extend(vec![ - key_hint::plain(KeyCode::Up).into(), - "/".into(), - key_hint::plain(KeyCode::Down).into(), - " scroll | ".into(), - key_hint::plain(KeyCode::Enter).into(), - " next question | ".into(), - ]); - if self.question_count() > 1 { - hint_spans.extend(vec![ - key_hint::plain(KeyCode::PageUp).into(), - " prev | ".into(), - key_hint::plain(KeyCode::PageDown).into(), - " next | ".into(), - ]); - } - hint_spans.extend(vec![ - key_hint::plain(KeyCode::Esc).into(), - " interrupt".into(), - ]); - Paragraph::new(Line::from(hint_spans).dim()).render( - Rect { - x: content_area.x, - y: hint_y, - width: content_area.width, + let tip_lines = self.footer_tip_lines(footer_area.width); + for (row_idx, tips) in tip_lines + .into_iter() + .take(footer_area.height as usize) + .enumerate() + { + let mut spans = Vec::new(); + for (tip_idx, tip) in tips.into_iter().enumerate() { + if tip_idx > 0 { + spans.push(TIP_SEPARATOR.into()); + } + if tip.highlight { + spans.push(tip.text.cyan().bold().not_dim()); + } else { + spans.push(tip.text.into()); + } + } + let line = Line::from(spans).dim(); + let line = truncate_line_word_boundary_with_ellipsis(line, footer_area.width as usize); + let row_area = Rect { + x: footer_area.x, + y: footer_area.y.saturating_add(row_idx as u16), + width: footer_area.width, height: 1, - }, - buf, - ); + }; + Paragraph::new(line).render(row_area, buf); + } } /// Return the cursor position when editing notes, if visible. pub(super) fn cursor_pos_impl(&self, area: Rect) -> Option<(u16, u16)> { + let has_options = self.has_options(); + let notes_visible = self.notes_ui_visible(); + if !self.focus_is_notes() { return None; } + if has_options && !notes_visible { + return None; + } let content_area = menu_surface_inset(area); if content_area.width == 0 || content_area.height == 0 { return None; @@ -246,16 +252,118 @@ impl RequestUserInputOverlay { } self.composer.render(area, buf); } - - fn focus_is_options(&self) -> bool { - matches!(self.focus, super::Focus::Options) - } - - fn focus_is_notes(&self) -> bool { - matches!(self.focus, super::Focus::Notes) - } - - fn focus_is_notes_without_options(&self) -> bool { - !self.has_options() && self.focus_is_notes() - } +} + +fn line_width(line: &Line<'_>) -> usize { + line.iter() + .map(|span| UnicodeWidthStr::width(span.content.as_ref())) + .sum() +} + +/// Truncate a styled line to `max_width`, preferring a word boundary, and append an ellipsis. +/// +/// This walks spans character-by-character, tracking the last width-safe position and the last +/// whitespace boundary within the available width (excluding the ellipsis width). If the line +/// overflows, it truncates at the last word boundary when possible (falling back to the last +/// fitting character), trims trailing whitespace, then appends an ellipsis styled to match the +/// last visible span (or the line style if nothing was kept). +fn truncate_line_word_boundary_with_ellipsis( + line: Line<'static>, + max_width: usize, +) -> Line<'static> { + if max_width == 0 { + return Line::from(Vec::>::new()); + } + + if line_width(&line) <= max_width { + return line; + } + + let ellipsis = "…"; + let ellipsis_width = UnicodeWidthStr::width(ellipsis); + if ellipsis_width >= max_width { + return Line::from(ellipsis); + } + let limit = max_width.saturating_sub(ellipsis_width); + + #[derive(Clone, Copy)] + struct BreakPoint { + span_idx: usize, + byte_end: usize, + } + + // Track display width as we scan, along with the best "cut here" positions. + let mut used = 0usize; + let mut last_fit: Option = None; + let mut last_word_break: Option = None; + let mut overflowed = false; + + 'outer: for (span_idx, span) in line.spans.iter().enumerate() { + let text = span.content.as_ref(); + for (byte_idx, ch) in text.char_indices() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if used.saturating_add(ch_width) > limit { + overflowed = true; + break 'outer; + } + used = used.saturating_add(ch_width); + let bp = BreakPoint { + span_idx, + byte_end: byte_idx + ch.len_utf8(), + }; + last_fit = Some(bp); + if ch.is_whitespace() { + last_word_break = Some(bp); + } + } + } + + // If we never overflowed, the original line already fits. + if !overflowed { + return line; + } + + // Prefer breaking on whitespace; otherwise fall back to the last fitting character. + let chosen_break = last_word_break.or(last_fit); + let Some(chosen_break) = chosen_break else { + return Line::from(ellipsis); + }; + + let line_style = line.style; + let mut spans_out: Vec> = Vec::new(); + for (idx, span) in line.spans.into_iter().enumerate() { + if idx < chosen_break.span_idx { + spans_out.push(span); + continue; + } + if idx == chosen_break.span_idx { + let text = span.content.into_owned(); + let truncated = text[..chosen_break.byte_end].to_string(); + if !truncated.is_empty() { + spans_out.push(Span::styled(truncated, span.style)); + } + } + break; + } + + while let Some(last) = spans_out.last_mut() { + let trimmed = last + .content + .trim_end_matches(char::is_whitespace) + .to_string(); + if trimmed.is_empty() { + spans_out.pop(); + } else { + last.content = trimmed.into(); + break; + } + } + + let ellipsis_style = spans_out + .last() + .map(|span| span.style) + .unwrap_or(line_style); + spans_out.push(Span::styled(ellipsis, ellipsis_style)); + + Line::from(spans_out).style(line_style) } diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_footer_wrap.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_footer_wrap.snap new file mode 100644 index 000000000..02f91a7b4 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_footer_wrap.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 1/2 (1 unanswered) + Pick one + Choose an option. + + ( ) Option 1 First choice. + (x) Option 2 Second choice. + ( ) Option 3 Third choice. + + Option 2 of 3 | ↑/↓ scroll | Tab: add notes + Enter: submit answer | Ctrl+n next + Esc: interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap index dd5ced6fc..3e947c015 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap @@ -2,12 +2,12 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- - - Question 1/1 - Goal - Share details. - - › Type your answer (optional) - - Unanswered: 1 | Will submit as skipped - ↑/↓ scroll | enter next question | esc interrupt + + Question 1/1 (1 unanswered) + Goal + Share details. + + › Type your answer (optional) + + + Enter: submit answer | Esc: interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multi_question_first.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multi_question_first.snap new file mode 100644 index 000000000..cdae6785d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multi_question_first.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 1/2 (2 unanswered) + Area + Choose an option. + + ( ) Option 1 First choice. + ( ) Option 2 Second choice. + ( ) Option 3 Third choice. + + Option 1 of 3 | ↑/↓ scroll | Tab: add notes | Enter: submit answer | Ctrl+n next | Esc: interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multi_question_last.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multi_question_last.snap new file mode 100644 index 000000000..d007b6b43 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multi_question_last.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 2/2 (2 unanswered) + Goal + Share details. + + › Type your answer (optional) + + + + + Enter: submit all answers | Ctrl+n next | Esc: interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap index 37ed03647..67eb6dc71 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap @@ -2,18 +2,13 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- - - Question 1/1 - Area - Choose an option. - Answer - (x) Option 1 First choice. - ( ) Option 2 Second choice. - ( ) Option 3 Third choice. - Notes for Option 1 (optional) - - › Add notes (optional) - - - - Option 1 of 3 | ↑/↓ scroll | enter next question | esc inter + + Question 1/1 (1 unanswered) + Area + Choose an option. + + ( ) Option 1 First choice. + ( ) Option 2 Second choice. + ( ) Option 3 Third choice. + + Option 1 of 3 | ↑/↓ scroll | Tab: add notes | Enter: submit answer | Esc: interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options_notes_visible.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options_notes_visible.snap new file mode 100644 index 000000000..24799ed63 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options_notes_visible.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 1/1 + Area + Choose an option. + (x) Option 1 First choice. + ( ) Option 2 Second choice. + ( ) Option 3 Third choice. + Notes for Option 1 + + › Add notes + + + + + Option 1 of 3 | ↑/↓ scroll | Tab: clear notes | Enter: submit answer | Esc: interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap index ace8eb453..95813336b 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap @@ -2,13 +2,14 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- - - Next Step - What would you like to do next? - ( ) Discuss a code change (Recommended) Walk through a plan and - edit code together. - ( ) Run tests Pick a crate and run - its tests. - ( ) Review a diff Summarize or review - current changes. - Option 4 of 5 | ↑/↓ scroll | enter next question | esc interrupt + + Question 1/1 + Next Step + What would you like to do next? + + ( ) Discuss a code change (Recommended) Walk through a plan and edit code together. + ( ) Run tests Pick a crate and run its tests. + ( ) Review a diff Summarize or review current changes. + (x) Refactor Tighten structure and remove dead code. + + Option 4 of 5 | ↑/↓ scroll | Tab: add notes | Enter: submit answer | Esc: interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap index e8cd2bd22..a963370be 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap @@ -2,11 +2,12 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- - - Area - Choose an option. - (x) Option 1 First choice. - ( ) Option 2 Second choice. - ( ) Option 3 Third choice. - - Option 1 of 3 | ↑/↓ scroll | enter next question | esc i + + Question 1/1 (1 unanswered) + Area + Choose an option. + + ( ) Option 1 First choice. + ( ) Option 2 Second choice. + + Option 1 of 3 | ↑/↓ scroll | Tab: add notes | Enter: submit answer | Esc: interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_options.snap index 4ae9dd048..0d1e72742 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_options.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_options.snap @@ -2,20 +2,13 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- - - Next Step - Choose the next step for this task. - (x) Discuss a code change Walk through a plan, - then implement it - together with careful - checks. - ( ) Run targeted tests Pick the most - relevant crate and - validate the current - behavior first. - ( ) Review the diff Summarize the changes - and highlight the - most important risks - and gaps. - - Option 1 of 3 | ↑/↓ scroll | enter next question + + Question 1/1 + Next Step + Choose the next step for this task. + + (x) Discuss a code change Walk through a plan, then implement it together with careful checks. + ( ) Run targeted tests Pick the most relevant crate and validate the current behavior first. + ( ) Review the diff Summarize the changes and highlight the most important risks and gaps. + + Option 1 of 3 | ↑/↓ scroll | Tab: add notes | Enter: submit answer | Esc: interrupt