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