Add request-user-input overlay (#9585)

- Add request-user-input overlay and routing in the TUI
This commit is contained in:
Ahmed Ibrahim
2026-01-21 00:19:35 -08:00
committed by GitHub
parent ebc88f29f8
commit 5f55ed666b
13 changed files with 1447 additions and 3 deletions

View File

@@ -961,6 +961,24 @@ impl App {
.submit_op(Op::PatchApproval { id, decision });
}
}
Op::UserInputAnswer { id, response } => {
if let Some((thread_id, original_id)) =
self.external_approval_routes.remove(&id)
{
self.forward_external_op(
thread_id,
Op::UserInputAnswer {
id: original_id,
response,
},
)
.await;
self.finish_external_approval();
} else {
self.chat_widget
.submit_op(Op::UserInputAnswer { id, response });
}
}
// Standard path where this is not an external approval response.
_ => self.chat_widget.submit_op(op),
},
@@ -1465,6 +1483,14 @@ impl App {
/// can be routed back to the correct thread.
fn handle_external_approval_request(&mut self, thread_id: ThreadId, mut event: Event) {
match &mut event.msg {
EventMsg::RequestUserInput(ev) => {
let original_id = ev.turn_id.clone();
let routing_id = format!("{thread_id}:{original_id}");
self.external_approval_routes
.insert(routing_id.clone(), (thread_id, original_id));
ev.turn_id = routing_id.clone();
event.id = routing_id;
}
EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) => {
let original_id = event.id.clone();
let routing_id = format!("{thread_id}:{original_id}");
@@ -1517,7 +1543,9 @@ impl App {
}
};
match event.msg {
EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) => {
EventMsg::ExecApprovalRequest(_)
| EventMsg::ApplyPatchApprovalRequest(_)
| EventMsg::RequestUserInput(_) => {
app_event_tx.send(AppEvent::ExternalApprovalRequest { thread_id, event });
}
_ => {}

View File

@@ -1,5 +1,6 @@
use crate::bottom_pane::ApprovalRequest;
use crate::render::renderable::Renderable;
use codex_protocol::request_user_input::RequestUserInputEvent;
use crossterm::event::KeyEvent;
use super::CancellationEvent;
@@ -34,4 +35,13 @@ pub(crate) trait BottomPaneView: Renderable {
) -> Option<ApprovalRequest> {
Some(request)
}
/// Try to handle request_user_input; return the original value if not
/// consumed.
fn try_consume_user_input_request(
&mut self,
request: RequestUserInputEvent,
) -> Option<RequestUserInputEvent> {
Some(request)
}
}

View File

@@ -28,6 +28,7 @@ use bottom_pane_view::BottomPaneView;
use codex_core::features::Features;
use codex_core::skills::model::SkillMetadata;
use codex_file_search::FileMatch;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::user_input::TextElement;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -37,8 +38,10 @@ use ratatui::text::Line;
use std::time::Duration;
mod approval_overlay;
mod request_user_input;
pub(crate) use approval_overlay::ApprovalOverlay;
pub(crate) use approval_overlay::ApprovalRequest;
pub(crate) use request_user_input::RequestUserInputOverlay;
mod bottom_pane_view;
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -612,8 +615,32 @@ impl BottomPane {
self.push_view(Box::new(modal));
}
/// Called when the agent requests user input.
pub fn push_user_input_request(&mut self, request: RequestUserInputEvent) {
let request = if let Some(view) = self.view_stack.last_mut() {
match view.try_consume_user_input_request(request) {
Some(request) => request,
None => {
self.request_redraw();
return;
}
}
} else {
request
};
let modal = RequestUserInputOverlay::new(request, self.app_event_tx.clone());
self.pause_status_timer_for_modal();
self.set_composer_input_enabled(
false,
Some("Answer the questions to continue.".to_string()),
);
self.push_view(Box::new(modal));
}
fn on_active_view_complete(&mut self) {
self.resume_status_timer_after_modal();
self.set_composer_input_enabled(true, None);
}
fn pause_status_timer_for_modal(&mut self) {

View File

@@ -0,0 +1,151 @@
use ratatui::layout::Rect;
use super::RequestUserInputOverlay;
pub(super) struct LayoutSections {
pub(super) progress_area: Rect,
pub(super) header_area: Rect,
pub(super) question_area: Rect,
pub(super) answer_title_area: Rect,
// Wrapped question text lines to render in the question area.
pub(super) question_lines: Vec<String>,
pub(super) options_area: Rect,
pub(super) notes_title_area: Rect,
pub(super) notes_area: Rect,
// Number of footer rows (status + hints).
pub(super) footer_lines: u16,
}
impl RequestUserInputOverlay {
/// Compute layout sections, collapsing notes and hints as space shrinks.
pub(super) fn layout_sections(&self, area: Rect) -> LayoutSections {
let question_lines = self
.current_question()
.map(|q| {
textwrap::wrap(&q.question, area.width.max(1) as usize)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let question_text_height = question_lines.len() as u16;
let has_options = self.has_options();
let mut notes_input_height = self.notes_input_height(area.width);
// Keep the question + options visible first; notes and hints collapse as space shrinks.
let footer_lines = if self.unanswered_count() > 0 { 2 } else { 1 };
let mut notes_title_height = if has_options { 1 } else { 0 };
let mut cursor_y = area.y;
let progress_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: 1,
};
cursor_y = cursor_y.saturating_add(1);
let header_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: 1,
};
cursor_y = cursor_y.saturating_add(1);
let question_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: question_text_height,
};
cursor_y = cursor_y.saturating_add(question_text_height);
// Remaining height after progress/header/question areas.
let remaining = area.height.saturating_sub(cursor_y.saturating_sub(area.y));
let mut answer_title_height = if has_options { 1 } else { 0 };
let mut options_height = 0;
if has_options {
let remaining_content = remaining.saturating_sub(footer_lines);
let options_len = self.options_len() as u16;
if remaining_content == 0 {
answer_title_height = 0;
notes_title_height = 0;
notes_input_height = 0;
options_height = 0;
} else {
let min_notes = 1u16;
let full_notes = 3u16;
// Prefer to keep all options visible, then allocate notes height.
if remaining_content
>= options_len + answer_title_height + notes_title_height + full_notes
{
let max_notes = remaining_content
.saturating_sub(options_len)
.saturating_sub(answer_title_height)
.saturating_sub(notes_title_height);
notes_input_height = notes_input_height.min(max_notes).max(full_notes);
} else if remaining_content > options_len + answer_title_height + min_notes {
notes_title_height = 0;
notes_input_height = min_notes;
} else {
// Tight layout: hide section titles and shrink notes to one line.
answer_title_height = 0;
notes_title_height = 0;
notes_input_height = min_notes;
}
// Reserve notes/answer title area so options are scrollable if needed.
let reserved = answer_title_height
.saturating_add(notes_title_height)
.saturating_add(notes_input_height);
options_height = remaining_content.saturating_sub(reserved);
}
} else {
let max_notes = remaining.saturating_sub(footer_lines);
if max_notes == 0 {
notes_input_height = 0;
} else {
// When no options exist, notes are the primary input.
notes_input_height = notes_input_height.min(max_notes).max(3.min(max_notes));
}
}
let answer_title_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: answer_title_height,
};
cursor_y = cursor_y.saturating_add(answer_title_height);
let options_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: options_height,
};
cursor_y = cursor_y.saturating_add(options_height);
let notes_title_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: notes_title_height,
};
cursor_y = cursor_y.saturating_add(notes_title_height);
let notes_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: notes_input_height,
};
LayoutSections {
progress_area,
header_area,
question_area,
answer_title_area,
question_lines,
options_area,
notes_title_area,
notes_area,
footer_lines,
}
}
}

View File

@@ -0,0 +1,732 @@
//! Request-user-input overlay state machine.
//!
//! Core behaviors:
//! - Each question can be answered by selecting one option and/or providing notes.
//! - When options exist, notes are stored per selected option (notes become "other").
//! - 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.
//! - Freeform-only questions submit "skipped" when empty.
use std::cell::RefCell;
use std::collections::HashMap;
use std::collections::VecDeque;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
mod layout;
mod render;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::bottom_pane_view::BottomPaneView;
use crate::bottom_pane::scroll_state::ScrollState;
use crate::bottom_pane::textarea::TextArea;
use crate::bottom_pane::textarea::TextAreaState;
use codex_core::protocol::Op;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::request_user_input::RequestUserInputResponse;
const NOTES_PLACEHOLDER: &str = "Add notes (optional)";
const ANSWER_PLACEHOLDER: &str = "Type your answer (optional)";
const SELECT_OPTION_PLACEHOLDER: &str = "Select an option to add notes (optional)";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Focus {
Options,
Notes,
}
struct NotesEntry {
text: TextArea,
state: RefCell<TextAreaState>,
}
impl NotesEntry {
fn new() -> Self {
Self {
text: TextArea::new(),
state: RefCell::new(TextAreaState::default()),
}
}
}
struct AnswerState {
// Final selection for the question (always set for option questions).
selected: Option<usize>,
// Scrollable cursor state for option navigation/highlight.
option_state: ScrollState,
// Notes for freeform-only questions.
notes: NotesEntry,
// Per-option notes for option questions.
option_notes: Vec<NotesEntry>,
}
pub(crate) struct RequestUserInputOverlay {
app_event_tx: AppEventSender,
request: RequestUserInputEvent,
// Queue of incoming requests to process after the current one.
queue: VecDeque<RequestUserInputEvent>,
answers: Vec<AnswerState>,
current_idx: usize,
focus: Focus,
done: bool,
}
impl RequestUserInputOverlay {
pub(crate) fn new(request: RequestUserInputEvent, app_event_tx: AppEventSender) -> Self {
let mut overlay = Self {
app_event_tx,
request,
queue: VecDeque::new(),
answers: Vec::new(),
current_idx: 0,
focus: Focus::Options,
done: false,
};
overlay.reset_for_request();
overlay.ensure_focus_available();
overlay
}
fn current_index(&self) -> usize {
self.current_idx
}
fn current_question(
&self,
) -> Option<&codex_protocol::request_user_input::RequestUserInputQuestion> {
self.request.questions.get(self.current_index())
}
fn current_answer_mut(&mut self) -> Option<&mut AnswerState> {
let idx = self.current_index();
self.answers.get_mut(idx)
}
fn current_answer(&self) -> Option<&AnswerState> {
let idx = self.current_index();
self.answers.get(idx)
}
fn question_count(&self) -> usize {
self.request.questions.len()
}
fn has_options(&self) -> bool {
self.current_question()
.and_then(|question| question.options.as_ref())
.is_some_and(|options| !options.is_empty())
}
fn options_len(&self) -> usize {
self.current_question()
.and_then(|question| question.options.as_ref())
.map(std::vec::Vec::len)
.unwrap_or(0)
}
fn selected_option_index(&self) -> Option<usize> {
if !self.has_options() {
return None;
}
self.current_answer()
.and_then(|answer| answer.selected.or(answer.option_state.selected_idx))
}
fn current_option_label(&self) -> Option<&str> {
let idx = self.selected_option_index()?;
self.current_question()
.and_then(|question| question.options.as_ref())
.and_then(|options| options.get(idx))
.map(|option| option.label.as_str())
}
fn current_notes_entry(&self) -> Option<&NotesEntry> {
let answer = self.current_answer()?;
if !self.has_options() {
return Some(&answer.notes);
}
let idx = self
.selected_option_index()
.or(answer.option_state.selected_idx)?;
answer.option_notes.get(idx)
}
fn current_notes_entry_mut(&mut self) -> Option<&mut NotesEntry> {
let has_options = self.has_options();
let answer = self.current_answer_mut()?;
if !has_options {
return Some(&mut answer.notes);
}
let idx = answer
.selected
.or(answer.option_state.selected_idx)
.or_else(|| answer.option_notes.is_empty().then_some(0))?;
answer.option_notes.get_mut(idx)
}
fn notes_placeholder(&self) -> &'static str {
if self.has_options()
&& self
.current_answer()
.is_some_and(|answer| answer.selected.is_none())
{
SELECT_OPTION_PLACEHOLDER
} else if self.has_options() {
NOTES_PLACEHOLDER
} else {
ANSWER_PLACEHOLDER
}
}
/// Ensure the focus mode is valid for the current question.
fn ensure_focus_available(&mut self) {
if self.question_count() == 0 {
return;
}
if !self.has_options() {
self.focus = Focus::Notes;
}
}
/// Rebuild local answer state from the current request.
fn reset_for_request(&mut self) {
self.answers = self
.request
.questions
.iter()
.map(|question| {
let mut option_state = ScrollState::new();
let mut option_notes = Vec::new();
if let Some(options) = question.options.as_ref()
&& !options.is_empty()
{
option_state.selected_idx = Some(0);
option_notes = (0..options.len()).map(|_| NotesEntry::new()).collect();
}
AnswerState {
selected: option_state.selected_idx,
option_state,
notes: NotesEntry::new(),
option_notes,
}
})
.collect();
self.current_idx = 0;
self.focus = Focus::Options;
}
/// Move to the next/previous question, wrapping in either direction.
fn move_question(&mut self, next: bool) {
let len = self.question_count();
if len == 0 {
return;
}
let offset = if next { 1 } else { len.saturating_sub(1) };
self.current_idx = (self.current_idx + offset) % len;
self.ensure_focus_available();
}
/// Synchronize selection state to the currently focused option.
fn select_current_option(&mut self) {
if !self.has_options() {
return;
}
let options_len = self.options_len();
let Some(answer) = self.current_answer_mut() else {
return;
};
answer.option_state.clamp_selection(options_len);
answer.selected = answer.option_state.selected_idx;
}
/// Ensure there is a selection before allowing notes entry.
fn ensure_selected_for_notes(&mut self) {
if self.has_options()
&& self
.current_answer()
.is_some_and(|answer| answer.selected.is_none())
{
self.select_current_option();
}
}
/// Advance to next question, or submit when on the last one.
fn go_next_or_submit(&mut self) {
if self.current_index() + 1 >= self.question_count() {
self.submit_answers();
} else {
self.move_question(true);
}
}
/// Build the response payload and dispatch it to the app.
fn submit_answers(&mut self) {
let mut answers = HashMap::new();
for (idx, question) in self.request.questions.iter().enumerate() {
let answer_state = &self.answers[idx];
let options = question.options.as_ref();
// For option questions we always produce a selection.
let selected_idx = if options.is_some_and(|opts| !opts.is_empty()) {
answer_state
.selected
.or(answer_state.option_state.selected_idx)
} else {
answer_state.selected
};
// Notes map to "other". When options exist, notes are per selected option.
let notes = if options.is_some_and(|opts| !opts.is_empty()) {
selected_idx
.and_then(|selected| answer_state.option_notes.get(selected))
.map(|entry| entry.text.text().trim().to_string())
.unwrap_or_default()
} else {
answer_state.notes.text.text().trim().to_string()
};
let selected_label = selected_idx.and_then(|selected_idx| {
question
.options
.as_ref()
.and_then(|opts| opts.get(selected_idx))
.map(|opt| opt.label.clone())
});
let selected = selected_label.into_iter().collect::<Vec<_>>();
// For option questions, only send notes when present.
let other = if notes.is_empty() && options.is_some_and(|opts| !opts.is_empty()) {
None
} else if notes.is_empty() && selected.is_empty() {
Some("skipped".to_string())
} else {
Some(notes)
};
answers.insert(
question.id.clone(),
RequestUserInputAnswer { selected, other },
);
}
self.app_event_tx
.send(AppEvent::CodexOp(Op::UserInputAnswer {
id: self.request.turn_id.clone(),
response: RequestUserInputResponse { answers },
}));
if let Some(next) = self.queue.pop_front() {
self.request = next;
self.reset_for_request();
self.ensure_focus_available();
} else {
self.done = true;
}
}
/// Count freeform-only questions that have no notes.
fn unanswered_count(&self) -> usize {
self.request
.questions
.iter()
.enumerate()
.filter(|(idx, question)| {
let answer = &self.answers[*idx];
let options = question.options.as_ref();
if options.is_some_and(|opts| !opts.is_empty()) {
false
} else {
answer.notes.text.text().trim().is_empty()
}
})
.count()
}
/// Compute the preferred notes input height for the current question.
fn notes_input_height(&self, width: u16) -> u16 {
let Some(entry) = self.current_notes_entry() else {
return 3;
};
let usable_width = width.saturating_sub(2);
let text_height = entry.text.desired_height(usable_width).clamp(1, 6);
text_height.saturating_add(2).clamp(3, 8)
}
}
impl BottomPaneView for RequestUserInputOverlay {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Release {
return;
}
if matches!(key_event.code, KeyCode::Esc) {
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
self.done = true;
return;
}
// Question navigation is always available.
match key_event.code {
KeyCode::PageUp => {
self.move_question(false);
return;
}
KeyCode::PageDown => {
self.move_question(true);
return;
}
_ => {}
}
match self.focus {
Focus::Options => {
let options_len = self.options_len();
let Some(answer) = self.current_answer_mut() else {
return;
};
// Keep selection synchronized as the user moves.
match key_event.code {
KeyCode::Up => {
answer.option_state.move_up_wrap(options_len);
answer.selected = answer.option_state.selected_idx;
}
KeyCode::Down => {
answer.option_state.move_down_wrap(options_len);
answer.selected = answer.option_state.selected_idx;
}
KeyCode::Char(' ') => {
self.select_current_option();
}
KeyCode::Enter => {
self.select_current_option();
self.go_next_or_submit();
}
KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete => {
// Any typing while in options switches to notes for fast freeform input.
self.focus = Focus::Notes;
self.ensure_selected_for_notes();
if let Some(entry) = self.current_notes_entry_mut() {
entry.text.input(key_event);
}
}
_ => {}
}
}
Focus::Notes => {
if matches!(key_event.code, KeyCode::Enter) {
self.go_next_or_submit();
return;
}
// Notes are per option when options exist.
self.ensure_selected_for_notes();
if let Some(entry) = self.current_notes_entry_mut() {
entry.text.input(key_event);
}
}
}
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
self.done = true;
CancellationEvent::Handled
}
fn is_complete(&self) -> bool {
self.done
}
fn handle_paste(&mut self, pasted: String) -> bool {
if pasted.is_empty() {
return false;
}
if matches!(self.focus, Focus::Notes) {
self.ensure_selected_for_notes();
if let Some(entry) = self.current_notes_entry_mut() {
entry.text.insert_str(&pasted);
return true;
}
return true;
}
if matches!(self.focus, Focus::Options) {
// Treat pastes the same as typing: switch into notes.
self.focus = Focus::Notes;
self.ensure_selected_for_notes();
if let Some(entry) = self.current_notes_entry_mut() {
entry.text.insert_str(&pasted);
return true;
}
return true;
}
false
}
fn try_consume_user_input_request(
&mut self,
request: RequestUserInputEvent,
) -> Option<RequestUserInputEvent> {
self.queue.push_back(request);
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_event::AppEvent;
use crate::render::renderable::Renderable;
use codex_protocol::request_user_input::RequestUserInputQuestion;
use codex_protocol::request_user_input::RequestUserInputQuestionOption;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use tokio::sync::mpsc::unbounded_channel;
fn test_sender() -> (
AppEventSender,
tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
) {
let (tx_raw, rx) = unbounded_channel::<AppEvent>();
(AppEventSender::new(tx_raw), rx)
}
fn question_with_options(id: &str, header: &str) -> RequestUserInputQuestion {
RequestUserInputQuestion {
id: id.to_string(),
header: header.to_string(),
question: "Choose an option.".to_string(),
options: Some(vec![
RequestUserInputQuestionOption {
label: "Option 1".to_string(),
description: "First choice.".to_string(),
},
RequestUserInputQuestionOption {
label: "Option 2".to_string(),
description: "Second choice.".to_string(),
},
RequestUserInputQuestionOption {
label: "Option 3".to_string(),
description: "Third choice.".to_string(),
},
]),
}
}
fn question_without_options(id: &str, header: &str) -> RequestUserInputQuestion {
RequestUserInputQuestion {
id: id.to_string(),
header: header.to_string(),
question: "Share details.".to_string(),
options: None,
}
}
fn request_event(
turn_id: &str,
questions: Vec<RequestUserInputQuestion>,
) -> RequestUserInputEvent {
RequestUserInputEvent {
call_id: "call-1".to_string(),
turn_id: turn_id.to_string(),
questions,
}
}
fn snapshot_buffer(buf: &Buffer) -> String {
let mut lines = Vec::new();
for y in 0..buf.area().height {
let mut row = String::new();
for x in 0..buf.area().width {
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
lines.push(row);
}
lines.join("\n")
}
fn render_snapshot(overlay: &RequestUserInputOverlay, area: Rect) -> String {
let mut buf = Buffer::empty(area);
overlay.render(area, &mut buf);
snapshot_buffer(&buf)
}
#[test]
fn queued_requests_are_fifo() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "First")]),
tx,
);
overlay.try_consume_user_input_request(request_event(
"turn-2",
vec![question_with_options("q2", "Second")],
));
overlay.try_consume_user_input_request(request_event(
"turn-3",
vec![question_with_options("q3", "Third")],
));
overlay.submit_answers();
assert_eq!(overlay.request.turn_id, "turn-2");
overlay.submit_answers();
assert_eq!(overlay.request.turn_id, "turn-3");
}
#[test]
fn options_always_return_a_selection() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
);
overlay.submit_answers();
let event = rx.try_recv().expect("expected AppEvent");
let AppEvent::CodexOp(Op::UserInputAnswer { id, response }) = event else {
panic!("expected UserInputAnswer");
};
assert_eq!(id, "turn-1");
let answer = response.answers.get("q1").expect("answer missing");
assert_eq!(answer.selected, vec!["Option 1".to_string()]);
assert_eq!(answer.other, None);
}
#[test]
fn freeform_questions_submit_skipped_when_empty() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_without_options("q1", "Notes")]),
tx,
);
overlay.submit_answers();
let event = rx.try_recv().expect("expected AppEvent");
let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else {
panic!("expected UserInputAnswer");
};
let answer = response.answers.get("q1").expect("answer missing");
assert_eq!(answer.selected, Vec::<String>::new());
assert_eq!(answer.other, Some("skipped".to_string()));
}
#[test]
fn notes_are_captured_for_selected_option() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
);
{
let answer = overlay.current_answer_mut().expect("answer missing");
answer.option_state.selected_idx = Some(1);
}
overlay.select_current_option();
overlay
.current_notes_entry_mut()
.expect("notes entry missing")
.text
.insert_str("Notes for option 2");
overlay.submit_answers();
let event = rx.try_recv().expect("expected AppEvent");
let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else {
panic!("expected UserInputAnswer");
};
let answer = response.answers.get("q1").expect("answer missing");
assert_eq!(answer.selected, vec!["Option 2".to_string()]);
assert_eq!(answer.other, Some("Notes for option 2".to_string()));
}
#[test]
fn request_user_input_options_snapshot() {
let (tx, _rx) = test_sender();
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Area")]),
tx,
);
let area = Rect::new(0, 0, 64, 16);
insta::assert_snapshot!(
"request_user_input_options",
render_snapshot(&overlay, area)
);
}
#[test]
fn request_user_input_tight_height_snapshot() {
let (tx, _rx) = test_sender();
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Area")]),
tx,
);
let area = Rect::new(0, 0, 60, 8);
insta::assert_snapshot!(
"request_user_input_tight_height",
render_snapshot(&overlay, area)
);
}
#[test]
fn request_user_input_scroll_options_snapshot() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![RequestUserInputQuestion {
id: "q1".to_string(),
header: "Next Step".to_string(),
question: "What would you like to do next?".to_string(),
options: Some(vec![
RequestUserInputQuestionOption {
label: "Discuss a code change (Recommended)".to_string(),
description: "Walk through a plan and edit code together.".to_string(),
},
RequestUserInputQuestionOption {
label: "Run tests".to_string(),
description: "Pick a crate and run its tests.".to_string(),
},
RequestUserInputQuestionOption {
label: "Review a diff".to_string(),
description: "Summarize or review current changes.".to_string(),
},
RequestUserInputQuestionOption {
label: "Refactor".to_string(),
description: "Tighten structure and remove dead code.".to_string(),
},
RequestUserInputQuestionOption {
label: "Ship it".to_string(),
description: "Finalize and open a PR.".to_string(),
},
]),
}],
),
tx,
);
{
let answer = overlay.current_answer_mut().expect("answer missing");
answer.option_state.selected_idx = Some(3);
answer.selected = Some(3);
}
let area = Rect::new(0, 0, 68, 10);
insta::assert_snapshot!(
"request_user_input_scrolling_options",
render_snapshot(&overlay, area)
);
}
#[test]
fn request_user_input_freeform_snapshot() {
let (tx, _rx) = test_sender();
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_without_options("q1", "Goal")]),
tx,
);
let area = Rect::new(0, 0, 64, 10);
insta::assert_snapshot!(
"request_user_input_freeform",
render_snapshot(&overlay, area)
);
}
}

View File

@@ -0,0 +1,375 @@
use crossterm::event::KeyCode;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::Widget;
use crate::bottom_pane::selection_popup_common::GenericDisplayRow;
use crate::bottom_pane::selection_popup_common::render_rows;
use crate::key_hint;
use crate::render::renderable::Renderable;
use super::RequestUserInputOverlay;
impl Renderable for RequestUserInputOverlay {
fn desired_height(&self, width: u16) -> u16 {
let sections = self.layout_sections(Rect::new(0, 0, width, u16::MAX));
let mut height = sections
.question_lines
.len()
.saturating_add(5)
.saturating_add(self.notes_input_height(width) as usize)
.saturating_add(sections.footer_lines as usize);
if self.has_options() {
height = height.saturating_add(2);
}
height = height.max(8);
height as u16
}
fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_ui(area, buf);
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.cursor_pos_impl(area)
}
}
impl RequestUserInputOverlay {
/// Render the full request-user-input overlay.
pub(super) fn render_ui(&self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let sections = self.layout_sections(area);
// Progress header keeps the user oriented across multiple questions.
let progress_line = if self.question_count() > 0 {
let idx = self.current_index() + 1;
let total = self.question_count();
Line::from(format!("Question {idx}/{total}").dim())
} else {
Line::from("No questions".dim())
};
Paragraph::new(progress_line).render(sections.progress_area, buf);
// Question title and wrapped prompt text.
let question_header = self.current_question().map(|q| q.header.clone());
let header_line = if let Some(header) = question_header {
Line::from(header.bold())
} else {
Line::from("No questions".dim())
};
Paragraph::new(header_line).render(sections.header_area, buf);
let question_y = sections.question_area.y;
for (offset, line) in sections.question_lines.iter().enumerate() {
if question_y.saturating_add(offset as u16)
>= sections.question_area.y + sections.question_area.height
{
break;
}
Paragraph::new(Line::from(line.clone())).render(
Rect {
x: sections.question_area.x,
y: question_y.saturating_add(offset as u16),
width: sections.question_area.width,
height: 1,
},
buf,
);
}
if sections.answer_title_area.height > 0 {
let answer_label = "Answer";
let answer_title = if self.focus_is_options() || self.focus_is_notes_without_options() {
answer_label.cyan().bold()
} else {
answer_label.dim()
};
Paragraph::new(Line::from(answer_title)).render(sections.answer_title_area, buf);
}
// Build rows with selection markers for the shared selection renderer.
let option_rows = self
.current_question()
.and_then(|question| question.options.as_ref())
.map(|options| {
options
.iter()
.enumerate()
.map(|(idx, opt)| {
let selected = self
.current_answer()
.and_then(|answer| answer.selected)
.is_some_and(|sel| sel == idx);
let prefix = if selected { "(x)" } else { "( )" };
GenericDisplayRow {
name: format!("{prefix} {}", opt.label),
description: Some(opt.description.clone()),
..Default::default()
}
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if self.has_options() {
let mut option_state = self
.current_answer()
.map(|answer| answer.option_state)
.unwrap_or_default();
if sections.options_area.height > 0 {
// Ensure the selected option is visible in the scroll window.
option_state
.ensure_visible(option_rows.len(), sections.options_area.height as usize);
render_rows(
sections.options_area,
buf,
&option_rows,
&option_state,
option_rows.len().max(1),
"No options",
);
}
}
if sections.notes_title_area.height > 0 {
let notes_label = if self.has_options()
&& self
.current_answer()
.is_some_and(|answer| answer.selected.is_some())
{
if let Some(label) = self.current_option_label() {
format!("Notes for {label} (optional)")
} else {
"Notes (optional)".to_string()
}
} else {
"Notes (optional)".to_string()
};
let notes_title = if self.focus_is_notes() {
notes_label.as_str().cyan().bold()
} else {
notes_label.as_str().dim()
};
Paragraph::new(Line::from(notes_title)).render(sections.notes_title_area, buf);
}
if sections.notes_area.height > 0 {
self.render_notes_input(sections.notes_area, buf);
}
let footer_y = sections
.notes_area
.y
.saturating_add(sections.notes_area.height);
if sections.footer_lines == 2 {
// Status line for unanswered count when any question is empty.
let warning = format!(
"Unanswered: {} | Will submit as skipped",
self.unanswered_count()
);
Paragraph::new(Line::from(warning.dim())).render(
Rect {
x: area.x,
y: footer_y,
width: area.width,
height: 1,
},
buf,
);
}
let hint_y = footer_y.saturating_add(sections.footer_lines.saturating_sub(1));
// Footer hints (selection index + navigation keys).
let mut hint_spans = Vec::new();
if self.has_options() {
let options_len = self.options_len();
let option_index = self.selected_option_index().map_or(0, |idx| idx + 1);
hint_spans.extend(vec![
format!("Option {option_index} of {options_len}").into(),
" | ".into(),
]);
}
hint_spans.extend(vec![
key_hint::plain(KeyCode::Up).into(),
"/".into(),
key_hint::plain(KeyCode::Down).into(),
" scroll | ".into(),
key_hint::plain(KeyCode::Enter).into(),
" next question | ".into(),
]);
if self.question_count() > 1 {
hint_spans.extend(vec![
key_hint::plain(KeyCode::PageUp).into(),
" prev | ".into(),
key_hint::plain(KeyCode::PageDown).into(),
" next | ".into(),
]);
}
hint_spans.extend(vec![
key_hint::plain(KeyCode::Esc).into(),
" interrupt".into(),
]);
Paragraph::new(Line::from(hint_spans).dim()).render(
Rect {
x: area.x,
y: hint_y,
width: area.width,
height: 1,
},
buf,
);
}
/// Return the cursor position when editing notes, if visible.
pub(super) fn cursor_pos_impl(&self, area: Rect) -> Option<(u16, u16)> {
if !self.focus_is_notes() {
return None;
}
let sections = self.layout_sections(area);
let entry = self.current_notes_entry()?;
let input_area = sections.notes_area;
if input_area.width <= 2 || input_area.height == 0 {
return None;
}
if input_area.height < 3 {
// Inline notes layout uses a prefix and a single-line text area.
let prefix = notes_prefix();
let prefix_width = prefix.len() as u16;
if input_area.width <= prefix_width {
return None;
}
let textarea_rect = Rect {
x: input_area.x.saturating_add(prefix_width),
y: input_area.y,
width: input_area.width.saturating_sub(prefix_width),
height: 1,
};
let state = *entry.state.borrow();
return entry.text.cursor_pos_with_state(textarea_rect, state);
}
let text_area_height = input_area.height.saturating_sub(2);
let textarea_rect = Rect {
x: input_area.x.saturating_add(1),
y: input_area.y.saturating_add(1),
width: input_area.width.saturating_sub(2),
height: text_area_height,
};
let state = *entry.state.borrow();
entry.text.cursor_pos_with_state(textarea_rect, state)
}
/// Render the notes input box or inline notes field.
fn render_notes_input(&self, area: Rect, buf: &mut Buffer) {
let Some(entry) = self.current_notes_entry() else {
return;
};
if area.width < 2 || area.height == 0 {
return;
}
if area.height < 3 {
// Inline notes field for tight layouts.
let prefix = notes_prefix();
let prefix_width = prefix.len() as u16;
if area.width <= prefix_width {
Paragraph::new(Line::from(prefix.dim())).render(area, buf);
return;
}
Paragraph::new(Line::from(prefix.dim())).render(
Rect {
x: area.x,
y: area.y,
width: prefix_width,
height: 1,
},
buf,
);
let textarea_rect = Rect {
x: area.x.saturating_add(prefix_width),
y: area.y,
width: area.width.saturating_sub(prefix_width),
height: 1,
};
let mut state = entry.state.borrow_mut();
Clear.render(textarea_rect, buf);
StatefulWidgetRef::render_ref(&(&entry.text), textarea_rect, buf, &mut state);
if entry.text.text().is_empty() {
Paragraph::new(Line::from(self.notes_placeholder().dim()))
.render(textarea_rect, buf);
}
return;
}
// Draw a light ASCII frame around the notes area.
let top_border = format!("+{}+", "-".repeat(area.width.saturating_sub(2) as usize));
let bottom_border = top_border.clone();
Paragraph::new(Line::from(top_border)).render(
Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
},
buf,
);
Paragraph::new(Line::from(bottom_border)).render(
Rect {
x: area.x,
y: area.y.saturating_add(area.height.saturating_sub(1)),
width: area.width,
height: 1,
},
buf,
);
for row in 1..area.height.saturating_sub(1) {
Line::from(vec![
"|".into(),
" ".repeat(area.width.saturating_sub(2) as usize).into(),
"|".into(),
])
.render(
Rect {
x: area.x,
y: area.y.saturating_add(row),
width: area.width,
height: 1,
},
buf,
);
}
let text_area_height = area.height.saturating_sub(2);
let textarea_rect = Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: text_area_height,
};
let mut state = entry.state.borrow_mut();
Clear.render(textarea_rect, buf);
StatefulWidgetRef::render_ref(&(&entry.text), textarea_rect, buf, &mut state);
if entry.text.text().is_empty() {
Paragraph::new(Line::from(self.notes_placeholder().dim())).render(textarea_rect, buf);
}
}
fn focus_is_options(&self) -> bool {
matches!(self.focus, super::Focus::Options)
}
fn focus_is_notes(&self) -> bool {
matches!(self.focus, super::Focus::Notes)
}
fn focus_is_notes_without_options(&self) -> bool {
!self.has_options() && self.focus_is_notes()
}
}
fn notes_prefix() -> &'static str {
"Notes: "
}

View File

@@ -0,0 +1,12 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1
Goal
Share details.
+--------------------------------------------------------------+
|Type your answer (optional) |
+--------------------------------------------------------------+
Unanswered: 1 | Will submit as skipped
↑/↓ scroll | enter next question | esc interrupt

View File

@@ -0,0 +1,20 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1
Area
Choose an option.
Answer
(x) Option 1 First choice.
( ) Option 2 Second choice.
( ) Option 3 Third choice.
Notes for Option 1 (optional)
+--------------------------------------------------------------+
|Add notes (optional) |
+--------------------------------------------------------------+
Option 1 of 3 | ↑/↓ scroll | enter next question | esc interrupt

View File

@@ -0,0 +1,14 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1
Next Step
What would you like to do next?
( ) Discuss a code change (Recommended) Walk through a plan and
edit code together.
( ) Run tests Pick a crate and run its
tests.
( ) Review a diff Summarize or review current
Notes: Add notes (optional)
Option 4 of 5 | ↑/↓ scroll | enter next question | esc interrupt

View File

@@ -0,0 +1,12 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1
Area
Choose an option.
(x) Option 1 First choice.
( ) Option 2 Second choice.
( ) Option 3 Third choice.
Notes: Add notes (optional)
Option 1 of 3 | ↑/↓ scroll | enter next question | esc inter

View File

@@ -96,6 +96,7 @@ use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Settings;
use codex_protocol::models::local_image_label_text;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::user_input::TextElement;
use codex_protocol::user_input::UserInput;
use crossterm::event::KeyCode;
@@ -1209,6 +1210,14 @@ impl ChatWidget {
);
}
fn on_request_user_input(&mut self, ev: RequestUserInputEvent) {
let ev2 = ev.clone();
self.defer_or_handle(
|q| q.push_user_input(ev),
|s| s.handle_request_user_input_now(ev2),
);
}
fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) {
self.flush_answer_stream_with_separator();
if is_unified_exec_source(ev.source) {
@@ -1676,6 +1685,12 @@ impl ChatWidget {
self.request_redraw();
}
pub(crate) fn handle_request_user_input_now(&mut self, ev: RequestUserInputEvent) {
self.flush_answer_stream_with_separator();
self.bottom_pane.push_user_input_request(ev);
self.request_redraw();
}
pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) {
// Ensure the status indicator is visible while the command runs.
self.running_commands.insert(
@@ -2674,6 +2689,9 @@ impl ChatWidget {
EventMsg::ElicitationRequest(ev) => {
self.on_elicitation_request(ev);
}
EventMsg::RequestUserInput(ev) => {
self.on_request_user_input(ev);
}
EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev),
EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta),
EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta),
@@ -2734,8 +2752,7 @@ impl ChatWidget {
| EventMsg::ItemCompleted(_)
| EventMsg::AgentMessageContentDelta(_)
| EventMsg::ReasoningContentDelta(_)
| EventMsg::ReasoningRawContentDelta(_)
| EventMsg::RequestUserInput(_) => {}
| EventMsg::ReasoningRawContentDelta(_) => {}
}
}

View File

@@ -8,6 +8,7 @@ use codex_core::protocol::McpToolCallBeginEvent;
use codex_core::protocol::McpToolCallEndEvent;
use codex_core::protocol::PatchApplyEndEvent;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::request_user_input::RequestUserInputEvent;
use super::ChatWidget;
@@ -16,6 +17,7 @@ pub(crate) enum QueuedInterrupt {
ExecApproval(String, ExecApprovalRequestEvent),
ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent),
Elicitation(ElicitationRequestEvent),
RequestUserInput(RequestUserInputEvent),
ExecBegin(ExecCommandBeginEvent),
ExecEnd(ExecCommandEndEvent),
McpBegin(McpToolCallBeginEvent),
@@ -57,6 +59,10 @@ impl InterruptManager {
self.queue.push_back(QueuedInterrupt::Elicitation(ev));
}
pub(crate) fn push_user_input(&mut self, ev: RequestUserInputEvent) {
self.queue.push_back(QueuedInterrupt::RequestUserInput(ev));
}
pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) {
self.queue.push_back(QueuedInterrupt::ExecBegin(ev));
}
@@ -85,6 +91,7 @@ impl InterruptManager {
chat.handle_apply_patch_approval_now(id, ev)
}
QueuedInterrupt::Elicitation(ev) => chat.handle_elicitation_request_now(ev),
QueuedInterrupt::RequestUserInput(ev) => chat.handle_request_user_input_now(ev),
QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev),
QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev),
QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev),

View File

@@ -0,0 +1,39 @@
# Request user input overlay (TUI)
This note documents the TUI overlay used to gather answers for
`RequestUserInputEvent`.
## Overview
The overlay renders one question at a time and collects:
- A single selected option (when options exist).
- Freeform notes (always available).
When options are present, notes are stored per selected option and the first
option is selected by default, so every option question has an answer. If a
question has no options and no notes are provided, the answer is submitted as
`skipped`.
## Focus and input routing
The overlay tracks a small focus state:
- **Options**: Up/Down move the selection and Space selects.
- **Notes**: Text input edits notes for the currently selected option.
Typing while focused on options switches into notes automatically to reduce
friction for freeform input.
## Navigation
- 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.
## Layout priorities
The layout prefers to keep the question and all options visible. Notes and
footer hints collapse as space shrinks, with notes falling back to a single-line
"Notes: ..." input in tight terminals.