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
Unverified
parent f41b1638c9
commit 4ad3b59de3
17 changed files with 406 additions and 136 deletions
@@ -12,11 +12,13 @@ use crate::wrapping::adaptive_wrap_lines;
/// Widget that displays pending steers plus user messages queued while a turn is in progress.
///
/// 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).
/// The widget renders pending steers first, then queued user messages, as two
/// labeled sections. Pending steers explain that they will be submitted after
/// the next tool/result boundary unless the user presses Esc to interrupt and
/// send them immediately. The edit hint at the bottom only appears 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>,
@@ -55,6 +57,15 @@ impl PendingInputPreview {
}
}
fn push_section_header(lines: &mut Vec<Line<'static>>, width: u16, header: Line<'static>) {
let mut spans = vec!["".dim()];
spans.extend(header.spans);
lines.extend(adaptive_wrap_lines(
std::iter::once(Line::from(spans)),
RtOptions::new(width as usize).subsequent_indent(Line::from(" ".dim())),
));
}
fn as_renderable(&self, width: u16) -> Box<dyn Renderable> {
if (self.pending_steers.is_empty() && self.queued_messages.is_empty()) || width < 4 {
return Box::new(());
@@ -62,28 +73,48 @@ impl PendingInputPreview {
let mut lines = vec![];
for steer in &self.pending_steers {
let wrapped = adaptive_wrap_lines(
steer.lines().map(|line| Line::from(line.dim())),
RtOptions::new(width as usize)
.initial_indent(Line::from(" ! pending steer: ".dim()))
.subsequent_indent(Line::from(" ")),
if !self.pending_steers.is_empty() {
Self::push_section_header(
&mut lines,
width,
Line::from(vec![
"Messages to be submitted after next tool call".into(),
" (press ".dim(),
key_hint::plain(KeyCode::Esc).into(),
" to interrupt and send immediately)".dim(),
]),
);
Self::push_truncated_preview_lines(&mut lines, wrapped, Line::from("".dim()));
for steer in &self.pending_steers {
let wrapped = adaptive_wrap_lines(
steer.lines().map(|line| Line::from(line.dim())),
RtOptions::new(width as usize)
.initial_indent(Line::from("".dim()))
.subsequent_indent(Line::from(" ")),
);
Self::push_truncated_preview_lines(&mut lines, wrapped, Line::from("".dim()));
}
}
for message in &self.queued_messages {
let wrapped = adaptive_wrap_lines(
message.lines().map(|line| Line::from(line.dim().italic())),
RtOptions::new(width as usize)
.initial_indent(Line::from("".dim()))
.subsequent_indent(Line::from(" ")),
);
Self::push_truncated_preview_lines(
&mut lines,
wrapped,
Line::from(" ".dim().italic()),
);
if !self.queued_messages.is_empty() {
if !lines.is_empty() {
lines.push(Line::from(""));
}
Self::push_section_header(&mut lines, width, "Queued follow-up messages".into());
for message in &self.queued_messages {
let wrapped = adaptive_wrap_lines(
message.lines().map(|line| Line::from(line.dim().italic())),
RtOptions::new(width as usize)
.initial_indent(Line::from(" ".dim()))
.subsequent_indent(Line::from(" ")),
);
Self::push_truncated_preview_lines(
&mut lines,
wrapped,
Line::from("".dim().italic()),
);
}
}
if !self.queued_messages.is_empty() {
@@ -91,7 +122,7 @@ impl PendingInputPreview {
Line::from(vec![
" ".into(),
self.edit_binding.into(),
" edit".into(),
" edit last queued message".into(),
])
.dim(),
);
@@ -131,7 +162,7 @@ mod tests {
fn desired_height_one_message() {
let mut queue = PendingInputPreview::new();
queue.queued_messages.push("Hello, world!".to_string());
assert_eq!(queue.desired_height(40), 2);
assert_eq!(queue.desired_height(40), 3);
}
#[test]
@@ -219,8 +250,8 @@ mod tests {
let width = 36;
let height = queue.desired_height(width);
assert_eq!(
height, 2,
"expected one message row plus hint row for URL-like token"
height, 3,
"expected header, one message row, and hint row for URL-like token"
);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
@@ -1,27 +1,30 @@
---
source: tui/src/bottom_pane/queued_user_messages.rs
source: tui/src/bottom_pane/pending_input_preview.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 40, height: 5 },
area: Rect { x: 0, y: 0, width: 40, height: 6 },
content: [
"• Queued follow-up messages ",
" ↳ This is ",
" a message ",
" with many ",
" … ",
" ⌥ + ↑ edit ",
" ⌥ + ↑ edit last queued message ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 11, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 5, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 13, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 5, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 34, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -1,30 +1,33 @@
---
source: tui/src/bottom_pane/queued_user_messages.rs
source: tui/src/bottom_pane/pending_input_preview.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 40, height: 5 },
area: Rect { x: 0, y: 0, width: 40, height: 6 },
content: [
"• Queued follow-up messages ",
" ↳ Hello, world! ",
" ↳ This is another message ",
" ↳ This is a third message ",
" ↳ This is a fourth message ",
" ⌥ + ↑ edit ",
" ⌥ + ↑ edit last queued message ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 17, 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: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 28, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 27, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 28, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 34, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -3,21 +3,27 @@ source: tui/src/bottom_pane/pending_input_preview.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 48, height: 4 },
area: Rect { x: 0, y: 0, width: 48, height: 6 },
content: [
" ! pending steer: First line ",
"• Messages to be submitted after next tool call ",
" (press esc to interrupt and send immediately) ",
" ↳ First line ",
" Second line ",
" Third line ",
" … ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 29, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 15, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 47, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 5, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 15, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 5, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -1,18 +1,21 @@
---
source: tui/src/bottom_pane/queued_user_messages.rs
source: tui/src/bottom_pane/pending_input_preview.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 40, height: 2 },
area: Rect { x: 0, y: 0, width: 40, height: 3 },
content: [
"• Queued follow-up messages ",
" ↳ Hello, world! ",
" ⌥ + ↑ edit ",
" ⌥ + ↑ edit last queued message ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 34, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -1,15 +1,20 @@
---
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 },
area: Rect { x: 0, y: 0, width: 48, height: 3 },
content: [
" ! pending steer: Please continue. ",
"• Messages to be submitted after next tool call ",
" (press esc to interrupt and send immediately) ",
" ↳ 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,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 47, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 20, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -1,25 +1,34 @@
---
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 },
area: Rect { x: 0, y: 0, width: 52, height: 8 },
content: [
" ! pending steer: Please continue. ",
" ! pending steer: Check the last command output. ",
"• Messages to be submitted after next tool call ",
" (press esc to interrupt and send immediately) ",
" ↳ Please continue. ",
" ↳ Check the last command output. ",
" ",
"• Queued follow-up messages ",
" ↳ Queued follow-up question ",
" ⌥ + ↑ edit ",
" ⌥ + ↑ edit last queued message ",
],
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: 2, 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: 47, 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: 20, 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,
x: 34, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 4, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 29, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 34, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -1,22 +1,25 @@
---
source: tui/src/bottom_pane/queued_user_messages.rs
source: tui/src/bottom_pane/pending_input_preview.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 40, height: 3 },
area: Rect { x: 0, y: 0, width: 40, height: 4 },
content: [
"• Queued follow-up messages ",
" ↳ Hello, world! ",
" ↳ This is another message ",
" ⌥ + ↑ edit ",
" ⌥ + ↑ edit last queued message ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 34, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -1,25 +1,28 @@
---
source: tui/src/bottom_pane/queued_user_messages.rs
source: tui/src/bottom_pane/pending_input_preview.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 40, height: 4 },
area: Rect { x: 0, y: 0, width: 40, height: 5 },
content: [
"• Queued follow-up messages ",
" ↳ This is a longer message that should",
" be wrapped ",
" ↳ This is another message ",
" ⌥ + ↑ edit ",
" ⌥ + ↑ edit last queued message ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, 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,
x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 27, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 34, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -2,8 +2,9 @@
source: tui/src/bottom_pane/mod.rs
expression: "render_snapshot(&pane, area)"
---
• Queued follow-up messages
↳ Queued follow-up question
⌥ + ↑ edit
⌥ + ↑ edit last queued message
Ask Codex to do anything
@@ -4,8 +4,9 @@ expression: "render_snapshot(&pane, area)"
---
• Working (0s • esc to interrupt)
• Queued follow-up messages
↳ Queued follow-up question
⌥ + ↑ edit
⌥ + ↑ edit last queued message
Ask Codex to do anything
@@ -6,8 +6,9 @@ expression: "render_snapshot(&pane, area)"
└ First detail line
Second detail line
• Queued follow-up messages
↳ Queued follow-up question
⌥ + ↑ edit
⌥ + ↑ edit last queued message
Ask Codex to do anything
+87 -40
View File
@@ -630,6 +630,9 @@ pub(crate) struct ChatWidget {
// The bottom pane shows these above queued drafts until core records the
// corresponding user message item.
pending_steers: VecDeque<PendingSteer>,
// When set, the next interrupt should resubmit all pending steers as one
// fresh user turn instead of restoring them into the composer.
submit_pending_steers_after_interrupt: bool,
/// Terminal-appropriate keybinding for popping the most-recently queued
/// message back into the composer. Determined once at construction time via
/// [`queued_message_edit_binding_for_terminal`] and propagated to
@@ -922,6 +925,45 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize)
}
}
fn merge_user_messages(messages: Vec<UserMessage>) -> UserMessage {
let mut combined = UserMessage {
text: String::new(),
text_elements: Vec::new(),
local_images: Vec::new(),
remote_image_urls: Vec::new(),
mention_bindings: Vec::new(),
};
let total_remote_images = messages
.iter()
.map(|message| message.remote_image_urls.len())
.sum::<usize>();
let mut next_image_label = total_remote_images + 1;
for (idx, message) in messages.into_iter().enumerate() {
if idx > 0 {
combined.text.push('\n');
}
let UserMessage {
text,
text_elements,
local_images,
remote_image_urls,
mention_bindings,
} = remap_placeholders_for_message(message, &mut next_image_label);
append_text_with_rebased_elements(
&mut combined.text,
&mut combined.text_elements,
&text,
text_elements,
);
combined.local_images.extend(local_images);
combined.remote_image_urls.extend(remote_image_urls);
combined.mention_bindings.extend(mention_bindings);
}
combined
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ReplayKind {
ResumeInitialMessages,
@@ -1521,6 +1563,7 @@ impl ChatWidget {
}
fn on_task_complete(&mut self, last_agent_message: Option<String>, from_replay: bool) {
self.submit_pending_steers_after_interrupt = false;
if let Some(message) = last_agent_message.as_ref()
&& !message.trim().is_empty()
{
@@ -1874,6 +1917,7 @@ impl ChatWidget {
}
fn on_server_overloaded_error(&mut self, message: String) {
self.submit_pending_steers_after_interrupt = false;
self.finalize_turn();
let message = if message.trim().is_empty() {
@@ -1888,6 +1932,7 @@ impl ChatWidget {
}
fn on_error(&mut self, message: String) {
self.submit_pending_steers_after_interrupt = false;
self.finalize_turn();
self.add_to_history(history_cell::new_error_event(message));
self.request_redraw();
@@ -1978,15 +2023,35 @@ impl ChatWidget {
if reason == TurnAbortReason::Interrupted {
self.clear_unified_exec_processes();
}
let send_pending_steers_immediately = self.submit_pending_steers_after_interrupt;
self.submit_pending_steers_after_interrupt = false;
if reason != TurnAbortReason::ReviewEnded {
self.add_to_history(history_cell::new_error_event(
"Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_owned(),
));
if send_pending_steers_immediately {
self.add_to_history(history_cell::new_info_event(
"Model interrupted to submit steer instructions.".to_owned(),
None,
));
} else {
self.add_to_history(history_cell::new_error_event(
"Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_owned(),
));
}
}
// Core clears pending_input before emitting TurnAborted, so any unacknowledged steers
// still tracked here must be restored locally instead of waiting for a later commit.
if let Some(combined) = self.drain_pending_messages_for_restore() {
if send_pending_steers_immediately {
let pending_steers: Vec<UserMessage> = self
.pending_steers
.drain(..)
.map(|pending| pending.user_message)
.collect();
if !pending_steers.is_empty() {
self.submit_user_message(merge_user_messages(pending_steers));
} else if let Some(combined) = self.drain_pending_messages_for_restore() {
self.restore_user_message_to_composer(combined);
}
} else if let Some(combined) = self.drain_pending_messages_for_restore() {
self.restore_user_message_to_composer(combined);
}
self.refresh_pending_input_preview();
@@ -2027,42 +2092,7 @@ impl ChatWidget {
to_merge.push(existing_message);
}
let mut combined = UserMessage {
text: String::new(),
text_elements: Vec::new(),
local_images: Vec::new(),
remote_image_urls: Vec::new(),
mention_bindings: Vec::new(),
};
let total_remote_images = to_merge
.iter()
.map(|message| message.remote_image_urls.len())
.sum::<usize>();
let mut next_image_label = total_remote_images + 1;
for (idx, message) in to_merge.into_iter().enumerate() {
if idx > 0 {
combined.text.push('\n');
}
let UserMessage {
text,
text_elements,
local_images,
remote_image_urls,
mention_bindings,
} = remap_placeholders_for_message(message, &mut next_image_label);
append_text_with_rebased_elements(
&mut combined.text,
&mut combined.text_elements,
&text,
text_elements,
);
combined.local_images.extend(local_images);
combined.remote_image_urls.extend(remote_image_urls);
combined.mention_bindings.extend(mention_bindings);
}
Some(combined)
Some(merge_user_messages(to_merge))
}
fn restore_user_message_to_composer(&mut self, user_message: UserMessage) {
@@ -3161,6 +3191,7 @@ impl ChatWidget {
forked_from: None,
queued_user_messages: VecDeque::new(),
pending_steers: VecDeque::new(),
submit_pending_steers_after_interrupt: false,
queued_message_edit_binding,
show_welcome_banner: is_first_run,
startup_tooltip_override,
@@ -3347,6 +3378,7 @@ impl ChatWidget {
plan_item_active: false,
queued_user_messages: VecDeque::new(),
pending_steers: VecDeque::new(),
submit_pending_steers_after_interrupt: false,
queued_message_edit_binding,
show_welcome_banner: is_first_run,
startup_tooltip_override,
@@ -3517,6 +3549,7 @@ impl ChatWidget {
forked_from: None,
queued_user_messages: VecDeque::new(),
pending_steers: VecDeque::new(),
submit_pending_steers_after_interrupt: false,
queued_message_edit_binding,
show_welcome_banner: false,
startup_tooltip_override: None,
@@ -3656,6 +3689,19 @@ impl ChatWidget {
return;
}
if matches!(key_event.code, KeyCode::Esc)
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& !self.pending_steers.is_empty()
&& self.bottom_pane.is_task_running()
&& self.bottom_pane.no_modal_or_popup_active()
{
self.submit_pending_steers_after_interrupt = true;
if !self.submit_op(Op::Interrupt) {
self.submit_pending_steers_after_interrupt = false;
}
return;
}
match key_event {
KeyEvent {
code: KeyCode::BackTab,
@@ -4798,6 +4844,7 @@ impl ChatWidget {
self.on_interrupted_turn(ev.reason);
}
TurnAbortReason::Replaced => {
self.submit_pending_steers_after_interrupt = false;
self.pending_steers.clear();
self.refresh_pending_input_preview();
self.on_error("Turn aborted: replaced by a new task".to_owned())
@@ -5,6 +5,7 @@ expression: term.backend().vt100().screen().contents()
• Working (0s • esc to interrupt)
• Queued follow-up messages
↳ Hello, world! 0
↳ Hello, world! 1
↳ Hello, world! 2
@@ -21,7 +22,6 @@ expression: term.backend().vt100().screen().contents()
↳ Hello, world! 13
↳ Hello, world! 14
↳ Hello, world! 15
↳ Hello, world! 16
Ask Codex to do anything
@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
expression: info
---
• Model interrupted to submit steer instructions.
@@ -11,11 +11,11 @@ expression: term.backend().vt100().screen().contents()
• Working (0s • esc to interrupt)
• Queued follow-up messages
↳ Queued while /review is running.
⌥ + ↑ edit
⌥ + ↑ edit last queued message
Ask Codex to do anything
+149
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]