TUI: Render request_user_input results in history and simplify interrupt handling (#10064)
## Summary This PR improves the TUI experience for `request_user_input` by rendering submitted question/answer sets directly in conversation history with clear, structured formatting. It also intentionally simplifies interrupt behavior for now: on `Esc` / `Ctrl+C`, the questions overlay interrupts the turn without attempting to submit partial answers. <img width="1344" height="573" alt="Screenshot 2026-02-02 at 4 51 40 PM" src="https://github.com/user-attachments/assets/ff752131-7060-44c1-9ded-af061969a533" /> ## Scope - TUI-only changes. - No core/protocol/app-server behavior changes in this PR. - Resume reconstruction of interrupted question sets is out of scope for this PR. ## What Changed - Added a new history cell: `RequestUserInputResultCell` in `codex-rs/tui/src/history_cell.rs`. - On normal `request_user_input` submission, TUI now inserts that history cell immediately after sending `Op::UserInputAnswer`. - Rendering includes a `Questions` header with `answered/total` count. - Rendering shows each question as a bullet item. - Rendering styles submitted answer lines in cyan. - Rendering styles notes (for option questions) as `note:` lines in cyan. - Rendering styles freeform text (for no-option questions) as `answer:` lines in cyan. - Rendering dims only the `(unanswered)` suffix. - Rendering can include an interrupted suffix and summary text when the cell is marked interrupted. - Rendering redacts secret questions as `••••••` instead of showing raw values. - Added `wrap_with_prefix(...)` in `history_cell.rs` for wrapped prefixed lines. - Added `split_request_user_input_answer(...)` in `history_cell.rs` for decoding `"user_note: ..."` entries. ## Interrupt Behavior (Intentional for this PR) - `Esc` / `Ctrl+C` in the questions overlay now performs `Op::Interrupt` and exits the overlay. - It does **not** submit partial/committed answers on interrupt. - Added TODO comments in `request_user_input` overlay interrupt paths indicating where interrupted partial result emission should be reintroduced once core support is finalized. - Queued `request_user_input` overlays are discarded on interrupt in the current behavior. ## Tests Updated - Updated/added overlay tests in `codex-rs/tui/src/bottom_pane/request_user_input/mod.rs` to reflect interrupt-only behavior. - Added helper assertion for interrupt-only event expectation. - Existing submission-path tests now validate history insertion behavior and expected answer maps. ## Behavior Notes - Completed question flows now produce a readable `Questions` block in transcript history. - Interrupted flows currently do not persist partial answers to model-visible tool output. ## Follow-ups - Reintroduce partial-answer-on-interrupt semantics once core can persist/sequence interrupted `request_user_input` outputs safely. - Optionally add replay/resume rendering for interrupted question sets as a separate PR. ## Codex author `codex fork 019bfb8d-2a65-7313-9be2-ea7100d19a61`
This commit is contained in:
parent
1096d6453c
commit
8f5edddf71
2 changed files with 262 additions and 25 deletions
|
|
@ -27,6 +27,7 @@ use crate::bottom_pane::bottom_pane_view::BottomPaneView;
|
|||
use crate::bottom_pane::scroll_state::ScrollState;
|
||||
use crate::bottom_pane::selection_popup_common::GenericDisplayRow;
|
||||
use crate::bottom_pane::selection_popup_common::measure_rows_height;
|
||||
use crate::history_cell;
|
||||
use crate::render::renderable::Renderable;
|
||||
|
||||
use codex_core::protocol::Op;
|
||||
|
|
@ -722,8 +723,17 @@ impl RequestUserInputOverlay {
|
|||
self.app_event_tx
|
||||
.send(AppEvent::CodexOp(Op::UserInputAnswer {
|
||||
id: self.request.turn_id.clone(),
|
||||
response: RequestUserInputResponse { answers },
|
||||
response: RequestUserInputResponse {
|
||||
answers: answers.clone(),
|
||||
},
|
||||
}));
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::RequestUserInputResultCell {
|
||||
questions: self.request.questions.clone(),
|
||||
answers,
|
||||
interrupted: false,
|
||||
},
|
||||
)));
|
||||
if let Some(next) = self.queue.pop_front() {
|
||||
self.request = next;
|
||||
self.reset_for_request();
|
||||
|
|
@ -966,6 +976,8 @@ impl BottomPaneView for RequestUserInputOverlay {
|
|||
}
|
||||
|
||||
if matches!(key_event.code, KeyCode::Esc) {
|
||||
// TODO: Emit interrupted request_user_input results (including committed answers)
|
||||
// once core supports persisting them reliably without follow-up turn issues.
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
|
||||
self.done = true;
|
||||
return;
|
||||
|
|
@ -1173,6 +1185,8 @@ impl BottomPaneView for RequestUserInputOverlay {
|
|||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
if self.confirm_unanswered_active() {
|
||||
self.close_unanswered_confirmation();
|
||||
// TODO: Emit interrupted request_user_input results (including committed answers)
|
||||
// once core supports persisting them reliably without follow-up turn issues.
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
|
||||
self.done = true;
|
||||
return CancellationEvent::Handled;
|
||||
|
|
@ -1182,6 +1196,8 @@ impl BottomPaneView for RequestUserInputOverlay {
|
|||
return CancellationEvent::Handled;
|
||||
}
|
||||
|
||||
// TODO: Emit interrupted request_user_input results (including committed answers)
|
||||
// once core supports persisting them reliably without follow-up turn issues.
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
|
||||
self.done = true;
|
||||
CancellationEvent::Handled
|
||||
|
|
@ -1234,6 +1250,7 @@ mod tests {
|
|||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
|
|
@ -1245,6 +1262,18 @@ mod tests {
|
|||
(AppEventSender::new(tx_raw), rx)
|
||||
}
|
||||
|
||||
fn expect_interrupt_only(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) {
|
||||
let event = rx.try_recv().expect("expected interrupt AppEvent");
|
||||
let AppEvent::CodexOp(op) = event else {
|
||||
panic!("expected CodexOp");
|
||||
};
|
||||
assert_eq!(op, Op::Interrupt);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"unexpected AppEvents before interrupt completion"
|
||||
);
|
||||
}
|
||||
|
||||
fn question_with_options(id: &str, header: &str) -> RequestUserInputQuestion {
|
||||
RequestUserInputQuestion {
|
||||
id: id.to_string(),
|
||||
|
|
@ -1389,6 +1418,33 @@ mod tests {
|
|||
assert_eq!(overlay.request.turn_id, "turn-3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupt_discards_queued_requests_and_emits_interrupt() {
|
||||
let (tx, mut rx) = test_sender();
|
||||
let mut overlay = RequestUserInputOverlay::new(
|
||||
request_event("turn-1", vec![question_with_options("q1", "First")]),
|
||||
tx,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
overlay.try_consume_user_input_request(RequestUserInputEvent {
|
||||
call_id: "call-2".to_string(),
|
||||
turn_id: "turn-2".to_string(),
|
||||
questions: vec![question_with_options("q2", "Second")],
|
||||
});
|
||||
overlay.try_consume_user_input_request(RequestUserInputEvent {
|
||||
call_id: "call-3".to_string(),
|
||||
turn_id: "turn-3".to_string(),
|
||||
questions: vec![question_with_options("q3", "Third")],
|
||||
});
|
||||
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
|
||||
assert!(overlay.done, "expected overlay to be done");
|
||||
expect_interrupt_only(&mut rx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_can_submit_empty_when_unanswered() {
|
||||
let (tx, mut rx) = test_sender();
|
||||
|
|
@ -1403,7 +1459,7 @@ mod tests {
|
|||
overlay.submit_answers();
|
||||
|
||||
let event = rx.try_recv().expect("expected AppEvent");
|
||||
let AppEvent::CodexOp(Op::UserInputAnswer { id, response }) = event else {
|
||||
let AppEvent::CodexOp(Op::UserInputAnswer { id, response, .. }) = event else {
|
||||
panic!("expected UserInputAnswer");
|
||||
};
|
||||
assert_eq!(id, "turn-1");
|
||||
|
|
@ -1454,15 +1510,30 @@ mod tests {
|
|||
let first_answer = &overlay.answers[0];
|
||||
assert!(first_answer.answer_committed);
|
||||
assert_eq!(first_answer.options_state.selected_idx, Some(0));
|
||||
assert!(rx.try_recv().is_err());
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"unexpected AppEvent before full submission"
|
||||
);
|
||||
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
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.answers, vec!["Option 1".to_string()]);
|
||||
let mut expected = HashMap::new();
|
||||
expected.insert(
|
||||
"q1".to_string(),
|
||||
RequestUserInputAnswer {
|
||||
answers: vec!["Option 1".to_string()],
|
||||
},
|
||||
);
|
||||
expected.insert(
|
||||
"q2".to_string(),
|
||||
RequestUserInputAnswer {
|
||||
answers: vec!["Option 1".to_string()],
|
||||
},
|
||||
);
|
||||
assert_eq!(response.answers, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1630,6 +1701,10 @@ mod tests {
|
|||
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
assert!(overlay.confirm_unanswered_active());
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"unexpected AppEvent before confirmation submit"
|
||||
);
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Char('1')));
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
|
||||
|
|
@ -1657,11 +1732,7 @@ mod tests {
|
|||
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
|
||||
assert_eq!(overlay.done, true);
|
||||
let event = rx.try_recv().expect("expected AppEvent");
|
||||
let AppEvent::CodexOp(op) = event else {
|
||||
panic!("expected CodexOp");
|
||||
};
|
||||
assert_eq!(op, Op::Interrupt);
|
||||
expect_interrupt_only(&mut rx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1678,11 +1749,7 @@ mod tests {
|
|||
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
|
||||
assert_eq!(overlay.done, true);
|
||||
let event = rx.try_recv().expect("expected AppEvent");
|
||||
let AppEvent::CodexOp(op) = event else {
|
||||
panic!("expected CodexOp");
|
||||
};
|
||||
assert_eq!(op, Op::Interrupt);
|
||||
expect_interrupt_only(&mut rx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1697,16 +1764,13 @@ mod tests {
|
|||
);
|
||||
let answer = overlay.current_answer_mut().expect("answer missing");
|
||||
answer.options_state.selected_idx = Some(0);
|
||||
answer.answer_committed = true;
|
||||
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Tab));
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
|
||||
assert_eq!(overlay.done, true);
|
||||
let event = rx.try_recv().expect("expected AppEvent");
|
||||
let AppEvent::CodexOp(op) = event else {
|
||||
panic!("expected CodexOp");
|
||||
};
|
||||
assert_eq!(op, Op::Interrupt);
|
||||
expect_interrupt_only(&mut rx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1721,17 +1785,42 @@ mod tests {
|
|||
);
|
||||
let answer = overlay.current_answer_mut().expect("answer missing");
|
||||
answer.options_state.selected_idx = Some(0);
|
||||
answer.answer_committed = true;
|
||||
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Tab));
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Char('a')));
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
|
||||
assert_eq!(overlay.done, true);
|
||||
let event = rx.try_recv().expect("expected AppEvent");
|
||||
let AppEvent::CodexOp(op) = event else {
|
||||
panic!("expected CodexOp");
|
||||
};
|
||||
assert_eq!(op, Op::Interrupt);
|
||||
expect_interrupt_only(&mut rx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_drops_committed_answers() {
|
||||
let (tx, mut rx) = test_sender();
|
||||
let mut overlay = RequestUserInputOverlay::new(
|
||||
request_event(
|
||||
"turn-1",
|
||||
vec![
|
||||
question_with_options("q1", "First"),
|
||||
question_without_options("q2", "Second"),
|
||||
],
|
||||
),
|
||||
tx,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"unexpected AppEvent before interruption"
|
||||
);
|
||||
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
|
||||
expect_interrupt_only(&mut rx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1961,6 +2050,7 @@ mod tests {
|
|||
overlay.composer.move_cursor_to_end();
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
assert_eq!(overlay.answers[0].answer_committed, true);
|
||||
let _ = rx.try_recv();
|
||||
|
||||
overlay.move_question(false);
|
||||
overlay
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
|||
use codex_protocol::plan_tool::PlanItemArg;
|
||||
use codex_protocol::plan_tool::StepStatus;
|
||||
use codex_protocol::plan_tool::UpdatePlanArgs;
|
||||
use codex_protocol::request_user_input::RequestUserInputAnswer;
|
||||
use codex_protocol::request_user_input::RequestUserInputQuestion;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use image::DynamicImage;
|
||||
use image::ImageReader;
|
||||
|
|
@ -1762,6 +1764,151 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
|
|||
PlainHistoryCell { lines }
|
||||
}
|
||||
|
||||
/// Renders a completed (or interrupted) request_user_input exchange in history.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RequestUserInputResultCell {
|
||||
pub(crate) questions: Vec<RequestUserInputQuestion>,
|
||||
pub(crate) answers: HashMap<String, RequestUserInputAnswer>,
|
||||
pub(crate) interrupted: bool,
|
||||
}
|
||||
|
||||
impl HistoryCell for RequestUserInputResultCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let width = width.max(1) as usize;
|
||||
let total = self.questions.len();
|
||||
let answered = self
|
||||
.questions
|
||||
.iter()
|
||||
.filter(|question| {
|
||||
self.answers
|
||||
.get(&question.id)
|
||||
.is_some_and(|answer| !answer.answers.is_empty())
|
||||
})
|
||||
.count();
|
||||
let unanswered = total.saturating_sub(answered);
|
||||
|
||||
let mut header = vec!["•".dim(), " ".into(), "Questions".bold()];
|
||||
header.push(format!(" {answered}/{total} answered").dim());
|
||||
if self.interrupted {
|
||||
header.push(" (interrupted)".cyan());
|
||||
}
|
||||
|
||||
let mut lines: Vec<Line<'static>> = vec![header.into()];
|
||||
|
||||
for question in &self.questions {
|
||||
let answer = self.answers.get(&question.id);
|
||||
let answer_missing = match answer {
|
||||
Some(answer) => answer.answers.is_empty(),
|
||||
None => true,
|
||||
};
|
||||
let mut question_lines = wrap_with_prefix(
|
||||
&question.question,
|
||||
width,
|
||||
" • ".into(),
|
||||
" ".into(),
|
||||
Style::default(),
|
||||
);
|
||||
if answer_missing && let Some(last) = question_lines.last_mut() {
|
||||
last.spans.push(" (unanswered)".dim());
|
||||
}
|
||||
lines.extend(question_lines);
|
||||
|
||||
let Some(answer) = answer.filter(|answer| !answer.answers.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
if question.is_secret {
|
||||
lines.extend(wrap_with_prefix(
|
||||
"••••••",
|
||||
width,
|
||||
" answer: ".dim(),
|
||||
" ".dim(),
|
||||
Style::default().fg(Color::Cyan),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
let (options, note) = split_request_user_input_answer(answer);
|
||||
|
||||
for option in options {
|
||||
lines.extend(wrap_with_prefix(
|
||||
&option,
|
||||
width,
|
||||
" answer: ".dim(),
|
||||
" ".dim(),
|
||||
Style::default().fg(Color::Cyan),
|
||||
));
|
||||
}
|
||||
if let Some(note) = note {
|
||||
let (label, continuation, style) = if question.options.is_some() {
|
||||
(
|
||||
" note: ".dim(),
|
||||
" ".dim(),
|
||||
Style::default().fg(Color::Cyan),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
" answer: ".dim(),
|
||||
" ".dim(),
|
||||
Style::default().fg(Color::Cyan),
|
||||
)
|
||||
};
|
||||
lines.extend(wrap_with_prefix(¬e, width, label, continuation, style));
|
||||
}
|
||||
}
|
||||
|
||||
if self.interrupted && unanswered > 0 {
|
||||
let summary = format!("interrupted with {unanswered} unanswered");
|
||||
lines.extend(wrap_with_prefix(
|
||||
&summary,
|
||||
width,
|
||||
" ↳ ".cyan().dim(),
|
||||
" ".dim(),
|
||||
Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM),
|
||||
));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a plain string with textwrap and prefix each line, while applying a style to the content.
|
||||
fn wrap_with_prefix(
|
||||
text: &str,
|
||||
width: usize,
|
||||
initial_prefix: Span<'static>,
|
||||
subsequent_prefix: Span<'static>,
|
||||
style: Style,
|
||||
) -> Vec<Line<'static>> {
|
||||
let prefix_width = initial_prefix
|
||||
.content
|
||||
.width()
|
||||
.max(subsequent_prefix.content.width());
|
||||
let wrap_width = width.saturating_sub(prefix_width).max(1);
|
||||
let wrapped = textwrap::wrap(text, wrap_width);
|
||||
let wrapped_lines = wrapped
|
||||
.into_iter()
|
||||
.map(|segment| Span::from(segment.to_string()).set_style(style).into())
|
||||
.collect::<Vec<Line<'static>>>();
|
||||
prefix_lines(wrapped_lines, initial_prefix, subsequent_prefix)
|
||||
}
|
||||
|
||||
/// Split a request_user_input answer into option labels and an optional freeform note.
|
||||
/// Notes are encoded as "user_note: <text>" entries in the answers list.
|
||||
fn split_request_user_input_answer(
|
||||
answer: &RequestUserInputAnswer,
|
||||
) -> (Vec<String>, Option<String>) {
|
||||
let mut options = Vec::new();
|
||||
let mut note = None;
|
||||
for entry in &answer.answers {
|
||||
if let Some(note_text) = entry.strip_prefix("user_note: ") {
|
||||
note = Some(note_text.to_string());
|
||||
} else {
|
||||
options.push(entry.clone());
|
||||
}
|
||||
}
|
||||
(options, note)
|
||||
}
|
||||
|
||||
/// Render a user‑friendly plan update styled like a checkbox todo list.
|
||||
pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell {
|
||||
let UpdatePlanArgs { explanation, plan } = update;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue