Add redo support to Vim composer history (#42140)

## What changed

- Add a bounded redo stack for complete composer drafts, including paste payloads and image attachments. `Ctrl+R` now reapplies the last undone edit in Vim normal mode, while new edits clear stale redo history.
- Add the configurable `vim_normal.redo` action to the keymap schema and picker. Preserve reverse prompt-history search in Vim insert mode and non-Vim mode, and honor explicitly configured bindings.
- Keep pending edit snapshots outside the shared undo/redo byte budget so canceled commands cannot evict committed history.

## Testing

- Cover grouped edits, Vim searches, direct draft changes, history limits, empty redo stacks, and custom or unbound shortcuts.

GitOrigin-RevId: 0cf5bb376ff865ddc0a77705dcb322ef6b57b3a3
This commit is contained in:
Benjamin Carlsson authored and copyberry committed 2026-09-01 21:30:27 +00:00
1 parent 612e6491d5
commit b192442fc7
18 files changed
+318 -59

No files matched your search

+2
View File
@@ -271,6 +271,8 @@ pub struct TuiVimNormalKeymap {
pub start_change_operator: Option<KeybindingsSpec>, pub start_change_operator: Option<KeybindingsSpec>,
/// Undo the last complete edit (`u`). /// Undo the last complete edit (`u`).
pub undo: Option<KeybindingsSpec>, pub undo: Option<KeybindingsSpec>,
/// Redo the last undone edit (`ctrl-r`).
pub redo: Option<KeybindingsSpec>,
/// Cancel a pending operator and return to normal mode. /// Cancel a pending operator and return to normal mode.
pub cancel_operator: Option<KeybindingsSpec>, pub cancel_operator: Option<KeybindingsSpec>,
} }
+10
View File
@@ -3944,6 +3944,7 @@
"open_line_above": null, "open_line_above": null,
"open_line_below": null, "open_line_below": null,
"paste_after": null, "paste_after": null,
"redo": null,
"repeat_last_change": null, "repeat_last_change": null,
"replace_char": null, "replace_char": null,
"start_change_operator": null, "start_change_operator": null,
@@ -4771,6 +4772,7 @@
"open_line_above": null, "open_line_above": null,
"open_line_below": null, "open_line_below": null,
"paste_after": null, "paste_after": null,
"redo": null,
"repeat_last_change": null, "repeat_last_change": null,
"replace_char": null, "replace_char": null,
"start_change_operator": null, "start_change_operator": null,
@@ -5234,6 +5236,14 @@
], ],
"description": "Paste after cursor (`p`)." "description": "Paste after cursor (`p`)."
}, },
"redo": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Redo the last undone edit (`ctrl-r`)."
},
"repeat_last_change": { "repeat_last_change": {
"allOf": [ "allOf": [
{ {
@@ -63,8 +63,11 @@
//! `Ctrl+R` opens a reverse incremental search mode. The footer becomes the search input; once the //! `Ctrl+R` opens a reverse incremental search mode. The footer becomes the search input; once the
//! query is non-empty, the composer body previews the current match. `Enter` accepts the preview as //! query is non-empty, the composer body previews the current match. `Enter` accepts the preview as
//! an editable draft and `Esc` restores the draft that was active when search started. //! an editable draft and `Esc` restores the draft that was active when search started.
//! Vim undo snapshots the complete draft and groups direct edits with active Vim transactions. //! Vim undo/redo snapshots complete drafts and groups direct edits with active Vim transactions.
//! Canceled history previews restore both; accepting another prompt resets undo history. //! An active edit keeps one separately capped snapshot; canceling does not evict committed history.
//! Canceled history previews restore history and active commands; accepting another prompt resets them.
//! Normal-mode Ctrl+R redoes an edit, or does nothing when redo is empty. Insert-mode Ctrl+R
//! keeps prompt-history search; explicitly configured keybindings retain precedence.
//! Vim queries stay draft-local. //! Vim queries stay draft-local.
//! //!
//! Slash commands are staged for local history instead of being recorded immediately. Command //! Slash commands are staged for local history instead of being recorded immediately. Command
@@ -800,6 +800,9 @@ mod tests {
.history .history
.record_local_submission(HistoryEntry::new("git status".to_string())); .record_local_submission(HistoryEntry::new("git status".to_string()));
composer.set_vim_enabled(/*enabled*/ true); composer.set_vim_enabled(/*enabled*/ true);
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
keymap.vim_normal.redo.clear();
composer.set_keymap_bindings(&keymap);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
for ch in ['g', 'i', 't'] { for ch in ['g', 'i', 't'] {
@@ -1,8 +1,10 @@
//! Bounded Vim undo history for the complete composer-owned draft. //! Bounded Vim undo/redo history for the complete composer-owned draft.
//! //!
//! A textarea does not own pending paste payloads, image attachments, or mention targets. Keeping //! A textarea does not own pending paste payloads, image attachments, or mention targets. Keeping
//! edit transactions here lets one snapshot restore those values together with visible text. Keys //! edit transactions here lets one snapshot restore those values together with visible text. Keys
//! and direct composer-owned changes share the same bounded transaction state. //! and direct composer-owned changes share the same bounded transaction state.
//! Committed snapshots share a byte budget. One pending edit snapshot has its own matching cap,
//! so canceled commands cannot evict committed history.
use std::collections::VecDeque; use std::collections::VecDeque;
@@ -18,6 +20,7 @@ const MAX_VIM_UNDO_BYTES: usize = 1024 * 1024;
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub(super) struct VimHistory { pub(super) struct VimHistory {
undo: VecDeque<ComposerDraft>, undo: VecDeque<ComposerDraft>,
redo: VecDeque<ComposerDraft>,
pending: Option<ComposerDraft>, pending: Option<ComposerDraft>,
} }
@@ -48,28 +51,46 @@ impl VimHistory {
|| self || self
.undo .undo
.iter() .iter()
.chain(self.pending.iter()) .chain(self.redo.iter())
.map(ComposerDraft::vim_history_bytes) .map(ComposerDraft::vim_history_bytes)
.sum::<usize>() .sum::<usize>()
> MAX_VIM_UNDO_BYTES > MAX_VIM_UNDO_BYTES
{ {
self.undo.pop_front(); if self.undo.pop_front().is_none() {
self.redo.pop_front();
}
} }
} }
} }
impl ChatComposer { impl ChatComposer {
/// Apply undo only before normal-mode commands begin. /// Consume undo and redo before normal-mode commands, even when their history is empty.
pub(super) fn handle_vim_history_key(&mut self, event: KeyEvent) -> bool { pub(super) fn handle_vim_history_key(&mut self, event: KeyEvent) -> bool {
if !self.draft.textarea.is_vim_normal_mode() if !self.draft.textarea.is_vim_normal_mode()
|| self.draft.textarea.is_vim_operator_pending() || self.draft.textarea.is_vim_operator_pending()
|| self.popups.active() || self.popups.active()
|| !self.vim_normal_keymap.undo.is_pressed(event)
{ {
return false; return false;
} }
if let Some(snapshot) = self.vim_history.undo.pop_back() { let redo = self.vim_normal_keymap.redo.is_pressed(event);
let undo = self.vim_normal_keymap.undo.is_pressed(event);
if !undo && !redo {
return false;
}
let snapshot = if redo {
self.vim_history.redo.pop_back()
} else {
self.vim_history.undo.pop_back()
};
if let Some(snapshot) = snapshot {
let current = self.snapshot_draft();
if redo {
self.vim_history.undo.push_back(current);
} else {
self.vim_history.redo.push_back(current);
}
let history = std::mem::take(&mut self.vim_history); let history = std::mem::take(&mut self.vim_history);
let mut vim_state = VimPersistentState::default(); let mut vim_state = VimPersistentState::default();
self.draft self.draft
@@ -188,6 +209,7 @@ impl ChatComposer {
return; return;
} }
self.vim_history.redo.clear();
self.vim_history.undo.push_back(snapshot); self.vim_history.undo.push_back(snapshot);
self.vim_history.trim(); self.vim_history.trim();
} }
@@ -1,4 +1,4 @@
//! Composer undo transactions preserve rich draft data and active Vim commands. //! Composer undo/redo transactions preserve rich draft data and active Vim commands.
use std::path::PathBuf; use std::path::PathBuf;
@@ -46,7 +46,7 @@ fn ctrl_r(composer: &mut ChatComposer) {
} }
#[test] #[test]
fn undo_groups_complete_vim_edits() { fn undo_and_redo_group_complete_vim_edits() {
for (original, command, edited) in [ for (original, command, edited) in [
("one two", "cwX", "X two"), ("one two", "cwX", "X two"),
("abc", "iXYZ", "XYZabc"), ("abc", "iXYZ", "XYZabc"),
@@ -70,6 +70,8 @@ fn undo_groups_complete_vim_edits() {
(composer.current_text(), composer.cursor()), (composer.current_text(), composer.cursor()),
(original.to_owned(), 0) (original.to_owned(), 0)
); );
ctrl_r(&mut composer);
assert_eq!(composer.current_text(), edited);
} }
} }
@@ -90,7 +92,7 @@ fn undo_preserves_repeat_and_pending_operator_bindings() {
} }
#[test] #[test]
fn undo_preserves_confirmed_vim_search() { fn undo_and_redo_preserve_confirmed_vim_search() {
let mut composer = vim_composer("alpha beta alpha"); let mut composer = vim_composer("alpha beta alpha");
keys(&mut composer, "/alpha\n0xun"); keys(&mut composer, "/alpha\n0xun");
assert_eq!( assert_eq!(
@@ -105,6 +107,13 @@ fn undo_preserves_confirmed_vim_search() {
(composer.current_text(), composer.cursor()), (composer.current_text(), composer.cursor()),
("Zalpha beta alpha".to_owned(), 12) ("Zalpha beta alpha".to_owned(), 12)
); );
keys(&mut composer, "u");
ctrl_r(&mut composer);
keys(&mut composer, "n");
assert_eq!(
(composer.current_text(), composer.cursor()),
("Zalpha beta alpha".to_owned(), 1)
);
} }
#[test] #[test]
@@ -114,10 +123,12 @@ fn externally_canceled_search_operator_finishes_the_undo_transaction() {
assert!(composer.cancel_vim_search()); assert!(composer.cancel_vim_search());
keys(&mut composer, "uxu"); keys(&mut composer, "uxu");
assert_eq!(composer.current_text(), "abc"); assert_eq!(composer.current_text(), "abc");
ctrl_r(&mut composer);
assert_eq!(composer.current_text(), "bc");
} }
#[test] #[test]
fn normal_mode_explicit_paste_is_its_own_undoable_edit() { fn normal_mode_explicit_paste_is_its_own_edit_and_replaces_stale_redo() {
let mut composer = vim_composer("abc"); let mut composer = vim_composer("abc");
keys(&mut composer, "x"); keys(&mut composer, "x");
composer.handle_paste("Z".to_owned()); composer.handle_paste("Z".to_owned());
@@ -126,23 +137,43 @@ fn normal_mode_explicit_paste_is_its_own_undoable_edit() {
assert_eq!(composer.current_text(), "bc"); assert_eq!(composer.current_text(), "bc");
keys(&mut composer, "u"); keys(&mut composer, "u");
assert_eq!(composer.current_text(), "abc"); assert_eq!(composer.current_text(), "abc");
composer.handle_paste("Z".to_owned());
assert_eq!(composer.current_text(), "Zabc");
assert!(composer.vim_history.redo.is_empty());
ctrl_r(&mut composer);
assert_eq!(composer.current_text(), "Zabc");
assert!(!composer.history_search_active());
keys(&mut composer, "u");
assert_eq!(composer.current_text(), "abc");
ctrl_r(&mut composer);
assert_eq!(composer.current_text(), "Zabc");
} }
#[test] #[test]
fn normal_mode_direct_insert_and_image_attachment_are_undoable() { fn normal_mode_direct_insert_and_image_attachment_are_undoable() {
let mut composer = vim_composer("abc"); let mut composer = vim_composer("abc");
keys(&mut composer, "xu");
composer.insert_str("Z"); composer.insert_str("Z");
assert_eq!(composer.current_text(), "Zabc"); assert_eq!(composer.current_text(), "Zabc");
assert!(composer.vim_history.redo.is_empty());
keys(&mut composer, "u"); keys(&mut composer, "u");
assert_eq!(composer.current_text(), "abc"); assert_eq!(composer.current_text(), "abc");
ctrl_r(&mut composer);
assert_eq!(composer.current_text(), "Zabc");
keys(&mut composer, "u");
let path = PathBuf::from("example.png"); let path = PathBuf::from("example.png");
composer.attach_image(path.clone()); composer.attach_image(path.clone());
let with_image = composer.draft_snapshot();
assert_eq!(composer.local_image_paths(), vec![path]); assert_eq!(composer.local_image_paths(), vec![path]);
assert!(composer.vim_history.redo.is_empty());
keys(&mut composer, "u"); keys(&mut composer, "u");
assert_eq!(composer.current_text(), "abc"); assert_eq!(composer.current_text(), "abc");
assert!(composer.local_image_paths().is_empty()); assert!(composer.local_image_paths().is_empty());
keys(&mut composer, "\nABC"); ctrl_r(&mut composer);
assert_eq!(composer.draft_snapshot(), with_image);
keys(&mut composer, "u\nABC");
assert_eq!(composer.current_text(), "ABC"); assert_eq!(composer.current_text(), "ABC");
escape(&mut composer); escape(&mut composer);
keys(&mut composer, "u"); keys(&mut composer, "u");
@@ -157,13 +188,19 @@ fn deleting_a_selected_remote_image_is_one_undoable_vim_edit() {
let mut composer = vim_composer("abc"); let mut composer = vim_composer("abc");
let url = "https://example.com/remote.png".to_owned(); let url = "https://example.com/remote.png".to_owned();
composer.set_remote_image_urls(vec![url.clone()]); composer.set_remote_image_urls(vec![url.clone()]);
keys(&mut composer, "xu");
assert_eq!(composer.vim_history.redo.len(), 1);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert!(composer.vim_history.pending.is_none()); assert!(composer.vim_history.pending.is_none());
let _ = composer.handle_key_event(KeyEvent::new(key, KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(key, KeyModifiers::NONE));
assert!(composer.remote_image_urls().is_empty()); assert!(composer.remote_image_urls().is_empty());
assert!(composer.vim_history.redo.is_empty());
keys(&mut composer, "u"); keys(&mut composer, "u");
assert_eq!(composer.remote_image_urls(), vec![url]); assert_eq!(composer.remote_image_urls(), vec![url]);
assert_eq!(composer.current_text(), "abc"); assert_eq!(composer.current_text(), "abc");
ctrl_r(&mut composer);
assert!(composer.remote_image_urls().is_empty());
assert_eq!(composer.current_text(), "abc");
} }
} }
@@ -175,10 +212,89 @@ fn direct_edits_join_the_active_vim_insert_transaction() {
composer.attach_image(PathBuf::from("example.png")); composer.attach_image(PathBuf::from("example.png"));
keys(&mut composer, "D"); keys(&mut composer, "D");
escape(&mut composer); escape(&mut composer);
let edited = composer.draft_snapshot();
assert_eq!(composer.vim_history.undo.len(), 1); assert_eq!(composer.vim_history.undo.len(), 1);
keys(&mut composer, "u"); keys(&mut composer, "u");
assert_eq!(composer.current_text(), "abc"); assert_eq!(composer.current_text(), "abc");
assert!(composer.local_image_paths().is_empty()); assert!(composer.local_image_paths().is_empty());
ctrl_r(&mut composer);
assert_eq!(composer.draft_snapshot(), edited);
}
#[test]
fn vim_search_query_paste_does_not_invalidate_draft_redo() {
let mut composer = vim_composer("alpha beta alpha");
keys(&mut composer, "xu/");
composer.handle_paste("beta".to_owned());
assert_eq!(composer.current_text(), "alpha beta alpha");
assert_eq!(
composer.draft.textarea.vim_query().unwrap().editor.text(),
"beta"
);
assert!(composer.vim_history.pending.is_none());
escape(&mut composer);
ctrl_r(&mut composer);
assert_eq!(composer.current_text(), "lpha beta alpha");
}
#[test]
fn vim_redo_keeps_its_binding_when_history_is_empty() {
let mut composer = vim_composer("abc");
let before = composer.draft_snapshot();
ctrl_r(&mut composer);
assert_eq!(composer.draft_snapshot(), before);
assert!(!composer.history_search_active());
keys(&mut composer, "xu");
assert_eq!(composer.current_text(), "abc");
ctrl_r(&mut composer);
assert_eq!(composer.current_text(), "bc");
assert!(!composer.history_search_active());
let after = composer.draft_snapshot();
ctrl_r(&mut composer);
assert_eq!(composer.draft_snapshot(), after);
assert!(composer.draft.textarea.is_vim_normal_mode());
assert!(!composer.history_search_active());
super::super::tests::snapshot_composer_state_with_width(
"vim_empty_redo_stays_normal",
/*width*/ 60,
/*enhanced_keys_supported*/ true,
|target| *target = composer,
);
let original = "a".repeat(super::MAX_VIM_UNDO_BYTES / 2 + 1);
let mut composer = vim_composer(&original);
keys(&mut composer, "xu");
for command in ["d", "i"] {
keys(&mut composer, command);
escape(&mut composer);
assert_eq!(composer.vim_history.redo.len(), 1);
}
ctrl_r(&mut composer);
assert_eq!(composer.current_text(), original[1..]);
assert!(!composer.history_search_active());
keys(&mut composer, "uiZ");
escape(&mut composer);
assert!(composer.vim_history.redo.is_empty());
keys(&mut composer, "x");
assert_eq!(composer.vim_history.undo.len(), 1);
keys(&mut composer, "u");
assert_eq!(composer.current_text(), format!("Z{original}"));
}
#[test]
fn ctrl_r_searches_history_in_insert_and_non_vim_modes() {
let mut composer = vim_composer("draft");
keys(&mut composer, "i");
ctrl_r(&mut composer);
assert!(composer.history_search_active());
escape(&mut composer);
assert_eq!(composer.draft.textarea.vim_mode_label(), Some("Insert"));
composer.set_vim_enabled(/*enabled*/ false);
ctrl_r(&mut composer);
assert!(composer.history_search_active());
} }
#[test] #[test]
@@ -197,6 +313,9 @@ fn removing_the_shell_prefix_is_its_own_undoable_edit() {
#[test] #[test]
fn canceled_reverse_history_preview_preserves_vim_undo_and_repeat() { fn canceled_reverse_history_preview_preserves_vim_undo_and_repeat() {
let mut composer = vim_composer("alpha beta alpha"); let mut composer = vim_composer("alpha beta alpha");
let mut keymap = RuntimeKeymap::defaults();
keymap.vim_normal.redo.clear();
composer.set_keymap_bindings(&keymap);
composer composer
.history .history
.record_local_submission(HistoryEntry::new("archived prompt".to_owned())); .record_local_submission(HistoryEntry::new("archived prompt".to_owned()));
@@ -252,18 +371,23 @@ fn accepted_reverse_history_preview_starts_a_fresh_vim_edit_history() {
} }
#[test] #[test]
fn undo_uses_configured_bindings_and_supports_unbinding() { fn undo_and_redo_use_configured_bindings_and_support_unbinding() {
let mut composer = vim_composer("abc"); let mut composer = vim_composer("abc");
let mut keymap = RuntimeKeymap::defaults(); let mut keymap = RuntimeKeymap::defaults();
keymap.vim_normal.undo = vec![crate::key_hint::plain(KeyCode::Char('z'))]; keymap.vim_normal.undo = vec![crate::key_hint::plain(KeyCode::Char('z'))];
keymap.vim_normal.redo = vec![crate::key_hint::plain(KeyCode::Char('v'))];
composer.set_keymap_bindings(&keymap); composer.set_keymap_bindings(&keymap);
keys(&mut composer, "xu"); keys(&mut composer, "xu");
assert_eq!(composer.current_text(), "bc"); assert_eq!(composer.current_text(), "bc");
keys(&mut composer, "z"); keys(&mut composer, "z");
assert_eq!(composer.current_text(), "abc"); assert_eq!(composer.current_text(), "abc");
keys(&mut composer, "v");
assert_eq!(composer.current_text(), "bc");
keys(&mut composer, "z");
keymap.vim_normal.undo.clear(); keymap.vim_normal.undo.clear();
keymap.vim_normal.redo.clear();
composer.set_keymap_bindings(&keymap); composer.set_keymap_bindings(&keymap);
keys(&mut composer, "xz"); keys(&mut composer, "xzv");
assert_eq!(composer.current_text(), "bc"); assert_eq!(composer.current_text(), "bc");
let mut config = TuiKeymap::default(); let mut config = TuiKeymap::default();
@@ -278,6 +402,23 @@ fn undo_uses_configured_bindings_and_supports_unbinding() {
keys(&mut composer, "u"); keys(&mut composer, "u");
assert!(!composer.history_search_active()); assert!(!composer.history_search_active());
let mut composer = vim_composer("abc");
keys(&mut composer, "xu");
let mut config = TuiKeymap::default();
config.composer.history_search_previous =
Some(KeybindingsSpec::One(KeybindingSpec("ctrl-r".to_owned())));
composer.set_keymap_bindings(&RuntimeKeymap::from_config(&config).unwrap());
ctrl_r(&mut composer);
assert!(composer.history_search_active());
escape(&mut composer);
config.vim_normal.redo = Some(KeybindingsSpec::One(KeybindingSpec("ctrl-r".to_owned())));
composer.set_keymap_bindings(&RuntimeKeymap::from_config(&config).unwrap());
ctrl_r(&mut composer);
assert!(!composer.history_search_active());
assert_eq!(composer.current_text(), "bc");
ctrl_r(&mut composer);
assert!(!composer.history_search_active());
let mut composer = vim_composer("abc"); let mut composer = vim_composer("abc");
let mut config = TuiKeymap::default(); let mut config = TuiKeymap::default();
config.composer.submit = Some(KeybindingsSpec::One(KeybindingSpec("u".to_owned()))); config.composer.submit = Some(KeybindingsSpec::One(KeybindingSpec("u".to_owned())));
@@ -294,7 +435,7 @@ fn undo_uses_configured_bindings_and_supports_unbinding() {
} }
#[test] #[test]
fn undo_restores_deleted_large_paste_payload() { fn undo_and_redo_restore_deleted_large_paste_payload() {
let mut composer = vim_composer("alpha "); let mut composer = vim_composer("alpha ");
composer.draft.textarea.set_cursor("alpha ".len()); composer.draft.textarea.set_cursor("alpha ".len());
keys(&mut composer, "i"); keys(&mut composer, "i");
@@ -311,10 +452,13 @@ fn undo_restores_deleted_large_paste_payload() {
composer.current_text_with_pending(), composer.current_text_with_pending(),
format!("alpha {pasted}") format!("alpha {pasted}")
); );
ctrl_r(&mut composer);
assert_eq!(composer.current_text(), "alpha ");
assert!(composer.draft.pending_pastes.is_empty());
} }
#[test] #[test]
fn undo_restores_deleted_image_attachment() { fn undo_and_redo_restore_deleted_image_attachment() {
let mut composer = vim_composer("alpha "); let mut composer = vim_composer("alpha ");
composer.draft.textarea.set_cursor("alpha ".len()); composer.draft.textarea.set_cursor("alpha ".len());
composer.attach_image(PathBuf::from("example.png")); composer.attach_image(PathBuf::from("example.png"));
@@ -325,6 +469,9 @@ fn undo_restores_deleted_image_attachment() {
assert!(composer.local_image_paths().is_empty()); assert!(composer.local_image_paths().is_empty());
keys(&mut composer, "u"); keys(&mut composer, "u");
assert_eq!(composer.draft_snapshot(), before); assert_eq!(composer.draft_snapshot(), before);
ctrl_r(&mut composer);
assert_eq!(composer.current_text(), "alpha ");
assert!(composer.local_image_paths().is_empty());
} }
#[test] #[test]
@@ -359,6 +506,9 @@ fn find_jump_and_search_navigation_never_snapshot_the_draft() {
} }
assert_eq!(composer.current_text(), text); assert_eq!(composer.current_text(), text);
} }
let mut keymap = RuntimeKeymap::defaults();
keymap.vim_normal.redo.clear();
composer.set_keymap_bindings(&keymap);
composer composer
.history .history
.record_local_submission(HistoryEntry::new("alpha archived".to_owned())); .record_local_submission(HistoryEntry::new("alpha archived".to_owned()));
@@ -0,0 +1,14 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 5360
expression: terminal.backend()
---
" "
"› bc "
" "
" "
" "
" "
" "
" "
" 100% context left | Vim: Normal "
+67 -34
View File
@@ -211,6 +211,7 @@ pub(crate) struct VimNormalKeymap {
pub(crate) start_yank_operator: Vec<KeyBinding>, pub(crate) start_yank_operator: Vec<KeyBinding>,
pub(crate) start_change_operator: Vec<KeyBinding>, pub(crate) start_change_operator: Vec<KeyBinding>,
pub(crate) undo: Vec<KeyBinding>, pub(crate) undo: Vec<KeyBinding>,
pub(crate) redo: Vec<KeyBinding>,
pub(crate) cancel_operator: Vec<KeyBinding>, pub(crate) cancel_operator: Vec<KeyBinding>,
} }
@@ -759,6 +760,7 @@ impl RuntimeKeymap {
start_change_operator start_change_operator
), ),
undo: resolve_local!(keymap, defaults, vim_normal, undo), undo: resolve_local!(keymap, defaults, vim_normal, undo),
redo: resolve_local!(keymap, defaults, vim_normal, redo),
cancel_operator: resolve_local!(keymap, defaults, vim_normal, cancel_operator), cancel_operator: resolve_local!(keymap, defaults, vim_normal, cancel_operator),
}; };
@@ -892,6 +894,7 @@ impl RuntimeKeymap {
vim_normal.start_change_operator.as_slice(), vim_normal.start_change_operator.as_slice(),
), ),
(keymap.vim_normal.undo.as_ref(), vim_normal.undo.as_slice()), (keymap.vim_normal.undo.as_ref(), vim_normal.undo.as_slice()),
(keymap.vim_normal.redo.as_ref(), vim_normal.redo.as_slice()),
( (
keymap.vim_normal.cancel_operator.as_ref(), keymap.vim_normal.cancel_operator.as_ref(),
vim_normal.cancel_operator.as_slice(), vim_normal.cancel_operator.as_slice(),
@@ -1365,22 +1368,33 @@ impl RuntimeKeymap {
approval, approval,
}; };
if keymap.vim_normal.undo.is_none() { let configured: Vec<_> = runtime_action_bindings(&resolved)
let configured: Vec<_> = runtime_action_bindings(&resolved) .filter(|action| {
.filter(|action| { action.id.context.overlaps(KeymapContext::VimNormal)
action.id.context.overlaps(KeymapContext::VimNormal) && bindings::configured_binding_for_action(keymap, action.id)
&& bindings::configured_binding_for_action(keymap, action.id) .is_some_and(Option::is_some)
.is_some_and(Option::is_some) })
}) .flat_map(|action| action.bindings.iter().copied())
.flat_map(|action| action.bindings.iter().copied()) .collect();
.collect(); for (setting, bindings) in [
resolved.vim_normal.undo.retain(|binding| { (
!configured.contains(binding) keymap.vim_normal.undo.as_ref(),
&& !resolved.chords.bindings.iter().any(|chord| { &mut resolved.vim_normal.undo,
chord.action.context.overlaps(KeymapContext::VimNormal) ),
&& chord.chord.prefix.parts() == binding.parts() (
}) keymap.vim_normal.redo.as_ref(),
}); &mut resolved.vim_normal.redo,
),
] {
if setting.is_none() {
bindings.retain(|binding| {
!configured.contains(binding)
&& !resolved.chords.bindings.iter().any(|chord| {
chord.action.context.overlaps(KeymapContext::VimNormal)
&& chord.chord.prefix.parts() == binding.parts()
})
});
}
} }
resolved.configure_vim_search(keymap)?; resolved.configure_vim_search(keymap)?;
resolved.validate_conflicts()?; resolved.validate_conflicts()?;
@@ -1559,6 +1573,7 @@ impl RuntimeKeymap {
start_yank_operator: default_bindings![plain(KeyCode::Char('y'))], start_yank_operator: default_bindings![plain(KeyCode::Char('y'))],
start_change_operator: default_bindings![plain(KeyCode::Char('c'))], start_change_operator: default_bindings![plain(KeyCode::Char('c'))],
undo: default_bindings![plain(KeyCode::Char('u'))], undo: default_bindings![plain(KeyCode::Char('u'))],
redo: default_bindings![ctrl(KeyCode::Char('r'))],
cancel_operator: default_bindings![plain(KeyCode::Esc)], cancel_operator: default_bindings![plain(KeyCode::Esc)],
}, },
vim_search: VimSearchKeymap::default(), vim_search: VimSearchKeymap::default(),
@@ -2982,24 +2997,35 @@ mod tests {
#[test] #[test]
fn configured_legacy_bindings_prune_new_history_defaults() { fn configured_legacy_bindings_prune_new_history_defaults() {
for (context, action, binding) in [ for key in ["u", "ctrl-r"] {
("vim_normal", "move_left", "u"), for (context, action, suffix) in [
("vim_normal", "move_left", "u g"), ("vim_normal", "move_left", ""),
("vim_search", "forward", "u"), ("vim_normal", "move_left", " g"),
("vim_search", "forward", "u g"), ("vim_search", "forward", ""),
("composer", "submit", "u"), ("vim_search", "forward", " g"),
("global", "submit", "u"), ("composer", "submit", ""),
("global", "queue", "u"), ("global", "submit", ""),
("global", "toggle_shortcuts", "u"), ("global", "queue", ""),
] { ("global", "toggle_shortcuts", ""),
let keymap = serde_json::from_value(serde_json::json!({ ] {
(context): { (action): binding } let mut keymap: TuiKeymap = serde_json::from_value(serde_json::json!({
})) (context): { (action): format!("{key}{suffix}") }
.expect("config should deserialize"); }))
.expect("config should deserialize");
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); if key == "ctrl-r"
&& (!suffix.is_empty() || matches!(context, "composer" | "global"))
assert_eq!(runtime.vim_normal.undo, Vec::new()); {
// Ctrl+R chords and main-surface actions must unbind the older history key.
keymap.composer.history_search_previous = Some(KeybindingsSpec::Many(vec![]));
}
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
let bindings = if key == "u" {
runtime.vim_normal.undo
} else {
runtime.vim_normal.redo
};
assert_eq!(bindings, Vec::new());
}
} }
} }
@@ -3009,16 +3035,23 @@ mod tests {
keymap.vim_normal.move_left = Some(one("u")); keymap.vim_normal.move_left = Some(one("u"));
keymap.vim_normal.undo = Some(one("u")); keymap.vim_normal.undo = Some(one("u"));
expect_conflict(&keymap, "move_left", "undo"); expect_conflict(&keymap, "move_left", "undo");
keymap.vim_normal.move_left = Some(one("ctrl-r"));
keymap.vim_normal.undo = None;
keymap.vim_normal.redo = Some(one("ctrl-r"));
expect_conflict(&keymap, "move_left", "redo");
} }
#[test] #[test]
fn explicit_empty_arrays_unbind_vim_history_actions() { fn explicit_empty_arrays_unbind_vim_history_actions() {
let mut keymap = TuiKeymap::default(); let mut keymap = TuiKeymap::default();
keymap.vim_normal.undo = Some(KeybindingsSpec::Many(vec![])); keymap.vim_normal.undo = Some(KeybindingsSpec::Many(vec![]));
keymap.vim_normal.redo = Some(KeybindingsSpec::Many(vec![]));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert!(runtime.vim_normal.undo.is_empty()); assert!(runtime.vim_normal.undo.is_empty());
assert!(runtime.vim_normal.redo.is_empty());
} }
#[test] #[test]
+1
View File
@@ -320,6 +320,7 @@ define_runtime_action_bindings! {
start_yank_operator, start_yank_operator,
start_change_operator, start_change_operator,
undo, undo,
redo,
cancel_operator, cancel_operator,
], ],
"vim_search" => VimSearch, vim_search, vim_search [forward, backward, next, previous], "vim_search" => VimSearch, vim_search, vim_search [forward, backward, next, previous],
+15 -1
View File
@@ -1121,7 +1121,7 @@ mod tests {
} }
#[test] #[test]
fn picker_repeat_last_change_render_snapshot() { fn picker_repeat_and_redo_render_snapshots() {
let runtime = RuntimeKeymap::defaults(); let runtime = RuntimeKeymap::defaults();
let params = build_keymap_picker_params_for_selected_action( let params = build_keymap_picker_params_for_selected_action(
&runtime, &runtime,
@@ -1136,6 +1136,20 @@ mod tests {
.expect("repeat-last-change row should render"); .expect("repeat-last-change row should render");
assert_snapshot!("keymap_picker_repeat_last_change", repeat_row); assert_snapshot!("keymap_picker_repeat_last_change", repeat_row);
let params = build_keymap_picker_params_for_selected_action(
&runtime,
&TuiKeymap::default(),
"vim_normal",
"redo",
);
let rendered = render_picker(params, /*width*/ 120);
let redo_row = rendered
.lines()
.find(|line| line.contains("Redo"))
.expect("redo row should render");
assert_snapshot!("keymap_picker_redo", redo_row);
} }
#[test] #[test]
+2
View File
@@ -154,6 +154,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("vim_normal", "Vim normal", "start_yank_operator", "Begin a yank operator and wait for a motion."), action("vim_normal", "Vim normal", "start_yank_operator", "Begin a yank operator and wait for a motion."),
action("vim_normal", "Vim normal", "start_change_operator", "Begin a change operator and wait for a motion or text object."), action("vim_normal", "Vim normal", "start_change_operator", "Begin a change operator and wait for a motion or text object."),
action("vim_normal", "Vim normal", "undo", "Undo the last complete edit."), action("vim_normal", "Vim normal", "undo", "Undo the last complete edit."),
action("vim_normal", "Vim normal", "redo", "Redo the last undone edit."),
action("vim_normal", "Vim normal", "cancel_operator", "Cancel a pending Vim operator."), action("vim_normal", "Vim normal", "cancel_operator", "Cancel a pending Vim operator."),
action("vim_search", "Vim search", "forward", "Search forward in the active buffer."), action("vim_search", "Vim search", "forward", "Search forward in the active buffer."),
action("vim_search", "Vim search", "backward", "Search backward in the active buffer."), action("vim_search", "Vim search", "backward", "Search backward in the active buffer."),
@@ -325,6 +326,7 @@ pub(super) fn binding_slot<'a>(
("vim_normal", "start_yank_operator") => Some(&mut keymap.vim_normal.start_yank_operator), ("vim_normal", "start_yank_operator") => Some(&mut keymap.vim_normal.start_yank_operator),
("vim_normal", "start_change_operator") => Some(&mut keymap.vim_normal.start_change_operator), ("vim_normal", "start_change_operator") => Some(&mut keymap.vim_normal.start_change_operator),
("vim_normal", "undo") => Some(&mut keymap.vim_normal.undo), ("vim_normal", "undo") => Some(&mut keymap.vim_normal.undo),
("vim_normal", "redo") => Some(&mut keymap.vim_normal.redo),
("vim_normal", "cancel_operator") => Some(&mut keymap.vim_normal.cancel_operator), ("vim_normal", "cancel_operator") => Some(&mut keymap.vim_normal.cancel_operator),
("vim_search", "forward") => Some(&mut keymap.vim_search.forward), ("vim_search", "forward") => Some(&mut keymap.vim_search.forward),
("vim_search", "backward") => Some(&mut keymap.vim_search.backward), ("vim_search", "backward") => Some(&mut keymap.vim_search.backward),
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap Keymap
All configurable shortcuts. All configurable shortcuts.
136 actions, 1 customized, 5 unbound. 137 actions, 1 customized, 5 unbound.
[All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug [All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap Keymap
All configurable shortcuts. All configurable shortcuts.
137 actions, 0 customized, 6 unbound. 138 actions, 0 customized, 6 unbound.
[All] Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug [All] Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug
@@ -2,14 +2,14 @@
source: tui/src/keymap_setup.rs source: tui/src/keymap_setup.rs
expression: snapshot expression: snapshot
--- ---
tab: All (136 selectable) tab: All (137 selectable)
tab: Common (20 selectable) tab: Common (20 selectable)
tab: Customized (0) (0 selectable) tab: Customized (0) (0 selectable)
tab: Unbound (5) (5 selectable) tab: Unbound (5) (5 selectable)
tab: App (14 selectable) tab: App (14 selectable)
tab: Composer (5 selectable) tab: Composer (5 selectable)
tab: Editor (17 selectable) tab: Editor (17 selectable)
tab: Vim (67 selectable) tab: Vim (68 selectable)
tab: Navigation (20 selectable) tab: Navigation (20 selectable)
tab: Agents (5 selectable) tab: Agents (5 selectable)
tab: Approval (8 selectable) tab: Approval (8 selectable)
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap Keymap
All configurable shortcuts. All configurable shortcuts.
136 actions, 1 customized, 5 unbound. 137 actions, 1 customized, 5 unbound.
[All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug [All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
Keymap Keymap
All configurable shortcuts. All configurable shortcuts.
136 actions, 0 customized, 5 unbound. 137 actions, 0 customized, 5 unbound.
[All] Common Customized (0) Unbound (5) App Composer Editor Vim [All] Common Customized (0) Unbound (5) App Composer Editor Vim
Navigation Agents Approval Debug Navigation Agents Approval Debug
@@ -0,0 +1,5 @@
---
source: tui/src/keymap_setup.rs
expression: redo_row
---
› Vim normal Redo ctrl-r
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap Keymap
All configurable shortcuts. All configurable shortcuts.
136 actions, 0 customized, 5 unbound. 137 actions, 0 customized, 5 unbound.
[All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug [All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug