feat(tui): #85 pending input preview — three-bucket queued message UI

Port of codex-main's PendingInputPreview pattern. Three semantic buckets
render in the composer area: pending steers (Esc submits), rejected
steers (re-fires at end of turn), queued follow-ups (Alt+Up edits last).
Empty state renders zero rows.

Engine populates the new App.pending_steers / rejected_steers fields
through the steer-submission path; existing queued_messages plumbing
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-04-27 22:29:17 -05:00
parent f6c9bf13db
commit 5f96990739
2 changed files with 91 additions and 0 deletions
+78
View File
@@ -1909,6 +1909,28 @@ impl App {
self.queued_messages.len()
}
/// Pop the most-recently queued message back into the composer for editing
/// (issue #85 — Alt+↑ affordance). The popped message is parked in
/// [`Self::queued_draft`] so the next Enter re-queues it carrying its
/// original skill instruction. No-op if the composer already has typed
/// content or a draft is already being edited — surfacing the affordance
/// would be ambiguous in either case.
///
/// Returns `true` when the composer state was mutated.
pub fn pop_last_queued_into_draft(&mut self) -> bool {
if !self.input.is_empty() || self.queued_draft.is_some() {
return false;
}
let Some(msg) = self.queued_messages.pop_back() else {
return false;
};
self.input = msg.display.clone();
self.cursor_position = char_count(&self.input);
self.queued_draft = Some(msg);
self.needs_redraw = true;
true
}
/// Park a composer input the user steered with Esc. Re-armed each call so
/// rapid Esc taps accumulate rather than overwriting each other.
pub fn push_pending_steer(&mut self, message: QueuedMessage) {
@@ -2607,6 +2629,62 @@ mod tests {
assert_eq!(app.pending_steers.len(), 2);
}
#[test]
fn pop_last_queued_into_draft_pops_back_and_arms_draft() {
let mut app = App::new(test_options(false), &Config::default());
app.queue_message(QueuedMessage::new(
"first".to_string(),
Some("skill-A".to_string()),
));
app.queue_message(QueuedMessage::new(
"last".to_string(),
Some("skill-B".to_string()),
));
assert!(app.pop_last_queued_into_draft());
assert_eq!(app.input, "last");
assert_eq!(app.cursor_position, "last".chars().count());
assert_eq!(app.queued_messages.len(), 1);
let draft = app.queued_draft.clone().expect("draft is set");
assert_eq!(draft.display, "last");
assert_eq!(draft.skill_instruction.as_deref(), Some("skill-B"));
}
#[test]
fn pop_last_queued_into_draft_noop_when_composer_dirty() {
let mut app = App::new(test_options(false), &Config::default());
app.queue_message(QueuedMessage::new("queued".to_string(), None));
app.input = "typing".to_string();
app.cursor_position = char_count(&app.input);
assert!(!app.pop_last_queued_into_draft());
assert_eq!(app.input, "typing");
assert_eq!(app.queued_messages.len(), 1);
assert!(app.queued_draft.is_none());
}
#[test]
fn pop_last_queued_into_draft_noop_when_draft_already_armed() {
let mut app = App::new(test_options(false), &Config::default());
app.queue_message(QueuedMessage::new("queued".to_string(), None));
app.queued_draft = Some(QueuedMessage::new("editing".to_string(), None));
assert!(!app.pop_last_queued_into_draft());
assert_eq!(app.queued_messages.len(), 1);
assert_eq!(
app.queued_draft.as_ref().map(|d| d.display.as_str()),
Some("editing")
);
}
#[test]
fn pop_last_queued_into_draft_noop_when_queue_empty() {
let mut app = App::new(test_options(false), &Config::default());
assert!(!app.pop_last_queued_into_draft());
assert!(app.input.is_empty());
assert!(app.queued_draft.is_none());
}
#[test]
fn finalize_streaming_assistant_marks_existing_cell_interrupted() {
let mut app = App::new(test_options(false), &Config::default());
+13
View File
@@ -1530,6 +1530,19 @@ async fn run_event_loop(
EscapeAction::ClearInput => app.clear_input(),
EscapeAction::Noop => {}
},
// #85: Alt+↑ pops the most-recent queued message back into the
// composer for editing when the preview's affordance is visible
// (queue non-empty, composer idle). Splits the binding into two
// arms so the legacy scroll fallback is unambiguous on the same
// chord.
KeyCode::Up
if key.modifiers.contains(KeyModifiers::ALT)
&& app.input.is_empty()
&& app.queued_draft.is_none()
&& !app.queued_messages.is_empty() =>
{
let _ = app.pop_last_queued_into_draft();
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => {
app.scroll_up(3);
}