tui: make Esc clear request_user_input notes while notes are shown (#10569)

## Summary

This PR updates the `request_user_input` TUI overlay so `Esc` is
context-aware:

- When notes are visible for an option question, `Esc` now clears notes
and exits notes mode.
- When notes are not visible (normal option selection UI), `Esc` still
interrupts as before.

It also updates footer guidance text to match behavior.

## Changes

- Added a shared notes-clear path for option questions:
- `Tab` and `Esc` now both clear notes and return focus to options when
notes are visible.
- Updated footer hint text in notes-visible state:
  - from: `tab to clear notes | ... | esc to interrupt`
  - to: `tab or esc to clear notes | ...`
- Hid `esc to interrupt` hint while notes are visible for option
questions.
- Kept `esc to interrupt` visible and functional in normal
option-selection mode.
- Updated tests to assert the new `Esc` behavior in notes mode.
- Updated snapshot output for the notes-visible footer row.
- Updated docs in `docs/tui-request-user-input.md` to reflect
mode-specific `Esc` behavior.
This commit is contained in:
Charley Cunningham 2026-02-03 16:17:06 -08:00 committed by GitHub
parent 16647b188b
commit a9eb766f33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 52 additions and 21 deletions

View file

@ -427,8 +427,8 @@ impl RequestUserInputOverlay {
if self.selected_option_index().is_some() && !notes_visible {
tips.push(FooterTip::highlighted("tab to add notes"));
}
if self.selected_option_index().is_some() && notes_visible && self.focus_is_notes() {
tips.push(FooterTip::new("tab to clear notes"));
if self.selected_option_index().is_some() && notes_visible {
tips.push(FooterTip::new("tab or esc to clear notes"));
}
}
@ -449,7 +449,9 @@ impl RequestUserInputOverlay {
tips.push(FooterTip::new("ctrl + n next question"));
}
}
tips.push(FooterTip::new("esc to interrupt"));
if !(self.has_options() && notes_visible) {
tips.push(FooterTip::new("esc to interrupt"));
}
tips
}
@ -663,6 +665,23 @@ impl RequestUserInputOverlay {
self.sync_composer_placeholder();
}
fn clear_notes_and_focus_options(&mut self) {
if !self.has_options() {
return;
}
if let Some(answer) = self.current_answer_mut() {
answer.draft = ComposerDraft::default();
answer.answer_committed = false;
answer.notes_visible = false;
}
self.pending_submission_draft = None;
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();
}
/// Ensure there is a selection before allowing notes entry.
fn ensure_selected_for_notes(&mut self) {
if let Some(answer) = self.current_answer_mut() {
@ -976,6 +995,10 @@ impl BottomPaneView for RequestUserInputOverlay {
}
if matches!(key_event.code, KeyCode::Esc) {
if self.has_options() && self.notes_ui_visible() {
self.clear_notes_and_focus_options();
return;
}
// TODO: Emit interrupted request_user_input results (including committed answers)
// once core supports persisting them reliably without follow-up turn issues.
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
@ -1093,16 +1116,7 @@ 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.answer_committed = false;
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();
self.clear_notes_and_focus_options();
return;
}
if self.has_options() && matches!(key_event.code, KeyCode::Backspace) && notes_empty
@ -1753,7 +1767,7 @@ mod tests {
}
#[test]
fn esc_in_notes_mode_interrupts() {
fn esc_in_notes_mode_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")]),
@ -1769,12 +1783,19 @@ mod tests {
overlay.handle_key_event(KeyEvent::from(KeyCode::Tab));
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
assert_eq!(overlay.done, true);
expect_interrupt_only(&mut rx);
let answer = overlay.current_answer().expect("answer missing");
assert_eq!(overlay.done, false);
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.options_state.selected_idx, Some(0));
assert_eq!(answer.answer_committed, false);
assert!(rx.try_recv().is_err());
}
#[test]
fn esc_in_notes_mode_interrupts_with_notes_visible() {
fn esc_in_notes_mode_with_text_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")]),
@ -1791,8 +1812,15 @@ mod tests {
overlay.handle_key_event(KeyEvent::from(KeyCode::Char('a')));
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
assert_eq!(overlay.done, true);
expect_interrupt_only(&mut rx);
let answer = overlay.current_answer().expect("answer missing");
assert_eq!(overlay.done, false);
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.options_state.selected_idx, Some(0));
assert_eq!(answer.answer_committed, false);
assert!(rx.try_recv().is_err());
}
#[test]

View file

@ -1,5 +1,6 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
assertion_line: 2321
expression: "render_snapshot(&overlay, area)"
---
@ -16,4 +17,4 @@ expression: "render_snapshot(&overlay, area)"
tab to clear notes | enter to submit answer | esc to interrupt
tab or esc to clear notes | enter to submit answer

View file

@ -30,7 +30,9 @@ friction for freeform input.
- Enter advances to the next question.
- Enter on the last question submits all answers.
- PageUp/PageDown navigate across questions (when multiple are present).
- Esc interrupts the run.
- Esc interrupts the run in option selection mode.
- When notes are open for an option question, Tab or Esc clears notes and returns
to option selection.
## Layout priorities