tui: clarify pending steer follow-ups (#13841)

## Summary
- split the pending input preview into labeled pending-steer and queued
follow-up sections
- explain that pending steers submit after the next tool call and that
Esc can interrupt and send them immediately
- treat Esc as an interrupt-plus-resubmit path when pending steers
exist, with updated TUI snapshots and tests

Queues and steers:
<img width="1038" height="263" alt="Screenshot 2026-03-07 at 10 17
17 PM"
src="https://github.com/user-attachments/assets/4ef433ef-27a3-4b7c-ad69-2046f6eb89e6"
/>

After pressing Esc:
<img width="1046" height="320" alt="Screenshot 2026-03-07 at 10 17
21 PM"
src="https://github.com/user-attachments/assets/0f4d89e0-b6b9-486a-9f04-b6021f169ba7"
/>

## Codex author
`codex resume 019cc6f4-2cca-7803-b717-8264526dbd97`

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Charley Cunningham
2026-03-08 20:13:21 -07:00
committed by GitHub
parent f41b1638c9
commit 4ad3b59de3
17 changed files with 406 additions and 136 deletions

View File

@@ -1866,6 +1866,7 @@ async fn make_chatwidget_manual(
startup_tooltip_override: None,
queued_user_messages: VecDeque::new(),
pending_steers: VecDeque::new(),
submit_pending_steers_after_interrupt: false,
queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up),
suppress_session_configured_redraw: false,
pending_notification: None,
@@ -1913,6 +1914,17 @@ fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) -> Op {
}
}
fn next_interrupt_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
loop {
match op_rx.try_recv() {
Ok(Op::Interrupt) => return,
Ok(_) => continue,
Err(TryRecvError::Empty) => panic!("expected interrupt op but queue was empty"),
Err(TryRecvError::Disconnected) => panic!("expected interrupt op but channel closed"),
}
}
}
fn assert_no_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
while let Ok(op) = op_rx.try_recv() {
assert!(
@@ -4378,6 +4390,107 @@ async fn manual_interrupt_restores_pending_steers_to_composer() {
);
}
#[tokio::test]
async fn esc_interrupt_sends_all_pending_steers_immediately_and_keeps_existing_draft() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
chat.on_agent_message_delta("Final answer line\n".to_string());
chat.bottom_pane
.set_composer_text("first pending steer".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "first pending steer".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected Op::UserTurn, got {other:?}"),
}
chat.bottom_pane
.set_composer_text("second pending steer".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "second pending steer".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected Op::UserTurn, got {other:?}"),
}
chat.queued_user_messages
.push_back(UserMessage::from("queued draft".to_string()));
chat.refresh_pending_input_preview();
chat.bottom_pane
.set_composer_text("still editing".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
next_interrupt_op(&mut op_rx);
chat.on_interrupted_turn(TurnAbortReason::Interrupted);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "first pending steer\nsecond pending steer".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected merged pending steers to submit, got {other:?}"),
}
assert!(chat.pending_steers.is_empty());
assert_eq!(chat.bottom_pane.composer_text(), "still editing");
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().text,
"queued draft"
);
let inserted = drain_insert_history(&mut rx);
assert!(
inserted
.iter()
.any(|cell| lines_to_single_string(cell).contains("first pending steer"))
);
assert!(
inserted
.iter()
.any(|cell| lines_to_single_string(cell).contains("second pending steer"))
);
}
#[tokio::test]
async fn esc_with_pending_steers_overrides_agent_command_interrupt_behavior() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
chat.bottom_pane
.set_composer_text("pending steer".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match next_submit_op(&mut op_rx) {
Op::UserTurn { .. } => {}
other => panic!("expected Op::UserTurn, got {other:?}"),
}
chat.bottom_pane
.set_composer_text("/agent ".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
next_interrupt_op(&mut op_rx);
assert_eq!(chat.bottom_pane.composer_text(), "/agent ");
}
#[tokio::test]
async fn manual_interrupt_restores_pending_steer_mention_bindings_to_composer() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
@@ -6249,6 +6362,42 @@ async fn interrupted_turn_error_message_snapshot() {
assert_snapshot!("interrupted_turn_error_message", last);
}
// Snapshot test: interrupting specifically to submit pending steers shows an
// informational banner instead of the generic "tell the model what to do
// differently" error prompt.
#[tokio::test]
async fn interrupted_turn_pending_steers_message_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.pending_steers.push_back(pending_steer("steer 1"));
chat.submit_pending_steers_after_interrupt = true;
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
}),
});
let cells = drain_insert_history(&mut rx);
let info = cells
.iter()
.map(|cell| lines_to_single_string(cell))
.find(|line| line.contains("Model interrupted to submit steer instructions."))
.expect("expected steer interrupt info message to be inserted");
assert_snapshot!("interrupted_turn_pending_steers_message", info);
}
/// Opening custom prompt from the review popup, pressing Esc returns to the
/// parent popup, pressing Esc again dismisses all panels (back to normal mode).
#[tokio::test]