From 5f55ed666b7551e030d0d0bf7576dd69909d512f Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 21 Jan 2026 00:19:35 -0800 Subject: [PATCH] Add request-user-input overlay (#9585) - Add request-user-input overlay and routing in the TUI --- codex-rs/tui/src/app.rs | 30 +- .../tui/src/bottom_pane/bottom_pane_view.rs | 10 + codex-rs/tui/src/bottom_pane/mod.rs | 27 + .../bottom_pane/request_user_input/layout.rs | 151 ++++ .../src/bottom_pane/request_user_input/mod.rs | 732 ++++++++++++++++++ .../bottom_pane/request_user_input/render.rs | 375 +++++++++ ...t__tests__request_user_input_freeform.snap | 12 + ...ut__tests__request_user_input_options.snap | 20 + ..._request_user_input_scrolling_options.snap | 14 + ...ests__request_user_input_tight_height.snap | 12 + codex-rs/tui/src/chatwidget.rs | 21 +- codex-rs/tui/src/chatwidget/interrupts.rs | 7 + docs/tui-request-user-input.md | 39 + 13 files changed, 1447 insertions(+), 3 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/request_user_input/layout.rs create mode 100644 codex-rs/tui/src/bottom_pane/request_user_input/mod.rs create mode 100644 codex-rs/tui/src/bottom_pane/request_user_input/render.rs create mode 100644 codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap create mode 100644 codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap create mode 100644 codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap create mode 100644 codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap create mode 100644 docs/tui-request-user-input.md diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8d879d55a8..4378ec602f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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 }); } _ => {} diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 499801cbb0..b3be4fed1f 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -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 { 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 { + Some(request) + } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 3057544197..f249e20f0e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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) { diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs new file mode 100644 index 0000000000..fd5066c994 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs @@ -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, + 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::>() + }) + .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, + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs new file mode 100644 index 0000000000..960ae97d46 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -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, +} + +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, + // 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, +} + +pub(crate) struct RequestUserInputOverlay { + app_event_tx: AppEventSender, + request: RequestUserInputEvent, + // Queue of incoming requests to process after the current one. + queue: VecDeque, + answers: Vec, + 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 { + 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::>(); + // 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 { + 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, + ) { + let (tx_raw, rx) = unbounded_channel::(); + (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, + ) -> 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::::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) + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs new file mode 100644 index 0000000000..68b7eefdc3 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs @@ -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::>() + }) + .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: " +} diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap new file mode 100644 index 0000000000..b698776b13 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap new file mode 100644 index 0000000000..bc3d7d55b2 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap new file mode 100644 index 0000000000..22cb487413 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap new file mode 100644 index 0000000000..e1d01df7af --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a004c472f8..0d0624cff9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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(_) => {} } } diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index dc1e683ea5..5241c79b89 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -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), diff --git a/docs/tui-request-user-input.md b/docs/tui-request-user-input.md new file mode 100644 index 0000000000..f8eb7aeb73 --- /dev/null +++ b/docs/tui-request-user-input.md @@ -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.