Edit queued messages with Vim history-up (#38907)

## What changed

- When the composer is empty in Vim normal mode, make the configured history-up binding restore the latest queued follow-up for editing.
- Remove the restored message from the queue so submitting the edited version replaces it instead of creating a duplicate.
- Preserve normal history navigation when the composer has text or a popup is active, and honor remapped Vim bindings.

## Testing

- Add TUI tests covering repeated edit-and-requeue cycles and a remapped history-up binding.

GitOrigin-RevId: 0b04dd964d3fc6fdcd10712aa4d5fd81cbbd2255
This commit is contained in:
Benjamin Carlsson
2026-08-16 21:48:41 +00:00
committed by copyberry
parent 6c108912ee
commit 2bc43d516e
4 changed files with 108 additions and 2 deletions
@@ -44,6 +44,11 @@
//! - Persistent cross-session history (text-only; no element ranges or attachments).
//! - Local in-session history (full text + text elements + local/remote image attachments).
//!
//! Before composer history handles Vim-normal history-up, `ChatWidget` restores the latest queued
//! follow-up when the composer is empty and no popup is active. It removes the message from the
//! queue before placing it in the composer, so editing and requeuing it replaces the prior
//! revision instead of creating duplicates.
//!
//! When recalling a local entry, the composer rehydrates text elements and both attachment kinds
//! (local image paths + remote image URLs).
//! When recalling a persistent entry, only the text is restored.
+5 -1
View File
@@ -1222,8 +1222,12 @@ impl BottomPane {
popup_consts::standard_popup_hint_line_for_keymap(&self.keymap.list)
}
pub(crate) fn runtime_keymap(&self) -> &RuntimeKeymap {
&self.keymap
}
pub(crate) fn list_keymap(&self) -> crate::keymap::ListKeymap {
self.keymap.list.clone()
self.runtime_keymap().list.clone()
}
/// Replace one or more active views whose IDs are in `view_ids` with a
+12 -1
View File
@@ -110,7 +110,18 @@ impl ChatWidget {
}
if key_event.kind == KeyEventKind::Press
&& self.chat_keymap.edit_queued_message.is_pressed(key_event)
&& (self.chat_keymap.edit_queued_message.is_pressed(key_event)
|| (self.bottom_pane.composer_is_empty()
&& self
.bottom_pane
.keymap_contexts()
.contains(crate::keymap::KeymapContext::VimNormal)
&& self
.bottom_pane
.runtime_keymap()
.vim_normal
.move_up
.is_pressed(key_event)))
&& self.has_queued_follow_up_messages()
&& self.bottom_pane.no_modal_or_popup_active()
{
@@ -1676,6 +1676,92 @@ async fn alt_up_edits_most_recent_queued_message() {
);
}
#[tokio::test]
async fn vim_normal_history_up_edits_queued_message_without_duplicating_it() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.toggle_vim_mode_and_notify();
chat.bottom_pane.set_task_running(/*running*/ true);
chat.bottom_pane
.set_composer_text("first queued message".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
for expected in ["first queued message", "first queued message edited"] {
chat.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_text(), expected);
assert!(chat.input_queue.queued_user_messages.is_empty());
assert!(
chat.input_queue
.queued_user_message_history_records
.is_empty()
);
chat.bottom_pane
.set_composer_text(format!("{expected} edited"), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
assert_eq!(
chat.input_queue.queued_user_message_history_records.len(),
1
);
}
assert_eq!(
chat.queued_user_message_texts(),
vec!["first queued message edited edited".to_string()]
);
}
#[tokio::test]
async fn vim_normal_queued_message_edit_uses_remapped_history_up() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
keymap.vim_normal.move_up = vec![crate::key_hint::plain(KeyCode::F(2))];
chat.bottom_pane.set_keymap_bindings(&keymap);
chat.toggle_vim_mode_and_notify();
chat.bottom_pane.set_task_running(/*running*/ true);
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("queued message").into());
chat.input_queue
.queued_user_message_history_records
.push_back(UserMessageHistoryRecord::UserMessageText);
chat.refresh_pending_input_preview();
chat.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
assert!(chat.bottom_pane.composer_is_empty());
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
chat.handle_key_event(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_text(), "queued message");
assert!(chat.input_queue.queued_user_messages.is_empty());
assert!(
chat.input_queue
.queued_user_message_history_records
.is_empty()
);
let width = 80;
let height = chat.desired_height(width);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height))
.expect("create terminal");
terminal
.draw(|frame| chat.render(frame.area(), frame.buffer_mut()))
.expect("render restored queued message");
insta::assert_snapshot!(
normalized_backend_snapshot(terminal.backend())
.lines()
.filter(|line| line.contains("queued message"))
.map(|line| line.trim_matches('"').trim())
.collect::<Vec<_>>()
.join("\n"),
@"› queued message"
);
}
#[tokio::test]
async fn unbound_queued_message_edit_does_not_fall_back_to_alt_up() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;