tui: align pending steers with core acceptance (#12868)

## Summary
- submit `Enter` steers immediately while a turn is already running
instead of routing them through `queued_user_messages`
- keep those submitted steers visible in the footer as `pending_steers`
until core records them as a user message or aborts the turn
- reconcile pending steers on `ItemCompleted(UserMessage)`, not
`RawResponseItem`
- emit user-message item lifecycle for leftover pending input at task
finish, then remove the TUI `TurnComplete` fallback
- keep `queued_user_messages` for actual queued drafts, rendered below
pending steers

## Problem
While the assistant was generating, pressing `Enter` could send the
input into `queued_user_messages`. That queue only drains after the turn
ends, so ordinary steers behaved like queued drafts instead of landing
at the next core sampling boundary.

The first version of this fix also used `RawResponseItem` to decide when
a steer had landed. Review feedback was that this is the wrong
abstraction for client behavior.

There was also a late edge case in core: if pending steer input was
accepted after the final sampling decision but before `TurnComplete`,
core would record that user message into history at task finish without
emitting `ItemStarted(UserMessage)` / `ItemCompleted(UserMessage)`. TUI
had a fallback to paper over that gap locally.

## Approach
- `Enter` during an active turn now submits a normal `Op::UserTurn`
immediately
- TUI keeps a local pending-steer preview instead of rendering that user
message into history immediately
- when core records the steer as `ItemCompleted(UserMessage)`, TUI
matches and removes the corresponding pending preview, then renders the
committed user message
- core now emits the same user-message lifecycle when
`on_task_finished(...)` drains leftover pending user input, before
`TurnComplete`
- with that lifecycle gap closed in core, TUI no longer needs to flush
pending steers into history on `TurnComplete`
- if the turn is interrupted, pending steers and queued drafts are both
restored into the composer, with pending steers first

## Notes
- `Tab` still uses the real queued-message path
- `queued_user_messages` and `pending_steers` are separate state with
separate semantics
- the pending-steer matching key is built directly from `UserInput`
- this removes the new TUI dependency on `RawResponseItem`

## Validation
- `just fmt`
- `cargo test -p codex-core
task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input --
--nocapture`
- `cargo test -p codex-tui`
This commit is contained in:
Charley Cunningham
2026-03-03 15:31:52 -08:00
committed by GitHub
parent 24a2d0c696
commit 299b8ac445
15 changed files with 1247 additions and 264 deletions

View File

@@ -17,8 +17,8 @@ use std::path::PathBuf;
use crate::app_event::ConnectorsSnapshot;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::pending_input_preview::PendingInputPreview;
use crate::bottom_pane::pending_thread_approvals::PendingThreadApprovals;
use crate::bottom_pane::queued_user_messages::QueuedUserMessages;
use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
use crate::key_hint;
use crate::key_hint::KeyBinding;
@@ -93,9 +93,9 @@ pub(crate) use skills_toggle_view::SkillsToggleView;
pub(crate) use status_line_setup::StatusLineItem;
pub(crate) use status_line_setup::StatusLineSetupView;
mod paste_burst;
mod pending_input_preview;
mod pending_thread_approvals;
pub mod popup_consts;
mod queued_user_messages;
mod scroll_state;
mod selection_popup_common;
mod textarea;
@@ -172,8 +172,8 @@ pub(crate) struct BottomPane {
/// When a status row exists, this summary is mirrored inline in that row;
/// when no status row exists, it renders as its own footer row.
unified_exec_footer: UnifiedExecFooter,
/// Queued user messages to show above the composer while a turn is running.
queued_user_messages: QueuedUserMessages,
/// Preview of pending steers and queued drafts shown above the composer.
pending_input_preview: PendingInputPreview,
/// Inactive threads with pending approval requests.
pending_thread_approvals: PendingThreadApprovals,
context_window_percent: Option<i64>,
@@ -223,7 +223,7 @@ impl BottomPane {
is_task_running: false,
status: None,
unified_exec_footer: UnifiedExecFooter::new(),
queued_user_messages: QueuedUserMessages::new(),
pending_input_preview: PendingInputPreview::new(),
pending_thread_approvals: PendingThreadApprovals::new(),
esc_backtrack_hint: false,
animations_enabled,
@@ -317,7 +317,7 @@ impl BottomPane {
/// Update the key hint shown next to queued messages so it matches the
/// binding that `ChatWidget` actually listens for.
pub(crate) fn set_queued_message_edit_binding(&mut self, binding: KeyBinding) {
self.queued_user_messages.set_edit_binding(binding);
self.pending_input_preview.set_edit_binding(binding);
self.request_redraw();
}
@@ -774,9 +774,14 @@ impl BottomPane {
true
}
/// Update the queued messages preview shown above the composer.
pub(crate) fn set_queued_user_messages(&mut self, queued: Vec<String>) {
self.queued_user_messages.messages = queued;
/// Update the pending-input preview shown above the composer.
pub(crate) fn set_pending_input_preview(
&mut self,
queued: Vec<String>,
pending_steers: Vec<String>,
) {
self.pending_input_preview.pending_steers = pending_steers;
self.pending_input_preview.queued_messages = queued;
self.request_redraw();
}
@@ -1019,18 +1024,19 @@ impl BottomPane {
flex.push(0, RenderableItem::Borrowed(&self.unified_exec_footer));
}
let has_pending_thread_approvals = !self.pending_thread_approvals.is_empty();
let has_queued_messages = !self.queued_user_messages.messages.is_empty();
let has_pending_input = !self.pending_input_preview.queued_messages.is_empty()
|| !self.pending_input_preview.pending_steers.is_empty();
let has_status_or_footer =
self.status.is_some() || !self.unified_exec_footer.is_empty();
let has_inline_previews = has_pending_thread_approvals || has_queued_messages;
let has_inline_previews = has_pending_thread_approvals || has_pending_input;
if has_inline_previews && has_status_or_footer {
flex.push(0, RenderableItem::Owned("".into()));
}
flex.push(1, RenderableItem::Borrowed(&self.pending_thread_approvals));
if has_pending_thread_approvals && has_queued_messages {
if has_pending_thread_approvals && has_pending_input {
flex.push(0, RenderableItem::Owned("".into()));
}
flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages));
flex.push(1, RenderableItem::Borrowed(&self.pending_input_preview));
if !has_inline_previews && has_status_or_footer {
flex.push(0, RenderableItem::Owned("".into()));
}
@@ -1406,7 +1412,7 @@ mod tests {
StatusDetailsCapitalization::CapitalizeFirst,
STATUS_DETAILS_DEFAULT_MAX_LINES,
);
pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]);
pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new());
let width = 48;
let height = pane.desired_height(width);
@@ -1433,7 +1439,7 @@ mod tests {
});
pane.set_task_running(true);
pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]);
pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new());
pane.hide_status_indicator();
let width = 48;
@@ -1461,7 +1467,7 @@ mod tests {
});
pane.set_task_running(true);
pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]);
pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new());
let width = 48;
let height = pane.desired_height(width);

View File

@@ -10,23 +10,26 @@ use crate::render::renderable::Renderable;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_lines;
/// Widget that displays a list of user messages queued while a turn is in progress.
/// Widget that displays pending steers plus user messages queued while a turn is in progress.
///
/// The widget shows a key hint at the bottom (e.g. "⌥ + ↑ edit") telling the
/// user how to pop the most recent queued message back into the composer.
/// Because some terminals intercept certain modifier-key combinations, the
/// displayed binding is configurable via [`set_edit_binding`](Self::set_edit_binding).
pub(crate) struct QueuedUserMessages {
pub messages: Vec<String>,
/// The widget shows pending steers first, then queued user messages. It only
/// shows the edit hint at the bottom (e.g. "⌥ + ↑ edit") when there are actual
/// queued user messages to pop back into the composer. Because some terminals
/// intercept certain modifier-key combinations, the displayed binding is
/// configurable via [`set_edit_binding`](Self::set_edit_binding).
pub(crate) struct PendingInputPreview {
pub pending_steers: Vec<String>,
pub queued_messages: Vec<String>,
/// Key combination rendered in the hint line. Defaults to Alt+Up but may
/// be overridden for terminals where that chord is unavailable.
edit_binding: key_hint::KeyBinding,
}
impl QueuedUserMessages {
impl PendingInputPreview {
pub(crate) fn new() -> Self {
Self {
messages: Vec::new(),
pending_steers: Vec::new(),
queued_messages: Vec::new(),
edit_binding: key_hint::alt(KeyCode::Up),
}
}
@@ -39,13 +42,31 @@ impl QueuedUserMessages {
}
fn as_renderable(&self, width: u16) -> Box<dyn Renderable> {
if self.messages.is_empty() || width < 4 {
if (self.pending_steers.is_empty() && self.queued_messages.is_empty()) || width < 4 {
return Box::new(());
}
let mut lines = vec![];
for message in &self.messages {
for steer in &self.pending_steers {
let wrapped = adaptive_wrap_lines(
steer
.lines()
.map(|line| format!("pending steer: {line}").dim()),
RtOptions::new(width as usize)
.initial_indent(Line::from(" ! ".dim()))
.subsequent_indent(Line::from(" ")),
);
let len = wrapped.len();
for line in wrapped.into_iter().take(3) {
lines.push(line);
}
if len > 3 {
lines.push(Line::from("".dim()));
}
}
for message in &self.queued_messages {
let wrapped = adaptive_wrap_lines(
message.lines().map(|line| line.dim().italic()),
RtOptions::new(width as usize)
@@ -61,20 +82,22 @@ impl QueuedUserMessages {
}
}
lines.push(
Line::from(vec![
" ".into(),
self.edit_binding.into(),
" edit".into(),
])
.dim(),
);
if !self.queued_messages.is_empty() {
lines.push(
Line::from(vec![
" ".into(),
self.edit_binding.into(),
" edit".into(),
])
.dim(),
);
}
Paragraph::new(lines).into()
}
}
impl Renderable for QueuedUserMessages {
impl Renderable for PendingInputPreview {
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
@@ -96,21 +119,21 @@ mod tests {
#[test]
fn desired_height_empty() {
let queue = QueuedUserMessages::new();
let queue = PendingInputPreview::new();
assert_eq!(queue.desired_height(40), 0);
}
#[test]
fn desired_height_one_message() {
let mut queue = QueuedUserMessages::new();
queue.messages.push("Hello, world!".to_string());
let mut queue = PendingInputPreview::new();
queue.queued_messages.push("Hello, world!".to_string());
assert_eq!(queue.desired_height(40), 2);
}
#[test]
fn render_one_message() {
let mut queue = QueuedUserMessages::new();
queue.messages.push("Hello, world!".to_string());
let mut queue = PendingInputPreview::new();
queue.queued_messages.push("Hello, world!".to_string());
let width = 40;
let height = queue.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
@@ -120,9 +143,11 @@ mod tests {
#[test]
fn render_two_messages() {
let mut queue = QueuedUserMessages::new();
queue.messages.push("Hello, world!".to_string());
queue.messages.push("This is another message".to_string());
let mut queue = PendingInputPreview::new();
queue.queued_messages.push("Hello, world!".to_string());
queue
.queued_messages
.push("This is another message".to_string());
let width = 40;
let height = queue.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
@@ -132,11 +157,17 @@ mod tests {
#[test]
fn render_more_than_three_messages() {
let mut queue = QueuedUserMessages::new();
queue.messages.push("Hello, world!".to_string());
queue.messages.push("This is another message".to_string());
queue.messages.push("This is a third message".to_string());
queue.messages.push("This is a fourth message".to_string());
let mut queue = PendingInputPreview::new();
queue.queued_messages.push("Hello, world!".to_string());
queue
.queued_messages
.push("This is another message".to_string());
queue
.queued_messages
.push("This is a third message".to_string());
queue
.queued_messages
.push("This is a fourth message".to_string());
let width = 40;
let height = queue.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
@@ -146,11 +177,13 @@ mod tests {
#[test]
fn render_wrapped_message() {
let mut queue = QueuedUserMessages::new();
let mut queue = PendingInputPreview::new();
queue
.messages
.queued_messages
.push("This is a longer message that should be wrapped".to_string());
queue.messages.push("This is another message".to_string());
queue
.queued_messages
.push("This is another message".to_string());
let width = 40;
let height = queue.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
@@ -160,9 +193,9 @@ mod tests {
#[test]
fn render_many_line_message() {
let mut queue = QueuedUserMessages::new();
let mut queue = PendingInputPreview::new();
queue
.messages
.queued_messages
.push("This is\na message\nwith many\nlines".to_string());
let width = 40;
let height = queue.desired_height(width);
@@ -173,8 +206,8 @@ mod tests {
#[test]
fn long_url_like_message_does_not_expand_into_wrapped_ellipsis_rows() {
let mut queue = QueuedUserMessages::new();
queue.messages.push(
let mut queue = PendingInputPreview::new();
queue.queued_messages.push(
"example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/session_id=abc123def456ghi789"
.to_string(),
);
@@ -202,4 +235,35 @@ mod tests {
"expected no wrapped-ellipsis row for URL-like token, got rows: {rendered_rows:?}"
);
}
#[test]
fn render_one_pending_steer() {
let mut queue = PendingInputPreview::new();
queue.pending_steers.push("Please continue.".to_string());
let width = 48;
let height = queue.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
queue.render(Rect::new(0, 0, width, height), &mut buf);
assert_snapshot!("render_one_pending_steer", format!("{buf:?}"));
}
#[test]
fn render_pending_steers_above_queued_messages() {
let mut queue = PendingInputPreview::new();
queue.pending_steers.push("Please continue.".to_string());
queue
.pending_steers
.push("Check the last command output.".to_string());
queue
.queued_messages
.push("Queued follow-up question".to_string());
let width = 52;
let height = queue.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
queue.render(Rect::new(0, 0, width, height), &mut buf);
assert_snapshot!(
"render_pending_steers_above_queued_messages",
format!("{buf:?}")
);
}
}

View File

@@ -0,0 +1,15 @@
---
source: tui/src/bottom_pane/pending_input_preview.rs
assertion_line: 237
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 48, height: 1 },
content: [
" ! pending steer: Please continue. ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 35, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}

View File

@@ -0,0 +1,25 @@
---
source: tui/src/bottom_pane/pending_input_preview.rs
assertion_line: 252
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 52, height: 4 },
content: [
" ! pending steer: Please continue. ",
" ! pending steer: Check the last command output. ",
" ↳ Queued follow-up question ",
" ⌥ + ↑ edit ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 35, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 49, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 29, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 14, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}