From b59d8a57fe1b6436f8b6b8294e73566dcdbdb2a9 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 26 Jan 2026 00:43:35 -0800 Subject: [PATCH 01/29] Add composer config and shared menu surface helpers --- .../src/bottom_pane/selection_popup_common.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index 89ef8b3b3f28..29c6f273eaa7 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -61,6 +61,31 @@ pub(crate) fn render_menu_surface(area: Rect, buf: &mut Buffer) -> Rect { menu_surface_inset(area) } +const MENU_SURFACE_INSET_V: u16 = 1; +const MENU_SURFACE_INSET_H: u16 = 2; + +/// Apply the shared "menu surface" padding used by bottom-pane overlays. +/// +/// Rendering code should generally call [`render_menu_surface`] and then lay +/// out content inside the returned inset rect. +pub(crate) fn menu_surface_inset(area: Rect) -> Rect { + area.inset(Insets::vh(MENU_SURFACE_INSET_V, MENU_SURFACE_INSET_H)) +} + +/// Paint the shared menu background and return the inset content area. +/// +/// This keeps the surface treatment consistent across selection-style overlays +/// (for example `/model`, approvals, and request-user-input). +pub(crate) fn render_menu_surface(area: Rect, buf: &mut Buffer) -> Rect { + if area.is_empty() { + return area; + } + Block::default() + .style(user_message_style()) + .render(area, buf); + menu_surface_inset(area) +} + pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec> { use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; From c01171cbca8ce443b04911f0f7afdcd28c31397d Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 26 Jan 2026 00:49:41 -0800 Subject: [PATCH 02/29] Reuse ChatComposer in request_user_input overlay --- codex-rs/tui/src/bottom_pane/selection_popup_common.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index 29c6f273eaa7..099ff12d054a 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -72,6 +72,11 @@ pub(crate) fn menu_surface_inset(area: Rect) -> Rect { area.inset(Insets::vh(MENU_SURFACE_INSET_V, MENU_SURFACE_INSET_H)) } +/// Total vertical padding introduced by the menu surface treatment. +pub(crate) const fn menu_surface_padding_height() -> u16 { + MENU_SURFACE_INSET_V * 2 +} + /// Paint the shared menu background and return the inset content area. /// /// This keeps the surface treatment consistent across selection-style overlays From 20740f2cc70b95d283fdf35d9838ab3da8412136 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 26 Jan 2026 15:22:56 -0800 Subject: [PATCH 03/29] Resolve duplicate menu surface helpers after cherry-pick --- .../src/bottom_pane/selection_popup_common.rs | 30 ------------------- 1 file changed, 30 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index 099ff12d054a..89ef8b3b3f28 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -61,36 +61,6 @@ pub(crate) fn render_menu_surface(area: Rect, buf: &mut Buffer) -> Rect { menu_surface_inset(area) } -const MENU_SURFACE_INSET_V: u16 = 1; -const MENU_SURFACE_INSET_H: u16 = 2; - -/// Apply the shared "menu surface" padding used by bottom-pane overlays. -/// -/// Rendering code should generally call [`render_menu_surface`] and then lay -/// out content inside the returned inset rect. -pub(crate) fn menu_surface_inset(area: Rect) -> Rect { - area.inset(Insets::vh(MENU_SURFACE_INSET_V, MENU_SURFACE_INSET_H)) -} - -/// Total vertical padding introduced by the menu surface treatment. -pub(crate) const fn menu_surface_padding_height() -> u16 { - MENU_SURFACE_INSET_V * 2 -} - -/// Paint the shared menu background and return the inset content area. -/// -/// This keeps the surface treatment consistent across selection-style overlays -/// (for example `/model`, approvals, and request-user-input). -pub(crate) fn render_menu_surface(area: Rect, buf: &mut Buffer) -> Rect { - if area.is_empty() { - return area; - } - Block::default() - .style(user_message_style()) - .render(area, buf); - menu_surface_inset(area) -} - pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec> { use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; From 5aa2ac66d1b5a245258754089b2478d9dd6c8d7e Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 26 Jan 2026 00:43:35 -0800 Subject: [PATCH 04/29] Add composer config and shared menu surface helpers --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 2 -- .../src/bottom_pane/selection_popup_common.rs | 25 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index e6a3266d6a32..d101e042e37c 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/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index 89ef8b3b3f28..29c6f273eaa7 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -61,6 +61,31 @@ pub(crate) fn render_menu_surface(area: Rect, buf: &mut Buffer) -> Rect { menu_surface_inset(area) } +const MENU_SURFACE_INSET_V: u16 = 1; +const MENU_SURFACE_INSET_H: u16 = 2; + +/// Apply the shared "menu surface" padding used by bottom-pane overlays. +/// +/// Rendering code should generally call [`render_menu_surface`] and then lay +/// out content inside the returned inset rect. +pub(crate) fn menu_surface_inset(area: Rect) -> Rect { + area.inset(Insets::vh(MENU_SURFACE_INSET_V, MENU_SURFACE_INSET_H)) +} + +/// Paint the shared menu background and return the inset content area. +/// +/// This keeps the surface treatment consistent across selection-style overlays +/// (for example `/model`, approvals, and request-user-input). +pub(crate) fn render_menu_surface(area: Rect, buf: &mut Buffer) -> Rect { + if area.is_empty() { + return area; + } + Block::default() + .style(user_message_style()) + .render(area, buf); + menu_surface_inset(area) +} + pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec> { use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; From 8b8661d915819d6b152e39e630501a05d7aa79f4 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Mon, 26 Jan 2026 16:18:27 -0800 Subject: [PATCH 05/29] Improve ask user question tool UI footer --- .../src/bottom_pane/request_user_input/mod.rs | 85 ++++++++++++++----- .../bottom_pane/request_user_input/render.rs | 83 +++++++++--------- ...t__tests__request_user_input_freeform.snap | 5 +- ...ut__tests__request_user_input_options.snap | 9 +- codex-rs/tui/src/key_hint.rs | 10 +-- 5 files changed, 120 insertions(+), 72 deletions(-) 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 1da955ec3b70..47feea75d551 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; @@ -54,7 +55,7 @@ struct ComposerDraft { } struct AnswerState { - // Final selection for the question (always set for option questions). + // Final selection for the question (may be None when unanswered). selected: Option, // Scrollable cursor state for option navigation/highlight. option_state: ScrollState, @@ -272,6 +273,11 @@ impl RequestUserInputOverlay { } } + fn sync_composer_placeholder(&mut self) { + self.composer + .set_placeholder_text(self.notes_placeholder().to_string()); + } + /// Ensure the focus mode is valid for the current question. fn ensure_focus_available(&mut self) { if self.question_count() == 0 { @@ -288,15 +294,10 @@ impl RequestUserInputOverlay { .request .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); - } + .map(|_| { + let option_state = ScrollState::new(); AnswerState { - selected: option_state.selected_idx, + selected: None, option_state, draft: ComposerDraft::default(), } @@ -328,11 +329,16 @@ 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.option_state.clamp_selection(options_len); + answer.selected = answer.option_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(); + } } /// Ensure there is a selection before allowing notes entry. @@ -344,6 +350,7 @@ impl RequestUserInputOverlay { { self.select_current_option(); } + self.sync_composer_placeholder(); } /// Advance to next question, or submit when on the last one. @@ -362,7 +369,7 @@ 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 @@ -489,12 +496,20 @@ 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; } @@ -507,15 +522,27 @@ impl BottomPaneView for RequestUserInputOverlay { // Keep selection synchronized as the user moves. match key_event.code { KeyCode::Up => { - if let Some(answer) = self.current_answer_mut() { + let moved = if let Some(answer) = self.current_answer_mut() { answer.option_state.move_up_wrap(options_len); answer.selected = answer.option_state.selected_idx; + true + } else { + false + }; + if moved { + self.sync_composer_placeholder(); } } KeyCode::Down => { - if let Some(answer) = self.current_answer_mut() { + let moved = if let Some(answer) = self.current_answer_mut() { answer.option_state.move_down_wrap(options_len); answer.selected = answer.option_state.selected_idx; + true + } else { + false + }; + if moved { + self.sync_composer_placeholder(); } } KeyCode::Char(' ') => { @@ -548,15 +575,27 @@ impl BottomPaneView for RequestUserInputOverlay { let options_len = self.options_len(); match key_event.code { KeyCode::Up => { - if let Some(answer) = self.current_answer_mut() { + let moved = if let Some(answer) = self.current_answer_mut() { answer.option_state.move_up_wrap(options_len); answer.selected = answer.option_state.selected_idx; + true + } else { + false + }; + if moved { + self.sync_composer_placeholder(); } } KeyCode::Down => { - if let Some(answer) = self.current_answer_mut() { + let moved = if let Some(answer) = self.current_answer_mut() { answer.option_state.move_down_wrap(options_len); answer.selected = answer.option_state.selected_idx; + true + } else { + false + }; + if moved { + self.sync_composer_placeholder(); } } _ => {} @@ -747,7 +786,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,7 +804,7 @@ mod tests { }; assert_eq!(id, "turn-1"); let answer = response.answers.get("q1").expect("answer missing"); - assert_eq!(answer.answers, vec!["Option 1".to_string()]); + assert_eq!(answer.answers, Vec::::new()); } #[test] 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 b64f70434043..e101eea913ee 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 @@ -102,13 +102,7 @@ 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); + Paragraph::new(Line::from("Answer".dim())).render(sections.answer_title_area, buf); } // Build rows with selection markers for the shared selection renderer. @@ -148,7 +142,15 @@ impl RequestUserInputOverlay { } else { "Notes (optional)".to_string() }; - let notes_title = if self.focus_is_notes() { + let notes_active = if self.has_options() { + self.focus_is_notes() + && self + .current_answer() + .is_some_and(|answer| answer.selected.is_some()) + } else { + self.focus_is_notes() + }; + let notes_title = if notes_active { notes_label.as_str().cyan().bold() } else { notes_label.as_str().dim() @@ -166,10 +168,7 @@ impl RequestUserInputOverlay { .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() - ); + let warning = format!("Unanswered: {}", self.unanswered_count()); Paragraph::new(Line::from(warning.dim())).render( Rect { x: content_area.x, @@ -185,32 +184,39 @@ impl RequestUserInputOverlay { 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); + if let Some(selected_idx) = self.selected_option_index() { + let option_index = selected_idx + 1; + hint_spans.extend(vec![ + format!("Option {option_index} of {options_len}").into(), + " | ".into(), + ]); + } else { + hint_spans.extend(vec!["No option selected".into(), " | ".into()]); + } hint_spans.extend(vec![ - format!("Option {option_index} of {options_len}").into(), - " | ".into(), + key_hint::plain(KeyCode::Up).into(), + "/".into(), + key_hint::plain(KeyCode::Down).into(), + " scroll | ".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(), - ]); + let is_last_question = + self.question_count() > 0 && self.current_index() + 1 >= self.question_count(); + let enter_hint = if is_last_question { + "Enter to submit all answers" + } else { + "Enter to submit answer" + }; + hint_spans.extend(vec![enter_hint.dim(), " | ".into()]); if self.question_count() > 1 { hint_spans.extend(vec![ - key_hint::plain(KeyCode::PageUp).into(), + key_hint::ctrl(KeyCode::Char('p')).into(), " prev | ".into(), - key_hint::plain(KeyCode::PageDown).into(), + key_hint::ctrl(KeyCode::Char('n')).into(), " next | ".into(), ]); } - hint_spans.extend(vec![ - key_hint::plain(KeyCode::Esc).into(), - " interrupt".into(), - ]); + hint_spans.extend(vec!["Esc to interrupt".dim()]); Paragraph::new(Line::from(hint_spans).dim()).render( Rect { x: content_area.x, @@ -224,7 +230,16 @@ impl RequestUserInputOverlay { /// Return the cursor position when editing notes, if visible. pub(super) fn cursor_pos_impl(&self, area: Rect) -> Option<(u16, u16)> { - if !self.focus_is_notes() { + let has_options = self.has_options(); + let has_selected_option = self + .current_answer() + .is_some_and(|answer| answer.selected.is_some()); + + if !self.focus_is_notes() && !(has_options && has_selected_option) { + return None; + } + // When options exist, only show the cursor after a concrete selection. + if has_options && !has_selected_option { return None; } let content_area = menu_surface_inset(area); @@ -247,15 +262,7 @@ 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() - } } 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 dd5ced6fcaaf..ec0b6bcc8ae4 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 @@ -9,5 +9,6 @@ expression: "render_snapshot(&overlay, area)" › Type your answer (optional) - Unanswered: 1 | Will submit as skipped - ↑/↓ scroll | enter next question | esc interrupt + Unanswered: 1 + Enter to submit all answers | Esc to 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 37ed03647870..0130e8485986 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 @@ -7,13 +7,14 @@ expression: "render_snapshot(&overlay, area)" Area Choose an option. Answer - (x) Option 1 First choice. + ( ) Option 1 First choice. ( ) Option 2 Second choice. ( ) Option 3 Third choice. - Notes for Option 1 (optional) + Notes (optional) - › Add notes (optional) + › Select an option to add notes (optional) - Option 1 of 3 | ↑/↓ scroll | enter next question | esc inter + + No option selected | ↑/↓ scroll | Enter to submit all answer diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index f277f0738450..55b5dfb37dda 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -7,13 +7,13 @@ use ratatui::style::Stylize; use ratatui::text::Span; #[cfg(test)] -const ALT_PREFIX: &str = "⌥ + "; +const ALT_PREFIX: &str = "⌥+"; #[cfg(all(not(test), target_os = "macos"))] -const ALT_PREFIX: &str = "⌥ + "; +const ALT_PREFIX: &str = "⌥+"; #[cfg(all(not(test), not(target_os = "macos")))] -const ALT_PREFIX: &str = "alt + "; -const CTRL_PREFIX: &str = "ctrl + "; -const SHIFT_PREFIX: &str = "shift + "; +const ALT_PREFIX: &str = "Alt+"; +const CTRL_PREFIX: &str = "Ctrl+"; +const SHIFT_PREFIX: &str = "Shift+"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct KeyBinding { From 83605dc67007b9ee2c0efd38dbf00880a667c781 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Mon, 26 Jan 2026 17:15:37 -0800 Subject: [PATCH 06/29] Lint --- .../src/bottom_pane/selection_popup_common.rs | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index 29c6f273eaa7..89ef8b3b3f28 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -61,31 +61,6 @@ pub(crate) fn render_menu_surface(area: Rect, buf: &mut Buffer) -> Rect { menu_surface_inset(area) } -const MENU_SURFACE_INSET_V: u16 = 1; -const MENU_SURFACE_INSET_H: u16 = 2; - -/// Apply the shared "menu surface" padding used by bottom-pane overlays. -/// -/// Rendering code should generally call [`render_menu_surface`] and then lay -/// out content inside the returned inset rect. -pub(crate) fn menu_surface_inset(area: Rect) -> Rect { - area.inset(Insets::vh(MENU_SURFACE_INSET_V, MENU_SURFACE_INSET_H)) -} - -/// Paint the shared menu background and return the inset content area. -/// -/// This keeps the surface treatment consistent across selection-style overlays -/// (for example `/model`, approvals, and request-user-input). -pub(crate) fn render_menu_surface(area: Rect, buf: &mut Buffer) -> Rect { - if area.is_empty() { - return area; - } - Block::default() - .style(user_message_style()) - .render(area, buf); - menu_surface_inset(area) -} - pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec> { use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; From bda4132e4d796de9be31a77f8a58d1915245f74f Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Mon, 26 Jan 2026 17:26:43 -0800 Subject: [PATCH 07/29] Simplify notes cursor gating boolean --- codex-rs/tui/src/bottom_pane/request_user_input/render.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e101eea913ee..584fdd68771e 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 @@ -235,7 +235,7 @@ impl RequestUserInputOverlay { .current_answer() .is_some_and(|answer| answer.selected.is_some()); - if !self.focus_is_notes() && !(has_options && has_selected_option) { + if !(self.focus_is_notes() || has_options && has_selected_option) { return None; } // When options exist, only show the cursor after a concrete selection. From 9af659a2059f4a6629a12883ff9412152a9dc985 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Mon, 26 Jan 2026 18:31:06 -0800 Subject: [PATCH 08/29] Snapshot test tweaks --- .../src/bottom_pane/request_user_input/mod.rs | 56 +++++++- .../bottom_pane/request_user_input/render.rs | 120 +++++++++++++++++- ...t__tests__request_user_input_freeform.snap | 19 ++- ...quest_user_input_multi_question_first.snap | 15 +++ ...equest_user_input_multi_question_last.snap | 13 ++ ...ut__tests__request_user_input_options.snap | 31 +++-- ..._request_user_input_scrolling_options.snap | 19 ++- ...ests__request_user_input_tight_height.snap | 15 +-- 8 files changed, 235 insertions(+), 53 deletions(-) create mode 100644 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 create mode 100644 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 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 47feea75d551..c13acfbf1fd0 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 @@ -899,7 +899,7 @@ 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) @@ -916,7 +916,7 @@ mod tests { false, false, ); - let area = Rect::new(0, 0, 60, 8); + let area = Rect::new(0, 0, 120, 8); insta::assert_snapshot!( "request_user_input_tight_height", render_snapshot(&overlay, area) @@ -1022,7 +1022,7 @@ mod tests { answer.option_state.selected_idx = Some(3); answer.selected = Some(3); } - let area = Rect::new(0, 0, 68, 10); + let area = Rect::new(0, 0, 120, 10); insta::assert_snapshot!( "request_user_input_scrolling_options", render_snapshot(&overlay, area) @@ -1039,13 +1039,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, 12); + 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(); @@ -1056,6 +1103,7 @@ mod tests { false, false, ); + overlay.select_current_option(); overlay.focus = Focus::Notes; overlay .composer 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 584fdd68771e..d4ca4134f846 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 @@ -3,8 +3,11 @@ 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; @@ -200,15 +203,15 @@ impl RequestUserInputOverlay { " scroll | ".into(), ]); } - let is_last_question = - self.question_count() > 0 && self.current_index() + 1 >= self.question_count(); - let enter_hint = if is_last_question { + let question_count = self.question_count(); + let is_last_question = question_count > 0 && self.current_index() + 1 >= question_count; + let enter_hint = if question_count > 1 && is_last_question { "Enter to submit all answers" } else { "Enter to submit answer" }; hint_spans.extend(vec![enter_hint.dim(), " | ".into()]); - if self.question_count() > 1 { + if question_count > 1 { hint_spans.extend(vec![ key_hint::ctrl(KeyCode::Char('p')).into(), " prev | ".into(), @@ -217,7 +220,10 @@ impl RequestUserInputOverlay { ]); } hint_spans.extend(vec!["Esc to interrupt".dim()]); - Paragraph::new(Line::from(hint_spans).dim()).render( + let hint_line = Line::from(hint_spans).dim(); + let hint_line = + truncate_line_word_boundary_with_ellipsis(hint_line, content_area.width as usize); + Paragraph::new(hint_line).render( Rect { x: content_area.x, y: hint_y, @@ -266,3 +272,107 @@ impl RequestUserInputOverlay { matches!(self.focus, super::Focus::Notes) } } + +fn line_width(line: &Line<'_>) -> usize { + line.iter() + .map(|span| UnicodeWidthStr::width(span.content.as_ref())) + .sum() +} + +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, + } + + 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 !overflowed { + return line; + } + + 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_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 ec0b6bcc8ae4..bae2394d99bc 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,13 +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 - Enter to submit all answers | Esc to interrupt - + + Question 1/1 + Goal + Share details. + + › Type your answer (optional) + + Unanswered: 1 + Enter to submit answer | Esc to 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 000000000000..ea5b72bde57e --- /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,15 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 1/2 + Area + Choose an option. + ( ) Option 1 First choice. + ( ) Option 2 Second choice. + ( ) Option 3 Third choice. + + + Unanswered: 1 + No option selected | ↑/↓ scroll | Enter to submit answer | Ctrl+p prev | Ctrl+n next | Esc to 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 000000000000..c170d8e25714 --- /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,13 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 2/2 + Goal + Share details. + + › Type your answer (optional) + + Unanswered: 1 + Enter to submit all answers | Ctrl+p prev | Ctrl+n next | Esc to 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 0130e8485986..1c485aabba30 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,19 +2,18 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- - - Question 1/1 - Area - Choose an option. - Answer - ( ) Option 1 First choice. - ( ) Option 2 Second choice. - ( ) Option 3 Third choice. - Notes (optional) - - › Select an option to add notes (optional) - - - - - No option selected | ↑/↓ scroll | Enter to submit all answer + + Question 1/1 + Area + Choose an option. + Answer + ( ) Option 1 First choice. + ( ) Option 2 Second choice. + ( ) Option 3 Third choice. + Notes (optional) + + › Select an option to add notes (optional) + + + + No option selected | ↑/↓ scroll | Enter to submit answer | Esc to 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 ace8eb4537e1..06438a0f8923 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,12 @@ 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? + ( ) 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 | Enter to submit answer | Esc to 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 e8cd2bd22e98..a187a1d27c87 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,10 @@ 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 + Area + Choose an option. + ( ) Option 1 First choice. + + No option selected | ↑/↓ scroll | Enter to submit answer | Esc to interrupt From 8642bddc166372d367045f644c333cca27478d69 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Mon, 26 Jan 2026 22:24:53 -0800 Subject: [PATCH 09/29] Constrain Ctrl+P formatting to just questions UI --- .../tui/src/bottom_pane/request_user_input/render.rs | 8 ++++++-- codex-rs/tui/src/key_hint.rs | 10 +++++----- 2 files changed, 11 insertions(+), 7 deletions(-) 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 d4ca4134f846..38fe6a14d6b1 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 @@ -213,9 +213,9 @@ impl RequestUserInputOverlay { hint_spans.extend(vec![enter_hint.dim(), " | ".into()]); if question_count > 1 { hint_spans.extend(vec![ - key_hint::ctrl(KeyCode::Char('p')).into(), + ctrl_hint('p'), " prev | ".into(), - key_hint::ctrl(KeyCode::Char('n')).into(), + ctrl_hint('n'), " next | ".into(), ]); } @@ -279,6 +279,10 @@ fn line_width(line: &Line<'_>) -> usize { .sum() } +fn ctrl_hint(key: char) -> Span<'static> { + format!("Ctrl+{key}").dim() +} + fn truncate_line_word_boundary_with_ellipsis( line: Line<'static>, max_width: usize, diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index 55b5dfb37dda..f277f0738450 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -7,13 +7,13 @@ use ratatui::style::Stylize; use ratatui::text::Span; #[cfg(test)] -const ALT_PREFIX: &str = "⌥+"; +const ALT_PREFIX: &str = "⌥ + "; #[cfg(all(not(test), target_os = "macos"))] -const ALT_PREFIX: &str = "⌥+"; +const ALT_PREFIX: &str = "⌥ + "; #[cfg(all(not(test), not(target_os = "macos")))] -const ALT_PREFIX: &str = "Alt+"; -const CTRL_PREFIX: &str = "Ctrl+"; -const SHIFT_PREFIX: &str = "Shift+"; +const ALT_PREFIX: &str = "alt + "; +const CTRL_PREFIX: &str = "ctrl + "; +const SHIFT_PREFIX: &str = "shift + "; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct KeyBinding { From 8b894c597551c3be81138a3d95f74aa2e74b70cd Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Mon, 26 Jan 2026 22:57:18 -0800 Subject: [PATCH 10/29] Unanswered warning --- .../src/bottom_pane/request_user_input/mod.rs | 74 +++++++++++++++---- ...quest_user_input_multi_question_first.snap | 6 +- ...equest_user_input_multi_question_last.snap | 4 +- ...ut__tests__request_user_input_options.snap | 2 +- ..._request_user_input_scrolling_options.snap | 11 ++- ...ests__request_user_input_tight_height.snap | 5 +- ...s__request_user_input_wrapped_options.snap | 29 +++----- 7 files changed, 89 insertions(+), 42 deletions(-) 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 c13acfbf1fd0..3e25e7a45fcd 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 @@ -412,7 +412,7 @@ impl RequestUserInputOverlay { } } - /// Count freeform-only questions that have no notes. + /// Count questions that would submit an empty answer list. fn unanswered_count(&self) -> usize { let current_text = self.composer.current_text(); self.request @@ -421,16 +421,22 @@ impl RequestUserInputOverlay { .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 + let notes = if *idx == self.current_index() { + current_text.as_str() } else { - let notes = if *idx == self.current_index() { - current_text.as_str() - } else { - answer.draft.text.as_str() - }; - notes.trim().is_empty() + answer.draft.text.as_str() + }; + let notes_empty = notes.trim().is_empty(); + let has_options = question + .options + .as_ref() + .is_some_and(|options| !options.is_empty()); + + if has_options { + let selected_idx = answer.selected.or(answer.option_state.selected_idx); + selected_idx.is_none() && notes_empty + } else { + notes_empty } }) .count() @@ -807,6 +813,36 @@ mod tests { assert_eq!(answer.answers, Vec::::new()); } + #[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_not_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.option_state.selected_idx = Some(0); + + assert_eq!(overlay.unanswered_count(), 0); + } + #[test] fn freeform_questions_submit_empty_when_empty() { let (tx, mut rx) = test_sender(); @@ -916,7 +952,7 @@ mod tests { false, false, ); - let area = Rect::new(0, 0, 120, 8); + let area = Rect::new(0, 0, 120, 10); insta::assert_snapshot!( "request_user_input_tight_height", render_snapshot(&overlay, area) @@ -952,7 +988,7 @@ mod tests { #[test] fn request_user_input_wrapped_options_snapshot() { let (tx, _rx) = test_sender(); - let overlay = RequestUserInputOverlay::new( + let mut overlay = RequestUserInputOverlay::new( request_event( "turn-1", vec![question_with_wrapped_options("q1", "Next Step")], @@ -963,13 +999,19 @@ mod tests { false, ); - let width = 52u16; + { + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.option_state.selected_idx = Some(0); + answer.selected = 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", @@ -1022,7 +1064,7 @@ mod tests { answer.option_state.selected_idx = Some(3); answer.selected = Some(3); } - let area = Rect::new(0, 0, 120, 10); + let area = Rect::new(0, 0, 120, 12); insta::assert_snapshot!( "request_user_input_scrolling_options", render_snapshot(&overlay, area) @@ -1062,7 +1104,7 @@ mod tests { false, false, ); - let area = Rect::new(0, 0, 120, 12); + let area = Rect::new(0, 0, 120, 14); insta::assert_snapshot!( "request_user_input_multi_question_first", render_snapshot(&overlay, area) 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 index ea5b72bde57e..aae15a1ee4b7 100644 --- 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 @@ -3,13 +3,15 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- - Question 1/2 Area Choose an option. + Answer ( ) Option 1 First choice. ( ) Option 2 Second choice. ( ) Option 3 Third choice. + Notes (optional) + › Select an option to add notes (optional) - Unanswered: 1 + Unanswered: 2 No option selected | ↑/↓ scroll | Enter to submit answer | Ctrl+p prev | Ctrl+n next | Esc to 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 index c170d8e25714..5a44e8054923 100644 --- 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 @@ -9,5 +9,7 @@ expression: "render_snapshot(&overlay, area)" › Type your answer (optional) - Unanswered: 1 + + + Unanswered: 2 Enter to submit all answers | Ctrl+p prev | Ctrl+n next | Esc to 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 1c485aabba30..c24e2d10d715 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 @@ -15,5 +15,5 @@ expression: "render_snapshot(&overlay, area)" › Select an option to add notes (optional) - + Unanswered: 1 No option selected | ↑/↓ scroll | Enter to submit answer | Esc to 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 06438a0f8923..1ad710894671 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 @@ -3,11 +3,14 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- - Question 1/1 Next Step What would you like to do next? - ( ) 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. + ( ) 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. + ( ) Ship it Finalize and open a PR. + + › Select an option to add notes (optional) Option 4 of 5 | ↑/↓ scroll | Enter to submit answer | Esc to 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 a187a1d27c87..e204a0e90bfa 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 @@ -3,9 +3,12 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- - Question 1/1 Area Choose an option. ( ) Option 1 First choice. + ( ) Option 2 Second choice. + ( ) Option 3 Third choice. + + › Select an option to add notes (optional) No option selected | ↑/↓ scroll | Enter to submit answer | Esc to 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 4ae9dd048ff9..63447ab49df0 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,15 @@ 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 + + Next Step + Choose the next step for this task. + Answer + (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. + Notes for Discuss a code change (optional) + + › Select an option to add notes (optional) + + Option 1 of 3 | ↑/↓ scroll | Enter to submit answer | Esc to interrupt From 86f79b06e0e999b83e5429938075fec9f56928ad Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Mon, 26 Jan 2026 23:05:37 -0800 Subject: [PATCH 11/29] Last question enter guard --- .../src/bottom_pane/request_user_input/mod.rs | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) 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 3e25e7a45fcd..7f37399e1c34 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 @@ -555,7 +555,11 @@ impl BottomPaneView for RequestUserInputOverlay { self.select_current_option(); } KeyCode::Enter => { - self.select_current_option(); + let is_last_question = self.current_index() + 1 >= self.question_count(); + let has_selection = self.selected_option_index().is_some(); + if !is_last_question || has_selection { + self.select_current_option(); + } self.go_next_or_submit(); } KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete => { @@ -813,6 +817,27 @@ mod tests { assert_eq!(answer.answers, Vec::::new()); } + #[test] + fn enter_does_not_auto_select_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::::new()); + } + #[test] fn skipped_option_questions_count_as_unanswered() { let (tx, _rx) = test_sender(); From 31d8ab4efdd4fa7b6b93bd9e20ea1aa878ed28c2 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Mon, 26 Jan 2026 23:33:07 -0800 Subject: [PATCH 12/29] Fix test --- codex-rs/tui/src/bottom_pane/request_user_input/mod.rs | 2 +- ...r_input__tests__request_user_input_multi_question_first.snap | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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 7f37399e1c34..65069bd9ed65 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 @@ -1129,7 +1129,7 @@ mod tests { false, false, ); - let area = Rect::new(0, 0, 120, 14); + let area = Rect::new(0, 0, 120, 15); insta::assert_snapshot!( "request_user_input_multi_question_first", render_snapshot(&overlay, area) 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 index aae15a1ee4b7..c4b97f562f94 100644 --- 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 @@ -3,6 +3,7 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- + Question 1/2 Area Choose an option. Answer From 067f38d2c86f797c772330615a4e1626bed6fb66 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 27 Jan 2026 00:25:11 -0800 Subject: [PATCH 13/29] Only show notes upon tab --- .../bottom_pane/request_user_input/layout.rs | 38 +++-- .../src/bottom_pane/request_user_input/mod.rs | 149 +++++++++++++++++- .../bottom_pane/request_user_input/render.rs | 40 +++-- ...quest_user_input_multi_question_first.snap | 4 - ...ut__tests__request_user_input_options.snap | 5 - ...uest_user_input_options_notes_visible.snap | 19 +++ ..._request_user_input_scrolling_options.snap | 7 +- ...ests__request_user_input_tight_height.snap | 5 +- ...s__request_user_input_wrapped_options.snap | 7 +- 9 files changed, 230 insertions(+), 44 deletions(-) create mode 100644 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 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 4a1817da89dd..72c95b14cfae 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 @@ -20,6 +20,7 @@ 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 notes_visible = !has_options || self.notes_ui_visible(); let footer_pref = if self.unanswered_count() > 0 { 2 } else { 1 }; let notes_pref_height = self.notes_input_height(area.width); let mut question_lines = self.wrapped_question_lines(area.width); @@ -40,6 +41,7 @@ impl RequestUserInputOverlay { question_height, notes_pref_height, footer_pref, + notes_visible, &mut question_lines, ) } else { @@ -98,6 +100,7 @@ impl RequestUserInputOverlay { question_height: u16, notes_pref_height: u16, footer_pref: u16, + notes_visible: bool, question_lines: &mut Vec, ) -> (u16, u16, u16, u16, u16, u16, u16) { let options_required_height = self.options_required_height(width); @@ -120,6 +123,7 @@ impl RequestUserInputOverlay { options_required_height, notes_pref_height, footer_pref, + notes_visible, ) } } @@ -143,8 +147,8 @@ impl RequestUserInputOverlay { (adjusted_question_height, 0, 0, 0, 0, options_height, 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 + labels first, then + /// progress, and only allocate notes when explicitly visible. fn layout_with_options_normal( &self, available_height: u16, @@ -152,6 +156,7 @@ impl RequestUserInputOverlay { options_required_height: u16, notes_pref_height: u16, footer_pref: u16, + notes_visible: bool, ) -> (u16, u16, u16, u16, u16, u16, u16) { let options_height = options_required_height; let used = 1u16 @@ -159,10 +164,6 @@ impl RequestUserInputOverlay { .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); @@ -172,19 +173,34 @@ impl RequestUserInputOverlay { remaining = remaining.saturating_sub(1); } - let mut notes_title_height = 0; + let mut progress_height = 0; if remaining > 0 { - notes_title_height = 1; + progress_height = 1; remaining = remaining.saturating_sub(1); } - let mut progress_height = 0; + if !notes_visible { + return ( + question_height, + progress_height, + answer_title_height, + 0, + 0, + options_height, + footer_lines, + ); + } + + // Prefer notes next, then labels, with any leftover rows expanding notes. + let mut notes_height = notes_pref_height.min(remaining); + remaining = remaining.saturating_sub(notes_height); + + let mut notes_title_height = 0; if remaining > 0 { - progress_height = 1; + notes_title_height = 1; remaining = remaining.saturating_sub(1); } - // Expand the notes composer with any leftover rows. notes_height = notes_height.saturating_add(remaining); ( 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 65069bd9ed65..43681e8eee62 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 @@ -61,6 +61,8 @@ struct AnswerState { option_state: ScrollState, // Per-question notes draft. draft: ComposerDraft, + // Whether the notes UI has been explicitly opened for this question. + notes_visible: bool, } pub(crate) struct RequestUserInputOverlay { @@ -167,6 +169,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| { @@ -238,8 +257,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; + } } } @@ -285,6 +308,9 @@ impl RequestUserInputOverlay { } if !self.has_options() { self.focus = Focus::Notes; + if let Some(answer) = self.current_answer_mut() { + answer.notes_visible = true; + } } } @@ -294,12 +320,16 @@ impl RequestUserInputOverlay { .request .questions .iter() - .map(|_| { - let option_state = ScrollState::new(); + .map(|question| { + let has_options = question + .options + .as_ref() + .is_some_and(|options| !options.is_empty()); AnswerState { selected: None, - option_state, + option_state: ScrollState::new(), draft: ComposerDraft::default(), + notes_visible: !has_options, } }) .collect(); @@ -350,6 +380,9 @@ impl RequestUserInputOverlay { { self.select_current_option(); } + if let Some(answer) = self.current_answer_mut() { + answer.notes_visible = true; + } self.sync_composer_placeholder(); } @@ -496,6 +529,21 @@ impl BottomPaneView for RequestUserInputOverlay { } if matches!(key_event.code, KeyCode::Esc) { + if self.has_options() && matches!(self.focus, Focus::Notes) { + // Let the composer flush any pending paste-burst state (e.g., held first char). + let _ = self + .composer + .handle_key_event(KeyEvent::from(KeyCode::Left)); + self.composer.move_cursor_to_end(); + let notes_empty = self.composer.current_text_with_pending().trim().is_empty(); + self.save_current_draft(); + if notes_empty && let Some(answer) = self.current_answer_mut() { + answer.notes_visible = false; + } + self.focus = Focus::Options; + self.sync_composer_placeholder(); + return; + } self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); self.done = true; return; @@ -554,6 +602,12 @@ impl BottomPaneView for RequestUserInputOverlay { KeyCode::Char(' ') => { self.select_current_option(); } + KeyCode::Tab => { + if self.selected_option_index().is_some() { + self.focus = Focus::Notes; + self.ensure_selected_for_notes(); + } + } KeyCode::Enter => { let is_last_question = self.current_index() + 1 >= self.question_count(); let has_selection = self.selected_option_index().is_some(); @@ -838,6 +892,71 @@ mod tests { assert_eq!(answer.answers, Vec::::new()); } + #[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.option_state.selected_idx = Some(1); + answer.selected = 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 esc_in_notes_mode_returns_to_options_without_interrupt() { + 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.option_state.selected_idx = Some(0); + answer.selected = Some(0); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Tab)); + overlay.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + assert!(matches!(overlay.focus, Focus::Options)); + assert_eq!(overlay.notes_ui_visible(), false); + assert_eq!(overlay.done, false); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn esc_keeps_notes_visible_when_notes_present() { + 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.option_state.selected_idx = Some(0); + answer.selected = 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!(matches!(overlay.focus, Focus::Options)); + assert_eq!(overlay.notes_ui_visible(), true); + } + #[test] fn skipped_option_questions_count_as_unanswered() { let (tx, _rx) = test_sender(); @@ -967,6 +1086,30 @@ mod tests { ); } + #[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.option_state.selected_idx = Some(0); + answer.selected = 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(); 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 38fe6a14d6b1..2ec17e8c3731 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 @@ -25,7 +25,12 @@ impl Renderable for RequestUserInputOverlay { let inner_width = inner.width.max(1); 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 notes_visible = !self.has_options() || self.notes_ui_visible(); + let notes_height = if notes_visible { + self.notes_input_height(inner_width) as usize + } else { + 0 + }; let footer_height = if self.unanswered_count() > 0 { 2 } else { 1 }; // Tight minimum height: progress + header + question + (optional) titles/options @@ -36,9 +41,10 @@ impl Renderable for RequestUserInputOverlay { .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 + height = height.saturating_add(1); // answer title + if notes_visible { + height = height.saturating_add(1); // notes title + } } height = height.saturating_add(menu_surface_padding_height() as usize); height.max(8) as u16 @@ -66,6 +72,7 @@ impl RequestUserInputOverlay { return; } let sections = self.layout_sections(content_area); + let notes_visible = self.notes_ui_visible(); // Progress header keeps the user oriented across multiple questions. let progress_line = if self.question_count() > 0 { @@ -131,7 +138,7 @@ impl RequestUserInputOverlay { } } - 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() @@ -161,7 +168,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); } @@ -202,6 +209,12 @@ impl RequestUserInputOverlay { key_hint::plain(KeyCode::Down).into(), " scroll | ".into(), ]); + if self.selected_option_index().is_some() && !notes_visible { + hint_spans.extend(vec![ + key_hint::plain(KeyCode::Tab).into(), + " add notes | ".into(), + ]); + } } let question_count = self.question_count(); let is_last_question = question_count > 0 && self.current_index() + 1 >= question_count; @@ -219,7 +232,15 @@ impl RequestUserInputOverlay { " next | ".into(), ]); } - hint_spans.extend(vec!["Esc to interrupt".dim()]); + if self.has_options() && notes_visible && self.focus_is_notes() { + hint_spans.extend(vec!["Notes optional | ".dim()]); + } + let esc_hint = if self.has_options() && notes_visible && self.focus_is_notes() { + "Esc to change answer" + } else { + "Esc to interrupt" + }; + hint_spans.extend(vec![esc_hint.dim()]); let hint_line = Line::from(hint_spans).dim(); let hint_line = truncate_line_word_boundary_with_ellipsis(hint_line, content_area.width as usize); @@ -237,15 +258,16 @@ impl RequestUserInputOverlay { /// 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(); let has_selected_option = self .current_answer() .is_some_and(|answer| answer.selected.is_some()); - if !(self.focus_is_notes() || has_options && has_selected_option) { + if !self.focus_is_notes() { return None; } // When options exist, only show the cursor after a concrete selection. - if has_options && !has_selected_option { + if has_options && (!notes_visible || !has_selected_option) { return None; } let content_area = menu_surface_inset(area); 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 index c4b97f562f94..88b4fa9a50ad 100644 --- 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 @@ -10,9 +10,5 @@ expression: "render_snapshot(&overlay, area)" ( ) Option 1 First choice. ( ) Option 2 Second choice. ( ) Option 3 Third choice. - Notes (optional) - - › Select an option to add notes (optional) - Unanswered: 2 No option selected | ↑/↓ scroll | Enter to submit answer | Ctrl+p prev | Ctrl+n next | Esc to 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 c24e2d10d715..87c473afc668 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 @@ -10,10 +10,5 @@ expression: "render_snapshot(&overlay, area)" ( ) Option 1 First choice. ( ) Option 2 Second choice. ( ) Option 3 Third choice. - Notes (optional) - - › Select an option to add notes (optional) - - Unanswered: 1 No option selected | ↑/↓ scroll | Enter to submit answer | Esc to 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 000000000000..71a1c5a49e16 --- /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. + 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 to submit answer | Notes optional | Esc to change answer 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 1ad710894671..6960cb76fa0c 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 @@ -3,14 +3,13 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- + Question 1/1 Next Step What would you like to do next? + Answer ( ) 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. ( ) Ship it Finalize and open a PR. - - › Select an option to add notes (optional) - - Option 4 of 5 | ↑/↓ scroll | Enter to submit answer | Esc to interrupt + Option 4 of 5 | ↑/↓ scroll | tab add notes | Enter to submit answer | Esc to 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 e204a0e90bfa..51340b9be916 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 @@ -5,10 +5,9 @@ expression: "render_snapshot(&overlay, area)" Area Choose an option. + Answer ( ) Option 1 First choice. ( ) Option 2 Second choice. ( ) Option 3 Third choice. - - › Select an option to add notes (optional) - + Unanswered: 1 No option selected | ↑/↓ scroll | Enter to submit answer | Esc to 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 63447ab49df0..77208a7f0558 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 @@ -3,14 +3,11 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- + Question 1/1 Next Step Choose the next step for this task. Answer (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. - Notes for Discuss a code change (optional) - - › Select an option to add notes (optional) - - Option 1 of 3 | ↑/↓ scroll | Enter to submit answer | Esc to interrupt + Option 1 of 3 | ↑/↓ scroll | tab add notes | Enter to submit answer | Esc to interrupt From 3a8f57d8614f073a5e498878c5b79465acc7345a Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 27 Jan 2026 01:21:02 -0800 Subject: [PATCH 14/29] Remove answer label --- .../bottom_pane/request_user_input/layout.rs | 78 ++++++------------- .../bottom_pane/request_user_input/render.rs | 39 +++------- ...t__tests__request_user_input_freeform.snap | 4 +- ...quest_user_input_multi_question_first.snap | 4 +- ...equest_user_input_multi_question_last.snap | 4 +- ...ut__tests__request_user_input_options.snap | 4 +- ...uest_user_input_options_notes_visible.snap | 2 +- ..._request_user_input_scrolling_options.snap | 3 +- ...ests__request_user_input_tight_height.snap | 3 +- ...s__request_user_input_wrapped_options.snap | 3 +- 10 files changed, 45 insertions(+), 99 deletions(-) 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 72c95b14cfae..ae27ee8fb9df 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 @@ -6,7 +6,6 @@ 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, @@ -21,7 +20,7 @@ impl RequestUserInputOverlay { pub(super) fn layout_sections(&self, area: Rect) -> LayoutSections { let has_options = self.has_options(); let notes_visible = !has_options || self.notes_ui_visible(); - let footer_pref = if self.unanswered_count() > 0 { 2 } else { 1 }; + let footer_pref = 1; 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; @@ -29,7 +28,6 @@ impl RequestUserInputOverlay { let ( question_height, progress_height, - answer_title_height, notes_title_height, notes_height, options_height, @@ -54,31 +52,22 @@ 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, + options_height, + notes_title_height, + notes_height, + }, + ); LayoutSections { progress_area, header_area, question_area, - answer_title_area, question_lines, options_area, notes_title_area, @@ -92,7 +81,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, notes_title_height, notes_height, options_height, footer_lines) fn layout_with_options( &self, available_height: u16, @@ -102,7 +91,7 @@ impl RequestUserInputOverlay { footer_pref: u16, notes_visible: bool, question_lines: &mut Vec, - ) -> (u16, u16, u16, u16, u16, u16, u16) { + ) -> (u16, u16, u16, u16, u16, u16) { let options_required_height = self.options_required_height(width); let min_options_height = 1u16; let required = 1u16 @@ -136,7 +125,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) { let max_question_height = available_height.saturating_sub(1u16.saturating_add(min_options_height)); let adjusted_question_height = question_height.min(max_question_height); @@ -144,11 +133,11 @@ 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, 0, options_height, 0) } - /// Normal layout for options case: allocate footer + labels first, then - /// progress, and only allocate notes when explicitly visible. + /// 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, @@ -157,7 +146,7 @@ impl RequestUserInputOverlay { notes_pref_height: u16, footer_pref: u16, notes_visible: bool, - ) -> (u16, u16, u16, u16, u16, u16, u16) { + ) -> (u16, u16, u16, u16, u16, u16) { let options_height = options_required_height; let used = 1u16 .saturating_add(question_height) @@ -167,12 +156,6 @@ impl RequestUserInputOverlay { 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 progress_height = 0; if remaining > 0 { progress_height = 1; @@ -183,7 +166,6 @@ impl RequestUserInputOverlay { return ( question_height, progress_height, - answer_title_height, 0, 0, options_height, @@ -206,7 +188,6 @@ impl RequestUserInputOverlay { ( question_height, progress_height, - answer_title_height, notes_title_height, notes_height, options_height, @@ -219,7 +200,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, notes_title_height, notes_height, options_height, footer_lines) fn layout_without_options( &self, available_height: u16, @@ -227,7 +208,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) { let required = 1u16.saturating_add(question_height); if required > available_height { self.layout_without_options_tight(available_height, question_height, question_lines) @@ -247,12 +228,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) { 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) } /// Normal layout for no-options case: allocate space for notes, footer, and progress. @@ -262,7 +243,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) { let required = 1u16.saturating_add(question_height); let mut remaining = available_height.saturating_sub(required); let mut notes_height = notes_pref_height.min(remaining); @@ -283,7 +264,6 @@ impl RequestUserInputOverlay { question_height, progress_height, 0, - 0, notes_height, 0, footer_lines, @@ -299,7 +279,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 @@ -328,13 +307,6 @@ impl RequestUserInputOverlay { }; cursor_y = cursor_y.saturating_add(heights.question_height); - 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, @@ -361,7 +333,6 @@ impl RequestUserInputOverlay { progress_area, header_area, question_area, - answer_title_area, options_area, notes_title_area, notes_area, @@ -373,7 +344,6 @@ impl RequestUserInputOverlay { struct LayoutHeights { progress_height: u16, question_height: u16, - answer_title_height: u16, options_height: u16, notes_title_height: u16, notes_height: u16, 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 2ec17e8c3731..dd65ea02ef74 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 @@ -31,7 +31,7 @@ impl Renderable for RequestUserInputOverlay { } else { 0 }; - let footer_height = if self.unanswered_count() > 0 { 2 } else { 1 }; + let footer_height = 1usize; // Tight minimum height: progress + header + question + (optional) titles/options // + notes composer + footer + menu padding. @@ -40,11 +40,8 @@ impl Renderable for RequestUserInputOverlay { .saturating_add(notes_height) .saturating_add(footer_height) .saturating_add(2); // progress + header - if self.has_options() { - height = height.saturating_add(1); // answer title - if notes_visible { - height = height.saturating_add(1); // notes title - } + if self.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 @@ -73,12 +70,18 @@ impl RequestUserInputOverlay { } 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()) }; @@ -111,10 +114,6 @@ impl RequestUserInputOverlay { ); } - if sections.answer_title_area.height > 0 { - Paragraph::new(Line::from("Answer".dim())).render(sections.answer_title_area, buf); - } - // Build rows with selection markers for the shared selection renderer. let option_rows = self.option_rows(); @@ -176,19 +175,6 @@ 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: {}", 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 hint_y = footer_y.saturating_add(sections.footer_lines.saturating_sub(1)); // Footer hints (selection index + navigation keys). let mut hint_spans = Vec::new(); @@ -210,10 +196,7 @@ impl RequestUserInputOverlay { " scroll | ".into(), ]); if self.selected_option_index().is_some() && !notes_visible { - hint_spans.extend(vec![ - key_hint::plain(KeyCode::Tab).into(), - " add notes | ".into(), - ]); + hint_spans.extend(vec!["Tab".dim(), " add notes | ".into()]); } } let question_count = self.question_count(); 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 bae2394d99bc..372a9dd690ab 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 @@ -3,11 +3,11 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- - Question 1/1 + Question 1/1 (1 unanswered) Goal Share details. › Type your answer (optional) - Unanswered: 1 + Enter to submit answer | Esc to 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 index 88b4fa9a50ad..67629c81086e 100644 --- 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 @@ -3,12 +3,10 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- - Question 1/2 + Question 1/2 (2 unanswered) Area Choose an option. - Answer ( ) Option 1 First choice. ( ) Option 2 Second choice. ( ) Option 3 Third choice. - Unanswered: 2 No option selected | ↑/↓ scroll | Enter to submit answer | Ctrl+p prev | Ctrl+n next | Esc to 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 index 5a44e8054923..021d2e7138d3 100644 --- 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 @@ -3,7 +3,7 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- - Question 2/2 + Question 2/2 (2 unanswered) Goal Share details. @@ -11,5 +11,5 @@ expression: "render_snapshot(&overlay, area)" - Unanswered: 2 + Enter to submit all answers | Ctrl+p prev | Ctrl+n next | Esc to 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 87c473afc668..40a8e671cbdf 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 @@ -3,12 +3,10 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- - Question 1/1 + Question 1/1 (1 unanswered) Area Choose an option. - Answer ( ) Option 1 First choice. ( ) Option 2 Second choice. ( ) Option 3 Third choice. - Unanswered: 1 No option selected | ↑/↓ scroll | Enter to submit answer | Esc to 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 index 71a1c5a49e16..4a0868f6075f 100644 --- 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 @@ -6,7 +6,6 @@ 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. @@ -16,4 +15,5 @@ expression: "render_snapshot(&overlay, area)" + Option 1 of 3 | ↑/↓ scroll | Enter to submit answer | Notes optional | Esc to change answer 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 6960cb76fa0c..258bcd7411e2 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 @@ -6,10 +6,9 @@ expression: "render_snapshot(&overlay, area)" Question 1/1 Next Step What would you like to do next? - Answer ( ) 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. ( ) Ship it Finalize and open a PR. - Option 4 of 5 | ↑/↓ scroll | tab add notes | Enter to submit answer | Esc to interrupt + Option 4 of 5 | ↑/↓ scroll | Tab add notes | Enter to submit answer | Esc to 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 51340b9be916..40a8e671cbdf 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 @@ -3,11 +3,10 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- + Question 1/1 (1 unanswered) Area Choose an option. - Answer ( ) Option 1 First choice. ( ) Option 2 Second choice. ( ) Option 3 Third choice. - Unanswered: 1 No option selected | ↑/↓ scroll | Enter to submit answer | Esc to 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 77208a7f0558..e2a5daae5703 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 @@ -6,8 +6,7 @@ expression: "render_snapshot(&overlay, area)" Question 1/1 Next Step Choose the next step for this task. - Answer (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 to submit answer | Esc to interrupt + Option 1 of 3 | ↑/↓ scroll | Tab add notes | Enter to submit answer | Esc to interrupt From 0367a2dcc62ede0ce8c103e04b6389614c73e410 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 27 Jan 2026 09:02:32 -0800 Subject: [PATCH 15/29] Add spacing to multiple choice UI --- .../bottom_pane/request_user_input/layout.rs | 70 +++++++++++++++---- .../src/bottom_pane/request_user_input/mod.rs | 27 +++++++ .../bottom_pane/request_user_input/render.rs | 10 +-- ...t__tests__request_user_input_freeform.snap | 2 +- ...quest_user_input_multi_question_first.snap | 4 +- ...equest_user_input_multi_question_last.snap | 2 +- ...ut__tests__request_user_input_options.snap | 4 +- ...uest_user_input_options_notes_visible.snap | 2 +- ...st_user_input_options_notes_with_text.snap | 21 ++++++ ..._request_user_input_scrolling_options.snap | 3 +- ...ests__request_user_input_tight_height.snap | 3 +- ...s__request_user_input_wrapped_options.snap | 4 +- 12 files changed, 124 insertions(+), 28 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options_notes_with_text.snap 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 ae27ee8fb9df..e6013139fea7 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 @@ -28,9 +28,11 @@ impl RequestUserInputOverlay { let ( question_height, progress_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( @@ -58,7 +60,9 @@ impl RequestUserInputOverlay { LayoutHeights { progress_height, question_height, + spacer_after_question, options_height, + spacer_after_options, notes_title_height, notes_height, }, @@ -81,7 +85,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, 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, @@ -91,7 +95,7 @@ impl RequestUserInputOverlay { footer_pref: u16, notes_visible: bool, question_lines: &mut Vec, - ) -> (u16, u16, u16, u16, u16, u16) { + ) -> (u16, u16, u16, u16, u16, u16, u16, u16) { let options_required_height = self.options_required_height(width); let min_options_height = 1u16; let required = 1u16 @@ -125,7 +129,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) { let max_question_height = available_height.saturating_sub(1u16.saturating_add(min_options_height)); let adjusted_question_height = question_height.min(max_question_height); @@ -133,7 +137,7 @@ impl RequestUserInputOverlay { let options_height = available_height.saturating_sub(1u16.saturating_add(adjusted_question_height)); - (adjusted_question_height, 0, 0, 0, options_height, 0) + (adjusted_question_height, 0, 0, options_height, 0, 0, 0, 0) } /// Normal layout for options case: allocate footer + progress first, and @@ -146,13 +150,28 @@ impl RequestUserInputOverlay { notes_pref_height: u16, footer_pref: u16, notes_visible: bool, - ) -> (u16, u16, u16, u16, u16, u16) { - let options_height = options_required_height; + ) -> (u16, u16, u16, u16, u16, u16, u16, u16) { + let min_options_height = 1u16; + let mut options_height = options_required_height; let used = 1u16 .saturating_add(question_height) .saturating_add(options_height); let mut remaining = available_height.saturating_sub(used); + // When notes are hidden, prefer to reserve room for progress, footer, + // and at least one spacer by shrinking the options window if needed. + let desired_spacers = if notes_visible { 0 } else { 1 }; + 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 footer_lines = footer_pref.min(remaining); remaining = remaining.saturating_sub(footer_lines); @@ -163,17 +182,30 @@ impl RequestUserInputOverlay { } if !notes_visible { + let mut spacer_after_question = 0; + if remaining > 0 { + spacer_after_question = 1; + remaining = remaining.saturating_sub(1); + } + let mut spacer_after_options = 0; + if remaining > 0 { + spacer_after_options = 1; + } return ( question_height, progress_height, + spacer_after_question, + options_height, + spacer_after_options, 0, 0, - options_height, 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); @@ -188,9 +220,11 @@ impl RequestUserInputOverlay { ( question_height, progress_height, + spacer_after_question, + options_height, + spacer_after_options, notes_title_height, notes_height, - options_height, footer_lines, ) } @@ -200,7 +234,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, 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, @@ -208,7 +242,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) { let required = 1u16.saturating_add(question_height); if required > available_height { self.layout_without_options_tight(available_height, question_height, question_lines) @@ -228,12 +262,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) { 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) + (adjusted_question_height, 0, 0, 0, 0, 0, 0, 0) } /// Normal layout for no-options case: allocate space for notes, footer, and progress. @@ -243,7 +277,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) { let required = 1u16.saturating_add(question_height); let mut remaining = available_height.saturating_sub(required); let mut notes_height = notes_pref_height.min(remaining); @@ -264,8 +298,10 @@ impl RequestUserInputOverlay { question_height, progress_height, 0, - notes_height, 0, + 0, + 0, + notes_height, footer_lines, ) } @@ -306,6 +342,7 @@ 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 options_area = Rect { x: area.x, @@ -314,6 +351,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, @@ -344,7 +382,9 @@ impl RequestUserInputOverlay { struct LayoutHeights { progress_height: u16, question_height: u16, + spacer_after_question: u16, options_height: u16, + spacer_after_options: u16, notes_title_height: u16, notes_height: 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 43681e8eee62..89deae57df8f 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 @@ -1110,6 +1110,33 @@ mod tests { ); } + #[test] + fn request_user_input_options_notes_with_text_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.option_state.selected_idx = Some(0); + answer.selected = Some(0); + } + overlay.handle_key_event(KeyEvent::from(KeyCode::Tab)); + overlay + .composer + .handle_paste("Include notes about follow-up tests.".to_string()); + + let area = Rect::new(0, 0, 120, 18); + insta::assert_snapshot!( + "request_user_input_options_notes_with_text", + render_snapshot(&overlay, area) + ); + } + #[test] fn request_user_input_tight_height_snapshot() { let (tx, _rx) = test_sender(); 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 dd65ea02ef74..9f5e09f7b007 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 @@ -196,15 +196,15 @@ impl RequestUserInputOverlay { " scroll | ".into(), ]); if self.selected_option_index().is_some() && !notes_visible { - hint_spans.extend(vec!["Tab".dim(), " add notes | ".into()]); + hint_spans.extend(vec!["Tab".dim(), ": add notes | ".into()]); } } let question_count = self.question_count(); let is_last_question = question_count > 0 && self.current_index() + 1 >= question_count; let enter_hint = if question_count > 1 && is_last_question { - "Enter to submit all answers" + "Enter: submit all answers" } else { - "Enter to submit answer" + "Enter: submit answer" }; hint_spans.extend(vec![enter_hint.dim(), " | ".into()]); if question_count > 1 { @@ -219,9 +219,9 @@ impl RequestUserInputOverlay { hint_spans.extend(vec!["Notes optional | ".dim()]); } let esc_hint = if self.has_options() && notes_visible && self.focus_is_notes() { - "Esc to change answer" + "Esc: change answer" } else { - "Esc to interrupt" + "Esc: interrupt" }; hint_spans.extend(vec![esc_hint.dim()]); let hint_line = Line::from(hint_spans).dim(); 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 372a9dd690ab..3e947c015bda 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 @@ -10,4 +10,4 @@ expression: "render_snapshot(&overlay, area)" › Type your answer (optional) - Enter to submit answer | Esc to interrupt + 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 index 67629c81086e..002441104e25 100644 --- 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 @@ -6,7 +6,9 @@ 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. - No option selected | ↑/↓ scroll | Enter to submit answer | Ctrl+p prev | Ctrl+n next | Esc to interrupt + + No option selected | ↑/↓ scroll | Enter: submit answer | Ctrl+p prev | 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 index 021d2e7138d3..d3db040d74cf 100644 --- 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 @@ -12,4 +12,4 @@ expression: "render_snapshot(&overlay, area)" - Enter to submit all answers | Ctrl+p prev | Ctrl+n next | Esc to interrupt + Enter: submit all answers | Ctrl+p prev | 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 40a8e671cbdf..8bf5dbd1b1ba 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 @@ -6,7 +6,9 @@ expression: "render_snapshot(&overlay, area)" Question 1/1 (1 unanswered) Area Choose an option. + ( ) Option 1 First choice. ( ) Option 2 Second choice. ( ) Option 3 Third choice. - No option selected | ↑/↓ scroll | Enter to submit answer | Esc to interrupt + + No option selected | ↑/↓ scroll | 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 index 4a0868f6075f..17c8c91d7983 100644 --- 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 @@ -16,4 +16,4 @@ expression: "render_snapshot(&overlay, area)" - Option 1 of 3 | ↑/↓ scroll | Enter to submit answer | Notes optional | Esc to change answer + Option 1 of 3 | ↑/↓ scroll | Enter: submit answer | Notes optional | Esc: change answer 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_with_text.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_with_text.snap new file mode 100644 index 000000000000..9625e0acfa77 --- /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_with_text.snap @@ -0,0 +1,21 @@ +--- +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 (optional) + + › Include notes about follow-up tests. + + + + + + + Option 1 of 3 | ↑/↓ scroll | Enter: submit answer | Notes optional | Esc: change answer 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 258bcd7411e2..3c289d013fd1 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 @@ -6,9 +6,10 @@ expression: "render_snapshot(&overlay, area)" 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. ( ) Ship it Finalize and open a PR. - Option 4 of 5 | ↑/↓ scroll | Tab add notes | Enter to submit answer | Esc to interrupt + 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 40a8e671cbdf..537b897900de 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 @@ -6,7 +6,8 @@ expression: "render_snapshot(&overlay, area)" Question 1/1 (1 unanswered) Area Choose an option. + ( ) Option 1 First choice. ( ) Option 2 Second choice. ( ) Option 3 Third choice. - No option selected | ↑/↓ scroll | Enter to submit answer | Esc to interrupt + No option selected | ↑/↓ scroll | 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 e2a5daae5703..0d1e7274223a 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 @@ -6,7 +6,9 @@ expression: "render_snapshot(&overlay, area)" 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 to submit answer | Esc to interrupt + + Option 1 of 3 | ↑/↓ scroll | Tab: add notes | Enter: submit answer | Esc: interrupt From b1799d232f3fdff7a4036fd2e3051765ce205c3e Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 27 Jan 2026 09:26:54 -0800 Subject: [PATCH 16/29] Reserve spacer below options before footer --- .../bottom_pane/request_user_input/layout.rs | 18 ++++++++++-------- ...__request_user_input_scrolling_options.snap | 2 +- ...tests__request_user_input_tight_height.snap | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) 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 e6013139fea7..ae3b15f26277 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 @@ -172,9 +172,6 @@ impl RequestUserInputOverlay { remaining = remaining.saturating_add(reduce_by); } - let footer_lines = footer_pref.min(remaining); - remaining = remaining.saturating_sub(footer_lines); - let mut progress_height = 0; if remaining > 0 { progress_height = 1; @@ -182,14 +179,16 @@ impl RequestUserInputOverlay { } if !notes_visible { - let mut spacer_after_question = 0; - if remaining > 0 { - spacer_after_question = 1; + let mut spacer_after_options = 0; + if remaining > footer_pref { + spacer_after_options = 1; remaining = remaining.saturating_sub(1); } - let mut spacer_after_options = 0; + let footer_lines = footer_pref.min(remaining); + remaining = remaining.saturating_sub(footer_lines); + let mut spacer_after_question = 0; if remaining > 0 { - spacer_after_options = 1; + spacer_after_question = 1; } return ( question_height, @@ -203,6 +202,9 @@ impl RequestUserInputOverlay { ); } + 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; 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 3c289d013fd1..eb1b25ce9d67 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 @@ -6,10 +6,10 @@ expression: "render_snapshot(&overlay, area)" 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. ( ) Ship it Finalize and open a PR. + 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 537b897900de..024d5bb8abdf 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 @@ -6,8 +6,8 @@ expression: "render_snapshot(&overlay, area)" Question 1/1 (1 unanswered) Area Choose an option. - ( ) Option 1 First choice. ( ) Option 2 Second choice. ( ) Option 3 Third choice. + No option selected | ↑/↓ scroll | Enter: submit answer | Esc: interrupt From 37ce0c880a5dd256178c74f925e6cf83c7c6f366 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 27 Jan 2026 11:26:04 -0800 Subject: [PATCH 17/29] UI improvements --- .../bottom_pane/request_user_input/layout.rs | 104 ++++++++---- .../src/bottom_pane/request_user_input/mod.rs | 150 ++++++++++++++++-- .../bottom_pane/request_user_input/render.rs | 20 ++- ..._request_user_input_scrolling_options.snap | 2 +- ...ests__request_user_input_tight_height.snap | 2 +- 5 files changed, 231 insertions(+), 47 deletions(-) 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 ae3b15f26277..12ecdc022875 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,5 +1,6 @@ use ratatui::layout::Rect; +use super::DESIRED_SPACERS_WHEN_NOTES_HIDDEN; use super::RequestUserInputOverlay; pub(super) struct LayoutSections { @@ -36,12 +37,14 @@ impl RequestUserInputOverlay { footer_lines, ) = if has_options { self.layout_with_options( - area.height, - area.width, - question_height, - notes_pref_height, - footer_pref, - notes_visible, + OptionsLayoutArgs { + available_height: area.height, + width: area.width, + question_height, + notes_pref_height, + footer_pref, + notes_visible, + }, &mut question_lines, ) } else { @@ -88,19 +91,25 @@ impl RequestUserInputOverlay { /// 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, - notes_visible: bool, + args: OptionsLayoutArgs, question_lines: &mut Vec, ) -> (u16, u16, u16, u16, u16, u16, u16, u16) { - let options_required_height = self.options_required_height(width); + 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( @@ -111,12 +120,14 @@ impl RequestUserInputOverlay { ) } else { self.layout_with_options_normal( - available_height, - question_height, - options_required_height, - notes_pref_height, - footer_pref, - notes_visible, + OptionsNormalArgs { + available_height, + question_height, + notes_pref_height, + footer_pref, + notes_visible, + }, + options_heights, ) } } @@ -144,23 +155,30 @@ impl RequestUserInputOverlay { /// 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, - notes_visible: bool, + 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_required_height; + 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); // When notes are hidden, prefer to reserve room for progress, footer, - // and at least one spacer by shrinking the options window if needed. - let desired_spacers = if notes_visible { 0 } else { 1 }; + // 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); @@ -189,7 +207,10 @@ impl RequestUserInputOverlay { 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, @@ -390,3 +411,28 @@ struct LayoutHeights { 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 89deae57df8f..4a2698f36ea7 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 @@ -40,6 +40,8 @@ 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)"; +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 { @@ -242,6 +244,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.option_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(), @@ -311,6 +335,11 @@ impl RequestUserInputOverlay { 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(); } } @@ -529,19 +558,21 @@ impl BottomPaneView for RequestUserInputOverlay { } if matches!(key_event.code, KeyCode::Esc) { - if self.has_options() && matches!(self.focus, Focus::Notes) { - // Let the composer flush any pending paste-burst state (e.g., held first char). - let _ = self - .composer - .handle_key_event(KeyEvent::from(KeyCode::Left)); - self.composer.move_cursor_to_end(); - let notes_empty = self.composer.current_text_with_pending().trim().is_empty(); - self.save_current_draft(); - if notes_empty && let Some(answer) = self.current_answer_mut() { - answer.notes_visible = false; + if matches!(self.focus, Focus::Notes) { + if self.has_options() { + // Let the composer flush any pending paste-burst state (e.g., held first char). + let _ = self + .composer + .handle_key_event(KeyEvent::from(KeyCode::Left)); + self.composer.move_cursor_to_end(); + let notes_empty = self.composer.current_text_with_pending().trim().is_empty(); + self.save_current_draft(); + if notes_empty && let Some(answer) = self.current_answer_mut() { + answer.notes_visible = false; + } + self.focus = Focus::Options; + self.sync_composer_placeholder(); } - self.focus = Focus::Options; - self.sync_composer_placeholder(); return; } self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); @@ -716,6 +747,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; @@ -912,6 +944,71 @@ mod tests { 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.selected, None); + assert_eq!(overlay.notes_ui_visible(), false); + } + + #[test] + fn esc_in_notes_mode_without_options_does_not_interrupt() { + 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!(matches!(overlay.focus, Focus::Notes)); + assert_eq!(overlay.done, false); + assert!(rx.try_recv().is_err()); + } + + #[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_returns_to_options_without_interrupt() { let (tx, mut rx) = test_sender(); @@ -1180,6 +1277,35 @@ mod tests { 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_title_area.y.saturating_sub(options_bottom); + assert_eq!(spacer_after_question, 1); + assert_eq!(spacer_after_options, 1); + } + #[test] fn request_user_input_wrapped_options_snapshot() { let (tx, _rx) = test_sender(); 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 9f5e09f7b007..0413519798dc 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 @@ -16,6 +16,7 @@ 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; impl Renderable for RequestUserInputOverlay { @@ -23,24 +24,35 @@ impl Renderable for RequestUserInputOverlay { 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_visible = !self.has_options() || self.notes_ui_visible(); + 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 = 1usize; // 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() && notes_visible { + if has_options && notes_visible { height = height.saturating_add(1); // notes title } height = height.saturating_add(menu_surface_padding_height() as usize); @@ -196,7 +208,7 @@ impl RequestUserInputOverlay { " scroll | ".into(), ]); if self.selected_option_index().is_some() && !notes_visible { - hint_spans.extend(vec!["Tab".dim(), ": add notes | ".into()]); + hint_spans.extend(vec!["Tab".blue().bold().not_dim(), ": add notes | ".into()]); } } let question_count = self.question_count(); 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 eb1b25ce9d67..95813336b2a7 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 @@ -6,10 +6,10 @@ expression: "render_snapshot(&overlay, area)" 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. - ( ) Ship it Finalize and open a PR. 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 024d5bb8abdf..b498c32ce5f1 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 @@ -6,8 +6,8 @@ expression: "render_snapshot(&overlay, area)" Question 1/1 (1 unanswered) Area Choose an option. + ( ) Option 1 First choice. ( ) Option 2 Second choice. - ( ) Option 3 Third choice. No option selected | ↑/↓ scroll | Enter: submit answer | Esc: interrupt From a2fd6a61c7de0d0b8534a520f664cd8c537f8b51 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 27 Jan 2026 12:21:28 -0800 Subject: [PATCH 18/29] Wrap request_user_input footer tips and refine backspace behavior --- .../bottom_pane/request_user_input/layout.rs | 2 +- .../src/bottom_pane/request_user_input/mod.rs | 313 +++++++++++++++++- .../bottom_pane/request_user_input/render.rs | 104 ++---- ...tests__request_user_input_footer_wrap.snap | 16 + ...uest_user_input_options_notes_visible.snap | 2 +- ...st_user_input_options_notes_with_text.snap | 2 +- 6 files changed, 362 insertions(+), 77 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_footer_wrap.snap 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 12ecdc022875..11856bd1df7f 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 @@ -21,7 +21,7 @@ impl RequestUserInputOverlay { pub(super) fn layout_sections(&self, area: Rect) -> LayoutSections { let has_options = self.has_options(); let notes_visible = !has_options || self.notes_ui_visible(); - let footer_pref = 1; + 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; 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 4a2698f36ea7..74116ff74799 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 @@ -34,12 +34,14 @@ 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 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)"; +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; @@ -67,6 +69,28 @@ struct AnswerState { 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 { app_event_tx: AppEventSender, request: RequestUserInputEvent, @@ -199,6 +223,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()) @@ -325,6 +353,90 @@ impl RequestUserInputOverlay { .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")); + } + } + + 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+p prev")); + tips.push(FooterTip::new("Ctrl+n next")); + } + if self.has_options() && notes_visible && self.focus_is_notes() { + tips.push(FooterTip::new("Notes optional")); + } + 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 { @@ -378,8 +490,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. @@ -400,6 +512,23 @@ impl RequestUserInputOverlay { } } + /// 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.selected = None; + answer.option_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() @@ -633,6 +762,9 @@ impl BottomPaneView for RequestUserInputOverlay { KeyCode::Char(' ') => { self.select_current_option(); } + KeyCode::Backspace => { + self.clear_selection(); + } KeyCode::Tab => { if self.selected_option_index().is_some() { self.focus = Focus::Notes; @@ -647,7 +779,7 @@ impl BottomPaneView for RequestUserInputOverlay { } self.go_next_or_submit(); } - KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete => { + KeyCode::Char(_) => { // Any typing while in options switches to notes for fast freeform input. self.focus = Focus::Notes; self.ensure_selected_for_notes(); @@ -658,6 +790,17 @@ impl BottomPaneView for RequestUserInputOverlay { } } Focus::Notes => { + let notes_empty = self.composer.current_text_with_pending().trim().is_empty(); + 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); @@ -755,6 +898,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, @@ -970,6 +1114,45 @@ mod tests { 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.selected, 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::::new()); + } + #[test] fn esc_in_notes_mode_without_options_does_not_interrupt() { let (tx, mut rx) = test_sender(); @@ -1054,6 +1237,56 @@ mod tests { assert_eq!(overlay.notes_ui_visible(), true); } + #[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.option_state.selected_idx = Some(1); + answer.selected = Some(1); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Backspace)); + + let answer = overlay.current_answer().expect("answer missing"); + assert_eq!(answer.selected, None); + assert_eq!(answer.option_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.option_state.selected_idx = Some(0); + answer.selected = 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.selected, Some(0)); + assert!(rx.try_recv().is_err()); + } + #[test] fn skipped_option_questions_count_as_unanswered() { let (tx, _rx) = test_sender(); @@ -1268,10 +1501,13 @@ 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); @@ -1306,6 +1542,44 @@ mod tests { 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.option_state.selected_idx = Some(0); + answer.selected = 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(); @@ -1340,6 +1614,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.option_state.selected_idx = Some(1); + answer.selected = 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(); 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 0413519798dc..16bc2f1c2d7d 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,4 +1,3 @@ -use crossterm::event::KeyCode; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Stylize; @@ -13,11 +12,11 @@ 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 { @@ -42,7 +41,7 @@ impl Renderable for RequestUserInputOverlay { } else { 0 }; - let footer_height = 1usize; + 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. @@ -187,67 +186,42 @@ impl RequestUserInputOverlay { .notes_area .y .saturating_add(sections.notes_area.height); - 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(); - if let Some(selected_idx) = self.selected_option_index() { - let option_index = selected_idx + 1; - hint_spans.extend(vec![ - format!("Option {option_index} of {options_len}").into(), - " | ".into(), - ]); - } else { - hint_spans.extend(vec!["No option selected".into(), " | ".into()]); - } - hint_spans.extend(vec![ - key_hint::plain(KeyCode::Up).into(), - "/".into(), - key_hint::plain(KeyCode::Down).into(), - " scroll | ".into(), - ]); - if self.selected_option_index().is_some() && !notes_visible { - hint_spans.extend(vec!["Tab".blue().bold().not_dim(), ": add notes | ".into()]); - } - } - let question_count = self.question_count(); - let is_last_question = question_count > 0 && self.current_index() + 1 >= question_count; - let enter_hint = if question_count > 1 && is_last_question { - "Enter: submit all answers" - } else { - "Enter: submit answer" + let footer_area = Rect { + x: content_area.x, + y: footer_y, + width: content_area.width, + height: sections.footer_lines, }; - hint_spans.extend(vec![enter_hint.dim(), " | ".into()]); - if question_count > 1 { - hint_spans.extend(vec![ - ctrl_hint('p'), - " prev | ".into(), - ctrl_hint('n'), - " next | ".into(), - ]); - } - if self.has_options() && notes_visible && self.focus_is_notes() { - hint_spans.extend(vec!["Notes optional | ".dim()]); + if footer_area.height == 0 { + return; } - let esc_hint = if self.has_options() && notes_visible && self.focus_is_notes() { - "Esc: change answer" - } else { - "Esc: interrupt" - }; - hint_spans.extend(vec![esc_hint.dim()]); - let hint_line = Line::from(hint_spans).dim(); - let hint_line = - truncate_line_word_boundary_with_ellipsis(hint_line, content_area.width as usize); - Paragraph::new(hint_line).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. @@ -284,10 +258,6 @@ impl RequestUserInputOverlay { } self.composer.render(area, buf); } - - fn focus_is_notes(&self) -> bool { - matches!(self.focus, super::Focus::Notes) - } } fn line_width(line: &Line<'_>) -> usize { @@ -296,10 +266,6 @@ fn line_width(line: &Line<'_>) -> usize { .sum() } -fn ctrl_hint(key: char) -> Span<'static> { - format!("Ctrl+{key}").dim() -} - fn truncate_line_word_boundary_with_ellipsis( line: Line<'static>, max_width: usize, 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 000000000000..cf298631ed25 --- /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+p prev | 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_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 index 17c8c91d7983..7ad45213604f 100644 --- 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 @@ -16,4 +16,4 @@ expression: "render_snapshot(&overlay, area)" - Option 1 of 3 | ↑/↓ scroll | Enter: submit answer | Notes optional | Esc: change answer + Option 1 of 3 | ↑/↓ scroll | Enter: submit answer | Notes optional | 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_with_text.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_with_text.snap index 9625e0acfa77..dc1c51094619 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options_notes_with_text.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_with_text.snap @@ -18,4 +18,4 @@ expression: "render_snapshot(&overlay, area)" - Option 1 of 3 | ↑/↓ scroll | Enter: submit answer | Notes optional | Esc: change answer + Option 1 of 3 | ↑/↓ scroll | Enter: submit answer | Notes optional | Esc: interrupt From f0600f93328d252d12b8a850af4c304782266688 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 27 Jan 2026 12:36:30 -0800 Subject: [PATCH 19/29] Avoid auto-selecting options on Enter without selection --- .../src/bottom_pane/request_user_input/mod.rs | 36 +++++++++++++++++-- .../bottom_pane/request_user_input/render.rs | 6 ++-- ...uest_user_input_options_notes_visible.snap | 2 +- ...st_user_input_options_notes_with_text.snap | 2 +- 4 files changed, 39 insertions(+), 7 deletions(-) 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 74116ff74799..2b9182922d79 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 @@ -772,9 +772,8 @@ impl BottomPaneView for RequestUserInputOverlay { } } KeyCode::Enter => { - let is_last_question = self.current_index() + 1 >= self.question_count(); let has_selection = self.selected_option_index().is_some(); - if !is_last_question || has_selection { + if has_selection { self.select_current_option(); } self.go_next_or_submit(); @@ -1068,6 +1067,39 @@ mod tests { assert_eq!(answer.answers, Vec::::new()); } + #[test] + fn enter_without_selection_does_not_auto_select_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.selected, None); + assert_eq!(first_answer.option_state.selected_idx, None); + 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::::new()); + } + #[test] fn tab_opens_notes_when_option_selected() { let (tx, _rx) = test_sender(); 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 16bc2f1c2d7d..d4a3b5abef3b 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 @@ -155,12 +155,12 @@ impl RequestUserInputOverlay { .is_some_and(|answer| answer.selected.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_active = if self.has_options() { self.focus_is_notes() 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 index 7ad45213604f..e89d75fb0f60 100644 --- 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 @@ -9,7 +9,7 @@ expression: "render_snapshot(&overlay, area)" (x) Option 1 First choice. ( ) Option 2 Second choice. ( ) Option 3 Third choice. - Notes for Option 1 (optional) + Notes for Option 1 › Add notes (optional) 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_with_text.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_with_text.snap index dc1c51094619..86137841dd2a 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options_notes_with_text.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_with_text.snap @@ -9,7 +9,7 @@ expression: "render_snapshot(&overlay, area)" (x) Option 1 First choice. ( ) Option 2 Second choice. ( ) Option 3 Third choice. - Notes for Option 1 (optional) + Notes for Option 1 › Include notes about follow-up tests. From 9561b4c887450a14f458a5c50f20c18908b3cae8 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 27 Jan 2026 12:46:45 -0800 Subject: [PATCH 20/29] Toggle notes with Tab in request_user_input --- .../src/bottom_pane/request_user_input/mod.rs | 45 +++++++++++++++++++ ...uest_user_input_options_notes_visible.snap | 2 +- ...st_user_input_options_notes_with_text.snap | 2 +- 3 files changed, 47 insertions(+), 2 deletions(-) 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 2b9182922d79..5463f553af3c 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 @@ -370,6 +370,9 @@ impl RequestUserInputOverlay { 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(); @@ -790,6 +793,18 @@ impl BottomPaneView for RequestUserInputOverlay { } 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(); @@ -1319,6 +1334,36 @@ mod tests { 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.option_state.selected_idx = Some(0); + answer.selected = 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.selected, Some(0)); + assert!(rx.try_recv().is_err()); + } + #[test] fn skipped_option_questions_count_as_unanswered() { let (tx, _rx) = test_sender(); 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 index e89d75fb0f60..b96a371a6999 100644 --- 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 @@ -16,4 +16,4 @@ expression: "render_snapshot(&overlay, area)" - Option 1 of 3 | ↑/↓ scroll | Enter: submit answer | Notes optional | Esc: interrupt + Option 1 of 3 | ↑/↓ scroll | Tab: clear notes | Enter: submit answer | Notes optional | 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_with_text.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_with_text.snap index 86137841dd2a..da4b578a6b9c 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options_notes_with_text.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_with_text.snap @@ -18,4 +18,4 @@ expression: "render_snapshot(&overlay, area)" - Option 1 of 3 | ↑/↓ scroll | Enter: submit answer | Notes optional | Esc: interrupt + Option 1 of 3 | ↑/↓ scroll | Tab: clear notes | Enter: submit answer | Notes optional | Esc: interrupt From 7baf2c15d47c9240057df9a04bef4575e9d0b093 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 27 Jan 2026 13:30:10 -0800 Subject: [PATCH 21/29] Refine ask-user-question navigation and answered state --- .../src/bottom_pane/request_user_input/mod.rs | 268 ++++++++++++++---- .../bottom_pane/request_user_input/render.rs | 22 +- ...tests__request_user_input_footer_wrap.snap | 2 +- ...quest_user_input_multi_question_first.snap | 2 +- ...equest_user_input_multi_question_last.snap | 2 +- ...ut__tests__request_user_input_options.snap | 2 +- ...uest_user_input_options_notes_visible.snap | 4 +- ...st_user_input_options_notes_with_text.snap | 2 +- ...ests__request_user_input_tight_height.snap | 2 +- 9 files changed, 226 insertions(+), 80 deletions(-) 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 5463f553af3c..68b51c4c70cb 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 @@ -36,11 +36,11 @@ 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; @@ -65,6 +65,8 @@ struct AnswerState { option_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, } @@ -335,11 +337,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 @@ -384,12 +382,9 @@ impl RequestUserInputOverlay { }; tips.push(FooterTip::new(enter_tip)); if question_count > 1 { - tips.push(FooterTip::new("Ctrl+p prev")); tips.push(FooterTip::new("Ctrl+n next")); } - if self.has_options() && notes_visible && self.focus_is_notes() { - tips.push(FooterTip::new("Notes optional")); - } + if self.has_options() && notes_visible && self.focus_is_notes() {} tips.push(FooterTip::new("Esc: interrupt")); tips } @@ -469,10 +464,15 @@ impl RequestUserInputOverlay { .options .as_ref() .is_some_and(|options| !options.is_empty()); + let mut option_state = ScrollState::new(); + if has_options { + option_state.selected_idx = Some(0); + } AnswerState { selected: None, - option_state: ScrollState::new(), + option_state, draft: ComposerDraft::default(), + freeform_committed: false, notes_visible: !has_options, } }) @@ -534,13 +534,6 @@ impl RequestUserInputOverlay { /// 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; } @@ -565,9 +558,7 @@ impl RequestUserInputOverlay { 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 - .selected - .or(answer_state.option_state.selected_idx) + answer_state.selected } else { answer_state.selected }; @@ -606,6 +597,29 @@ impl RequestUserInputOverlay { } } + 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.selected.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(); @@ -613,26 +627,7 @@ impl RequestUserInputOverlay { .questions .iter() .enumerate() - .filter(|(idx, question)| { - let answer = &self.answers[*idx]; - let notes = if *idx == self.current_index() { - current_text.as_str() - } else { - answer.draft.text.as_str() - }; - let notes_empty = notes.trim().is_empty(); - let has_options = question - .options - .as_ref() - .is_some_and(|options| !options.is_empty()); - - if has_options { - let selected_idx = answer.selected.or(answer.option_state.selected_idx); - selected_idx.is_none() && notes_empty - } else { - notes_empty - } - }) + .filter(|(idx, _question)| !self.is_question_answered(*idx, ¤t_text)) .count() } @@ -674,6 +669,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.option_state.clamp_selection(options_len); + answer.selected = answer.option_state.selected_idx; + } + } + if !self.has_options() { + if 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 @@ -730,6 +740,22 @@ impl BottomPaneView for RequestUserInputOverlay { 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; + } _ => {} } @@ -738,10 +764,9 @@ impl BottomPaneView for RequestUserInputOverlay { let options_len = self.options_len(); // Keep selection synchronized as the user moves. match key_event.code { - KeyCode::Up => { + KeyCode::Up | KeyCode::Char('k') => { let moved = if let Some(answer) = self.current_answer_mut() { answer.option_state.move_up_wrap(options_len); - answer.selected = answer.option_state.selected_idx; true } else { false @@ -750,10 +775,9 @@ impl BottomPaneView for RequestUserInputOverlay { self.sync_composer_placeholder(); } } - KeyCode::Down => { + KeyCode::Down | KeyCode::Char('j') => { let moved = if let Some(answer) = self.current_answer_mut() { answer.option_state.move_down_wrap(options_len); - answer.selected = answer.option_state.selected_idx; true } else { false @@ -829,7 +853,6 @@ impl BottomPaneView for RequestUserInputOverlay { KeyCode::Up => { let moved = if let Some(answer) = self.current_answer_mut() { answer.option_state.move_up_wrap(options_len); - answer.selected = answer.option_state.selected_idx; true } else { false @@ -841,7 +864,6 @@ impl BottomPaneView for RequestUserInputOverlay { KeyCode::Down => { let moved = if let Some(answer) = self.current_answer_mut() { answer.option_state.move_down_wrap(options_len); - answer.selected = answer.option_state.selected_idx; true } else { false @@ -1062,7 +1084,7 @@ mod tests { } #[test] - fn enter_does_not_auto_select_last_option_question() { + 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")]), @@ -1079,11 +1101,11 @@ mod tests { panic!("expected UserInputAnswer"); }; let answer = response.answers.get("q1").expect("answer missing"); - assert_eq!(answer.answers, Vec::::new()); + assert_eq!(answer.answers, vec!["Option 1".to_string()]); } #[test] - fn enter_without_selection_does_not_auto_select_non_last_option_question() { + fn enter_commits_default_selection_on_non_last_option_question() { let (tx, mut rx) = test_sender(); let mut overlay = RequestUserInputOverlay::new( request_event( @@ -1102,8 +1124,8 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert_eq!(overlay.current_index(), 1); let first_answer = &overlay.answers[0]; - assert_eq!(first_answer.selected, None); - assert_eq!(first_answer.option_state.selected_idx, None); + assert_eq!(first_answer.selected, Some(0)); + assert_eq!(first_answer.option_state.selected_idx, Some(0)); assert!(rx.try_recv().is_err()); overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); @@ -1112,7 +1134,53 @@ mod tests { panic!("expected UserInputAnswer"); }; let answer = response.answers.get("q1").expect("answer missing"); - assert_eq!(answer.answers, Vec::::new()); + 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.option_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.option_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.option_state.selected_idx, Some(0)); + } + + #[test] + fn vim_keys_move_between_questions_in_options() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_with_options("q1", "Pick one"), + question_with_options("q2", "Pick two"), + ], + ), + tx, + true, + false, + false, + ); + + assert_eq!(overlay.current_index(), 0); + overlay.handle_key_event(KeyEvent::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] @@ -1197,7 +1265,7 @@ mod tests { panic!("expected UserInputAnswer"); }; let answer = response.answers.get("q2").expect("answer missing"); - assert_eq!(answer.answers, Vec::::new()); + assert_eq!(answer.answers, vec!["Option 1".to_string()]); } #[test] @@ -1379,7 +1447,7 @@ mod tests { } #[test] - fn highlighted_option_questions_are_not_unanswered() { + 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")]), @@ -1391,7 +1459,59 @@ mod tests { let answer = overlay.current_answer_mut().expect("answer missing"); answer.option_state.selected_idx = Some(0); - assert_eq!(overlay.unanswered_count(), 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] @@ -1452,6 +1572,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.get(0).expect("answer missing"); + assert_eq!(answer.selected, Some(1)); + } + #[test] fn large_paste_is_preserved_when_switching_questions() { let (tx, _rx) = test_sender(); @@ -1856,6 +2007,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.selected, Some(0)); + assert_eq!(answer.option_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 d4a3b5abef3b..df32dfe46992 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 @@ -100,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()) }; @@ -162,14 +167,7 @@ impl RequestUserInputOverlay { } else { "Notes".to_string() }; - let notes_active = if self.has_options() { - self.focus_is_notes() - && self - .current_answer() - .is_some_and(|answer| answer.selected.is_some()) - } else { - self.focus_is_notes() - }; + let notes_active = self.focus_is_notes(); let notes_title = if notes_active { notes_label.as_str().cyan().bold() } else { @@ -228,15 +226,11 @@ impl RequestUserInputOverlay { 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(); - let has_selected_option = self - .current_answer() - .is_some_and(|answer| answer.selected.is_some()); if !self.focus_is_notes() { return None; } - // When options exist, only show the cursor after a concrete selection. - if has_options && (!notes_visible || !has_selected_option) { + if has_options && !notes_visible { return None; } let content_area = menu_surface_inset(area); 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 index cf298631ed25..02f91a7b476b 100644 --- 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 @@ -12,5 +12,5 @@ expression: "render_snapshot(&overlay, area)" ( ) Option 3 Third choice. Option 2 of 3 | ↑/↓ scroll | Tab: add notes - Enter: submit answer | Ctrl+p prev | Ctrl+n next + 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_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 index 002441104e25..cdae6785d9dc 100644 --- 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 @@ -11,4 +11,4 @@ expression: "render_snapshot(&overlay, area)" ( ) Option 2 Second choice. ( ) Option 3 Third choice. - No option selected | ↑/↓ scroll | Enter: submit answer | Ctrl+p prev | Ctrl+n next | Esc: interrupt + 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 index d3db040d74cf..d007b6b43c05 100644 --- 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 @@ -12,4 +12,4 @@ expression: "render_snapshot(&overlay, area)" - Enter: submit all answers | Ctrl+p prev | Ctrl+n next | Esc: interrupt + 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 8bf5dbd1b1ba..67eb6dc712d7 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 @@ -11,4 +11,4 @@ expression: "render_snapshot(&overlay, area)" ( ) Option 2 Second choice. ( ) Option 3 Third choice. - No option selected | ↑/↓ scroll | Enter: submit answer | Esc: interrupt + 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 index b96a371a6999..24799ed636e5 100644 --- 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 @@ -11,9 +11,9 @@ expression: "render_snapshot(&overlay, area)" ( ) Option 3 Third choice. Notes for Option 1 - › Add notes (optional) + › Add notes - Option 1 of 3 | ↑/↓ scroll | Tab: clear notes | Enter: submit answer | Notes optional | Esc: interrupt + 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_options_notes_with_text.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_with_text.snap index da4b578a6b9c..989e43a16186 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options_notes_with_text.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_with_text.snap @@ -18,4 +18,4 @@ expression: "render_snapshot(&overlay, area)" - Option 1 of 3 | ↑/↓ scroll | Tab: clear notes | Enter: submit answer | Notes optional | Esc: interrupt + 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_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 b498c32ce5f1..a963370be928 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 @@ -10,4 +10,4 @@ expression: "render_snapshot(&overlay, area)" ( ) Option 1 First choice. ( ) Option 2 Second choice. - No option selected | ↑/↓ scroll | Enter: submit answer | Esc: interrupt + Option 1 of 3 | ↑/↓ scroll | Tab: add notes | Enter: submit answer | Esc: interrupt From 9bd6abaf72163fb9840e95b44add4c2552fa5596 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 27 Jan 2026 13:37:38 -0800 Subject: [PATCH 22/29] Lint, dd unnecessary test --- .../src/bottom_pane/request_user_input/mod.rs | 38 +++---------------- ...st_user_input_options_notes_with_text.snap | 21 ---------- 2 files changed, 5 insertions(+), 54 deletions(-) delete mode 100644 codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options_notes_with_text.snap 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 68b51c4c70cb..9ab76dacbc50 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 @@ -384,7 +384,6 @@ impl RequestUserInputOverlay { if question_count > 1 { tips.push(FooterTip::new("Ctrl+n next")); } - if self.has_options() && notes_visible && self.focus_is_notes() {} tips.push(FooterTip::new("Esc: interrupt")); tips } @@ -560,7 +559,7 @@ impl RequestUserInputOverlay { let selected_idx = if options.is_some_and(|opts| !opts.is_empty()) { answer_state.selected } else { - answer_state.selected + None }; // Notes are appended as extra answers. let notes = answer_state.draft.text.trim().to_string(); @@ -679,10 +678,10 @@ impl RequestUserInputOverlay { answer.selected = answer.option_state.selected_idx; } } - if !self.has_options() { - if let Some(answer) = self.current_answer_mut() { - answer.freeform_committed = !text.trim().is_empty(); - } + 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(); @@ -1668,33 +1667,6 @@ mod tests { ); } - #[test] - fn request_user_input_options_notes_with_text_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.option_state.selected_idx = Some(0); - answer.selected = Some(0); - } - overlay.handle_key_event(KeyEvent::from(KeyCode::Tab)); - overlay - .composer - .handle_paste("Include notes about follow-up tests.".to_string()); - - let area = Rect::new(0, 0, 120, 18); - insta::assert_snapshot!( - "request_user_input_options_notes_with_text", - render_snapshot(&overlay, area) - ); - } - #[test] fn request_user_input_tight_height_snapshot() { let (tx, _rx) = test_sender(); 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_with_text.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_with_text.snap deleted file mode 100644 index 989e43a16186..000000000000 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options_notes_with_text.snap +++ /dev/null @@ -1,21 +0,0 @@ ---- -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 - - › Include notes about follow-up tests. - - - - - - - Option 1 of 3 | ↑/↓ scroll | Tab: clear notes | Enter: submit answer | Esc: interrupt From 40b694fa414802e5cf770f709e2d5d67aecc00db Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 27 Jan 2026 13:50:28 -0800 Subject: [PATCH 23/29] Polish ask-user-question UX details --- codex-rs/tui/src/bottom_pane/request_user_input/mod.rs | 2 +- .../tui/src/bottom_pane/request_user_input/render.rs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) 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 9ab76dacbc50..236e823373c2 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 @@ -1598,7 +1598,7 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert_eq!(overlay.current_index(), 1); - let answer = overlay.answers.get(0).expect("answer missing"); + let answer = overlay.answers.first().expect("answer missing"); assert_eq!(answer.selected, 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 df32dfe46992..650409d84797 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 @@ -260,6 +260,13 @@ fn line_width(line: &Line<'_>) -> usize { .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, @@ -285,6 +292,7 @@ fn truncate_line_word_boundary_with_ellipsis( 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; @@ -310,10 +318,12 @@ fn truncate_line_word_boundary_with_ellipsis( } } + // 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); From 1d8fd29b1883dda60d6fa919a7e47be3e47da756 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 27 Jan 2026 13:58:21 -0800 Subject: [PATCH 24/29] Only submit committed freeform answers --- .../src/bottom_pane/request_user_input/mod.rs | 88 ++++++++++++------- 1 file changed, 56 insertions(+), 32 deletions(-) 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 236e823373c2..df91420ee82e 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 @@ -561,8 +561,15 @@ impl RequestUserInputOverlay { } else { None }; - // Notes are appended as extra answers. - let notes = answer_state.draft.text.trim().to_string(); + // 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.draft.text.trim().to_string() + } else if answer_state.freeform_committed { + answer_state.draft.text.trim().to_string() + } else { + String::new() + }; let selected_label = selected_idx.and_then(|selected_idx| { question .options @@ -699,23 +706,6 @@ impl BottomPaneView for RequestUserInputOverlay { } if matches!(key_event.code, KeyCode::Esc) { - if matches!(self.focus, Focus::Notes) { - if self.has_options() { - // Let the composer flush any pending paste-burst state (e.g., held first char). - let _ = self - .composer - .handle_key_event(KeyEvent::from(KeyCode::Left)); - self.composer.move_cursor_to_end(); - let notes_empty = self.composer.current_text_with_pending().trim().is_empty(); - self.save_current_draft(); - if notes_empty && let Some(answer) = self.current_answer_mut() { - answer.notes_visible = false; - } - self.focus = Focus::Options; - self.sync_composer_placeholder(); - } - return; - } self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); self.done = true; return; @@ -1268,7 +1258,7 @@ mod tests { } #[test] - fn esc_in_notes_mode_without_options_does_not_interrupt() { + 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")]), @@ -1280,9 +1270,12 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Esc)); - assert!(matches!(overlay.focus, Focus::Notes)); - assert_eq!(overlay.done, false); - assert!(rx.try_recv().is_err()); + 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] @@ -1307,7 +1300,7 @@ mod tests { } #[test] - fn esc_in_notes_mode_returns_to_options_without_interrupt() { + 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")]), @@ -1323,15 +1316,17 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Tab)); overlay.handle_key_event(KeyEvent::from(KeyCode::Esc)); - assert!(matches!(overlay.focus, Focus::Options)); - assert_eq!(overlay.notes_ui_visible(), false); - assert_eq!(overlay.done, false); - assert!(rx.try_recv().is_err()); + 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_keeps_notes_visible_when_notes_present() { - let (tx, _rx) = test_sender(); + 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, @@ -1347,8 +1342,12 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Char('a'))); overlay.handle_key_event(KeyEvent::from(KeyCode::Esc)); - assert!(matches!(overlay.focus, Focus::Options)); - assert_eq!(overlay.notes_ui_visible(), true); + 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] @@ -1534,6 +1533,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(); From 07d939ed0fecae57f19436a939c80c10efa8dc7c Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 27 Jan 2026 14:06:00 -0800 Subject: [PATCH 25/29] Lint --- codex-rs/tui/src/bottom_pane/request_user_input/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 df91420ee82e..238bd0321cfd 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 @@ -563,9 +563,9 @@ impl RequestUserInputOverlay { }; // 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.draft.text.trim().to_string() - } else if answer_state.freeform_committed { + 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() From e5c43038854d0843bdfd64b8ed5074e14d84dc1b Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 27 Jan 2026 14:18:26 -0800 Subject: [PATCH 26/29] Restore typing for options focus --- .../src/bottom_pane/request_user_input/mod.rs | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) 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 238bd0321cfd..b2c7f1e5b7f5 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 @@ -729,22 +729,6 @@ impl BottomPaneView for RequestUserInputOverlay { 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; - } _ => {} } @@ -1149,7 +1133,7 @@ mod tests { } #[test] - fn vim_keys_move_between_questions_in_options() { + fn typing_in_options_enters_notes() { let (tx, _rx) = test_sender(); let mut overlay = RequestUserInputOverlay::new( request_event( @@ -1166,10 +1150,13 @@ mod tests { ); assert_eq!(overlay.current_index(), 0); - overlay.handle_key_event(KeyEvent::from(KeyCode::Char('l'))); - assert_eq!(overlay.current_index(), 1); + assert_eq!(overlay.notes_ui_visible(), false); + overlay.composer.set_disable_paste_burst(true); overlay.handle_key_event(KeyEvent::from(KeyCode::Char('h'))); assert_eq!(overlay.current_index(), 0); + assert_eq!(overlay.notes_ui_visible(), true); + assert!(matches!(overlay.focus, Focus::Notes)); + assert_eq!(overlay.composer.current_text_with_pending(), "h"); } #[test] From f8479c3589e4b8b4f18dfd82a61f7c308d146fa1 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 27 Jan 2026 14:29:08 -0800 Subject: [PATCH 27/29] Naming nits --- .../src/bottom_pane/request_user_input/mod.rs | 127 +++++++++--------- .../bottom_pane/request_user_input/render.rs | 10 +- 2 files changed, 70 insertions(+), 67 deletions(-) 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 b2c7f1e5b7f5..629b1f0fe2d4 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 @@ -59,10 +59,10 @@ struct ComposerDraft { } struct AnswerState { - // Final selection for the question (may be None when unanswered). - 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. @@ -185,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> { @@ -239,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 { @@ -265,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); @@ -286,7 +289,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); @@ -463,13 +466,13 @@ impl RequestUserInputOverlay { .options .as_ref() .is_some_and(|options| !options.is_empty()); - let mut option_state = ScrollState::new(); + let mut options_ui_state = ScrollState::new(); if has_options { - option_state.selected_idx = Some(0); + options_ui_state.selected_idx = Some(0); } AnswerState { - selected: None, - option_state, + committed_option_idx: None, + options_ui_state, draft: ComposerDraft::default(), freeform_committed: false, notes_visible: !has_options, @@ -503,8 +506,8 @@ impl RequestUserInputOverlay { } let options_len = self.options_len(); let updated = if let Some(answer) = self.current_answer_mut() { - answer.option_state.clamp_selection(options_len); - answer.selected = answer.option_state.selected_idx; + answer.options_ui_state.clamp_selection(options_len); + answer.committed_option_idx = answer.options_ui_state.selected_idx; true } else { false @@ -522,8 +525,8 @@ impl RequestUserInputOverlay { 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.selected = None; - answer.option_state.reset(); + answer.committed_option_idx = None; + answer.options_ui_state.reset(); if notes_empty { answer.notes_visible = false; } @@ -557,7 +560,7 @@ impl RequestUserInputOverlay { 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.selected + answer_state.committed_option_idx } else { None }; @@ -615,7 +618,7 @@ impl RequestUserInputOverlay { .as_ref() .is_some_and(|options| !options.is_empty()); if has_options { - answer.selected.is_some() + answer.committed_option_idx.is_some() } else { answer.freeform_committed } @@ -681,8 +684,8 @@ impl RequestUserInputOverlay { { let options_len = self.options_len(); if let Some(answer) = self.current_answer_mut() { - answer.option_state.clamp_selection(options_len); - answer.selected = answer.option_state.selected_idx; + answer.options_ui_state.clamp_selection(options_len); + answer.committed_option_idx = answer.options_ui_state.selected_idx; } } if !self.has_options() @@ -739,7 +742,7 @@ impl BottomPaneView for RequestUserInputOverlay { match key_event.code { KeyCode::Up | KeyCode::Char('k') => { let moved = if let Some(answer) = self.current_answer_mut() { - answer.option_state.move_up_wrap(options_len); + answer.options_ui_state.move_up_wrap(options_len); true } else { false @@ -750,7 +753,7 @@ impl BottomPaneView for RequestUserInputOverlay { } KeyCode::Down | KeyCode::Char('j') => { let moved = if let Some(answer) = self.current_answer_mut() { - answer.option_state.move_down_wrap(options_len); + answer.options_ui_state.move_down_wrap(options_len); true } else { false @@ -825,7 +828,7 @@ impl BottomPaneView for RequestUserInputOverlay { match key_event.code { KeyCode::Up => { let moved = if let Some(answer) = self.current_answer_mut() { - answer.option_state.move_up_wrap(options_len); + answer.options_ui_state.move_up_wrap(options_len); true } else { false @@ -836,7 +839,7 @@ impl BottomPaneView for RequestUserInputOverlay { } KeyCode::Down => { let moved = if let Some(answer) = self.current_answer_mut() { - answer.option_state.move_down_wrap(options_len); + answer.options_ui_state.move_down_wrap(options_len); true } else { false @@ -1097,8 +1100,8 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert_eq!(overlay.current_index(), 1); let first_answer = &overlay.answers[0]; - assert_eq!(first_answer.selected, Some(0)); - assert_eq!(first_answer.option_state.selected_idx, Some(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)); @@ -1121,15 +1124,15 @@ mod tests { false, ); let answer = overlay.current_answer().expect("answer missing"); - assert_eq!(answer.option_state.selected_idx, Some(0)); + 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.option_state.selected_idx, Some(1)); + 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.option_state.selected_idx, Some(0)); + assert_eq!(answer.options_ui_state.selected_idx, Some(0)); } #[test] @@ -1170,8 +1173,8 @@ mod tests { false, ); let answer = overlay.current_answer_mut().expect("answer missing"); - answer.option_state.selected_idx = Some(1); - answer.selected = Some(1); + 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)); @@ -1201,7 +1204,7 @@ mod tests { let answer = overlay.current_answer().expect("answer missing"); assert!(matches!(overlay.focus, Focus::Options)); - assert_eq!(answer.selected, None); + assert_eq!(answer.committed_option_idx, None); assert_eq!(overlay.notes_ui_visible(), false); } @@ -1232,7 +1235,7 @@ mod tests { let answer = overlay.current_answer().expect("answer missing"); assert!(matches!(overlay.focus, Focus::Options)); assert_eq!(overlay.notes_ui_visible(), false); - assert_eq!(answer.selected, None); + assert_eq!(answer.committed_option_idx, None); overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); @@ -1297,8 +1300,8 @@ mod tests { false, ); let answer = overlay.current_answer_mut().expect("answer missing"); - answer.option_state.selected_idx = Some(0); - answer.selected = Some(0); + 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)); @@ -1322,8 +1325,8 @@ mod tests { false, ); let answer = overlay.current_answer_mut().expect("answer missing"); - answer.option_state.selected_idx = Some(0); - answer.selected = Some(0); + 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'))); @@ -1348,14 +1351,14 @@ mod tests { false, ); let answer = overlay.current_answer_mut().expect("answer missing"); - answer.option_state.selected_idx = Some(1); - answer.selected = Some(1); + 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.selected, None); - assert_eq!(answer.option_state.selected_idx, None); + 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()); } @@ -1371,8 +1374,8 @@ mod tests { false, ); let answer = overlay.current_answer_mut().expect("answer missing"); - answer.option_state.selected_idx = Some(0); - answer.selected = Some(0); + 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)); @@ -1383,7 +1386,7 @@ mod tests { let answer = overlay.current_answer().expect("answer missing"); assert!(matches!(overlay.focus, Focus::Options)); assert_eq!(overlay.notes_ui_visible(), false); - assert_eq!(answer.selected, Some(0)); + assert_eq!(answer.committed_option_idx, Some(0)); assert!(rx.try_recv().is_err()); } @@ -1398,8 +1401,8 @@ mod tests { false, ); let answer = overlay.current_answer_mut().expect("answer missing"); - answer.option_state.selected_idx = Some(0); - answer.selected = Some(0); + answer.options_ui_state.selected_idx = Some(0); + answer.committed_option_idx = Some(0); overlay.handle_key_event(KeyEvent::from(KeyCode::Tab)); overlay @@ -1413,7 +1416,7 @@ mod tests { assert_eq!(overlay.notes_ui_visible(), false); assert_eq!(overlay.composer.current_text_with_pending(), ""); assert_eq!(answer.draft.text, ""); - assert_eq!(answer.selected, Some(0)); + assert_eq!(answer.committed_option_idx, Some(0)); assert!(rx.try_recv().is_err()); } @@ -1442,7 +1445,7 @@ mod tests { false, ); let answer = overlay.current_answer_mut().expect("answer missing"); - answer.option_state.selected_idx = Some(0); + answer.options_ui_state.selected_idx = Some(0); assert_eq!(overlay.unanswered_count(), 1); } @@ -1558,7 +1561,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 @@ -1610,7 +1613,7 @@ mod tests { assert_eq!(overlay.current_index(), 1); let answer = overlay.answers.first().expect("answer missing"); - assert_eq!(answer.selected, Some(1)); + assert_eq!(answer.committed_option_idx, Some(1)); } #[test] @@ -1666,8 +1669,8 @@ mod tests { ); { let answer = overlay.current_answer_mut().expect("answer missing"); - answer.option_state.selected_idx = Some(0); - answer.selected = Some(0); + answer.options_ui_state.selected_idx = Some(0); + answer.committed_option_idx = Some(0); } overlay.handle_key_event(KeyEvent::from(KeyCode::Tab)); @@ -1770,8 +1773,8 @@ mod tests { false, ); let answer = overlay.current_answer_mut().expect("answer missing"); - answer.option_state.selected_idx = Some(0); - answer.selected = Some(0); + answer.options_ui_state.selected_idx = Some(0); + answer.committed_option_idx = Some(0); let width = 36u16; let lines = overlay.footer_tip_lines(width); @@ -1807,8 +1810,8 @@ mod tests { { let answer = overlay.current_answer_mut().expect("answer missing"); - answer.option_state.selected_idx = Some(0); - answer.selected = Some(0); + answer.options_ui_state.selected_idx = Some(0); + answer.committed_option_idx = Some(0); } let width = 110u16; @@ -1842,8 +1845,8 @@ mod tests { false, ); let answer = overlay.current_answer_mut().expect("answer missing"); - answer.option_state.selected_idx = Some(1); - answer.selected = Some(1); + answer.options_ui_state.selected_idx = Some(1); + answer.committed_option_idx = Some(1); let width = 52u16; let height = overlay.desired_height(width); @@ -1896,8 +1899,8 @@ 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, 120, 12); insta::assert_snapshot!( @@ -1990,7 +1993,7 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Down)); let answer = overlay.current_answer().expect("answer missing"); - assert_eq!(answer.selected, Some(0)); - assert_eq!(answer.option_state.selected_idx, 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 650409d84797..0ab53fb8412e 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 @@ -134,19 +134,19 @@ impl RequestUserInputOverlay { 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", ); @@ -157,7 +157,7 @@ impl RequestUserInputOverlay { 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}") From 401917a16fa4fa99ea3761d95097cb7c65f6dedb Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 27 Jan 2026 14:35:21 -0800 Subject: [PATCH 28/29] Require tab to open notes --- .../src/bottom_pane/request_user_input/mod.rs | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) 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 629b1f0fe2d4..62607cd36cca 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 @@ -781,13 +781,6 @@ impl BottomPaneView for RequestUserInputOverlay { } self.go_next_or_submit(); } - KeyCode::Char(_) => { - // 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); - } _ => {} } } @@ -1136,7 +1129,7 @@ mod tests { } #[test] - fn typing_in_options_enters_notes() { + fn typing_in_options_does_not_open_notes() { let (tx, _rx) = test_sender(); let mut overlay = RequestUserInputOverlay::new( request_event( @@ -1154,12 +1147,11 @@ mod tests { assert_eq!(overlay.current_index(), 0); assert_eq!(overlay.notes_ui_visible(), false); - overlay.composer.set_disable_paste_burst(true); overlay.handle_key_event(KeyEvent::from(KeyCode::Char('h'))); assert_eq!(overlay.current_index(), 0); - assert_eq!(overlay.notes_ui_visible(), true); - assert!(matches!(overlay.focus, Focus::Notes)); - assert_eq!(overlay.composer.current_text_with_pending(), "h"); + assert_eq!(overlay.notes_ui_visible(), false); + assert!(matches!(overlay.focus, Focus::Options)); + assert_eq!(overlay.composer.current_text_with_pending(), ""); } #[test] From 8c8cd47a51a820d1f275eb2887da5ea0e0e500fb Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 27 Jan 2026 14:42:42 -0800 Subject: [PATCH 29/29] Restore h/l question navigation --- .../src/bottom_pane/request_user_input/mod.rs | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) 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 62607cd36cca..3fe16c4314c6 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 @@ -732,6 +732,22 @@ impl BottomPaneView for RequestUserInputOverlay { 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; + } _ => {} } @@ -1147,13 +1163,37 @@ mod tests { assert_eq!(overlay.current_index(), 0); assert_eq!(overlay.notes_ui_visible(), false); - overlay.handle_key_event(KeyEvent::from(KeyCode::Char('h'))); + 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();