From e98e93e277327c92c931da2e9f42deabc53bfb68 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 25 May 2026 16:42:09 -0300 Subject: [PATCH] feat(tui): complete vim change operations --- codex-rs/config/src/tui_keymap.rs | 12 +- codex-rs/core/config.schema.json | 34 ++++- codex-rs/tui/src/bottom_pane/textarea.rs | 144 +++++++++++++++--- codex-rs/tui/src/keymap.rs | 73 ++++++++- codex-rs/tui/src/keymap_setup/actions.rs | 11 +- ...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 +- 10 files changed, 253 insertions(+), 33 deletions(-) diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index e41ec860ee..85f62a6ebd 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -225,6 +225,10 @@ pub struct TuiVimNormalKeymap { pub delete_to_line_end: Option, /// Change from cursor to end of line and enter insert mode (`C`). pub change_to_line_end: Option, + /// Substitute the character under the cursor and enter insert mode (`s`). + pub substitute_char: Option, + /// Substitute the current line and enter insert mode (`S`). + pub substitute_line: Option, /// Yank the entire line (`Y`). pub yank_line: Option, /// Paste after cursor (`p`). @@ -233,7 +237,7 @@ pub struct TuiVimNormalKeymap { pub start_delete_operator: Option, /// Begin yank operator; next key selects motion (`y`). pub start_yank_operator: Option, - /// Begin change operator; next keys select a text object. + /// Begin change operator; next keys select a motion or text object. pub start_change_operator: Option, /// Cancel a pending operator and return to normal mode. pub cancel_operator: Option, @@ -241,8 +245,8 @@ pub struct TuiVimNormalKeymap { /// Vim operator-pending keybindings for modal editing inside text areas. /// -/// This context is active only while waiting for a motion after `d` or `y`. -/// Repeating the operator key (`dd`, `yy`) targets the entire line. Pressing +/// This context is active only while waiting for a motion after `d`, `y`, or `c`. +/// Repeating the operator key (`dd`, `yy`, `cc`) targets the entire line. Pressing /// `Esc` cancels the pending operator and returns to normal mode without /// modifying text. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] @@ -252,6 +256,8 @@ pub struct TuiVimOperatorKeymap { pub delete_line: Option, /// Repeat yank operator to yank the whole line (`yy`). pub yank_line: Option, + /// Repeat change operator to change the whole line (`cc`). + pub change_line: Option, /// Motion: left (`h`). pub motion_left: Option, /// Motion: right (`l`). diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index a8a1662c3b..1b736696bb 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2844,10 +2844,13 @@ "start_change_operator": null, "start_delete_operator": null, "start_yank_operator": null, + "substitute_char": null, + "substitute_line": null, "yank_line": null }, "vim_operator": { "cancel": null, + "change_line": null, "delete_line": null, "motion_down": null, "motion_left": null, @@ -3525,6 +3528,8 @@ "start_change_operator": null, "start_delete_operator": null, "start_yank_operator": null, + "substitute_char": null, + "substitute_line": null, "yank_line": null } }, @@ -3536,6 +3541,7 @@ ], "default": { "cancel": null, + "change_line": null, "delete_line": null, "motion_down": null, "motion_left": null, @@ -3934,7 +3940,7 @@ "$ref": "#/definitions/KeybindingsSpec" } ], - "description": "Begin change operator; next keys select a text object." + "description": "Begin change operator; next keys select a motion or text object." }, "start_delete_operator": { "allOf": [ @@ -3952,6 +3958,22 @@ ], "description": "Begin yank operator; next key selects motion (`y`)." }, + "substitute_char": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Substitute the character under the cursor and enter insert mode (`s`)." + }, + "substitute_line": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Substitute the current line and enter insert mode (`S`)." + }, "yank_line": { "allOf": [ { @@ -3965,7 +3987,7 @@ }, "TuiVimOperatorKeymap": { "additionalProperties": false, - "description": "Vim operator-pending keybindings for modal editing inside text areas.\n\nThis context is active only while waiting for a motion after `d` or `y`. Repeating the operator key (`dd`, `yy`) targets the entire line. Pressing `Esc` cancels the pending operator and returns to normal mode without modifying text.", + "description": "Vim operator-pending keybindings for modal editing inside text areas.\n\nThis context is active only while waiting for a motion after `d`, `y`, or `c`. Repeating the operator key (`dd`, `yy`, `cc`) targets the entire line. Pressing `Esc` cancels the pending operator and returns to normal mode without modifying text.", "properties": { "cancel": { "allOf": [ @@ -3975,6 +3997,14 @@ ], "description": "Cancel the pending operator and return to normal mode." }, + "change_line": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Repeat change operator to change the whole line (`cc`)." + }, "delete_line": { "allOf": [ { diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 39da5752c2..30ed462931 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -740,10 +740,18 @@ impl TextArea { return; } if self.vim_normal_keymap.change_to_line_end.is_pressed(event) { - self.vim_kill_to_end_of_line(); + self.apply_vim_operator(VimOperator::Change, VimMotion::LineEnd); + return; + } + if self.vim_normal_keymap.substitute_char.is_pressed(event) { + self.delete_forward_kill(/*n*/ 1); self.vim_mode = VimMode::Insert; return; } + if self.vim_normal_keymap.substitute_line.is_pressed(event) { + self.change_current_line(); + return; + } if self.vim_normal_keymap.yank_line.is_pressed(event) { self.yank_current_line(); return; @@ -786,6 +794,10 @@ impl TextArea { self.yank_current_line(); return true; } + if op == VimOperator::Change && self.vim_operator_keymap.change_line.is_pressed(event) { + self.change_current_line(); + return true; + } if self.vim_operator_keymap.cancel.is_pressed(event) { return true; } @@ -797,9 +809,7 @@ impl TextArea { return true; } - if op != VimOperator::Change - && let Some(motion) = self.vim_motion_for_event(event) - { + if let Some(motion) = self.vim_motion_for_event(event) { self.apply_vim_operator(op, motion); return true; } @@ -864,16 +874,41 @@ impl TextArea { } fn apply_vim_operator(&mut self, op: VimOperator, motion: VimMotion) { - let Some(range) = self.range_for_motion(motion) else { - return; - }; match op { - VimOperator::Delete => self.kill_range(range), - VimOperator::Yank => self.yank_range(range), - VimOperator::Change => {} + VimOperator::Delete => { + if let Some(range) = self.range_for_motion(motion) { + self.kill_range(range); + } + } + VimOperator::Yank => { + if let Some(range) = self.range_for_motion(motion) { + self.yank_range(range); + } + } + VimOperator::Change => { + if let Some(range) = self.range_for_change_motion(motion) { + self.kill_range(range); + self.vim_mode = VimMode::Insert; + } else if motion == VimMotion::LineEnd { + self.vim_mode = VimMode::Insert; + } + } } } + fn range_for_change_motion(&mut self, motion: VimMotion) -> Option> { + if motion == VimMotion::WordForward + && self.text[self.cursor_pos..] + .chars() + .next() + .is_some_and(|ch| !ch.is_whitespace()) + { + let end = self.end_of_next_word(); + return (self.cursor_pos < end).then_some(self.cursor_pos..end); + } + self.range_for_motion(motion) + } + fn apply_vim_operator_to_range(&mut self, op: VimOperator, range: Range) { match op { VimOperator::Delete => self.kill_range(range), @@ -1166,6 +1201,12 @@ impl TextArea { self.kill_line_range(range); } + fn change_current_line(&mut self) { + let range = self.beginning_of_current_line()..self.end_of_current_line(); + self.kill_line_range(range); + self.vim_mode = VimMode::Insert; + } + fn current_line_range_with_newline(&self) -> Range { let bol = self.beginning_of_current_line(); let eol = self.end_of_current_line(); @@ -2443,6 +2484,79 @@ mod tests { assert_eq!(t.vim_mode_label(), Some("Insert")); } + #[test] + fn vim_change_motion_and_linewise_aliases_enter_insert_mode() { + let mut t = ta_with("hello world"); + 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('w'), KeyModifiers::NONE)); + + assert_eq!(t.text(), " world"); + assert_eq!(t.kill_buffer, "hello"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + + let mut t = ta_with("hello world\nnext line"); + t.set_cursor(/*pos*/ 6); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('$'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello \nnext line"); + assert_eq!(t.kill_buffer, "world"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + + let mut t = ta_with("one\ntwo\nthree"); + t.set_cursor(/*pos*/ "one\nt".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "one\n\nthree"); + assert_eq!(t.kill_buffer, "two"); + assert_eq!(t.kill_buffer_kind, KillBufferKind::Linewise); + assert_eq!(t.cursor(), "one\n".len()); + assert_eq!(t.vim_mode_label(), Some("Insert")); + + let mut t = ta_with("one\ntwo\nthree"); + t.set_cursor(/*pos*/ "one\nt".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "one\n\nthree"); + assert_eq!(t.kill_buffer, "two"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + } + + #[test] + fn vim_substitute_char_preserves_grapheme_and_atomic_boundaries() { + let mut t = ta_with("👍 ok"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); + + assert_eq!(t.text(), " ok"); + assert_eq!(t.kill_buffer, "👍"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + + let mut t = ta_with("@file rest"); + t.add_element_range(0.."@file".len()) + .expect("valid element"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); + + assert_eq!(t.text(), " rest"); + assert_eq!(t.kill_buffer, "@file"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + } + #[test] fn vim_word_text_objects_cover_delete_yank_and_big_word() { let mut t = ta_with("hello world"); @@ -2578,19 +2692,11 @@ mod tests { } #[test] - fn vim_text_object_cancellation_and_unsupported_change_motions_do_not_edit() { + fn vim_text_object_cancellation_does_not_edit() { let mut t = ta_with("hello world"); t.set_cursor(/*pos*/ 1); t.set_vim_enabled(/*enabled*/ true); - t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); - t.input(KeyEvent::new(KeyCode::Char('$'), KeyModifiers::NONE)); - - assert_eq!(t.text(), "hello world"); - assert_eq!(t.kill_buffer, ""); - assert_eq!(t.vim_mode_label(), Some("Normal")); - assert!(!t.is_vim_operator_pending()); - t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); assert!(t.is_vim_operator_pending()); diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 8e6d40c764..5b8a84413a 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -134,7 +134,7 @@ pub(crate) struct EditorKeymap { /// /// Normal mode is the resting state when Vim is enabled. Pressing a movement /// or editing key here either moves the cursor, triggers an operator-pending -/// state (via `start_delete_operator` / `start_yank_operator`), or transitions +/// state (via an operator-start action), or transitions /// to insert mode. Default bindings include both `shift(letter)` and /// `plain(UPPERCASE)` variants for uppercase commands like `A`, `I`, `O` to /// handle cross-terminal shift-reporting inconsistencies. @@ -158,6 +158,8 @@ pub(crate) struct VimNormalKeymap { pub(crate) delete_char: Vec, pub(crate) delete_to_line_end: Vec, pub(crate) change_to_line_end: Vec, + pub(crate) substitute_char: Vec, + pub(crate) substitute_line: Vec, pub(crate) yank_line: Vec, pub(crate) paste_after: Vec, pub(crate) start_delete_operator: Vec, @@ -166,7 +168,7 @@ pub(crate) struct VimNormalKeymap { pub(crate) cancel_operator: Vec, } -/// Vim operator-pending keybindings active after `d` or `y` in normal mode. +/// Vim operator-pending keybindings active after `d`, `y`, or `c` in normal mode. /// /// When an operator (`start_delete_operator` or `start_yank_operator`) is /// pressed, the next keypress is matched against this context to determine the @@ -176,6 +178,7 @@ pub(crate) struct VimNormalKeymap { pub(crate) struct VimOperatorKeymap { pub(crate) delete_line: Vec, pub(crate) yank_line: Vec, + pub(crate) change_line: Vec, pub(crate) motion_left: Vec, pub(crate) motion_right: Vec, pub(crate) motion_up: Vec, @@ -489,6 +492,8 @@ impl RuntimeKeymap { 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), + substitute_char: resolve_local!(keymap, defaults, vim_normal, substitute_char), + substitute_line: resolve_local!(keymap, defaults, vim_normal, substitute_line), yank_line: resolve_local!(keymap, defaults, vim_normal, yank_line), paste_after: resolve_local!(keymap, defaults, vim_normal, paste_after), start_delete_operator: resolve_local!( @@ -576,6 +581,18 @@ impl RuntimeKeymap { keymap.vim_normal.delete_to_line_end.as_ref(), vim_normal.delete_to_line_end.as_slice(), ), + ( + keymap.vim_normal.change_to_line_end.as_ref(), + vim_normal.change_to_line_end.as_slice(), + ), + ( + keymap.vim_normal.substitute_char.as_ref(), + vim_normal.substitute_char.as_slice(), + ), + ( + keymap.vim_normal.substitute_line.as_ref(), + vim_normal.substitute_line.as_slice(), + ), ( keymap.vim_normal.yank_line.as_ref(), vim_normal.yank_line.as_slice(), @@ -603,10 +620,21 @@ impl RuntimeKeymap { .start_change_operator .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); } + if keymap.vim_normal.substitute_char.is_none() { + vim_normal + .substitute_char + .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); + } + if keymap.vim_normal.substitute_line.is_none() { + vim_normal + .substitute_line + .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); + } let mut vim_operator = VimOperatorKeymap { delete_line: resolve_local!(keymap, defaults, vim_operator, delete_line), yank_line: resolve_local!(keymap, defaults, vim_operator, yank_line), + change_line: resolve_local!(keymap, defaults, vim_operator, change_line), motion_left: resolve_local!(keymap, defaults, vim_operator, motion_left), motion_right: resolve_local!(keymap, defaults, vim_operator, motion_right), motion_up: resolve_local!(keymap, defaults, vim_operator, motion_up), @@ -650,6 +678,10 @@ impl RuntimeKeymap { keymap.vim_operator.yank_line.as_ref(), vim_operator.yank_line.as_slice(), ), + ( + keymap.vim_operator.change_line.as_ref(), + vim_operator.change_line.as_slice(), + ), ( keymap.vim_operator.motion_left.as_ref(), vim_operator.motion_left.as_slice(), @@ -702,6 +734,11 @@ impl RuntimeKeymap { .select_around_text_object .retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding)); } + if keymap.vim_operator.change_line.is_none() { + vim_operator + .change_line + .retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding)); + } let vim_text_object = VimTextObjectKeymap { word: resolve_local!(keymap, defaults, vim_text_object, word), @@ -990,6 +1027,11 @@ impl RuntimeKeymap { shift(KeyCode::Char('c')), plain(KeyCode::Char('C')) ], + substitute_char: default_bindings![plain(KeyCode::Char('s'))], + substitute_line: default_bindings![ + shift(KeyCode::Char('s')), + plain(KeyCode::Char('S')) + ], yank_line: default_bindings![shift(KeyCode::Char('y')), plain(KeyCode::Char('Y'))], paste_after: default_bindings![plain(KeyCode::Char('p'))], start_delete_operator: default_bindings![plain(KeyCode::Char('d'))], @@ -1000,6 +1042,7 @@ impl RuntimeKeymap { vim_operator: VimOperatorKeymap { delete_line: default_bindings![plain(KeyCode::Char('d'))], yank_line: default_bindings![plain(KeyCode::Char('y'))], + change_line: default_bindings![plain(KeyCode::Char('c'))], motion_left: default_bindings![plain(KeyCode::Char('h'))], motion_right: default_bindings![plain(KeyCode::Char('l'))], motion_up: default_bindings![plain(KeyCode::Char('k'))], @@ -1414,6 +1457,14 @@ impl RuntimeKeymap { "change_to_line_end", self.vim_normal.change_to_line_end.as_slice(), ), + ( + "substitute_char", + self.vim_normal.substitute_char.as_slice(), + ), + ( + "substitute_line", + self.vim_normal.substitute_line.as_slice(), + ), ("yank_line", self.vim_normal.yank_line.as_slice()), ("paste_after", self.vim_normal.paste_after.as_slice()), ( @@ -1440,6 +1491,7 @@ impl RuntimeKeymap { [ ("delete_line", self.vim_operator.delete_line.as_slice()), ("yank_line", self.vim_operator.yank_line.as_slice()), + ("change_line", self.vim_operator.change_line.as_slice()), ("motion_left", self.vim_operator.motion_left.as_slice()), ("motion_right", self.vim_operator.motion_right.as_slice()), ("motion_up", self.vim_operator.motion_up.as_slice()), @@ -2268,6 +2320,23 @@ mod tests { expect_conflict(&keymap, "move_left", "start_change_operator"); } + #[test] + fn configured_legacy_vim_bindings_prune_new_substitute_and_change_line_defaults() { + let mut keymap = TuiKeymap::default(); + keymap.vim_normal.move_left = Some(one("s")); + keymap.vim_normal.move_right = Some(one("shift-s")); + keymap.vim_operator.motion_left = Some(one("c")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!(runtime.vim_normal.substitute_char, Vec::new()); + assert_eq!( + runtime.vim_normal.substitute_line, + vec![key_hint::plain(KeyCode::Char('S'))] + ); + assert_eq!(runtime.vim_operator.change_line, 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 c8815c8c98..2b7526b8aa 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -136,14 +136,17 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ 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."), + action("vim_normal", "Vim normal", "substitute_char", "Substitute the character under the cursor and enter insert mode."), + action("vim_normal", "Vim normal", "substitute_line", "Substitute the current line and enter insert mode."), action("vim_normal", "Vim normal", "yank_line", "Yank the entire line."), action("vim_normal", "Vim normal", "paste_after", "Paste after the cursor."), action("vim_normal", "Vim normal", "start_delete_operator", "Begin a delete 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 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", "cancel_operator", "Cancel a pending Vim operator."), action("vim_operator", "Vim operator", "delete_line", "Repeat delete operator to delete the whole line."), action("vim_operator", "Vim operator", "yank_line", "Repeat yank operator to yank the whole line."), + action("vim_operator", "Vim operator", "change_line", "Repeat change operator to change the whole line."), action("vim_operator", "Vim operator", "motion_left", "Operator motion left."), action("vim_operator", "Vim operator", "motion_right", "Operator motion right."), action("vim_operator", "Vim operator", "motion_up", "Operator motion up."), @@ -277,6 +280,8 @@ pub(super) fn binding_slot<'a>( ("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), + ("vim_normal", "substitute_char") => Some(&mut keymap.vim_normal.substitute_char), + ("vim_normal", "substitute_line") => Some(&mut keymap.vim_normal.substitute_line), ("vim_normal", "yank_line") => Some(&mut keymap.vim_normal.yank_line), ("vim_normal", "paste_after") => Some(&mut keymap.vim_normal.paste_after), ("vim_normal", "start_delete_operator") => Some(&mut keymap.vim_normal.start_delete_operator), @@ -285,6 +290,7 @@ pub(super) fn binding_slot<'a>( ("vim_normal", "cancel_operator") => Some(&mut keymap.vim_normal.cancel_operator), ("vim_operator", "delete_line") => Some(&mut keymap.vim_operator.delete_line), ("vim_operator", "yank_line") => Some(&mut keymap.vim_operator.yank_line), + ("vim_operator", "change_line") => Some(&mut keymap.vim_operator.change_line), ("vim_operator", "motion_left") => Some(&mut keymap.vim_operator.motion_left), ("vim_operator", "motion_right") => Some(&mut keymap.vim_operator.motion_right), ("vim_operator", "motion_up") => Some(&mut keymap.vim_operator.motion_up), @@ -400,6 +406,8 @@ pub(super) fn bindings_for_action<'a>( ("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()), + ("vim_normal", "substitute_char") => Some(runtime_keymap.vim_normal.substitute_char.as_slice()), + ("vim_normal", "substitute_line") => Some(runtime_keymap.vim_normal.substitute_line.as_slice()), ("vim_normal", "yank_line") => Some(runtime_keymap.vim_normal.yank_line.as_slice()), ("vim_normal", "paste_after") => Some(runtime_keymap.vim_normal.paste_after.as_slice()), ("vim_normal", "start_delete_operator") => Some(runtime_keymap.vim_normal.start_delete_operator.as_slice()), @@ -408,6 +416,7 @@ pub(super) fn bindings_for_action<'a>( ("vim_normal", "cancel_operator") => Some(runtime_keymap.vim_normal.cancel_operator.as_slice()), ("vim_operator", "delete_line") => Some(runtime_keymap.vim_operator.delete_line.as_slice()), ("vim_operator", "yank_line") => Some(runtime_keymap.vim_operator.yank_line.as_slice()), + ("vim_operator", "change_line") => Some(runtime_keymap.vim_operator.change_line.as_slice()), ("vim_operator", "motion_left") => Some(runtime_keymap.vim_operator.motion_left.as_slice()), ("vim_operator", "motion_right") => Some(runtime_keymap.vim_operator.motion_right.as_slice()), ("vim_operator", "motion_up") => Some(runtime_keymap.vim_operator.motion_up.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 e212226ef5..e4d29158ce 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. - 106 actions, 1 customized, 2 unbound. + 109 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 22c0537540..3448442e77 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. - 107 actions, 0 customized, 3 unbound. + 110 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 4a88465ba8..f2bd94957b 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 (106 selectable) +tab: All (109 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 (47 selectable) +tab: Vim (50 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 26af4bd57a..082b7970cb 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. - 106 actions, 0 customized, 2 unbound. + 109 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 36f6a1d04c..71e7288104 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. - 106 actions, 0 customized, 2 unbound. + 109 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug