mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
Add request-user-input overlay (#9585)
- Add request-user-input overlay and routing in the TUI
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
151
codex-rs/tui/src/bottom_pane/request_user_input/layout.rs
Normal file
151
codex-rs/tui/src/bottom_pane/request_user_input/layout.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
732
codex-rs/tui/src/bottom_pane/request_user_input/mod.rs
Normal file
732
codex-rs/tui/src/bottom_pane/request_user_input/mod.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
375
codex-rs/tui/src/bottom_pane/request_user_input/render.rs
Normal file
375
codex-rs/tui/src/bottom_pane/request_user_input/render.rs
Normal 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: "
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
39
docs/tui-request-user-input.md
Normal file
39
docs/tui-request-user-input.md
Normal 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.
|
||||
Reference in New Issue
Block a user