diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 5b1e7d4cc8ea..e41ec860ee86 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -223,6 +223,8 @@ pub struct TuiVimNormalKeymap { pub delete_char: Option, /// Delete from cursor to end of line (`D`). pub delete_to_line_end: Option, + /// Change from cursor to end of line and enter insert mode (`C`). + pub change_to_line_end: Option, /// Yank the entire line (`Y`). pub yank_line: Option, /// Paste after cursor (`p`). @@ -231,6 +233,8 @@ 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. + pub start_change_operator: Option, /// Cancel a pending operator and return to normal mode. pub cancel_operator: Option, } @@ -266,10 +270,39 @@ pub struct TuiVimOperatorKeymap { pub motion_line_start: Option, /// Motion: to end of line (`$`). pub motion_line_end: Option, + /// Select an inner text object after an operator. + pub select_inner_text_object: Option, + /// Select an around text object after an operator. + pub select_around_text_object: Option, /// Cancel the pending operator and return to normal mode. pub cancel: Option, } +/// Vim text-object keybindings for modal editing inside text areas. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct TuiVimTextObjectKeymap { + /// Text object: word. + pub word: Option, + /// Text object: whitespace-delimited WORD. + pub big_word: Option, + /// Text object: parentheses. + pub parentheses: Option, + /// Text object: brackets. + pub brackets: Option, + /// Text object: braces. + pub braces: Option, + /// Text object: double quotes. + pub double_quote: Option, + /// Text object: single quotes. + pub single_quote: Option, + /// Text object: backticks. + pub backtick: Option, + /// Cancel the pending text-object command. + pub cancel: Option, +} + /// Pager context keybindings for transcript and static overlays. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[serde(deny_unknown_fields)] @@ -374,6 +407,8 @@ pub struct TuiKeymap { #[serde(default)] pub vim_operator: TuiVimOperatorKeymap, #[serde(default)] + pub vim_text_object: TuiVimTextObjectKeymap, + #[serde(default)] pub pager: TuiPagerKeymap, #[serde(default)] pub list: TuiListKeymap, @@ -560,6 +595,20 @@ mod tests { ); } + #[test] + fn misspelled_vim_text_object_action_is_rejected() { + let toml_input = r#" + [vim_text_object] + double_quotes = "shift-quote" + "#; + let err = toml::from_str::(toml_input) + .expect_err("expected unknown vim text object action"); + assert!( + err.to_string().contains("double_quotes"), + "expected error to mention misspelled field, got: {err}" + ); + } + #[test] fn removed_backtrack_actions_are_rejected() { for (context, action) in [ diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 8b653609de50..a8a1662c3bbc 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2824,6 +2824,7 @@ "append_after_cursor": null, "append_line_end": null, "cancel_operator": null, + "change_to_line_end": null, "delete_char": null, "delete_to_line_end": null, "enter_insert": null, @@ -2840,6 +2841,7 @@ "open_line_above": null, "open_line_below": null, "paste_after": null, + "start_change_operator": null, "start_delete_operator": null, "start_yank_operator": null, "yank_line": null @@ -2856,7 +2858,20 @@ "motion_word_backward": null, "motion_word_end": null, "motion_word_forward": null, + "select_around_text_object": null, + "select_inner_text_object": null, "yank_line": null + }, + "vim_text_object": { + "backtick": null, + "big_word": null, + "braces": null, + "brackets": null, + "cancel": null, + "double_quote": null, + "parentheses": null, + "single_quote": null, + "word": null } }, "description": "Keybinding overrides for the TUI.\n\nThis supports rebinding selected actions globally and by context. Context bindings take precedence over `global` bindings." @@ -3490,6 +3505,7 @@ "append_after_cursor": null, "append_line_end": null, "cancel_operator": null, + "change_to_line_end": null, "delete_char": null, "delete_to_line_end": null, "enter_insert": null, @@ -3506,6 +3522,7 @@ "open_line_above": null, "open_line_below": null, "paste_after": null, + "start_change_operator": null, "start_delete_operator": null, "start_yank_operator": null, "yank_line": null @@ -3529,8 +3546,28 @@ "motion_word_backward": null, "motion_word_end": null, "motion_word_forward": null, + "select_around_text_object": null, + "select_inner_text_object": null, "yank_line": null } + }, + "vim_text_object": { + "allOf": [ + { + "$ref": "#/definitions/TuiVimTextObjectKeymap" + } + ], + "default": { + "backtick": null, + "big_word": null, + "braces": null, + "brackets": null, + "cancel": null, + "double_quote": null, + "parentheses": null, + "single_quote": null, + "word": null + } } }, "type": "object" @@ -3755,6 +3792,14 @@ ], "description": "Cancel a pending operator and return to normal mode." }, + "change_to_line_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Change from cursor to end of line and enter insert mode (`C`)." + }, "delete_char": { "allOf": [ { @@ -3883,6 +3928,14 @@ ], "description": "Paste after cursor (`p`)." }, + "start_change_operator": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Begin change operator; next keys select a text object." + }, "start_delete_operator": { "allOf": [ { @@ -4002,6 +4055,22 @@ ], "description": "Motion: to start of next word (`w`)." }, + "select_around_text_object": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Select an around text object after an operator." + }, + "select_inner_text_object": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Select an inner text object after an operator." + }, "yank_line": { "allOf": [ { @@ -4013,6 +4082,85 @@ }, "type": "object" }, + "TuiVimTextObjectKeymap": { + "additionalProperties": false, + "description": "Vim text-object keybindings for modal editing inside text areas.", + "properties": { + "backtick": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: backticks." + }, + "big_word": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: whitespace-delimited WORD." + }, + "braces": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: braces." + }, + "brackets": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: brackets." + }, + "cancel": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Cancel the pending text-object command." + }, + "double_quote": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: double quotes." + }, + "parentheses": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: parentheses." + }, + "single_quote": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: single quotes." + }, + "word": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: word." + } + }, + "type": "object" + }, "UriBasedFileOpener": { "oneOf": [ { diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__vim_e_advances_from_each_word_end.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__vim_e_advances_from_each_word_end.snap new file mode 100644 index 000000000000..0a41c2d80fdb --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__vim_e_advances_from_each_word_end.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "states.join(\"\\n\\n\")" +--- +alpha beta gamma + ^ + +alpha beta gamma + ^ + +alpha beta gamma + ^ diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 96f6cf8cbb96..39da5752c26a 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -16,6 +16,7 @@ use crate::keymap::EditorKeymap; use crate::keymap::RuntimeKeymap; use crate::keymap::VimNormalKeymap; use crate::keymap::VimOperatorKeymap; +use crate::keymap::VimTextObjectKeymap; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement as UserTextElement; use crossterm::event::KeyCode; @@ -35,6 +36,13 @@ use textwrap::Options; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; +mod vim; +use self::vim::VimMode; +use self::vim::VimMotion; +use self::vim::VimOperator; +use self::vim::VimPending; +use self::vim::VimTextObjectScope; + const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?"; fn is_word_separator(ch: char) -> bool { @@ -101,10 +109,11 @@ pub(crate) struct TextArea { kill_buffer_kind: KillBufferKind, vim_enabled: bool, vim_mode: VimMode, - vim_operator: Option, + vim_pending: VimPending, editor_keymap: EditorKeymap, vim_normal_keymap: VimNormalKeymap, vim_operator_keymap: VimOperatorKeymap, + vim_text_object_keymap: VimTextObjectKeymap, } #[derive(Debug, Clone)] @@ -119,14 +128,6 @@ pub(crate) struct TextAreaState { scroll: u16, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum VimMode { - /// Normal mode routes printable keys to movement, operators, and mode transitions. - Normal, - /// Insert mode routes input through the regular editor keymap until Escape is pressed. - Insert, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum KillBufferKind { /// Characterwise kills and yanks paste at the cursor. @@ -135,36 +136,6 @@ enum KillBufferKind { Linewise, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum VimOperator { - /// Delete the range selected by the next motion or repeated operator key. - Delete, - /// Copy the range selected by the next motion or repeated operator key. - Yank, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum VimMotion { - /// Move one atomic boundary to the left. - Left, - /// Move one atomic boundary to the right. - Right, - /// Move one visual row up, preserving preferred display column. - Up, - /// Move one visual row down, preserving preferred display column. - Down, - /// Move to the start of the next word-like run. - WordForward, - /// Move to the start of the previous word-like run. - WordBackward, - /// Move to the end of the current or next word-like run. - WordEnd, - /// Move to the start of the current line. - LineStart, - /// Move to the end of the current line. - LineEnd, -} - impl TextArea { pub fn new() -> Self { let defaults = RuntimeKeymap::defaults(); @@ -179,10 +150,11 @@ impl TextArea { kill_buffer_kind: KillBufferKind::Characterwise, vim_enabled: false, vim_mode: VimMode::Insert, - vim_operator: None, + vim_pending: VimPending::None, editor_keymap: defaults.editor, vim_normal_keymap: defaults.vim_normal, vim_operator_keymap: defaults.vim_operator, + vim_text_object_keymap: defaults.vim_text_object, } } @@ -196,6 +168,7 @@ impl TextArea { self.editor_keymap = keymap.editor.clone(); self.vim_normal_keymap = keymap.vim_normal.clone(); self.vim_operator_keymap = keymap.vim_operator.clone(); + self.vim_text_object_keymap = keymap.vim_text_object.clone(); } /// Replace the visible textarea text and clear any existing text elements. @@ -257,7 +230,7 @@ impl TextArea { /// an old `d` or `y` command. pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { self.vim_enabled = enabled; - self.vim_operator = None; + self.vim_pending = VimPending::None; self.vim_mode = if enabled { VimMode::Normal } else { @@ -293,7 +266,7 @@ impl TextArea { /// This is observable so the composer can avoid stealing the second key of /// `d{motion}` or `y{motion}` for higher-level shortcuts. pub(crate) fn is_vim_operator_pending(&self) -> bool { - self.vim_operator.is_some() + !matches!(self.vim_pending, VimPending::None) } /// Enter Vim insert mode if modal editing is enabled. @@ -304,7 +277,7 @@ impl TextArea { pub(crate) fn enter_vim_insert_mode(&mut self) { if self.vim_enabled { self.vim_mode = VimMode::Insert; - self.vim_operator = None; + self.vim_pending = VimPending::None; } } @@ -317,7 +290,7 @@ impl TextArea { pub(crate) fn enter_vim_normal_mode(&mut self) { if self.vim_enabled { self.vim_mode = VimMode::Normal; - self.vim_operator = None; + self.vim_pending = VimPending::None; self.preferred_col = None; } } @@ -669,9 +642,17 @@ impl TextArea { } fn handle_vim_normal(&mut self, event: KeyEvent) { - if let Some(op) = self.vim_operator.take() { - self.handle_vim_operator(op, event); - return; + let pending = std::mem::replace(&mut self.vim_pending, VimPending::None); + match pending { + VimPending::None => {} + VimPending::Operator(op) => { + self.handle_vim_operator(op, event); + return; + } + VimPending::TextObject { operator, scope } => { + self.handle_vim_text_object(operator, scope, event); + return; + } } if self.vim_normal_keymap.enter_insert.is_pressed(event) { @@ -755,7 +736,12 @@ impl TextArea { return; } if self.vim_normal_keymap.delete_to_line_end.is_pressed(event) { - self.kill_to_end_of_line(); + self.vim_kill_to_end_of_line(); + return; + } + if self.vim_normal_keymap.change_to_line_end.is_pressed(event) { + self.vim_kill_to_end_of_line(); + self.vim_mode = VimMode::Insert; return; } if self.vim_normal_keymap.yank_line.is_pressed(event) { @@ -771,15 +757,23 @@ impl TextArea { .start_delete_operator .is_pressed(event) { - self.vim_operator = Some(VimOperator::Delete); + self.vim_pending = VimPending::Operator(VimOperator::Delete); return; } if self.vim_normal_keymap.start_yank_operator.is_pressed(event) { - self.vim_operator = Some(VimOperator::Yank); + self.vim_pending = VimPending::Operator(VimOperator::Yank); + return; + } + if self + .vim_normal_keymap + .start_change_operator + .is_pressed(event) + { + self.vim_pending = VimPending::Operator(VimOperator::Change); return; } if self.vim_normal_keymap.cancel_operator.is_pressed(event) { - self.vim_operator = None; + self.vim_pending = VimPending::None; } } @@ -795,14 +789,41 @@ impl TextArea { if self.vim_operator_keymap.cancel.is_pressed(event) { return true; } + if let Some(scope) = self.vim_text_object_scope_for_event(event) { + self.vim_pending = VimPending::TextObject { + operator: op, + scope, + }; + return true; + } - if let Some(motion) = self.vim_motion_for_event(event) { + if op != VimOperator::Change + && let Some(motion) = self.vim_motion_for_event(event) + { self.apply_vim_operator(op, motion); return true; } false } + fn handle_vim_text_object( + &mut self, + op: VimOperator, + scope: VimTextObjectScope, + event: KeyEvent, + ) -> bool { + if self.vim_text_object_keymap.cancel.is_pressed(event) { + return true; + } + let Some(object) = self.vim_text_object_for_event(event) else { + return false; + }; + if let Some(range) = self.text_object_range(object, scope) { + self.apply_vim_operator_to_range(op, range); + } + true + } + fn vim_motion_for_event(&self, event: KeyEvent) -> Option { if self.vim_operator_keymap.motion_left.is_pressed(event) { return Some(VimMotion::Left); @@ -849,6 +870,18 @@ impl TextArea { match op { VimOperator::Delete => self.kill_range(range), VimOperator::Yank => self.yank_range(range), + VimOperator::Change => {} + } + } + + fn apply_vim_operator_to_range(&mut self, op: VimOperator, range: Range) { + match op { + VimOperator::Delete => self.kill_range(range), + VimOperator::Yank => self.yank_range(range), + VimOperator::Change => { + self.kill_range(range); + self.vim_mode = VimMode::Insert; + } } } @@ -914,7 +947,7 @@ impl TextArea { VimMotion::Down => self.move_cursor_down(), VimMotion::WordForward => self.set_cursor(self.beginning_of_next_word()), VimMotion::WordBackward => self.set_cursor(self.beginning_of_previous_word()), - VimMotion::WordEnd => self.set_cursor(self.end_of_next_word()), + VimMotion::WordEnd => self.set_cursor(self.vim_word_end_exclusive()), VimMotion::LineStart => self.set_cursor(self.beginning_of_current_line()), VimMotion::LineEnd => self.set_cursor(self.end_of_current_line()), } @@ -1007,6 +1040,13 @@ impl TextArea { } } + fn vim_kill_to_end_of_line(&mut self) { + let eol = self.end_of_current_line(); + if self.cursor_pos < eol { + self.kill_range(self.cursor_pos..eol); + } + } + pub fn kill_to_beginning_of_line(&mut self) { let bol = self.beginning_of_current_line(); let range = if self.cursor_pos == bol { @@ -1734,7 +1774,11 @@ impl TextArea { } pub(crate) fn end_of_next_word(&self) -> usize { - let suffix = &self.text[self.cursor_pos..]; + self.end_of_next_word_from(self.cursor_pos) + } + + fn end_of_next_word_from(&self, cursor_pos: usize) -> usize { + let suffix = &self.text[cursor_pos..]; let Some(first_non_ws) = suffix.find(|ch: char| !ch.is_whitespace()) else { return self.text.len(); }; @@ -1742,16 +1786,16 @@ impl TextArea { let run = &run[..run.find(char::is_whitespace).unwrap_or(run.len())]; let mut pieces = split_word_pieces(run).into_iter().peekable(); let Some((start, piece)) = pieces.next() else { - return self.cursor_pos + first_non_ws; + return cursor_pos + first_non_ws; }; - let word_start = self.cursor_pos + first_non_ws + start; + let word_start = cursor_pos + first_non_ws + start; let mut end = word_start + piece.len(); if piece.chars().all(is_word_separator) { while let Some((idx, piece)) = pieces.peek() { if !piece.chars().all(is_word_separator) { break; } - end = self.cursor_pos + first_non_ws + *idx + piece.len(); + end = cursor_pos + first_non_ws + *idx + piece.len(); pieces.next(); } } @@ -1759,8 +1803,22 @@ impl TextArea { self.adjust_pos_out_of_elements(end, /*prefer_start*/ false) } - fn vim_word_end_cursor(&self) -> usize { + fn vim_word_end_exclusive(&self) -> usize { let end = self.end_of_next_word(); + let target = if end > self.cursor_pos { + self.prev_atomic_boundary(end) + } else { + end + }; + if target == self.cursor_pos && end < self.text.len() { + self.end_of_next_word_from(end) + } else { + end + } + } + + fn vim_word_end_cursor(&self) -> usize { + let end = self.vim_word_end_exclusive(); if end > self.cursor_pos { self.prev_atomic_boundary(end) } else { @@ -2202,7 +2260,7 @@ mod tests { let mut t = TextArea::new(); t.set_vim_enabled(/*enabled*/ true); - t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)); t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_eq!(t.vim_mode_label(), Some("Normal")); @@ -2275,6 +2333,60 @@ mod tests { assert_eq!(t.cursor(), 11); } + #[test] + fn vim_shift_c_changes_to_line_end_and_enters_insert_mode() { + 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::SHIFT)); + + assert_eq!(t.text(), "hello \nnext line"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + assert_eq!(t.cursor(), 6); + assert_eq!(t.kill_buffer, "world"); + } + + #[test] + fn vim_uppercase_c_changes_to_line_end() { + 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)); + + assert_eq!(t.text(), "hello \nnext line"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + assert_eq!(t.cursor(), 6); + } + + #[test] + fn vim_d_at_line_end_does_not_remove_newline() { + let mut t = ta_with("hello\nworld"); + t.set_cursor(/*pos*/ "hello".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('D'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello\nworld"); + assert_eq!(t.vim_mode_label(), Some("Normal")); + assert_eq!(t.kill_buffer, ""); + } + + #[test] + fn vim_c_at_line_end_enters_insert_without_removing_newline() { + let mut t = ta_with("hello\nworld"); + t.set_cursor(/*pos*/ "hello".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello\nworld"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + assert_eq!(t.cursor(), "hello".len()); + assert_eq!(t.kill_buffer, ""); + } + #[test] fn vim_shift_o_opens_line_above_with_shift_only_binding() { let mut t = ta_with("hello\nworld"); @@ -2315,6 +2427,180 @@ mod tests { assert_eq!(t.kill_buffer, "hello "); } + #[test] + fn vim_change_inner_word_deletes_word_and_enters_insert() { + let mut t = ta_with("hello world"); + t.set_cursor(/*pos*/ "hello ".len()); + 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)); + + assert_eq!(t.text(), "hello "); + assert_eq!(t.kill_buffer, "world"); + assert_eq!(t.cursor(), "hello ".len()); + 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"); + t.set_cursor(/*pos*/ 1); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello world"); + assert_eq!(t.kill_buffer, "hello "); + assert_eq!(t.vim_mode_label(), Some("Normal")); + + let mut t = ta_with("foo.bar/baz qux"); + t.set_cursor(/*pos*/ "foo.".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('W'), KeyModifiers::NONE)); + + assert_eq!(t.text(), " qux"); + assert_eq!(t.kill_buffer, "foo.bar/baz"); + } + + #[test] + fn vim_word_text_objects_accept_cursor_at_word_end() { + let mut t = ta_with("hello world"); + t.set_cursor(/*pos*/ "hello".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "world"); + assert_eq!(t.kill_buffer, "hello "); + + let mut t = ta_with("foo bar"); + t.set_cursor(t.text().len()); + 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)); + + assert_eq!(t.text(), "foo "); + assert_eq!(t.kill_buffer, "bar"); + assert_eq!(t.cursor(), "foo ".len()); + assert_eq!(t.vim_mode_label(), Some("Insert")); + } + + #[test] + fn vim_delimiter_text_objects_select_innermost_pair_and_aliases() { + let mut t = ta_with("a(b(c)d)e"); + t.set_cursor(/*pos*/ "a(b(".len()); + 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('b'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "a(b()d)e"); + assert_eq!(t.kill_buffer, "c"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + + let mut t = ta_with("a [b] c"); + t.set_cursor(/*pos*/ "a [".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "a c"); + assert_eq!(t.kill_buffer, "[b]"); + } + + #[test] + fn vim_empty_inner_text_objects_are_valid_targets() { + let mut t = ta_with("call()"); + t.set_cursor(/*pos*/ "call(".len()); + 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('('), KeyModifiers::NONE)); + + assert_eq!(t.text(), "call()"); + assert_eq!(t.kill_buffer, ""); + assert_eq!(t.cursor(), "call(".len()); + assert_eq!(t.vim_mode_label(), Some("Insert")); + + let mut t = ta_with(r#"say "" now"#); + t.set_cursor(/*pos*/ r#"say ""#.len()); + 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('"'), KeyModifiers::NONE)); + + assert_eq!(t.text(), r#"say "" now"#); + assert_eq!(t.kill_buffer, ""); + assert_eq!(t.cursor(), r#"say ""#.len()); + assert_eq!(t.vim_mode_label(), Some("Insert")); + } + + #[test] + fn vim_quote_text_objects_are_line_local_and_handle_escapes() { + let mut t = ta_with(r#"say "a \"b\" c" now"#); + t.set_cursor(/*pos*/ r#"say "a \"#.len()); + 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('"'), KeyModifiers::SHIFT)); + + assert_eq!(t.text(), r#"say "" now"#); + assert_eq!(t.kill_buffer, r#"a \"b\" c"#); + assert_eq!(t.vim_mode_label(), Some("Insert")); + + let mut t = ta_with("one \"two\nthree\" four"); + t.set_cursor(/*pos*/ "one \"two\n".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('"'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "one \"two\nthree\" four"); + assert_eq!(t.kill_buffer, ""); + } + + #[test] + fn vim_text_object_cancellation_and_unsupported_change_motions_do_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()); + t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello world"); + assert_eq!(t.kill_buffer, ""); + assert!(!t.is_vim_operator_pending()); + } + #[test] fn vim_operator_invalid_motion_is_consumed() { let mut t = ta_with("hello"); @@ -2324,7 +2610,7 @@ mod tests { t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); assert!(t.is_vim_operator_pending()); - t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)); assert_eq!(t.text(), "hello"); assert_eq!(t.vim_mode_label(), Some("Normal")); @@ -2348,6 +2634,62 @@ mod tests { assert_eq!(t.kill_buffer, "c"); } + #[test] + fn vim_e_advances_from_each_word_end() { + let mut t = ta_with("alpha beta gamma"); + t.set_cursor("alph".len()); // codespell:ignore alph + t.set_vim_enabled(/*enabled*/ true); + let mut states = Vec::new(); + + for _ in 0..3 { + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + states.push(format!("{}\n{}^", t.text(), " ".repeat(t.cursor()))); + } + + insta::assert_snapshot!("vim_e_advances_from_each_word_end", states.join("\n\n")); + } + + #[test] + fn vim_delete_to_word_end_advances_from_existing_word_end() { + let mut t = ta_with("alpha beta gamma"); + t.set_cursor("alph".len()); // codespell:ignore alph + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "alph gamma"); // codespell:ignore alph + assert_eq!(t.kill_buffer, "a beta"); + } + + #[test] + fn vim_e_from_word_end_can_land_on_trailing_space() { + let mut t = ta_with("alpha "); + t.set_cursor("alph".len()); // codespell:ignore alph + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + + assert_eq!(t.cursor(), "alpha ".len()); + } + + #[test] + fn vim_e_advances_across_atomic_element_word_ends() { + let mut t = TextArea::new(); + t.insert_str("alpha "); + t.insert_element(""); + t.insert_str(" gamma"); + let element_start = t.elements[0].range.start; + t.set_cursor("alph".len()); // codespell:ignore alph + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + assert_eq!(t.cursor(), element_start); + + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + assert_eq!(t.cursor(), "alpha gamm".len()); + } + #[test] fn vim_dollar_lands_on_line_end_character() { let mut t = ta_with("abc\n123"); diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim.rs b/codex-rs/tui/src/bottom_pane/textarea/vim.rs new file mode 100644 index 000000000000..2c1edc3fe7ea --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/vim.rs @@ -0,0 +1,320 @@ +use super::TextArea; +use super::split_word_pieces; +use crate::key_hint::KeyBindingListExt; +use crossterm::event::KeyEvent; +use std::ops::Range; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimMode { + /// Normal mode routes printable keys to movement, operators, and mode transitions. + Normal, + /// Insert mode routes input through the regular editor keymap until Escape is pressed. + Insert, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimOperator { + Delete, + Yank, + Change, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimPending { + None, + Operator(VimOperator), + TextObject { + operator: VimOperator, + scope: VimTextObjectScope, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimMotion { + Left, + Right, + Up, + Down, + WordForward, + WordBackward, + WordEnd, + LineStart, + LineEnd, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimTextObjectScope { + Inner, + Around, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimTextObject { + Word, + BigWord, + Parentheses, + Brackets, + Braces, + DoubleQuote, + SingleQuote, + Backtick, +} + +impl TextArea { + pub(super) fn vim_text_object_scope_for_event( + &self, + event: KeyEvent, + ) -> Option { + if self + .vim_operator_keymap + .select_inner_text_object + .is_pressed(event) + { + return Some(VimTextObjectScope::Inner); + } + if self + .vim_operator_keymap + .select_around_text_object + .is_pressed(event) + { + return Some(VimTextObjectScope::Around); + } + None + } + + pub(super) fn vim_text_object_for_event(&self, event: KeyEvent) -> Option { + if self.vim_text_object_keymap.word.is_pressed(event) { + return Some(VimTextObject::Word); + } + if self.vim_text_object_keymap.big_word.is_pressed(event) { + return Some(VimTextObject::BigWord); + } + if self.vim_text_object_keymap.parentheses.is_pressed(event) { + return Some(VimTextObject::Parentheses); + } + if self.vim_text_object_keymap.brackets.is_pressed(event) { + return Some(VimTextObject::Brackets); + } + if self.vim_text_object_keymap.braces.is_pressed(event) { + return Some(VimTextObject::Braces); + } + if self.vim_text_object_keymap.double_quote.is_pressed(event) { + return Some(VimTextObject::DoubleQuote); + } + if self.vim_text_object_keymap.single_quote.is_pressed(event) { + return Some(VimTextObject::SingleQuote); + } + if self.vim_text_object_keymap.backtick.is_pressed(event) { + return Some(VimTextObject::Backtick); + } + None + } + + pub(super) fn text_object_range( + &self, + object: VimTextObject, + scope: VimTextObjectScope, + ) -> Option> { + match object { + VimTextObject::Word => self.word_text_object_range(scope, /*big_word*/ false), + VimTextObject::BigWord => self.word_text_object_range(scope, /*big_word*/ true), + VimTextObject::Parentheses => self.paired_text_object_range(scope, '(', ')'), + VimTextObject::Brackets => self.paired_text_object_range(scope, '[', ']'), + VimTextObject::Braces => self.paired_text_object_range(scope, '{', '}'), + VimTextObject::DoubleQuote => self.quoted_text_object_range(scope, '"'), + VimTextObject::SingleQuote => self.quoted_text_object_range(scope, '\''), + VimTextObject::Backtick => self.quoted_text_object_range(scope, '`'), + } + } + + fn word_text_object_range( + &self, + scope: VimTextObjectScope, + big_word: bool, + ) -> Option> { + let inner = if big_word { + self.big_word_range_at_cursor()? + } else { + self.small_word_range_at_cursor()? + }; + Some(match scope { + VimTextObjectScope::Inner => inner, + VimTextObjectScope::Around => self.expand_word_around(inner), + }) + } + + fn big_word_range_at_cursor(&self) -> Option> { + self.non_ws_runs() + .into_iter() + .find(|range| self.cursor_overlaps_range(range) || self.cursor_is_at_range_end(range)) + } + + fn small_word_range_at_cursor(&self) -> Option> { + for run in self.non_ws_runs() { + if !self.cursor_overlaps_range(&run) && !self.cursor_is_at_range_end(&run) { + continue; + } + let mut last_piece = None; + for (piece_start, piece) in split_word_pieces(&self.text[run.clone()]) { + let piece = run.start + piece_start..run.start + piece_start + piece.len(); + if self.cursor_overlaps_range(&piece) { + return Some(piece); + } + last_piece = Some(piece); + } + if self.cursor_is_at_range_end(&run) { + return last_piece.or(Some(run)); + } + return Some(run); + } + None + } + + fn non_ws_runs(&self) -> Vec> { + let mut runs = Vec::new(); + let mut start = None; + for (idx, ch) in self.text.char_indices() { + if ch.is_whitespace() { + if let Some(run_start) = start.take() { + runs.push(run_start..idx); + } + } else if start.is_none() { + start = Some(idx); + } + } + if let Some(run_start) = start { + runs.push(run_start..self.text.len()); + } + runs + } + + fn cursor_overlaps_range(&self, range: &Range) -> bool { + range.start <= self.cursor_pos && self.cursor_pos < range.end + } + + fn cursor_is_at_range_end(&self, range: &Range) -> bool { + range.start < range.end && self.cursor_pos == range.end + } + + fn expand_word_around(&self, inner: Range) -> Range { + let following = self.following_whitespace_end(inner.end); + if following > inner.end { + return inner.start..following; + } + self.preceding_whitespace_start(inner.start)..inner.end + } + + fn following_whitespace_end(&self, start: usize) -> usize { + let mut end = start; + for (offset, ch) in self.text[start..].char_indices() { + if !ch.is_whitespace() { + break; + } + end = start + offset + ch.len_utf8(); + } + end + } + + fn preceding_whitespace_start(&self, end: usize) -> usize { + let mut start = end; + for (idx, ch) in self.text[..end].char_indices().rev() { + if !ch.is_whitespace() { + break; + } + start = idx; + } + start + } + + fn paired_text_object_range( + &self, + scope: VimTextObjectScope, + open: char, + close: char, + ) -> Option> { + let mut stack: Vec = Vec::new(); + let mut best: Option> = None; + for (idx, ch) in self.text.char_indices() { + if self.is_inside_element(idx) { + continue; + } + if ch == open { + stack.push(idx); + } else if ch == close { + let Some(open_idx) = stack.pop() else { + continue; + }; + let close_end = idx + ch.len_utf8(); + if open_idx <= self.cursor_pos && self.cursor_pos <= idx { + let candidate = match scope { + VimTextObjectScope::Inner => open_idx + open.len_utf8()..idx, + VimTextObjectScope::Around => open_idx..close_end, + }; + if candidate.start <= candidate.end + && best + .as_ref() + .is_none_or(|current| candidate.len() < current.len()) + { + best = Some(candidate); + } + } + } + } + best + } + + fn quoted_text_object_range( + &self, + scope: VimTextObjectScope, + quote: char, + ) -> Option> { + let line = self.beginning_of_current_line()..self.end_of_current_line(); + let mut open = None; + let mut best: Option> = None; + for (offset, ch) in self.text[line.clone()].char_indices() { + let idx = line.start + offset; + if self.is_inside_element(idx) || ch != quote || self.is_escaped(idx) { + continue; + } + if let Some(open_idx) = open.take() { + if open_idx <= self.cursor_pos && self.cursor_pos <= idx { + let candidate = match scope { + VimTextObjectScope::Inner => open_idx + quote.len_utf8()..idx, + VimTextObjectScope::Around => idx_range(open_idx, idx, quote), + }; + if candidate.start <= candidate.end + && best + .as_ref() + .is_none_or(|current| candidate.len() < current.len()) + { + best = Some(candidate); + } + } + } else { + open = Some(idx); + } + } + best + } + + fn is_inside_element(&self, pos: usize) -> bool { + self.elements + .iter() + .any(|element| pos >= element.range.start && pos < element.range.end) + } + + fn is_escaped(&self, pos: usize) -> bool { + let mut backslashes = 0; + for ch in self.text[..pos].chars().rev() { + if ch != '\\' { + break; + } + backslashes += 1; + } + backslashes % 2 == 1 + } +} + +fn idx_range(open_idx: usize, close_idx: usize, quote: char) -> Range { + open_idx..close_idx + quote.len_utf8() +} diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 533e28f3a23b..8e6d40c764c6 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -46,6 +46,7 @@ pub(crate) struct RuntimeKeymap { pub(crate) editor: EditorKeymap, pub(crate) vim_normal: VimNormalKeymap, pub(crate) vim_operator: VimOperatorKeymap, + pub(crate) vim_text_object: VimTextObjectKeymap, pub(crate) pager: PagerKeymap, pub(crate) list: ListKeymap, pub(crate) approval: ApprovalKeymap, @@ -156,10 +157,12 @@ pub(crate) struct VimNormalKeymap { pub(crate) move_line_end: Vec, pub(crate) delete_char: Vec, pub(crate) delete_to_line_end: Vec, + pub(crate) change_to_line_end: Vec, pub(crate) yank_line: Vec, pub(crate) paste_after: Vec, pub(crate) start_delete_operator: Vec, pub(crate) start_yank_operator: Vec, + pub(crate) start_change_operator: Vec, pub(crate) cancel_operator: Vec, } @@ -182,6 +185,22 @@ pub(crate) struct VimOperatorKeymap { pub(crate) motion_word_end: Vec, pub(crate) motion_line_start: Vec, pub(crate) motion_line_end: Vec, + pub(crate) select_inner_text_object: Vec, + pub(crate) select_around_text_object: Vec, + pub(crate) cancel: Vec, +} + +/// Vim text-object keybindings active after an operator plus inner/around prefix. +#[derive(Clone, Debug, Default)] +pub(crate) struct VimTextObjectKeymap { + pub(crate) word: Vec, + pub(crate) big_word: Vec, + pub(crate) parentheses: Vec, + pub(crate) brackets: Vec, + pub(crate) braces: Vec, + pub(crate) double_quote: Vec, + pub(crate) single_quote: Vec, + pub(crate) backtick: Vec, pub(crate) cancel: Vec, } @@ -451,7 +470,7 @@ impl RuntimeKeymap { yank: resolve_local!(keymap, defaults, editor, yank), }; - let vim_normal = VimNormalKeymap { + let mut vim_normal = VimNormalKeymap { enter_insert: resolve_local!(keymap, defaults, vim_normal, enter_insert), append_after_cursor: resolve_local!(keymap, defaults, vim_normal, append_after_cursor), append_line_end: resolve_local!(keymap, defaults, vim_normal, append_line_end), @@ -469,6 +488,7 @@ impl RuntimeKeymap { move_line_end: resolve_local!(keymap, defaults, vim_normal, move_line_end), 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), 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!( @@ -478,10 +498,113 @@ impl RuntimeKeymap { start_delete_operator ), start_yank_operator: resolve_local!(keymap, defaults, vim_normal, start_yank_operator), + start_change_operator: resolve_local!( + keymap, + defaults, + vim_normal, + start_change_operator + ), cancel_operator: resolve_local!(keymap, defaults, vim_normal, cancel_operator), }; - let vim_operator = VimOperatorKeymap { + let configured_vim_normal_bindings_to_preserve = configured_bindings_to_preserve([ + ( + keymap.vim_normal.enter_insert.as_ref(), + vim_normal.enter_insert.as_slice(), + ), + ( + keymap.vim_normal.append_after_cursor.as_ref(), + vim_normal.append_after_cursor.as_slice(), + ), + ( + keymap.vim_normal.append_line_end.as_ref(), + vim_normal.append_line_end.as_slice(), + ), + ( + keymap.vim_normal.insert_line_start.as_ref(), + vim_normal.insert_line_start.as_slice(), + ), + ( + keymap.vim_normal.open_line_below.as_ref(), + vim_normal.open_line_below.as_slice(), + ), + ( + keymap.vim_normal.open_line_above.as_ref(), + vim_normal.open_line_above.as_slice(), + ), + ( + keymap.vim_normal.move_left.as_ref(), + vim_normal.move_left.as_slice(), + ), + ( + keymap.vim_normal.move_right.as_ref(), + vim_normal.move_right.as_slice(), + ), + ( + keymap.vim_normal.move_up.as_ref(), + vim_normal.move_up.as_slice(), + ), + ( + keymap.vim_normal.move_down.as_ref(), + vim_normal.move_down.as_slice(), + ), + ( + keymap.vim_normal.move_word_forward.as_ref(), + vim_normal.move_word_forward.as_slice(), + ), + ( + keymap.vim_normal.move_word_backward.as_ref(), + vim_normal.move_word_backward.as_slice(), + ), + ( + keymap.vim_normal.move_word_end.as_ref(), + vim_normal.move_word_end.as_slice(), + ), + ( + keymap.vim_normal.move_line_start.as_ref(), + vim_normal.move_line_start.as_slice(), + ), + ( + keymap.vim_normal.move_line_end.as_ref(), + vim_normal.move_line_end.as_slice(), + ), + ( + keymap.vim_normal.delete_char.as_ref(), + vim_normal.delete_char.as_slice(), + ), + ( + keymap.vim_normal.delete_to_line_end.as_ref(), + vim_normal.delete_to_line_end.as_slice(), + ), + ( + keymap.vim_normal.yank_line.as_ref(), + vim_normal.yank_line.as_slice(), + ), + ( + keymap.vim_normal.paste_after.as_ref(), + vim_normal.paste_after.as_slice(), + ), + ( + keymap.vim_normal.start_delete_operator.as_ref(), + vim_normal.start_delete_operator.as_slice(), + ), + ( + keymap.vim_normal.start_yank_operator.as_ref(), + vim_normal.start_yank_operator.as_slice(), + ), + ( + keymap.vim_normal.cancel_operator.as_ref(), + vim_normal.cancel_operator.as_slice(), + ), + ]); + + if keymap.vim_normal.start_change_operator.is_none() { + vim_normal + .start_change_operator + .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), motion_left: resolve_local!(keymap, defaults, vim_operator, motion_left), @@ -503,9 +626,95 @@ impl RuntimeKeymap { motion_word_end: resolve_local!(keymap, defaults, vim_operator, motion_word_end), motion_line_start: resolve_local!(keymap, defaults, vim_operator, motion_line_start), motion_line_end: resolve_local!(keymap, defaults, vim_operator, motion_line_end), + select_inner_text_object: resolve_local!( + keymap, + defaults, + vim_operator, + select_inner_text_object + ), + select_around_text_object: resolve_local!( + keymap, + defaults, + vim_operator, + select_around_text_object + ), cancel: resolve_local!(keymap, defaults, vim_operator, cancel), }; + let configured_vim_operator_bindings_to_preserve = configured_bindings_to_preserve([ + ( + keymap.vim_operator.delete_line.as_ref(), + vim_operator.delete_line.as_slice(), + ), + ( + keymap.vim_operator.yank_line.as_ref(), + vim_operator.yank_line.as_slice(), + ), + ( + keymap.vim_operator.motion_left.as_ref(), + vim_operator.motion_left.as_slice(), + ), + ( + keymap.vim_operator.motion_right.as_ref(), + vim_operator.motion_right.as_slice(), + ), + ( + keymap.vim_operator.motion_up.as_ref(), + vim_operator.motion_up.as_slice(), + ), + ( + keymap.vim_operator.motion_down.as_ref(), + vim_operator.motion_down.as_slice(), + ), + ( + keymap.vim_operator.motion_word_forward.as_ref(), + vim_operator.motion_word_forward.as_slice(), + ), + ( + keymap.vim_operator.motion_word_backward.as_ref(), + vim_operator.motion_word_backward.as_slice(), + ), + ( + keymap.vim_operator.motion_word_end.as_ref(), + vim_operator.motion_word_end.as_slice(), + ), + ( + keymap.vim_operator.motion_line_start.as_ref(), + vim_operator.motion_line_start.as_slice(), + ), + ( + keymap.vim_operator.motion_line_end.as_ref(), + vim_operator.motion_line_end.as_slice(), + ), + ( + keymap.vim_operator.cancel.as_ref(), + vim_operator.cancel.as_slice(), + ), + ]); + + if keymap.vim_operator.select_inner_text_object.is_none() { + vim_operator + .select_inner_text_object + .retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding)); + } + if keymap.vim_operator.select_around_text_object.is_none() { + vim_operator + .select_around_text_object + .retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding)); + } + + let vim_text_object = VimTextObjectKeymap { + word: resolve_local!(keymap, defaults, vim_text_object, word), + big_word: resolve_local!(keymap, defaults, vim_text_object, big_word), + parentheses: resolve_local!(keymap, defaults, vim_text_object, parentheses), + brackets: resolve_local!(keymap, defaults, vim_text_object, brackets), + braces: resolve_local!(keymap, defaults, vim_text_object, braces), + double_quote: resolve_local!(keymap, defaults, vim_text_object, double_quote), + single_quote: resolve_local!(keymap, defaults, vim_text_object, single_quote), + backtick: resolve_local!(keymap, defaults, vim_text_object, backtick), + cancel: resolve_local!(keymap, defaults, vim_text_object, cancel), + }; + let pager = PagerKeymap { scroll_up: resolve_local!(keymap, defaults, pager, scroll_up), scroll_down: resolve_local!(keymap, defaults, pager, scroll_down), @@ -534,8 +743,7 @@ impl RuntimeKeymap { let list_move_down = resolve_local!(keymap, defaults, list, move_down); let list_accept = resolve_local!(keymap, defaults, list, accept); let list_cancel = resolve_local!(keymap, defaults, list, cancel); - let mut configured_bindings_to_preserve = Vec::new(); - for (configured, resolved) in [ + let configured_bindings_to_preserve = configured_bindings_to_preserve([ ( keymap.global.open_transcript.as_ref(), app.open_transcript.as_slice(), @@ -591,51 +799,42 @@ impl RuntimeKeymap { approval.decline.as_slice(), ), (keymap.approval.cancel.as_ref(), approval.cancel.as_slice()), - ] { - if configured.is_none() { - continue; - } - for binding in resolved { - if !configured_bindings_to_preserve.contains(binding) { - configured_bindings_to_preserve.push(*binding); - } - } - } + ]); let list = ListKeymap { move_up: list_move_up, move_down: list_move_down, - move_left: resolve_new_list_bindings( + move_left: resolve_new_default_bindings( keymap.list.move_left.as_ref(), &defaults.list.move_left, &configured_bindings_to_preserve, "tui.keymap.list.move_left", )?, - move_right: resolve_new_list_bindings( + move_right: resolve_new_default_bindings( keymap.list.move_right.as_ref(), &defaults.list.move_right, &configured_bindings_to_preserve, "tui.keymap.list.move_right", )?, - page_up: resolve_new_list_bindings( + page_up: resolve_new_default_bindings( keymap.list.page_up.as_ref(), &defaults.list.page_up, &configured_bindings_to_preserve, "tui.keymap.list.page_up", )?, - page_down: resolve_new_list_bindings( + page_down: resolve_new_default_bindings( keymap.list.page_down.as_ref(), &defaults.list.page_down, &configured_bindings_to_preserve, "tui.keymap.list.page_down", )?, - jump_top: resolve_new_list_bindings( + jump_top: resolve_new_default_bindings( keymap.list.jump_top.as_ref(), &defaults.list.jump_top, &configured_bindings_to_preserve, "tui.keymap.list.jump_top", )?, - jump_bottom: resolve_new_list_bindings( + jump_bottom: resolve_new_default_bindings( keymap.list.jump_bottom.as_ref(), &defaults.list.jump_bottom, &configured_bindings_to_preserve, @@ -652,6 +851,7 @@ impl RuntimeKeymap { editor, vim_normal, vim_operator, + vim_text_object, pager, list, approval, @@ -786,10 +986,15 @@ impl RuntimeKeymap { shift(KeyCode::Char('d')), plain(KeyCode::Char('D')) ], + change_to_line_end: default_bindings![ + shift(KeyCode::Char('c')), + plain(KeyCode::Char('C')) + ], 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'))], start_yank_operator: default_bindings![plain(KeyCode::Char('y'))], + start_change_operator: default_bindings![plain(KeyCode::Char('c'))], cancel_operator: default_bindings![plain(KeyCode::Esc)], }, vim_operator: VimOperatorKeymap { @@ -807,6 +1012,35 @@ impl RuntimeKeymap { plain(KeyCode::Char('$')), shift(KeyCode::Char('$')) ], + select_inner_text_object: default_bindings![plain(KeyCode::Char('i'))], + select_around_text_object: default_bindings![plain(KeyCode::Char('a'))], + cancel: default_bindings![plain(KeyCode::Esc)], + }, + vim_text_object: VimTextObjectKeymap { + word: default_bindings![plain(KeyCode::Char('w'))], + big_word: default_bindings![shift(KeyCode::Char('w')), plain(KeyCode::Char('W'))], + parentheses: default_bindings![ + plain(KeyCode::Char('(')), + shift(KeyCode::Char('(')), + plain(KeyCode::Char(')')), + shift(KeyCode::Char(')')), + plain(KeyCode::Char('b')) + ], + brackets: default_bindings![plain(KeyCode::Char('[')), plain(KeyCode::Char(']'))], + braces: default_bindings![ + plain(KeyCode::Char('{')), + shift(KeyCode::Char('{')), + plain(KeyCode::Char('}')), + shift(KeyCode::Char('}')), + shift(KeyCode::Char('b')), + plain(KeyCode::Char('B')) + ], + double_quote: default_bindings![ + plain(KeyCode::Char('"')), + shift(KeyCode::Char('"')) + ], + single_quote: default_bindings![plain(KeyCode::Char('\''))], + backtick: default_bindings![plain(KeyCode::Char('`'))], cancel: default_bindings![plain(KeyCode::Esc)], }, pager: PagerKeymap { @@ -1176,6 +1410,10 @@ impl RuntimeKeymap { "delete_to_line_end", self.vim_normal.delete_to_line_end.as_slice(), ), + ( + "change_to_line_end", + self.vim_normal.change_to_line_end.as_slice(), + ), ("yank_line", self.vim_normal.yank_line.as_slice()), ("paste_after", self.vim_normal.paste_after.as_slice()), ( @@ -1186,6 +1424,10 @@ impl RuntimeKeymap { "start_yank_operator", self.vim_normal.start_yank_operator.as_slice(), ), + ( + "start_change_operator", + self.vim_normal.start_change_operator.as_slice(), + ), ( "cancel_operator", self.vim_normal.cancel_operator.as_slice(), @@ -1222,10 +1464,33 @@ impl RuntimeKeymap { "motion_line_end", self.vim_operator.motion_line_end.as_slice(), ), + ( + "select_inner_text_object", + self.vim_operator.select_inner_text_object.as_slice(), + ), + ( + "select_around_text_object", + self.vim_operator.select_around_text_object.as_slice(), + ), ("cancel", self.vim_operator.cancel.as_slice()), ], )?; + validate_unique( + "vim_text_object", + [ + ("word", self.vim_text_object.word.as_slice()), + ("big_word", self.vim_text_object.big_word.as_slice()), + ("parentheses", self.vim_text_object.parentheses.as_slice()), + ("brackets", self.vim_text_object.brackets.as_slice()), + ("braces", self.vim_text_object.braces.as_slice()), + ("double_quote", self.vim_text_object.double_quote.as_slice()), + ("single_quote", self.vim_text_object.single_quote.as_slice()), + ("backtick", self.vim_text_object.backtick.as_slice()), + ("cancel", self.vim_text_object.cancel.as_slice()), + ], + )?; + validate_unique( "pager", [ @@ -1514,7 +1779,24 @@ fn resolve_bindings( parse_bindings(spec, path) } -fn resolve_new_list_bindings( +fn configured_bindings_to_preserve( + pairs: [(Option<&KeybindingsSpec>, &[KeyBinding]); N], +) -> Vec { + let mut configured_bindings = Vec::new(); + for (configured, resolved) in pairs { + if configured.is_none() { + continue; + } + for binding in resolved { + if !configured_bindings.contains(binding) { + configured_bindings.push(*binding); + } + } + } + configured_bindings +} + +fn resolve_new_default_bindings( configured: Option<&KeybindingsSpec>, fallback: &[KeyBinding], configured_bindings_to_preserve: &[KeyBinding], @@ -1963,6 +2245,58 @@ mod tests { expect_conflict(&keymap, "list.jump_top", "approval.approve"); } + #[test] + fn configured_legacy_vim_normal_bindings_prune_new_change_operator_default() { + let mut keymap = TuiKeymap::default(); + keymap.vim_normal.move_left = Some(one("c")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!( + runtime.vim_normal.move_left, + vec![key_hint::plain(KeyCode::Char('c'))] + ); + assert_eq!(runtime.vim_normal.start_change_operator, Vec::new()); + } + + #[test] + fn explicit_new_vim_normal_binding_still_conflicts_with_legacy_binding() { + let mut keymap = TuiKeymap::default(); + keymap.vim_normal.move_left = Some(one("c")); + keymap.vim_normal.start_change_operator = Some(one("c")); + + expect_conflict(&keymap, "move_left", "start_change_operator"); + } + + #[test] + fn configured_legacy_vim_operator_bindings_prune_new_text_object_defaults() { + let mut keymap = TuiKeymap::default(); + keymap.vim_operator.motion_left = Some(one("i")); + keymap.vim_operator.motion_right = Some(one("a")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!( + runtime.vim_operator.motion_left, + vec![key_hint::plain(KeyCode::Char('i'))] + ); + assert_eq!( + runtime.vim_operator.motion_right, + vec![key_hint::plain(KeyCode::Char('a'))] + ); + assert_eq!(runtime.vim_operator.select_inner_text_object, Vec::new()); + assert_eq!(runtime.vim_operator.select_around_text_object, Vec::new()); + } + + #[test] + fn explicit_new_vim_operator_binding_still_conflicts_with_legacy_binding() { + let mut keymap = TuiKeymap::default(); + keymap.vim_operator.motion_left = Some(one("i")); + keymap.vim_operator.select_inner_text_object = Some(one("i")); + + expect_conflict(&keymap, "motion_left", "select_inner_text_object"); + } + #[test] fn vim_normal_defaults_include_insert_and_arrow_aliases() { let runtime = RuntimeKeymap::defaults(); diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 9ce827b8a938..c8815c8c984f 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -135,10 +135,12 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("vim_normal", "Vim normal", "move_line_end", "Move to the end of the line."), 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", "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", "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."), @@ -151,7 +153,18 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("vim_operator", "Vim operator", "motion_word_end", "Operator motion to end of word."), action("vim_operator", "Vim operator", "motion_line_start", "Operator motion to line start."), action("vim_operator", "Vim operator", "motion_line_end", "Operator motion to line end."), + action("vim_operator", "Vim operator", "select_inner_text_object", "Select an inner text object."), + action("vim_operator", "Vim operator", "select_around_text_object", "Select an around text object."), action("vim_operator", "Vim operator", "cancel", "Cancel the pending operator."), + action("vim_text_object", "Vim text object", "word", "Target the current word."), + action("vim_text_object", "Vim text object", "big_word", "Target the current WORD."), + action("vim_text_object", "Vim text object", "parentheses", "Target enclosing parentheses."), + action("vim_text_object", "Vim text object", "brackets", "Target enclosing brackets."), + action("vim_text_object", "Vim text object", "braces", "Target enclosing braces."), + action("vim_text_object", "Vim text object", "double_quote", "Target enclosing double quotes."), + action("vim_text_object", "Vim text object", "single_quote", "Target enclosing single quotes."), + action("vim_text_object", "Vim text object", "backtick", "Target enclosing backticks."), + action("vim_text_object", "Vim text object", "cancel", "Cancel the pending text object."), action("pager", "Pager", "scroll_up", "Scroll up by one row."), action("pager", "Pager", "scroll_down", "Scroll down by one row."), action("pager", "Pager", "page_up", "Scroll up by one page."), @@ -263,10 +276,12 @@ pub(super) fn binding_slot<'a>( ("vim_normal", "move_line_end") => Some(&mut keymap.vim_normal.move_line_end), ("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", "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), ("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", "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), @@ -279,7 +294,18 @@ pub(super) fn binding_slot<'a>( ("vim_operator", "motion_word_end") => Some(&mut keymap.vim_operator.motion_word_end), ("vim_operator", "motion_line_start") => Some(&mut keymap.vim_operator.motion_line_start), ("vim_operator", "motion_line_end") => Some(&mut keymap.vim_operator.motion_line_end), + ("vim_operator", "select_inner_text_object") => Some(&mut keymap.vim_operator.select_inner_text_object), + ("vim_operator", "select_around_text_object") => Some(&mut keymap.vim_operator.select_around_text_object), ("vim_operator", "cancel") => Some(&mut keymap.vim_operator.cancel), + ("vim_text_object", "word") => Some(&mut keymap.vim_text_object.word), + ("vim_text_object", "big_word") => Some(&mut keymap.vim_text_object.big_word), + ("vim_text_object", "parentheses") => Some(&mut keymap.vim_text_object.parentheses), + ("vim_text_object", "brackets") => Some(&mut keymap.vim_text_object.brackets), + ("vim_text_object", "braces") => Some(&mut keymap.vim_text_object.braces), + ("vim_text_object", "double_quote") => Some(&mut keymap.vim_text_object.double_quote), + ("vim_text_object", "single_quote") => Some(&mut keymap.vim_text_object.single_quote), + ("vim_text_object", "backtick") => Some(&mut keymap.vim_text_object.backtick), + ("vim_text_object", "cancel") => Some(&mut keymap.vim_text_object.cancel), ("pager", "scroll_up") => Some(&mut keymap.pager.scroll_up), ("pager", "scroll_down") => Some(&mut keymap.pager.scroll_down), ("pager", "page_up") => Some(&mut keymap.pager.page_up), @@ -373,10 +399,12 @@ pub(super) fn bindings_for_action<'a>( ("vim_normal", "move_line_end") => Some(runtime_keymap.vim_normal.move_line_end.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()), ("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()), ("vim_normal", "start_yank_operator") => Some(runtime_keymap.vim_normal.start_yank_operator.as_slice()), + ("vim_normal", "start_change_operator") => Some(runtime_keymap.vim_normal.start_change_operator.as_slice()), ("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()), @@ -389,7 +417,18 @@ pub(super) fn bindings_for_action<'a>( ("vim_operator", "motion_word_end") => Some(runtime_keymap.vim_operator.motion_word_end.as_slice()), ("vim_operator", "motion_line_start") => Some(runtime_keymap.vim_operator.motion_line_start.as_slice()), ("vim_operator", "motion_line_end") => Some(runtime_keymap.vim_operator.motion_line_end.as_slice()), + ("vim_operator", "select_inner_text_object") => Some(runtime_keymap.vim_operator.select_inner_text_object.as_slice()), + ("vim_operator", "select_around_text_object") => Some(runtime_keymap.vim_operator.select_around_text_object.as_slice()), ("vim_operator", "cancel") => Some(runtime_keymap.vim_operator.cancel.as_slice()), + ("vim_text_object", "word") => Some(runtime_keymap.vim_text_object.word.as_slice()), + ("vim_text_object", "big_word") => Some(runtime_keymap.vim_text_object.big_word.as_slice()), + ("vim_text_object", "parentheses") => Some(runtime_keymap.vim_text_object.parentheses.as_slice()), + ("vim_text_object", "brackets") => Some(runtime_keymap.vim_text_object.brackets.as_slice()), + ("vim_text_object", "braces") => Some(runtime_keymap.vim_text_object.braces.as_slice()), + ("vim_text_object", "double_quote") => Some(runtime_keymap.vim_text_object.double_quote.as_slice()), + ("vim_text_object", "single_quote") => Some(runtime_keymap.vim_text_object.single_quote.as_slice()), + ("vim_text_object", "backtick") => Some(runtime_keymap.vim_text_object.backtick.as_slice()), + ("vim_text_object", "cancel") => Some(runtime_keymap.vim_text_object.cancel.as_slice()), ("pager", "scroll_up") => Some(runtime_keymap.pager.scroll_up.as_slice()), ("pager", "scroll_down") => Some(runtime_keymap.pager.scroll_down.as_slice()), ("pager", "page_up") => Some(runtime_keymap.pager.page_up.as_slice()), diff --git a/codex-rs/tui/src/keymap_setup/picker.rs b/codex-rs/tui/src/keymap_setup/picker.rs index b6c89d9585bf..52ec4a4b8b15 100644 --- a/codex-rs/tui/src/keymap_setup/picker.rs +++ b/codex-rs/tui/src/keymap_setup/picker.rs @@ -104,7 +104,7 @@ const KEYMAP_CONTEXT_TABS: &[KeymapContextTab] = &[ id: "vim-shortcuts", label: "Vim", description: "Vim normal-mode and operator shortcuts.", - contexts: &["vim_normal", "vim_operator"], + contexts: &["vim_normal", "vim_operator", "vim_text_object"], }, KeymapContextTab { id: "navigation-shortcuts", 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 898108c7558d..e212226ef5c5 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. - 93 actions, 1 customized, 2 unbound. + 106 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 4d9440cb4eb0..22c0537540dd 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. - 94 actions, 0 customized, 3 unbound. + 107 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 58b1b8080394..4a88465ba84a 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 (93 selectable) +tab: All (106 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 (34 selectable) +tab: Vim (47 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 6a98da923880..26af4bd57ae0 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. - 93 actions, 0 customized, 2 unbound. + 106 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 9d651ee4f0dd..36f6a1d04cba 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. - 93 actions, 0 customized, 2 unbound. + 106 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug