Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion codex-rs/tui/src/bottom_pane/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
194 changes: 171 additions & 23 deletions codex-rs/tui/src/bottom_pane/request_user_input/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)]
Expand Down Expand Up @@ -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,
}

Expand All @@ -158,7 +161,7 @@ impl RequestUserInputOverlay {
has_input_focus,
enhanced_keys_supported,
disable_paste_burst,
crate::keymap::RuntimeKeymap::defaults().list,
RuntimeKeymap::defaults(),
)
}

Expand All @@ -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.
Expand All @@ -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 {
Expand All @@ -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();
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Loading