From 40be41763c94a76755c8d18754502e4a9403fb8f Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 19 May 2026 18:01:38 -0300 Subject: [PATCH] fix(tui): preserve modified enter in plan questions (#23536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Plan mode questionnaires reuse the shared composer for free-form answers, but the surrounding `request_user_input` overlay still treated every `KeyCode::Enter` as “advance to the next question.” That made `Shift+Enter` insert a newline in the composer and then immediately advance the questionnaire anyway. Fixes #23448. ## What Changed - pass the live `RuntimeKeymap` into `RequestUserInputOverlay` so its embedded composer honors existing `/keymap` composer/editor remaps - advance free-form questions only on the configured composer submit binding, instead of any Enter-shaped key event - add regressions for `Shift+Enter` newline behavior and configured composer submit bindings inside the questionnaire UI ## How to Test 1. Start Codex in Plan mode and trigger a `request_user_input` questionnaire with a free-form answer field. 2. Focus the free-form field, type a line, then press `Shift+Enter`. 3. Confirm the answer gains a newline and the questionnaire stays on the same question. 4. Press the configured submit binding, or plain `Enter` with the default keymap, and confirm the questionnaire advances as before. Targeted tests: - `cargo test -p codex-tui bottom_pane::request_user_input::tests::freeform_ -- --nocapture` ## Notes - `cargo test -p codex-tui` still reaches an unrelated existing stack overflow in `app::tests::discard_side_thread_removes_agent_navigation_entry` on this checkout. - `just argument-comment-lint` is locally blocked by Bazel analysis failing in external `compiler-rt` before the lint runs. --- codex-rs/tui/src/bottom_pane/mod.rs | 2 +- .../src/bottom_pane/request_user_input/mod.rs | 194 +++++++++++++++--- ...t_user_input_freeform_remapped_submit.snap | 13 ++ 3 files changed, 185 insertions(+), 24 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_freeform_remapped_submit.snap diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 3f93c13898..5aeb2a4cfd 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1315,7 +1315,7 @@ impl BottomPane { self.has_input_focus, self.enhanced_keys_supported, self.disable_paste_burst, - self.keymap.list.clone(), + self.keymap.clone(), ); self.pause_status_timer_for_modal(); self.set_composer_input_enabled( 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 68c6755e45..13cbc34daa 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 @@ -4,7 +4,7 @@ //! - Each question can be answered by selecting one option and/or providing notes. //! - Notes are stored per question and appended as extra answers. //! - Typing while focused on options jumps into notes to keep freeform input fast. -//! - Enter advances to the next question; the last question submits all answers. +//! - The composer submit binding advances to the next question; the last question submits all answers. //! - Freeform-only questions submit an empty answer list when empty. use std::collections::HashMap; use std::collections::VecDeque; @@ -29,8 +29,10 @@ use crate::bottom_pane::scroll_state::ScrollState; use crate::bottom_pane::selection_popup_common::GenericDisplayRow; use crate::bottom_pane::selection_popup_common::measure_rows_height; use crate::history_cell; +use crate::key_hint::KeyBinding; use crate::key_hint::KeyBindingListExt; use crate::keymap::ListKeymap; +use crate::keymap::RuntimeKeymap; use crate::render::renderable::Renderable; #[cfg(test)] @@ -140,6 +142,7 @@ pub(crate) struct RequestUserInputOverlay { done: bool, pending_submission_draft: Option, confirm_unanswered: Option, + composer_submit_keys: Vec, list_keymap: ListKeymap, } @@ -158,7 +161,7 @@ impl RequestUserInputOverlay { has_input_focus, enhanced_keys_supported, disable_paste_burst, - crate::keymap::RuntimeKeymap::defaults().list, + RuntimeKeymap::defaults(), ) } @@ -168,7 +171,7 @@ impl RequestUserInputOverlay { has_input_focus: bool, enhanced_keys_supported: bool, disable_paste_burst: bool, - list_keymap: ListKeymap, + keymap: RuntimeKeymap, ) -> Self { // Use the same composer widget, but disable popups/slash-commands and // image-path attachment so it behaves like a focused notes field. @@ -180,6 +183,7 @@ impl RequestUserInputOverlay { disable_paste_burst, ChatComposerConfig::plain_text(), ); + composer.set_keymap_bindings(&keymap); // The overlay renders its own footer hints, so keep the composer footer empty. composer.set_footer_hint_override(Some(Vec::new())); let mut overlay = Self { @@ -193,7 +197,8 @@ impl RequestUserInputOverlay { done: false, pending_submission_draft: None, confirm_unanswered: None, - list_keymap, + composer_submit_keys: keymap.composer.submit.clone(), + list_keymap: keymap.list, }; overlay.reset_for_request(); overlay.ensure_focus_available(); @@ -477,14 +482,23 @@ impl RequestUserInputOverlay { let question_count = self.question_count(); let is_last_question = self.current_index().saturating_add(1) >= question_count; - let enter_tip = if question_count == 1 { - FooterTip::highlighted("enter to submit answer") - } else if is_last_question { - FooterTip::highlighted("enter to submit all") + let submit_key = if self.focus_is_notes() || !self.has_options() { + self.composer_submit_keys + .first() + .map(KeyBinding::display_label) } else { - FooterTip::new("enter to submit answer") + Some("enter".to_string()) }; - tips.push(enter_tip); + if let Some(submit_key) = submit_key { + let submit_tip = if question_count == 1 { + FooterTip::highlighted(format!("{submit_key} to submit answer")) + } else if is_last_question { + FooterTip::highlighted(format!("{submit_key} to submit all")) + } else { + FooterTip::new(format!("{submit_key} to submit answer")) + }; + tips.push(submit_tip); + } if question_count > 1 { if self.has_options() && !self.focus_is_notes() { tips.push(FooterTip::new("←/→ to navigate questions")); @@ -1054,6 +1068,20 @@ impl BottomPaneView for RequestUserInputOverlay { return; } + if self.focus_is_notes() && self.composer_submit_keys.is_pressed(key_event) { + self.ensure_selected_for_notes(); + self.pending_submission_draft = Some(self.capture_composer_draft()); + let (result, _) = self.composer.handle_key_event(key_event); + if !self.handle_composer_input_result(result) { + self.pending_submission_draft = None; + if self.has_options() { + self.select_current_option(/*committed*/ true); + } + self.go_next_or_submit(); + } + return; + } + // Question navigation is always available. match key_event { KeyEvent { @@ -1201,19 +1229,6 @@ impl BottomPaneView for RequestUserInputOverlay { self.sync_composer_placeholder(); return; } - if matches!(key_event.code, KeyCode::Enter) { - self.ensure_selected_for_notes(); - self.pending_submission_draft = Some(self.capture_composer_draft()); - let (result, _) = self.composer.handle_key_event(key_event); - if !self.handle_composer_input_result(result) { - self.pending_submission_draft = None; - if self.has_options() { - self.select_current_option(/*committed*/ true); - } - self.go_next_or_submit(); - } - return; - } if self.has_options() && matches!(key_event.code, KeyCode::Up | KeyCode::Down) { let options_len = self.options_len(); match key_event.code { @@ -2008,6 +2023,28 @@ mod tests { ); } + #[test] + fn freeform_footer_shows_configured_submit_binding() { + let (tx, _rx) = test_sender(); + let mut keymap = RuntimeKeymap::defaults(); + keymap.composer.submit = vec![crate::key_hint::ctrl(KeyCode::Char('j'))]; + let overlay = RequestUserInputOverlay::new_with_keymap( + request_event("turn-1", vec![question_without_options("q1", "Notes")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + + let tips = overlay.footer_tips(); + let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::>(); + assert_eq!( + tip_texts, + vec!["ctrl + j to submit answer", "esc to interrupt"] + ); + } + #[test] fn tab_opens_notes_when_option_selected() { let (tx, _rx) = test_sender(); @@ -2374,6 +2411,97 @@ mod tests { assert_eq!(overlay.unanswered_count(), 2); } + #[test] + fn freeform_shift_enter_inserts_newline_without_advancing() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_without_options("q1", "Notes"), + question_without_options("q2", "More"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ true, + /*disable_paste_burst*/ false, + ); + + overlay + .composer + .set_text_content("Draft".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); + + overlay.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)); + + assert_eq!(overlay.current_index(), 0); + assert_eq!(overlay.composer.current_text_with_pending(), "Draft\n"); + assert_eq!(overlay.answers[0].answer_committed, false); + } + + #[test] + fn freeform_uses_configured_composer_submit_binding() { + let (tx, _rx) = test_sender(); + let mut keymap = RuntimeKeymap::defaults(); + keymap.composer.submit = vec![crate::key_hint::ctrl(KeyCode::Char('j'))]; + let mut overlay = RequestUserInputOverlay::new_with_keymap( + request_event( + "turn-1", + vec![ + question_without_options("q1", "Notes"), + question_without_options("q2", "More"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + + overlay + .composer + .set_text_content("Draft".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); + + overlay.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)); + + assert_eq!(overlay.current_index(), 1); + assert_eq!(overlay.answers[0].answer_committed, true); + } + + #[test] + fn freeform_submit_binding_wins_over_question_navigation() { + let (tx, _rx) = test_sender(); + let mut keymap = RuntimeKeymap::defaults(); + keymap.composer.submit = vec![crate::key_hint::ctrl(KeyCode::Char('n'))]; + let mut overlay = RequestUserInputOverlay::new_with_keymap( + request_event( + "turn-1", + vec![ + question_without_options("q1", "Notes"), + question_without_options("q2", "More"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + + overlay + .composer + .set_text_content("Draft".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); + + overlay.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)); + + assert_eq!(overlay.current_index(), 1); + assert_eq!(overlay.answers[0].answer_committed, true); + } + #[test] fn freeform_questions_submit_empty_when_empty() { let (tx, mut rx) = test_sender(); @@ -3025,6 +3153,26 @@ mod tests { ); } + #[test] + fn request_user_input_freeform_remapped_submit_snapshot() { + let (tx, _rx) = test_sender(); + let mut keymap = RuntimeKeymap::defaults(); + keymap.composer.submit = vec![crate::key_hint::ctrl(KeyCode::Char('j'))]; + let overlay = RequestUserInputOverlay::new_with_keymap( + request_event("turn-1", vec![question_without_options("q1", "Goal")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + let area = Rect::new(0, 0, 120, 10); + insta::assert_snapshot!( + "request_user_input_freeform_remapped_submit", + render_snapshot(&overlay, area) + ); + } + #[test] fn request_user_input_multi_question_first_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_freeform_remapped_submit.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_submit.snap new file mode 100644 index 0000000000..a2aeaa4d3e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_submit.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 1/1 (1 unanswered) + Share details. + + › Type your answer (optional) + + + + ctrl + j to submit answer | esc to interrupt