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:
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user