fix(tui): preserve modified enter in plan questions (#23536)

## 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.
This commit is contained in:
Felipe Coury
2026-05-19 18:01:38 -03:00
committed by GitHub
parent 83af3abc68
commit 40be41763c
3 changed files with 185 additions and 24 deletions

View File

@@ -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(

View File

@@ -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<ComposerDraft>,
confirm_unanswered: Option<ScrollState>,
composer_submit_keys: Vec<KeyBinding>,
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::<Vec<_>>();
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();

View File

@@ -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