From ff077a6a0788ec61e5e85583892baabfb1f8618c Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 25 May 2026 17:09:34 -0300 Subject: [PATCH] feat(tui): add vim find and till motions --- codex-rs/config/src/tui_keymap.rs | 24 +++ codex-rs/core/config.schema.json | 120 +++++++++++ codex-rs/tui/src/bottom_pane/textarea.rs | 149 ++++++++++++++ codex-rs/tui/src/bottom_pane/textarea/vim.rs | 20 ++ .../tui/src/bottom_pane/textarea/vim/find.rs | 149 ++++++++++++++ codex-rs/tui/src/keymap.rs | 191 ++++++++++++++++++ codex-rs/tui/src/keymap_setup/actions.rs | 36 ++++ ...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 +- 12 files changed, 695 insertions(+), 6 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/textarea/vim/find.rs diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 85f62a6ebdd9..d774841d74eb 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -219,6 +219,18 @@ pub struct TuiVimNormalKeymap { pub move_line_start: Option, /// Move cursor to end of line (`$`). pub move_line_end: Option, + /// Find the next target character on the current line (`f{char}`). + pub find_forward: Option, + /// Find the previous target character on the current line (`F{char}`). + pub find_backward: Option, + /// Move until before the next target character on the current line (`t{char}`). + pub till_forward: Option, + /// Move until after the previous target character on the current line (`T{char}`). + pub till_backward: Option, + /// Repeat the most recent find/till motion (`;`). + pub repeat_find: Option, + /// Repeat the most recent find/till motion in the opposite direction (`,`). + pub repeat_find_reverse: Option, /// Delete character under cursor (`x`). pub delete_char: Option, /// Delete from cursor to end of line (`D`). @@ -276,6 +288,18 @@ pub struct TuiVimOperatorKeymap { pub motion_line_start: Option, /// Motion: to end of line (`$`). pub motion_line_end: Option, + /// Enter a forward find motion and await its literal target (`f{char}`). + pub find_forward: Option, + /// Enter a backward find motion and await its literal target (`F{char}`). + pub find_backward: Option, + /// Enter a forward till motion and await its literal target (`t{char}`). + pub till_forward: Option, + /// Enter a backward till motion and await its literal target (`T{char}`). + pub till_backward: Option, + /// Repeat the most recent find/till motion (`;`). + pub repeat_find: Option, + /// Repeat the most recent find/till motion in the opposite direction (`,`). + pub repeat_find_reverse: Option, /// Select an inner text object after an operator. pub select_inner_text_object: Option, /// Select an around text object after an operator. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 1b736696bbec..23c7c2bfbde2 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2828,6 +2828,8 @@ "delete_char": null, "delete_to_line_end": null, "enter_insert": null, + "find_backward": null, + "find_forward": null, "insert_line_start": null, "move_down": null, "move_left": null, @@ -2841,17 +2843,23 @@ "open_line_above": null, "open_line_below": null, "paste_after": null, + "repeat_find": null, + "repeat_find_reverse": null, "start_change_operator": null, "start_delete_operator": null, "start_yank_operator": null, "substitute_char": null, "substitute_line": null, + "till_backward": null, + "till_forward": null, "yank_line": null }, "vim_operator": { "cancel": null, "change_line": null, "delete_line": null, + "find_backward": null, + "find_forward": null, "motion_down": null, "motion_left": null, "motion_line_end": null, @@ -2861,8 +2869,12 @@ "motion_word_backward": null, "motion_word_end": null, "motion_word_forward": null, + "repeat_find": null, + "repeat_find_reverse": null, "select_around_text_object": null, "select_inner_text_object": null, + "till_backward": null, + "till_forward": null, "yank_line": null }, "vim_text_object": { @@ -3512,6 +3524,8 @@ "delete_char": null, "delete_to_line_end": null, "enter_insert": null, + "find_backward": null, + "find_forward": null, "insert_line_start": null, "move_down": null, "move_left": null, @@ -3525,11 +3539,15 @@ "open_line_above": null, "open_line_below": null, "paste_after": null, + "repeat_find": null, + "repeat_find_reverse": null, "start_change_operator": null, "start_delete_operator": null, "start_yank_operator": null, "substitute_char": null, "substitute_line": null, + "till_backward": null, + "till_forward": null, "yank_line": null } }, @@ -3543,6 +3561,8 @@ "cancel": null, "change_line": null, "delete_line": null, + "find_backward": null, + "find_forward": null, "motion_down": null, "motion_left": null, "motion_line_end": null, @@ -3552,8 +3572,12 @@ "motion_word_backward": null, "motion_word_end": null, "motion_word_forward": null, + "repeat_find": null, + "repeat_find_reverse": null, "select_around_text_object": null, "select_inner_text_object": null, + "till_backward": null, + "till_forward": null, "yank_line": null } }, @@ -3830,6 +3854,22 @@ ], "description": "Enter insert mode at cursor (`i`)." }, + "find_backward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Find the previous target character on the current line (`F{char}`)." + }, + "find_forward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Find the next target character on the current line (`f{char}`)." + }, "insert_line_start": { "allOf": [ { @@ -3934,6 +3974,22 @@ ], "description": "Paste after cursor (`p`)." }, + "repeat_find": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Repeat the most recent find/till motion (`;`)." + }, + "repeat_find_reverse": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Repeat the most recent find/till motion in the opposite direction (`,`)." + }, "start_change_operator": { "allOf": [ { @@ -3974,6 +4030,22 @@ ], "description": "Substitute the current line and enter insert mode (`S`)." }, + "till_backward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move until after the previous target character on the current line (`T{char}`)." + }, + "till_forward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move until before the next target character on the current line (`t{char}`)." + }, "yank_line": { "allOf": [ { @@ -4013,6 +4085,22 @@ ], "description": "Repeat delete operator to delete the whole line (`dd`)." }, + "find_backward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Enter a backward find motion and await its literal target (`F{char}`)." + }, + "find_forward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Enter a forward find motion and await its literal target (`f{char}`)." + }, "motion_down": { "allOf": [ { @@ -4085,6 +4173,22 @@ ], "description": "Motion: to start of next word (`w`)." }, + "repeat_find": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Repeat the most recent find/till motion (`;`)." + }, + "repeat_find_reverse": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Repeat the most recent find/till motion in the opposite direction (`,`)." + }, "select_around_text_object": { "allOf": [ { @@ -4101,6 +4205,22 @@ ], "description": "Select an inner text object after an operator." }, + "till_backward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Enter a backward till motion and await its literal target (`T{char}`)." + }, + "till_forward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Enter a forward till motion and await its literal target (`t{char}`)." + }, "yank_line": { "allOf": [ { diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 30ed462931cd..2fe1879844b3 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -37,6 +37,7 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; mod vim; +use self::vim::VimFind; use self::vim::VimMode; use self::vim::VimMotion; use self::vim::VimOperator; @@ -110,6 +111,7 @@ pub(crate) struct TextArea { vim_enabled: bool, vim_mode: VimMode, vim_pending: VimPending, + vim_last_find: Option, editor_keymap: EditorKeymap, vim_normal_keymap: VimNormalKeymap, vim_operator_keymap: VimOperatorKeymap, @@ -151,6 +153,7 @@ impl TextArea { vim_enabled: false, vim_mode: VimMode::Insert, vim_pending: VimPending::None, + vim_last_find: None, editor_keymap: defaults.editor, vim_normal_keymap: defaults.vim_normal, vim_operator_keymap: defaults.vim_operator, @@ -653,6 +656,10 @@ impl TextArea { self.handle_vim_text_object(operator, scope, event); return; } + VimPending::Find { operator, kind } => { + self.handle_vim_find_target(operator, kind, event); + return; + } } if self.vim_normal_keymap.enter_insert.is_pressed(event) { @@ -731,6 +738,21 @@ impl TextArea { self.set_cursor(self.vim_line_end_cursor()); return; } + if let Some(kind) = self.vim_normal_find_kind_for_event(event) { + self.vim_pending = VimPending::Find { + operator: None, + kind, + }; + return; + } + if self.vim_normal_keymap.repeat_find.is_pressed(event) { + self.repeat_vim_find(/*operator*/ None, /*reverse*/ false); + return; + } + if self.vim_normal_keymap.repeat_find_reverse.is_pressed(event) { + self.repeat_vim_find(/*operator*/ None, /*reverse*/ true); + return; + } if self.vim_normal_keymap.delete_char.is_pressed(event) { self.delete_forward_kill(/*n*/ 1); return; @@ -808,6 +830,25 @@ impl TextArea { }; return true; } + if let Some(kind) = self.vim_operator_find_kind_for_event(event) { + self.vim_pending = VimPending::Find { + operator: Some(op), + kind, + }; + return true; + } + if self.vim_operator_keymap.repeat_find.is_pressed(event) { + self.repeat_vim_find(Some(op), /*reverse*/ false); + return true; + } + if self + .vim_operator_keymap + .repeat_find_reverse + .is_pressed(event) + { + self.repeat_vim_find(Some(op), /*reverse*/ true); + return true; + } if let Some(motion) = self.vim_motion_for_event(event) { self.apply_vim_operator(op, motion); @@ -2557,6 +2598,114 @@ mod tests { assert_eq!(t.vim_mode_label(), Some("Insert")); } + #[test] + fn vim_find_till_motions_repeat_and_stay_on_current_line() { + let mut t = ta_with("ab,cd,ef\naz"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char(','), KeyModifiers::NONE)); + assert_eq!(t.cursor(), 2); + + t.input(KeyEvent::new(KeyCode::Char(';'), KeyModifiers::NONE)); + assert_eq!(t.cursor(), 5); + + t.input(KeyEvent::new(KeyCode::Char(','), KeyModifiers::NONE)); + assert_eq!(t.cursor(), 2); + + let mut t = ta_with("abc\naz"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + t.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)); + assert_eq!(t.cursor(), 0); + + t.set_cursor(/*pos*/ "abc\n".len()); + t.input(KeyEvent::new(KeyCode::Char(';'), KeyModifiers::NONE)); + assert_eq!(t.cursor(), "abc\na".len()); + + let mut t = ta_with("abc,def"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + t.input(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char(','), KeyModifiers::NONE)); + assert_eq!(t.cursor(), 2); + t.input(KeyEvent::new(KeyCode::Char('T'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + assert_eq!(t.cursor(), 1); + } + + #[test] + fn vim_find_till_operators_delete_yank_and_change() { + let mut t = ta_with("abxcd,ef"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + assert_eq!(t.text(), "cd,ef"); + assert_eq!(t.kill_buffer, "abx"); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char(';'), KeyModifiers::NONE)); + assert_eq!(t.text(), "cd,ef"); + + let mut t = ta_with("ab,cd"); + 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('t'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char(','), KeyModifiers::NONE)); + assert_eq!(t.text(), ",cd"); + assert_eq!(t.kill_buffer, "ab"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + + let mut t = ta_with("ab,cd,ef"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char(','), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char(';'), KeyModifiers::NONE)); + assert_eq!(t.text(), "ef"); + assert_eq!(t.kill_buffer, "cd,"); + + let mut t = ta_with("ab,cd"); + t.set_cursor(t.text().len()); + t.set_vim_enabled(/*enabled*/ true); + t.input(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('F'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char(','), KeyModifiers::NONE)); + assert_eq!(t.text(), "ab,cd"); + assert_eq!(t.kill_buffer, ",cd"); + } + + #[test] + fn vim_find_operators_preserve_unicode_and_atomic_elements() { + let mut t = ta_with("a👍,z"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char(','), KeyModifiers::NONE)); + assert_eq!(t.text(), "z"); + assert_eq!(t.kill_buffer, "a👍,"); + + let mut t = ta_with("a@file,z"); + t.add_element_range(1.."a@file".len()) + .expect("valid element"); + t.set_cursor(/*pos*/ 0); + t.set_vim_enabled(/*enabled*/ true); + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char(','), KeyModifiers::NONE)); + assert_eq!(t.text(), "z"); + assert_eq!(t.kill_buffer, "a@file,"); + } + #[test] fn vim_word_text_objects_cover_delete_yank_and_big_word() { let mut t = ta_with("hello world"); diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim.rs b/codex-rs/tui/src/bottom_pane/textarea/vim.rs index 2c1edc3fe7ea..02053170ee74 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/vim.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/vim.rs @@ -4,6 +4,8 @@ use crate::key_hint::KeyBindingListExt; use crossterm::event::KeyEvent; use std::ops::Range; +mod find; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum VimMode { /// Normal mode routes printable keys to movement, operators, and mode transitions. @@ -27,6 +29,10 @@ pub(super) enum VimPending { operator: VimOperator, scope: VimTextObjectScope, }, + Find { + operator: Option, + kind: VimFindKind, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -42,6 +48,20 @@ pub(super) enum VimMotion { LineEnd, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimFindKind { + FindForward, + FindBackward, + TillForward, + TillBackward, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct VimFind { + pub(super) kind: VimFindKind, + pub(super) target: char, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum VimTextObjectScope { Inner, diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim/find.rs b/codex-rs/tui/src/bottom_pane/textarea/vim/find.rs new file mode 100644 index 000000000000..da5ee1288135 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/vim/find.rs @@ -0,0 +1,149 @@ +use super::super::TextArea; +use super::VimFind; +use super::VimFindKind; +use super::VimOperator; +use crate::key_hint::KeyBindingListExt; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use std::ops::Range; + +impl VimFindKind { + pub(in super::super) fn reverse(self) -> Self { + match self { + Self::FindForward => Self::FindBackward, + Self::FindBackward => Self::FindForward, + Self::TillForward => Self::TillBackward, + Self::TillBackward => Self::TillForward, + } + } +} + +impl TextArea { + pub(in super::super) fn handle_vim_find_target( + &mut self, + operator: Option, + kind: VimFindKind, + event: KeyEvent, + ) -> bool { + if self.vim_normal_keymap.cancel_operator.is_pressed(event) + || self.vim_operator_keymap.cancel.is_pressed(event) + { + return true; + } + let KeyCode::Char(target) = event.code else { + return true; + }; + let find = VimFind { kind, target }; + self.vim_last_find = Some(find); + self.execute_vim_find(operator, find); + true + } + + pub(in super::super) fn vim_normal_find_kind_for_event( + &self, + event: KeyEvent, + ) -> Option { + if self.vim_normal_keymap.find_forward.is_pressed(event) { + return Some(VimFindKind::FindForward); + } + if self.vim_normal_keymap.find_backward.is_pressed(event) { + return Some(VimFindKind::FindBackward); + } + if self.vim_normal_keymap.till_forward.is_pressed(event) { + return Some(VimFindKind::TillForward); + } + if self.vim_normal_keymap.till_backward.is_pressed(event) { + return Some(VimFindKind::TillBackward); + } + None + } + + pub(in super::super) fn vim_operator_find_kind_for_event( + &self, + event: KeyEvent, + ) -> Option { + if self.vim_operator_keymap.find_forward.is_pressed(event) { + return Some(VimFindKind::FindForward); + } + if self.vim_operator_keymap.find_backward.is_pressed(event) { + return Some(VimFindKind::FindBackward); + } + if self.vim_operator_keymap.till_forward.is_pressed(event) { + return Some(VimFindKind::TillForward); + } + if self.vim_operator_keymap.till_backward.is_pressed(event) { + return Some(VimFindKind::TillBackward); + } + None + } + + pub(in super::super) fn repeat_vim_find( + &mut self, + operator: Option, + reverse: bool, + ) { + let Some(mut find) = self.vim_last_find else { + return; + }; + if reverse { + find.kind = find.kind.reverse(); + } + self.execute_vim_find(operator, find); + } + + fn execute_vim_find(&mut self, operator: Option, find: VimFind) { + if let Some(operator) = operator { + if let Some(range) = self.range_for_find(find) { + self.apply_vim_operator_to_range(operator, range); + } + } else if let Some(target) = self.target_for_find(find) { + self.set_cursor(target); + } + } + + pub(in super::super) fn target_for_find(&self, find: VimFind) -> Option { + let matched = self.find_match(find)?; + Some(match find.kind { + VimFindKind::FindForward | VimFindKind::FindBackward => matched, + VimFindKind::TillForward => self.prev_atomic_boundary(matched), + VimFindKind::TillBackward => self.next_atomic_boundary(matched), + }) + } + + pub(in super::super) fn range_for_find(&self, find: VimFind) -> Option> { + let matched = self.find_match(find)?; + let range = match find.kind { + VimFindKind::FindForward => self.cursor_pos..self.next_atomic_boundary(matched), + VimFindKind::TillForward => self.cursor_pos..matched, + VimFindKind::FindBackward => matched..self.cursor_pos, + VimFindKind::TillBackward => self.next_atomic_boundary(matched)..self.cursor_pos, + }; + (range.start < range.end).then_some(range) + } + + fn find_match(&self, find: VimFind) -> Option { + let line_start = self.beginning_of_current_line(); + let line_end = self.end_of_current_line(); + match find.kind { + VimFindKind::FindForward | VimFindKind::TillForward => { + let start = self.next_atomic_boundary(self.cursor_pos).min(line_end); + self.text[start..line_end] + .char_indices() + .map(|(offset, _)| start + offset) + .find(|&idx| self.matches_find_target(idx, find.target)) + } + VimFindKind::FindBackward | VimFindKind::TillBackward => self.text + [line_start..self.cursor_pos] + .char_indices() + .map(|(offset, _)| line_start + offset) + .rev() + .find(|&idx| self.matches_find_target(idx, find.target)), + } + } + + fn matches_find_target(&self, idx: usize, target: char) -> bool { + !self.is_inside_element(idx) + && self.clamp_pos_to_nearest_boundary(idx) == idx + && self.text[idx..].starts_with(target) + } +} diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 5b8a84413a82..5c68df228877 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -155,6 +155,12 @@ pub(crate) struct VimNormalKeymap { pub(crate) move_word_end: Vec, pub(crate) move_line_start: Vec, pub(crate) move_line_end: Vec, + pub(crate) find_forward: Vec, + pub(crate) find_backward: Vec, + pub(crate) till_forward: Vec, + pub(crate) till_backward: Vec, + pub(crate) repeat_find: Vec, + pub(crate) repeat_find_reverse: Vec, pub(crate) delete_char: Vec, pub(crate) delete_to_line_end: Vec, pub(crate) change_to_line_end: Vec, @@ -188,6 +194,12 @@ pub(crate) struct VimOperatorKeymap { pub(crate) motion_word_end: Vec, pub(crate) motion_line_start: Vec, pub(crate) motion_line_end: Vec, + pub(crate) find_forward: Vec, + pub(crate) find_backward: Vec, + pub(crate) till_forward: Vec, + pub(crate) till_backward: Vec, + pub(crate) repeat_find: Vec, + pub(crate) repeat_find_reverse: Vec, pub(crate) select_inner_text_object: Vec, pub(crate) select_around_text_object: Vec, pub(crate) cancel: Vec, @@ -489,6 +501,12 @@ impl RuntimeKeymap { move_word_end: resolve_local!(keymap, defaults, vim_normal, move_word_end), move_line_start: resolve_local!(keymap, defaults, vim_normal, move_line_start), move_line_end: resolve_local!(keymap, defaults, vim_normal, move_line_end), + find_forward: resolve_local!(keymap, defaults, vim_normal, find_forward), + find_backward: resolve_local!(keymap, defaults, vim_normal, find_backward), + till_forward: resolve_local!(keymap, defaults, vim_normal, till_forward), + till_backward: resolve_local!(keymap, defaults, vim_normal, till_backward), + repeat_find: resolve_local!(keymap, defaults, vim_normal, repeat_find), + repeat_find_reverse: resolve_local!(keymap, defaults, vim_normal, repeat_find_reverse), 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), @@ -573,6 +591,30 @@ impl RuntimeKeymap { keymap.vim_normal.move_line_end.as_ref(), vim_normal.move_line_end.as_slice(), ), + ( + keymap.vim_normal.find_forward.as_ref(), + vim_normal.find_forward.as_slice(), + ), + ( + keymap.vim_normal.find_backward.as_ref(), + vim_normal.find_backward.as_slice(), + ), + ( + keymap.vim_normal.till_forward.as_ref(), + vim_normal.till_forward.as_slice(), + ), + ( + keymap.vim_normal.till_backward.as_ref(), + vim_normal.till_backward.as_slice(), + ), + ( + keymap.vim_normal.repeat_find.as_ref(), + vim_normal.repeat_find.as_slice(), + ), + ( + keymap.vim_normal.repeat_find_reverse.as_ref(), + vim_normal.repeat_find_reverse.as_slice(), + ), ( keymap.vim_normal.delete_char.as_ref(), vim_normal.delete_char.as_slice(), @@ -630,6 +672,36 @@ impl RuntimeKeymap { .substitute_line .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); } + if keymap.vim_normal.find_forward.is_none() { + vim_normal + .find_forward + .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); + } + if keymap.vim_normal.find_backward.is_none() { + vim_normal + .find_backward + .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); + } + if keymap.vim_normal.till_forward.is_none() { + vim_normal + .till_forward + .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); + } + if keymap.vim_normal.till_backward.is_none() { + vim_normal + .till_backward + .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); + } + if keymap.vim_normal.repeat_find.is_none() { + vim_normal + .repeat_find + .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); + } + if keymap.vim_normal.repeat_find_reverse.is_none() { + vim_normal + .repeat_find_reverse + .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), @@ -654,6 +726,17 @@ 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), + find_forward: resolve_local!(keymap, defaults, vim_operator, find_forward), + find_backward: resolve_local!(keymap, defaults, vim_operator, find_backward), + till_forward: resolve_local!(keymap, defaults, vim_operator, till_forward), + till_backward: resolve_local!(keymap, defaults, vim_operator, till_backward), + repeat_find: resolve_local!(keymap, defaults, vim_operator, repeat_find), + repeat_find_reverse: resolve_local!( + keymap, + defaults, + vim_operator, + repeat_find_reverse + ), select_inner_text_object: resolve_local!( keymap, defaults, @@ -718,6 +801,30 @@ impl RuntimeKeymap { keymap.vim_operator.motion_line_end.as_ref(), vim_operator.motion_line_end.as_slice(), ), + ( + keymap.vim_operator.find_forward.as_ref(), + vim_operator.find_forward.as_slice(), + ), + ( + keymap.vim_operator.find_backward.as_ref(), + vim_operator.find_backward.as_slice(), + ), + ( + keymap.vim_operator.till_forward.as_ref(), + vim_operator.till_forward.as_slice(), + ), + ( + keymap.vim_operator.till_backward.as_ref(), + vim_operator.till_backward.as_slice(), + ), + ( + keymap.vim_operator.repeat_find.as_ref(), + vim_operator.repeat_find.as_slice(), + ), + ( + keymap.vim_operator.repeat_find_reverse.as_ref(), + vim_operator.repeat_find_reverse.as_slice(), + ), ( keymap.vim_operator.cancel.as_ref(), vim_operator.cancel.as_slice(), @@ -739,6 +846,36 @@ impl RuntimeKeymap { .change_line .retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding)); } + if keymap.vim_operator.find_forward.is_none() { + vim_operator + .find_forward + .retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding)); + } + if keymap.vim_operator.find_backward.is_none() { + vim_operator + .find_backward + .retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding)); + } + if keymap.vim_operator.till_forward.is_none() { + vim_operator + .till_forward + .retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding)); + } + if keymap.vim_operator.till_backward.is_none() { + vim_operator + .till_backward + .retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding)); + } + if keymap.vim_operator.repeat_find.is_none() { + vim_operator + .repeat_find + .retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding)); + } + if keymap.vim_operator.repeat_find_reverse.is_none() { + vim_operator + .repeat_find_reverse + .retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding)); + } let vim_text_object = VimTextObjectKeymap { word: resolve_local!(keymap, defaults, vim_text_object, word), @@ -1018,6 +1155,18 @@ impl RuntimeKeymap { plain(KeyCode::Char('$')), shift(KeyCode::Char('$')) ], + find_forward: default_bindings![plain(KeyCode::Char('f'))], + find_backward: default_bindings![ + shift(KeyCode::Char('f')), + plain(KeyCode::Char('F')) + ], + till_forward: default_bindings![plain(KeyCode::Char('t'))], + till_backward: default_bindings![ + shift(KeyCode::Char('t')), + plain(KeyCode::Char('T')) + ], + repeat_find: default_bindings![plain(KeyCode::Char(';'))], + repeat_find_reverse: default_bindings![plain(KeyCode::Char(','))], delete_char: default_bindings![plain(KeyCode::Char('x'))], delete_to_line_end: default_bindings![ shift(KeyCode::Char('d')), @@ -1055,6 +1204,18 @@ impl RuntimeKeymap { plain(KeyCode::Char('$')), shift(KeyCode::Char('$')) ], + find_forward: default_bindings![plain(KeyCode::Char('f'))], + find_backward: default_bindings![ + shift(KeyCode::Char('f')), + plain(KeyCode::Char('F')) + ], + till_forward: default_bindings![plain(KeyCode::Char('t'))], + till_backward: default_bindings![ + shift(KeyCode::Char('t')), + plain(KeyCode::Char('T')) + ], + repeat_find: default_bindings![plain(KeyCode::Char(';'))], + repeat_find_reverse: default_bindings![plain(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)], @@ -1448,6 +1609,15 @@ impl RuntimeKeymap { self.vim_normal.move_line_start.as_slice(), ), ("move_line_end", self.vim_normal.move_line_end.as_slice()), + ("find_forward", self.vim_normal.find_forward.as_slice()), + ("find_backward", self.vim_normal.find_backward.as_slice()), + ("till_forward", self.vim_normal.till_forward.as_slice()), + ("till_backward", self.vim_normal.till_backward.as_slice()), + ("repeat_find", self.vim_normal.repeat_find.as_slice()), + ( + "repeat_find_reverse", + self.vim_normal.repeat_find_reverse.as_slice(), + ), ("delete_char", self.vim_normal.delete_char.as_slice()), ( "delete_to_line_end", @@ -1516,6 +1686,15 @@ impl RuntimeKeymap { "motion_line_end", self.vim_operator.motion_line_end.as_slice(), ), + ("find_forward", self.vim_operator.find_forward.as_slice()), + ("find_backward", self.vim_operator.find_backward.as_slice()), + ("till_forward", self.vim_operator.till_forward.as_slice()), + ("till_backward", self.vim_operator.till_backward.as_slice()), + ("repeat_find", self.vim_operator.repeat_find.as_slice()), + ( + "repeat_find_reverse", + self.vim_operator.repeat_find_reverse.as_slice(), + ), ( "select_inner_text_object", self.vim_operator.select_inner_text_object.as_slice(), @@ -2337,6 +2516,18 @@ mod tests { assert_eq!(runtime.vim_operator.change_line, Vec::new()); } + #[test] + fn configured_legacy_vim_bindings_prune_new_find_defaults() { + let mut keymap = TuiKeymap::default(); + keymap.vim_normal.move_left = Some(one("f")); + keymap.vim_operator.motion_left = Some(one("t")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!(runtime.vim_normal.find_forward, Vec::new()); + assert_eq!(runtime.vim_operator.till_forward, 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 2b7526b8aa0a..187dfda07b40 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -133,6 +133,12 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("vim_normal", "Vim normal", "move_word_end", "Move to the end of the current or next word."), action("vim_normal", "Vim normal", "move_line_start", "Move to the start of the line."), action("vim_normal", "Vim normal", "move_line_end", "Move to the end of the line."), + action("vim_normal", "Vim normal", "find_forward", "Find the next target character on the current line."), + action("vim_normal", "Vim normal", "find_backward", "Find the previous target character on the current line."), + action("vim_normal", "Vim normal", "till_forward", "Move until before the next target character on the current line."), + action("vim_normal", "Vim normal", "till_backward", "Move until after the previous target character on the current line."), + action("vim_normal", "Vim normal", "repeat_find", "Repeat the latest find or till motion."), + action("vim_normal", "Vim normal", "repeat_find_reverse", "Repeat the latest find or till motion in reverse."), action("vim_normal", "Vim normal", "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."), @@ -156,6 +162,12 @@ 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", "find_forward", "Operator motion to the next target character."), + action("vim_operator", "Vim operator", "find_backward", "Operator motion to the previous target character."), + action("vim_operator", "Vim operator", "till_forward", "Operator motion until before the next target character."), + action("vim_operator", "Vim operator", "till_backward", "Operator motion until after the previous target character."), + action("vim_operator", "Vim operator", "repeat_find", "Repeat the latest find or till motion for this operator."), + action("vim_operator", "Vim operator", "repeat_find_reverse", "Repeat the latest find or till motion in reverse for this operator."), 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."), @@ -277,6 +289,12 @@ pub(super) fn binding_slot<'a>( ("vim_normal", "move_word_end") => Some(&mut keymap.vim_normal.move_word_end), ("vim_normal", "move_line_start") => Some(&mut keymap.vim_normal.move_line_start), ("vim_normal", "move_line_end") => Some(&mut keymap.vim_normal.move_line_end), + ("vim_normal", "find_forward") => Some(&mut keymap.vim_normal.find_forward), + ("vim_normal", "find_backward") => Some(&mut keymap.vim_normal.find_backward), + ("vim_normal", "till_forward") => Some(&mut keymap.vim_normal.till_forward), + ("vim_normal", "till_backward") => Some(&mut keymap.vim_normal.till_backward), + ("vim_normal", "repeat_find") => Some(&mut keymap.vim_normal.repeat_find), + ("vim_normal", "repeat_find_reverse") => Some(&mut keymap.vim_normal.repeat_find_reverse), ("vim_normal", "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), @@ -300,6 +318,12 @@ 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", "find_forward") => Some(&mut keymap.vim_operator.find_forward), + ("vim_operator", "find_backward") => Some(&mut keymap.vim_operator.find_backward), + ("vim_operator", "till_forward") => Some(&mut keymap.vim_operator.till_forward), + ("vim_operator", "till_backward") => Some(&mut keymap.vim_operator.till_backward), + ("vim_operator", "repeat_find") => Some(&mut keymap.vim_operator.repeat_find), + ("vim_operator", "repeat_find_reverse") => Some(&mut keymap.vim_operator.repeat_find_reverse), ("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), @@ -403,6 +427,12 @@ pub(super) fn bindings_for_action<'a>( ("vim_normal", "move_word_end") => Some(runtime_keymap.vim_normal.move_word_end.as_slice()), ("vim_normal", "move_line_start") => Some(runtime_keymap.vim_normal.move_line_start.as_slice()), ("vim_normal", "move_line_end") => Some(runtime_keymap.vim_normal.move_line_end.as_slice()), + ("vim_normal", "find_forward") => Some(runtime_keymap.vim_normal.find_forward.as_slice()), + ("vim_normal", "find_backward") => Some(runtime_keymap.vim_normal.find_backward.as_slice()), + ("vim_normal", "till_forward") => Some(runtime_keymap.vim_normal.till_forward.as_slice()), + ("vim_normal", "till_backward") => Some(runtime_keymap.vim_normal.till_backward.as_slice()), + ("vim_normal", "repeat_find") => Some(runtime_keymap.vim_normal.repeat_find.as_slice()), + ("vim_normal", "repeat_find_reverse") => Some(runtime_keymap.vim_normal.repeat_find_reverse.as_slice()), ("vim_normal", "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()), @@ -426,6 +456,12 @@ 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", "find_forward") => Some(runtime_keymap.vim_operator.find_forward.as_slice()), + ("vim_operator", "find_backward") => Some(runtime_keymap.vim_operator.find_backward.as_slice()), + ("vim_operator", "till_forward") => Some(runtime_keymap.vim_operator.till_forward.as_slice()), + ("vim_operator", "till_backward") => Some(runtime_keymap.vim_operator.till_backward.as_slice()), + ("vim_operator", "repeat_find") => Some(runtime_keymap.vim_operator.repeat_find.as_slice()), + ("vim_operator", "repeat_find_reverse") => Some(runtime_keymap.vim_operator.repeat_find_reverse.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()), 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 e4d29158ce10..60b47f6695b3 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. - 109 actions, 1 customized, 2 unbound. + 121 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 3448442e7704..e93b6ebb461d 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. - 110 actions, 0 customized, 3 unbound. + 122 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 f2bd94957b3e..5ee7af408dff 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 (109 selectable) +tab: All (121 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 (50 selectable) +tab: Vim (62 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 082b7970cb0f..33738302d352 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. - 109 actions, 0 customized, 2 unbound. + 121 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 71e728810479..5eead02e5160 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. - 109 actions, 0 customized, 2 unbound. + 121 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug