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);