From 5f96990739f8262985a547f59d6b23eecb7bd6c4 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 27 Apr 2026 22:29:17 -0500 Subject: [PATCH] =?UTF-8?q?feat(tui):=20#85=20pending=20input=20preview=20?= =?UTF-8?q?=E2=80=94=20three-bucket=20queued=20message=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/tui/src/tui/app.rs | 78 +++++++++++++++++++++++++++++++++++++++ crates/tui/src/tui/ui.rs | 13 +++++++ 2 files changed, 91 insertions(+) diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 8f8b9e69..71107e30 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -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()); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 10be3751..e34b8888 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -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); }