From f3d0c003ea321fc6a590e30b0a76f47efc4e7244 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 25 May 2026 19:35:59 -0300 Subject: [PATCH] feat(tui): add vim dot repeat --- codex-rs/config/src/tui_keymap.rs | 2 + codex-rs/core/config.schema.json | 10 + codex-rs/tui/src/bottom_pane/textarea.rs | 189 +++++++++- codex-rs/tui/src/bottom_pane/textarea/vim.rs | 64 ++++ .../tui/src/bottom_pane/textarea/vim/count.rs | 2 +- .../tui/src/bottom_pane/textarea/vim/find.rs | 7 +- .../src/bottom_pane/textarea/vim/register.rs | 2 + .../src/bottom_pane/textarea/vim/repeat.rs | 337 ++++++++++++++++++ .../src/bottom_pane/textarea/vim/visual.rs | 13 + codex-rs/tui/src/keymap.rs | 23 ++ codex-rs/tui/src/keymap_setup/actions.rs | 3 + ...ap_setup__tests__keymap_picker_custom.snap | 2 +- ...ests__keymap_picker_fast_mode_enabled.snap | 2 +- ...p__tests__keymap_picker_first_actions.snap | 4 +- ...ap_setup__tests__keymap_picker_narrow.snap | 2 +- ...ymap_setup__tests__keymap_picker_wide.snap | 2 +- 16 files changed, 648 insertions(+), 16 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/textarea/vim/repeat.rs diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 31592ec866b6..9d55e12d7347 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -231,6 +231,8 @@ pub struct TuiVimNormalKeymap { pub repeat_find: Option, /// Repeat the most recent find/till motion in the opposite direction (`,`). pub repeat_find_reverse: Option, + /// Repeat the most recent text-changing Vim command (`.`). + pub repeat_change: Option, /// Delete character under cursor (`x`). pub delete_char: Option, /// Delete from cursor to end of line (`D`). diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b9fdc52beb2d..17ba70202696 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2845,6 +2845,7 @@ "open_line_above": null, "open_line_below": null, "paste_after": null, + "repeat_change": null, "repeat_find": null, "repeat_find_reverse": null, "select_register": null, @@ -3553,6 +3554,7 @@ "open_line_above": null, "open_line_below": null, "paste_after": null, + "repeat_change": null, "repeat_find": null, "repeat_find_reverse": null, "select_register": null, @@ -4021,6 +4023,14 @@ ], "description": "Paste after cursor (`p`)." }, + "repeat_change": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Repeat the most recent text-changing Vim command (`.`)." + }, "repeat_find": { "allOf": [ { diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 32fc30696c5c..9caec3a2e3c7 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -40,11 +40,14 @@ use unicode_width::UnicodeWidthStr; mod vim; use self::vim::VimFind; +use self::vim::VimInsertRecording; use self::vim::VimMode; use self::vim::VimMotion; use self::vim::VimOperator; use self::vim::VimPending; use self::vim::VimRegisterSelection; +use self::vim::VimRepeatChange; +use self::vim::VimRepeatTarget; use self::vim::VimTextObjectScope; use self::vim::VimVisualKind; @@ -121,6 +124,9 @@ pub(crate) struct TextArea { vim_operator_count: Option, vim_last_find: Option, vim_visual_anchor: Option, + vim_last_change: Option, + vim_insert_recording: Option, + vim_replaying_change: bool, editor_keymap: EditorKeymap, vim_normal_keymap: VimNormalKeymap, vim_operator_keymap: VimOperatorKeymap, @@ -169,6 +175,9 @@ impl TextArea { vim_operator_count: None, vim_last_find: None, vim_visual_anchor: None, + vim_last_change: None, + vim_insert_recording: None, + vim_replaying_change: false, editor_keymap: defaults.editor, vim_normal_keymap: defaults.vim_normal, vim_operator_keymap: defaults.vim_operator, @@ -211,6 +220,7 @@ impl TextArea { } fn set_text_inner(&mut self, text: &str, elements: Option<&[UserTextElement]>) { + self.invalidate_vim_repeat_change(); // Stage 1: replace the raw text and keep the cursor in a safe byte range. self.text = text.to_string(); self.cursor_pos = self.cursor_pos.clamp(0, self.text.len()); @@ -255,6 +265,7 @@ impl TextArea { pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { self.vim_enabled = enabled; self.vim_pending = VimPending::None; + self.vim_insert_recording = None; self.clear_vim_counts(); self.clear_vim_register_selection(); self.clear_vim_visual_selection(); @@ -671,15 +682,14 @@ impl TextArea { fn handle_vim_insert(&mut self, event: KeyEvent) { if matches!(event.code, KeyCode::Esc) { - let bol = self.beginning_of_current_line(); - if self.cursor_pos > bol { - self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos).max(bol); - } - self.enter_vim_normal_mode(); + self.exit_vim_insert_after_change(); + self.complete_vim_change_recording(); return; } + let edit = self.vim_insert_edit_for_event(event); let keymap = self.editor_keymap.clone(); self.input_with_keymap(event, &keymap); + self.record_vim_insert_edit(edit); } fn handle_vim_normal(&mut self, event: KeyEvent) { @@ -836,9 +846,15 @@ impl TextArea { self.repeat_vim_find(/*operator*/ None, /*reverse*/ true, count); return; } + if self.vim_normal_keymap.repeat_change.is_pressed(event) { + self.repeat_vim_change(); + return; + } if self.vim_normal_keymap.delete_char.is_pressed(event) { let count = self.take_vim_count(); + let previous_len = self.text.len(); self.delete_forward_kill(count); + self.record_vim_delete_if_changed(VimRepeatTarget::Characters(count), previous_len); self.clear_vim_register_selection(); return; } @@ -857,12 +873,14 @@ impl TextArea { self.delete_forward_kill(count); self.clear_vim_register_selection(); self.vim_mode = VimMode::Insert; + self.begin_vim_change_recording(VimRepeatTarget::Characters(count)); return; } if self.vim_normal_keymap.substitute_line.is_pressed(event) { let count = self.take_vim_count(); self.vim_change_current_lines(count); self.clear_vim_register_selection(); + self.begin_vim_change_recording(VimRepeatTarget::Lines(count)); return; } if self.vim_normal_keymap.yank_line.is_pressed(event) { @@ -909,7 +927,9 @@ impl TextArea { fn handle_vim_operator(&mut self, op: VimOperator, event: KeyEvent) -> bool { if op == VimOperator::Delete && self.vim_operator_keymap.delete_line.is_pressed(event) { let count = self.take_vim_operator_count(); + let previous_len = self.text.len(); self.vim_kill_current_lines(count); + self.record_vim_delete_if_changed(VimRepeatTarget::Lines(count), previous_len); self.clear_vim_register_selection(); return true; } @@ -922,6 +942,7 @@ impl TextArea { if op == VimOperator::Change && self.vim_operator_keymap.change_line.is_pressed(event) { let count = self.take_vim_operator_count(); self.vim_change_current_lines(count); + self.begin_vim_change_recording(VimRepeatTarget::Lines(count)); self.clear_vim_register_selection(); return true; } @@ -987,7 +1008,12 @@ impl TextArea { }; let count = self.take_vim_operator_count(); if let Some(range) = self.counted_text_object_range(object, scope, count) { - self.apply_vim_operator_to_range(op, range); + let target = VimRepeatTarget::TextObject { + object, + scope, + count, + }; + self.apply_vim_operator_to_range(op, range, Some(target)); } self.clear_vim_register_selection(); true @@ -1033,6 +1059,7 @@ impl TextArea { } fn apply_vim_operator(&mut self, op: VimOperator, motion: VimMotion, count: usize) { + let previous_len = self.text.len(); match op { VimOperator::Delete => { if let Some(range) = self.range_for_motion(motion, count) { @@ -1053,6 +1080,14 @@ impl TextArea { } } } + let target = VimRepeatTarget::Motion { motion, count }; + match op { + VimOperator::Delete => self.record_vim_delete_if_changed(target, previous_len), + VimOperator::Change if self.vim_mode == VimMode::Insert => { + self.begin_vim_change_recording(target); + } + VimOperator::Yank | VimOperator::Change => {} + } self.clear_vim_register_selection(); } @@ -1070,7 +1105,13 @@ impl TextArea { self.range_for_motion(motion, count) } - fn apply_vim_operator_to_range(&mut self, op: VimOperator, range: Range) { + fn apply_vim_operator_to_range( + &mut self, + op: VimOperator, + range: Range, + repeat_target: Option, + ) { + let previous_len = self.text.len(); match op { VimOperator::Delete => self.kill_range(range), VimOperator::Yank => self.yank_range(range), @@ -1079,6 +1120,15 @@ impl TextArea { self.vim_mode = VimMode::Insert; } } + if let Some(target) = repeat_target { + match op { + VimOperator::Delete => self.record_vim_delete_if_changed(target, previous_len), + VimOperator::Change if self.vim_mode == VimMode::Insert => { + self.begin_vim_change_recording(target); + } + VimOperator::Yank | VimOperator::Change => {} + } + } self.clear_vim_register_selection(); } @@ -1604,6 +1654,7 @@ impl TextArea { if start > end || end > self.text.len() { return false; } + self.invalidate_vim_repeat_change(); let removed_len = end - start; let inserted_len = new.len(); @@ -1655,6 +1706,7 @@ impl TextArea { } pub fn insert_element(&mut self, text: &str) -> u64 { + self.invalidate_vim_repeat_change(); let start = self.clamp_pos_for_insertion(self.cursor_pos); self.insert_str_at(start, text); let end = start + text.len(); @@ -1666,6 +1718,7 @@ impl TextArea { #[cfg(not(target_os = "linux"))] pub fn insert_named_element(&mut self, text: &str, id: String) { + self.invalidate_vim_repeat_change(); let start = self.clamp_pos_for_insertion(self.cursor_pos); self.insert_str_at(start, text); let end = start + text.len(); @@ -1681,6 +1734,7 @@ impl TextArea { .iter() .position(|e| e.name.as_deref() == Some(id)) { + self.invalidate_vim_repeat_change(); let range = self.elements[idx].range.clone(); self.replace_range_raw(range, text); self.elements.retain(|e| e.name.as_deref() != Some(id)); @@ -1699,6 +1753,7 @@ impl TextArea { .iter() .position(|e| e.name.as_deref() == Some(id)) { + self.invalidate_vim_repeat_change(); let old_range = self.elements[elem_idx].range.clone(); let start = old_range.start; self.replace_range_raw(old_range, text); @@ -1757,6 +1812,7 @@ impl TextArea { return None; } let id = self.add_element(start..end); + self.invalidate_vim_repeat_change(); Some(id) } @@ -1769,7 +1825,11 @@ impl TextArea { let len_before = self.elements.len(); self.elements .retain(|elem| elem.range.start != start || elem.range.end != end); - len_before != self.elements.len() + let removed = len_before != self.elements.len(); + if removed { + self.invalidate_vim_repeat_change(); + } + removed } fn next_element_id(&mut self) -> u64 { @@ -2991,6 +3051,119 @@ mod tests { assert_eq!(t.vim_mode_label(), Some("Normal")); } + #[test] + fn vim_dot_repeats_deletes_and_change_insert_edits() { + let mut t = ta_with("one two three"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "three"); + + let mut t = ta_with("one two"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + t.set_cursor(/*pos*/ "bar ".len()); + t.input(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "bar bar"); + assert_eq!(t.vim_mode_label(), Some("Normal")); + } + + #[test] + fn vim_dot_repeats_paste_and_visual_changes() { + let mut t = ta_with("a"); + t.kill_buffer = "!".to_string(); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + t.input(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)); + assert_eq!(t.text(), "a!!"); + + let mut t = ta_with("one two"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + t.input(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + t.set_cursor(/*pos*/ "x ".len()); + t.input(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "x x"); + + let mut t = ta_with("one\ntwo"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + t.input(KeyEvent::new(KeyCode::Char('V'), KeyModifiers::SHIFT)); + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + t.set_cursor(/*pos*/ "x\n".len()); + t.input(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "x\nx"); + } + + #[test] + fn vim_dot_repeats_substitutions_and_find_deletes() { + let mut t = ta_with("ab cd"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + t.input(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); + t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + t.set_cursor(/*pos*/ "Xb ".len()); + t.input(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)); + assert_eq!(t.text(), "Xb Xd"); + + let mut t = ta_with("one\ntwo"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + t.input(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT)); + t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + t.set_cursor(/*pos*/ "x\n".len()); + t.input(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)); + assert_eq!(t.text(), "x\nx"); + + let mut t = ta_with("a,b,c"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char(','), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)); + assert_eq!(t.text(), "c"); + } + + #[test] + fn vim_atomic_element_updates_invalidate_dot_repeat() { + let mut t = ta_with("abc"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + assert_eq!(t.text(), "bc"); + + t.add_element_range(/*range*/ 0..1).expect("valid element"); + t.set_cursor(/*pos*/ 1); + t.input(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "bc"); + } + #[test] fn vim_find_till_motions_repeat_and_stay_on_current_line() { let mut t = ta_with("ab,cd,ef\naz"); diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim.rs b/codex-rs/tui/src/bottom_pane/textarea/vim.rs index f496bac9c3cb..2e549cf32af7 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/vim.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/vim.rs @@ -8,6 +8,7 @@ mod count; mod find; mod prose; mod register; +mod repeat; mod tag; mod visual; @@ -85,6 +86,69 @@ pub(super) struct VimFind { pub(super) target: char, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum VimRepeatTarget { + Characters(usize), + Lines(usize), + Motion { + motion: VimMotion, + count: usize, + }, + TextObject { + object: VimTextObject, + scope: VimTextObjectScope, + count: usize, + }, + Find { + find: VimFind, + count: usize, + }, + Visual { + kind: VimVisualKind, + atomic_units: usize, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum VimInsertEdit { + Insert(String), + DeleteBackward, + DeleteForward, + DeleteBackwardWord, + DeleteForwardWord, + KillLineStart, + KillWholeLine, + KillLineEnd, + MoveLeft, + MoveRight, + MoveUp, + MoveDown, + MoveWordLeft, + MoveWordRight, + MoveLineStart { move_up_at_bol: bool }, + MoveLineEnd { move_down_at_eol: bool }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct VimInsertRecording { + pub(super) target: VimRepeatTarget, + pub(super) edits: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum VimRepeatChange { + Delete(VimRepeatTarget), + Change { + target: VimRepeatTarget, + edits: Vec, + }, + Paste { + text: String, + kind: super::KillBufferKind, + count: usize, + }, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum VimTextObjectScope { Inner, diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim/count.rs b/codex-rs/tui/src/bottom_pane/textarea/vim/count.rs index ffdbab5fa105..c99eceac7e48 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/vim/count.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/vim/count.rs @@ -133,7 +133,7 @@ impl TextArea { self.vim_mode = super::VimMode::Insert; } - fn current_line_range_with_count(&self, count: usize) -> Range { + pub(in super::super) fn current_line_range_with_count(&self, count: usize) -> Range { let start = self.beginning_of_current_line(); let mut end = self.current_line_range_with_newline().end; for _ in 1..count { diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim/find.rs b/codex-rs/tui/src/bottom_pane/textarea/vim/find.rs index 606aff9216d4..8db4ebdaa604 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/vim/find.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/vim/find.rs @@ -2,6 +2,7 @@ use super::super::TextArea; use super::VimFind; use super::VimFindKind; use super::VimOperator; +use super::VimRepeatTarget; use crate::key_hint::KeyBindingListExt; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -102,7 +103,11 @@ impl TextArea { fn execute_vim_find(&mut self, operator: Option, find: VimFind, count: usize) { if let Some(operator) = operator { if let Some(range) = self.range_for_find(find, count) { - self.apply_vim_operator_to_range(operator, range); + self.apply_vim_operator_to_range( + operator, + range, + Some(VimRepeatTarget::Find { find, count }), + ); } } else if let Some(target) = self.target_for_find(find, count) { self.set_cursor(target); diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim/register.rs b/codex-rs/tui/src/bottom_pane/textarea/vim/register.rs index 67404f9e8f00..cb4999a97c79 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/vim/register.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/vim/register.rs @@ -66,8 +66,10 @@ impl TextArea { if source.0.is_empty() { return; } + let previous_len = self.text.len(); for _ in 0..count { self.paste_text_after_cursor(&source.0, source.1); } + self.record_vim_paste_if_changed(source.0, source.1, count, previous_len); } } diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim/repeat.rs b/codex-rs/tui/src/bottom_pane/textarea/vim/repeat.rs new file mode 100644 index 000000000000..873f8213a98f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/vim/repeat.rs @@ -0,0 +1,337 @@ +use super::super::KillBufferKind; +use super::super::TextArea; +use super::VimInsertEdit; +use super::VimInsertRecording; +use super::VimMode; +use super::VimOperator; +use super::VimRepeatChange; +use super::VimRepeatTarget; +use super::VimVisualKind; +use crate::key_hint::KeyBindingListExt; +use crate::key_hint::is_altgr; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use std::ops::Range; + +impl TextArea { + pub(in super::super) fn invalidate_vim_repeat_change(&mut self) { + if !self.vim_replaying_change { + self.vim_last_change = None; + self.vim_insert_recording = None; + } + } + + pub(in super::super) fn record_vim_delete_if_changed( + &mut self, + target: VimRepeatTarget, + previous_len: usize, + ) { + if !self.vim_replaying_change && self.text.len() < previous_len { + self.vim_last_change = Some(VimRepeatChange::Delete(target)); + self.vim_insert_recording = None; + } + } + + pub(in super::super) fn begin_vim_change_recording(&mut self, target: VimRepeatTarget) { + if !self.vim_replaying_change { + self.vim_insert_recording = Some(VimInsertRecording { + target, + edits: Vec::new(), + }); + } + } + + pub(in super::super) fn complete_vim_change_recording(&mut self) { + if self.vim_replaying_change { + return; + } + if let Some(recording) = self.vim_insert_recording.take() { + self.vim_last_change = Some(VimRepeatChange::Change { + target: recording.target, + edits: recording.edits, + }); + } + } + + pub(in super::super) fn record_vim_paste_if_changed( + &mut self, + text: String, + kind: KillBufferKind, + count: usize, + previous_len: usize, + ) { + if !self.vim_replaying_change && self.text.len() > previous_len { + self.vim_last_change = Some(VimRepeatChange::Paste { text, kind, count }); + self.vim_insert_recording = None; + } + } + + pub(in super::super) fn record_vim_insert_edit(&mut self, edit: Option) { + if self.vim_replaying_change { + return; + } + if let Some(recording) = self.vim_insert_recording.as_mut() + && let Some(edit) = edit + { + recording.edits.push(edit); + } + } + + pub(in super::super) fn vim_insert_edit_for_event( + &self, + event: KeyEvent, + ) -> Option { + let keymap = &self.editor_keymap; + if keymap.insert_newline.is_pressed(event) { + return Some(VimInsertEdit::Insert("\n".to_string())); + } + if keymap.delete_backward_word.is_pressed(event) { + return Some(VimInsertEdit::DeleteBackwardWord); + } + if let KeyEvent { + code: KeyCode::Char(c), + modifiers, + .. + } = event + && is_altgr(modifiers) + { + return Some(VimInsertEdit::Insert(c.to_string())); + } + if keymap.delete_backward.is_pressed(event) { + return Some(VimInsertEdit::DeleteBackward); + } + if keymap.delete_forward_word.is_pressed(event) { + return Some(VimInsertEdit::DeleteForwardWord); + } + if keymap.delete_forward.is_pressed(event) { + return Some(VimInsertEdit::DeleteForward); + } + if keymap.kill_line_start.is_pressed(event) { + return Some(VimInsertEdit::KillLineStart); + } + if keymap.kill_whole_line.is_pressed(event) { + return Some(VimInsertEdit::KillWholeLine); + } + if keymap.kill_line_end.is_pressed(event) { + return Some(VimInsertEdit::KillLineEnd); + } + if keymap.yank.is_pressed(event) { + return (!self.kill_buffer.is_empty()) + .then(|| VimInsertEdit::Insert(self.kill_buffer.clone())); + } + if keymap.move_word_left.is_pressed(event) { + return Some(VimInsertEdit::MoveWordLeft); + } + if keymap.move_word_right.is_pressed(event) { + return Some(VimInsertEdit::MoveWordRight); + } + if keymap.move_left.is_pressed(event) { + return Some(VimInsertEdit::MoveLeft); + } + if keymap.move_right.is_pressed(event) { + return Some(VimInsertEdit::MoveRight); + } + if keymap.move_up.is_pressed(event) { + return Some(VimInsertEdit::MoveUp); + } + if keymap.move_down.is_pressed(event) { + return Some(VimInsertEdit::MoveDown); + } + if keymap.move_line_start.is_pressed(event) { + let move_up_at_bol = matches!( + event, + KeyEvent { + code: KeyCode::Char('a'), + modifiers: KeyModifiers::CONTROL, + .. + } + ); + return Some(VimInsertEdit::MoveLineStart { move_up_at_bol }); + } + if keymap.move_line_end.is_pressed(event) { + let move_down_at_eol = matches!( + event, + KeyEvent { + code: KeyCode::Char('e'), + modifiers: KeyModifiers::CONTROL, + .. + } + ); + return Some(VimInsertEdit::MoveLineEnd { move_down_at_eol }); + } + if let KeyEvent { + code: KeyCode::Char(c), + modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT, + .. + } = event + && !c.is_ascii_control() + { + return Some(VimInsertEdit::Insert(c.to_string())); + } + None + } + + pub(in super::super) fn repeat_vim_change(&mut self) { + let Some(change) = self.vim_last_change.clone() else { + return; + }; + self.vim_replaying_change = true; + match change { + VimRepeatChange::Delete(target) => { + self.execute_vim_repeat_target(VimOperator::Delete, target); + } + VimRepeatChange::Change { target, edits } => { + if self.execute_vim_repeat_target(VimOperator::Change, target) { + for edit in edits { + self.apply_vim_insert_edit(edit); + } + self.exit_vim_insert_after_change(); + } + } + VimRepeatChange::Paste { text, kind, count } => { + for _ in 0..count { + self.paste_text_after_cursor(&text, kind); + } + } + } + self.vim_replaying_change = false; + self.clear_vim_counts(); + self.clear_vim_register_selection(); + } + + fn execute_vim_repeat_target( + &mut self, + operator: VimOperator, + target: VimRepeatTarget, + ) -> bool { + let previous_len = self.text.len(); + match target { + VimRepeatTarget::Characters(count) => { + self.delete_forward_kill(count); + if operator == VimOperator::Change { + self.vim_mode = VimMode::Insert; + } + } + VimRepeatTarget::Lines(count) => match operator { + VimOperator::Delete => self.vim_kill_current_lines(count), + VimOperator::Change => self.vim_change_current_lines(count), + VimOperator::Yank => unreachable!("repeat changes never yank"), + }, + VimRepeatTarget::Motion { motion, count } => { + self.apply_vim_operator(operator, motion, count); + } + VimRepeatTarget::TextObject { + object, + scope, + count, + } => { + if let Some(range) = self.counted_text_object_range(object, scope, count) { + self.apply_vim_operator_to_range(operator, range, /*repeat_target*/ None); + } + } + VimRepeatTarget::Find { find, count } => { + if let Some(range) = self.range_for_find(find, count) { + self.apply_vim_operator_to_range(operator, range, /*repeat_target*/ None); + } + } + VimRepeatTarget::Visual { kind, atomic_units } => { + if let Some(mut range) = self.visual_repeat_range(kind, atomic_units) { + if operator == VimOperator::Change + && kind == VimVisualKind::Linewise + && range.end > range.start + && self.text[..range.end].ends_with('\n') + { + range.end -= 1; + } + let buffer_kind = if kind == VimVisualKind::Linewise { + KillBufferKind::Linewise + } else { + KillBufferKind::Characterwise + }; + self.kill_range_with_kind(range, buffer_kind); + if operator == VimOperator::Change { + self.vim_mode = VimMode::Insert; + } + } + } + } + self.text.len() < previous_len + || operator == VimOperator::Change && self.vim_mode == VimMode::Insert + } + + pub(in super::super) fn visual_repeat_target( + &self, + kind: VimVisualKind, + range: Range, + ) -> VimRepeatTarget { + let atomic_units = match kind { + VimVisualKind::Characterwise => { + let mut pos = range.start; + let mut units = 0; + while pos < range.end { + let next = self.next_atomic_boundary(pos); + if next <= pos { + break; + } + units += 1; + pos = next; + } + units.max(1) + } + VimVisualKind::Linewise => self.text[range].lines().count().max(1), + }; + VimRepeatTarget::Visual { kind, atomic_units } + } + + fn visual_repeat_range( + &self, + kind: VimVisualKind, + atomic_units: usize, + ) -> Option> { + let range = match kind { + VimVisualKind::Characterwise => { + let mut end = self.cursor_pos; + for _ in 0..atomic_units { + end = self.next_atomic_boundary(end); + } + self.cursor_pos..end + } + VimVisualKind::Linewise => self.current_line_range_with_count(atomic_units), + }; + (range.start < range.end).then(|| self.expand_range_to_element_boundaries(range)) + } + + fn apply_vim_insert_edit(&mut self, edit: VimInsertEdit) { + match edit { + VimInsertEdit::Insert(text) => self.insert_str(&text), + VimInsertEdit::DeleteBackward => self.delete_backward(/*n*/ 1), + VimInsertEdit::DeleteForward => self.delete_forward(/*n*/ 1), + VimInsertEdit::DeleteBackwardWord => self.delete_backward_word(), + VimInsertEdit::DeleteForwardWord => self.delete_forward_word(), + VimInsertEdit::KillLineStart => self.kill_to_beginning_of_line(), + VimInsertEdit::KillWholeLine => self.kill_current_line(), + VimInsertEdit::KillLineEnd => self.kill_to_end_of_line(), + VimInsertEdit::MoveLeft => self.move_cursor_left(), + VimInsertEdit::MoveRight => self.move_cursor_right(), + VimInsertEdit::MoveUp => self.move_cursor_up(), + VimInsertEdit::MoveDown => self.move_cursor_down(), + VimInsertEdit::MoveWordLeft => self.set_cursor(self.beginning_of_previous_word()), + VimInsertEdit::MoveWordRight => self.set_cursor(self.end_of_next_word()), + VimInsertEdit::MoveLineStart { move_up_at_bol } => { + self.move_cursor_to_beginning_of_line(move_up_at_bol); + } + VimInsertEdit::MoveLineEnd { move_down_at_eol } => { + self.move_cursor_to_end_of_line(move_down_at_eol); + } + } + } + + pub(in super::super) fn exit_vim_insert_after_change(&mut self) { + let bol = self.beginning_of_current_line(); + if self.cursor_pos > bol { + self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos).max(bol); + } + self.enter_vim_normal_mode(); + } +} diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim/visual.rs b/codex-rs/tui/src/bottom_pane/textarea/vim/visual.rs index df07868757c0..ea930ff20cba 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/vim/visual.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/vim/visual.rs @@ -225,6 +225,15 @@ impl TextArea { } else { KillBufferKind::Characterwise }; + let repeat_target = self.visual_repeat_target( + if linewise { + VimVisualKind::Linewise + } else { + VimVisualKind::Characterwise + }, + range.clone(), + ); + let previous_len = self.text.len(); let start = range.start; match operator { VimOperator::Delete => self.kill_range_with_kind(range, kind), @@ -236,9 +245,13 @@ impl TextArea { self.clear_vim_register_selection(); if operator == VimOperator::Change { self.vim_mode = VimMode::Insert; + self.begin_vim_change_recording(repeat_target); } else { self.vim_mode = VimMode::Normal; self.set_cursor(start.min(self.vim_normal_end_cursor())); + if operator == VimOperator::Delete { + self.record_vim_delete_if_changed(repeat_target, previous_len); + } } } diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index cb8dac052be6..e7242abc77ae 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -162,6 +162,7 @@ pub(crate) struct VimNormalKeymap { pub(crate) till_backward: Vec, pub(crate) repeat_find: Vec, pub(crate) repeat_find_reverse: Vec, + pub(crate) repeat_change: Vec, pub(crate) delete_char: Vec, pub(crate) delete_to_line_end: Vec, pub(crate) change_to_line_end: Vec, @@ -523,6 +524,7 @@ impl RuntimeKeymap { till_backward: resolve_local!(keymap, defaults, vim_normal, till_backward), repeat_find: resolve_local!(keymap, defaults, vim_normal, repeat_find), repeat_find_reverse: resolve_local!(keymap, defaults, vim_normal, repeat_find_reverse), + repeat_change: resolve_local!(keymap, defaults, vim_normal, repeat_change), delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char), delete_to_line_end: resolve_local!(keymap, defaults, vim_normal, delete_to_line_end), change_to_line_end: resolve_local!(keymap, defaults, vim_normal, change_to_line_end), @@ -634,6 +636,10 @@ impl RuntimeKeymap { keymap.vim_normal.repeat_find_reverse.as_ref(), vim_normal.repeat_find_reverse.as_slice(), ), + ( + keymap.vim_normal.repeat_change.as_ref(), + vim_normal.repeat_change.as_slice(), + ), ( keymap.vim_normal.delete_char.as_ref(), vim_normal.delete_char.as_slice(), @@ -721,6 +727,11 @@ impl RuntimeKeymap { .repeat_find_reverse .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); } + if keymap.vim_normal.repeat_change.is_none() { + vim_normal + .repeat_change + .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); + } if keymap.vim_normal.select_register.is_none() { vim_normal .select_register @@ -1275,6 +1286,7 @@ impl RuntimeKeymap { ], repeat_find: default_bindings![plain(KeyCode::Char(';'))], repeat_find_reverse: default_bindings![plain(KeyCode::Char(','))], + repeat_change: default_bindings![plain(KeyCode::Char('.'))], delete_char: default_bindings![plain(KeyCode::Char('x'))], delete_to_line_end: default_bindings![ shift(KeyCode::Char('d')), @@ -1741,6 +1753,7 @@ impl RuntimeKeymap { "repeat_find_reverse", self.vim_normal.repeat_find_reverse.as_slice(), ), + ("repeat_change", self.vim_normal.repeat_change.as_slice()), ("delete_char", self.vim_normal.delete_char.as_slice()), ( "delete_to_line_end", @@ -2698,6 +2711,16 @@ mod tests { assert_eq!(runtime.vim_normal.enter_visual_line, Vec::new()); } + #[test] + fn configured_legacy_vim_binding_prunes_repeat_change_default() { + let mut keymap = TuiKeymap::default(); + keymap.vim_normal.move_left = Some(one(".")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!(runtime.vim_normal.repeat_change, Vec::new()); + } + #[test] fn configured_legacy_vim_operator_bindings_prune_new_text_object_defaults() { let mut keymap = TuiKeymap::default(); diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index b6622eb42806..e83d85dd9080 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -139,6 +139,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("vim_normal", "Vim normal", "till_backward", "Move until after the previous target character on the current line."), action("vim_normal", "Vim normal", "repeat_find", "Repeat the latest find or till motion."), action("vim_normal", "Vim normal", "repeat_find_reverse", "Repeat the latest find or till motion in reverse."), + action("vim_normal", "Vim normal", "repeat_change", "Repeat the most recent text-changing Vim command."), action("vim_normal", "Vim normal", "delete_char", "Delete the character under the cursor."), action("vim_normal", "Vim normal", "delete_to_line_end", "Delete from cursor to end of line."), action("vim_normal", "Vim normal", "change_to_line_end", "Change from cursor to end of line and enter insert mode."), @@ -305,6 +306,7 @@ pub(super) fn binding_slot<'a>( ("vim_normal", "till_backward") => Some(&mut keymap.vim_normal.till_backward), ("vim_normal", "repeat_find") => Some(&mut keymap.vim_normal.repeat_find), ("vim_normal", "repeat_find_reverse") => Some(&mut keymap.vim_normal.repeat_find_reverse), + ("vim_normal", "repeat_change") => Some(&mut keymap.vim_normal.repeat_change), ("vim_normal", "delete_char") => Some(&mut keymap.vim_normal.delete_char), ("vim_normal", "delete_to_line_end") => Some(&mut keymap.vim_normal.delete_to_line_end), ("vim_normal", "change_to_line_end") => Some(&mut keymap.vim_normal.change_to_line_end), @@ -453,6 +455,7 @@ pub(super) fn bindings_for_action<'a>( ("vim_normal", "till_backward") => Some(runtime_keymap.vim_normal.till_backward.as_slice()), ("vim_normal", "repeat_find") => Some(runtime_keymap.vim_normal.repeat_find.as_slice()), ("vim_normal", "repeat_find_reverse") => Some(runtime_keymap.vim_normal.repeat_find_reverse.as_slice()), + ("vim_normal", "repeat_change") => Some(runtime_keymap.vim_normal.repeat_change.as_slice()), ("vim_normal", "delete_char") => Some(runtime_keymap.vim_normal.delete_char.as_slice()), ("vim_normal", "delete_to_line_end") => Some(runtime_keymap.vim_normal.delete_to_line_end.as_slice()), ("vim_normal", "change_to_line_end") => Some(runtime_keymap.vim_normal.change_to_line_end.as_slice()), diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index 7b8c35d08c91..dccadc0fc411 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 131 actions, 1 customized, 2 unbound. + 132 actions, 1 customized, 2 unbound. [All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap index 04f47eaa6e5c..10820531bc44 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 132 actions, 0 customized, 3 unbound. + 133 actions, 0 customized, 3 unbound. [All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index 82ad302216ce..6138505ff016 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -2,14 +2,14 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (131 selectable) +tab: All (132 selectable) tab: Common (19 selectable) tab: Customized (0) (0 selectable) tab: Unbound (2) (2 selectable) tab: App (9 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) -tab: Vim (72 selectable) +tab: Vim (73 selectable) tab: Navigation (20 selectable) tab: Approval (8 selectable) tab: Debug (1 selectable) diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index 465a90fcb0b7..a4e11b9c0dd5 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 131 actions, 0 customized, 2 unbound. + 132 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index 5d4783c8b81e..82da86aa4a5a 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 131 actions, 0 customized, 2 unbound. + 132 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug