mirror of
https://github.com/openai/codex.git
synced 2026-05-21 19:45:26 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user