From d47e5cc0e2bbd35774dff15c83570519735d2ac4 Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Wed, 26 Aug 2026 19:02:56 +0000 Subject: [PATCH] Add Vim buffer jump motions (#40958) ## What changed - Add `gg` and `G` motions to jump to the first and last buffer lines. - Support the motions with delete, change, and yank operators, including dot-repeat for edits. - Expose normal-mode and operator-motion bindings in the configurable keymap while preserving conflicting user bindings. ## Testing - Cover buffer jumps, operator combinations, repeat behavior, chord routing, and custom remapping. GitOrigin-RevId: 24a9178cf7964053aaf9a8cd268c5434dda0778e --- codex-rs/config/src/tui_keymap.rs | 8 ++ codex-rs/core/config.schema.json | 40 ++++++++++ codex-rs/tui/src/app/tests/key_chords.rs | 32 ++++++++ .../src/bottom_pane/textarea/vim_commands.rs | 56 +++++++++++++ .../textarea/vim_commands_tests.rs | 64 ++++++++++++++- codex-rs/tui/src/keymap.rs | 72 ++++++++++++++++- codex-rs/tui/src/keymap/bindings.rs | 4 + codex-rs/tui/src/keymap/chords.rs | 48 +++++++++++- codex-rs/tui/src/keymap/chords_tests.rs | 78 +++++++++++++++++++ codex-rs/tui/src/keymap_setup.rs | 22 +++++- codex-rs/tui/src/keymap_setup/actions.rs | 8 ++ ...ap_setup__tests__keymap_picker_custom.snap | 2 +- ...ests__keymap_picker_fast_mode_enabled.snap | 2 +- ...p__tests__keymap_picker_first_actions.snap | 4 +- ...up__tests__keymap_picker_global_chord.snap | 2 +- ...ap_setup__tests__keymap_picker_narrow.snap | 2 +- ...ymap_setup__tests__keymap_picker_wide.snap | 2 +- 17 files changed, 433 insertions(+), 13 deletions(-) diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 8e6f430b32..bc7301e659 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -243,6 +243,10 @@ pub struct TuiVimNormalKeymap { pub till_forward: Option, /// Stop after the previous character on the current line (`T`). pub till_backward: Option, + /// Begin a jump to the first buffer line (`gg`). + pub jump_top: Option, + /// Jump to the last buffer line (`G`). + pub jump_bottom: Option, /// Delete character under cursor (`x`). pub delete_char: Option, /// Replace the character under the cursor (`r`). @@ -308,6 +312,10 @@ pub struct TuiVimOperatorKeymap { pub motion_till_forward: Option, /// Motion: stop after the previous character on the current line (`T`). pub motion_till_backward: Option, + /// Motion: begin a jump to the first buffer line (`gg`). + pub motion_jump_top: Option, + /// Motion: jump to the last buffer line (`G`). + pub motion_jump_bottom: 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 c579921ff6..eca98d5cb1 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -3818,6 +3818,8 @@ "find_backward": null, "find_forward": null, "insert_line_start": null, + "jump_bottom": null, + "jump_top": null, "move_down": null, "move_left": null, "move_line_end": null, @@ -3846,6 +3848,8 @@ "motion_down": null, "motion_find_backward": null, "motion_find_forward": null, + "motion_jump_bottom": null, + "motion_jump_top": null, "motion_left": null, "motion_line_end": null, "motion_line_start": null, @@ -4634,6 +4638,8 @@ "find_backward": null, "find_forward": null, "insert_line_start": null, + "jump_bottom": null, + "jump_top": null, "move_down": null, "move_left": null, "move_line_end": null, @@ -4669,6 +4675,8 @@ "motion_down": null, "motion_find_backward": null, "motion_find_forward": null, + "motion_jump_bottom": null, + "motion_jump_top": null, "motion_left": null, "motion_line_end": null, "motion_line_start": null, @@ -4981,6 +4989,22 @@ ], "description": "Enter insert mode at first non-blank of line (`I`)." }, + "jump_bottom": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Jump to the last buffer line (`G`)." + }, + "jump_top": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Begin a jump to the first buffer line (`gg`)." + }, "move_down": { "allOf": [ { @@ -5196,6 +5220,22 @@ ], "description": "Motion: find the next character on the current line (`f`)." }, + "motion_jump_bottom": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Motion: jump to the last buffer line (`G`)." + }, + "motion_jump_top": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Motion: begin a jump to the first buffer line (`gg`)." + }, "motion_left": { "allOf": [ { diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs index e5fcba5838..62210a3476 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -60,6 +60,38 @@ fn ctrl(ch: char) -> KeyEvent { KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL) } +#[tokio::test] +async fn vim_buffer_jumps_route_default_chords_in_normal_and_operator_contexts() -> Result<()> { + for (input, command, expected) in [ + ("one\ntwo\nthree", "gg", "!one\ntwo\nthree"), + ("one\ntwo\nthree", "dgg", "!"), + ("ag bg", "0fg", "a!g bg"), + ("ag bg", "0dfg", "! bg"), + ] { + let (mut app, mut tui, mut app_server) = chord_app().await?; + app.chat_widget.toggle_vim_mode_and_notify(); + app.chat_widget.insert_str(input); + + for (index, key) in command.chars().enumerate() { + press( + &mut app, + &mut tui, + &mut app_server, + KeyCode::Char(key).into(), + ) + .await?; + if key == 'g' && index + 1 < command.len() { + assert!(app.key_chord_matcher.is_pending()); + } + } + + assert!(!app.key_chord_matcher.is_pending()); + app.chat_widget.insert_str("!"); + assert_eq!(app.chat_widget.composer_text_with_pending(), expected); + } + Ok(()) +} + #[tokio::test] async fn completed_global_chord_reuses_the_existing_action_handler() -> Result<()> { let (mut app, mut tui, mut app_server) = chord_app().await?; diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs b/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs index 284933e096..757175849c 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs @@ -52,6 +52,9 @@ pub(super) enum VimEditTarget { motion: VimFindMotion, target: char, }, + BufferJump { + last: bool, + }, } #[derive(Clone, Copy, Debug)] @@ -262,6 +265,9 @@ impl TextArea { return false; } } + VimEditTarget::BufferJump { last } => { + self.jump_to_vim_buffer_line(last, Some(operator)); + } } if operator == VimOperator::Change { return self.vim_mode == VimMode::Insert; @@ -330,6 +336,10 @@ impl TextArea { self.start_vim_find(VimFindMotion::TillForward, /*operator*/ None); } else if self.vim_normal_keymap.till_backward.is_pressed(event) { self.start_vim_find(VimFindMotion::TillBackward, /*operator*/ None); + } else if self.vim_normal_keymap.jump_top.is_pressed(event) { + self.jump_to_vim_buffer_line(/*last*/ false, /*operator*/ None); + } else if self.vim_normal_keymap.jump_bottom.is_pressed(event) { + self.jump_to_vim_buffer_line(/*last*/ true, /*operator*/ None); } else { return false; } @@ -365,6 +375,25 @@ impl TextArea { .is_pressed(event) { self.start_vim_find(VimFindMotion::TillBackward, Some(operator)); + } else if self.vim_operator_keymap.motion_jump_top.is_pressed(event) + || self + .vim_operator_keymap + .motion_jump_bottom + .is_pressed(event) + { + let last = self + .vim_operator_keymap + .motion_jump_bottom + .is_pressed(event); + match operator { + VimOperator::Delete => { + self.start_vim_edit(VimAction::Delete(VimEditTarget::BufferJump { last })); + } + VimOperator::Change => { + self.start_vim_edit(VimAction::Change(VimEditTarget::BufferJump { last })); + } + VimOperator::Yank => self.jump_to_vim_buffer_line(last, Some(operator)), + } } else { return false; } @@ -475,6 +504,33 @@ impl TextArea { true } + fn jump_to_vim_buffer_line(&mut self, last: bool, operator: Option) { + if let Some(operator) = operator { + let current = self.current_line_range_with_newline(); + let range = if last { + current.start..self.text.len() + } else { + 0..current.end + }; + match operator { + VimOperator::Delete => self.kill_line_range(range), + VimOperator::Yank => self.yank_line_range(range), + VimOperator::Change => { + self.kill_line_range(range); + self.vim_mode = VimMode::Insert; + } + } + return; + } + let start = if last { + self.beginning_of_line(self.text.len()) + } else { + 0 + }; + self.set_cursor(start); + self.set_cursor(self.first_non_blank_of_current_line()); + } + fn is_vim_command_target(&self, position: usize) -> bool { !self .elements diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim_commands_tests.rs b/codex-rs/tui/src/bottom_pane/textarea/vim_commands_tests.rs index 868a0127df..0f2f868bbb 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/vim_commands_tests.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/vim_commands_tests.rs @@ -1,9 +1,14 @@ use super::super::TextArea; use super::VimAction; +use crate::keymap::KeyChordMatch; +use crate::keymap::KeyChordMatcher; +use crate::keymap::KeymapContextSet; +use crate::keymap::RuntimeKeymap; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; +use tokio::time::Instant; fn vim_textarea(text: &str, cursor: usize) -> TextArea { let mut textarea = TextArea::new(); @@ -14,13 +19,25 @@ fn vim_textarea(text: &str, cursor: usize) -> TextArea { } fn keys(textarea: &mut TextArea, keys: &str) { + let keymap = RuntimeKeymap::defaults(); + let mut matcher = KeyChordMatcher::default(); for key in keys.chars() { let code = if key == '\n' { KeyCode::Enter } else { KeyCode::Char(key) }; - textarea.input(KeyEvent::new(code, KeyModifiers::NONE)); + let event = KeyEvent::new(code, KeyModifiers::NONE); + match matcher.advance( + event, + &keymap.chords, + KeymapContextSet::new(textarea.keymap_context()), + Instant::now(), + ) { + KeyChordMatch::PassThrough => textarea.input(event), + KeyChordMatch::Completed(event) => textarea.input(event), + KeyChordMatch::Pending(_) | KeyChordMatch::Cancelled | KeyChordMatch::Ignored => {} + } } } @@ -383,7 +400,7 @@ fn find_and_till_handle_missing_cancelled_and_adjacent_targets() { } #[test] -fn dot_repeat_replays_character_find_operators() { +fn dot_repeat_replays_character_find_and_buffer_jump_operators() { let mut textarea = vim_textarea("one:two:three", /*cursor*/ 0); keys(&mut textarea, "df:."); assert_eq!(textarea.text(), "three"); @@ -403,6 +420,10 @@ fn dot_repeat_replays_character_find_operators() { (expected, Some("Normal")) ); } + + let mut textarea = vim_textarea("one\ntwo\nthree\nfour\nfive", "one\n".len()); + keys(&mut textarea, "dggj."); + assert_eq!(textarea.text(), "five"); } #[test] @@ -470,6 +491,9 @@ fn character_find_and_operator_motion_use_configured_bindings() { #[test] fn uppercase_commands_accept_shift_only_terminal_events() { let mut textarea = vim_textarea("alpha\nbeta", /*cursor*/ 0); + textarea.input(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::SHIFT)); + assert_eq!(textarea.cursor(), "alpha\n".len()); + textarea.set_cursor("alpha\nbe".len()); textarea.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::SHIFT)); keys(&mut textarea, "b"); @@ -480,6 +504,35 @@ fn uppercase_commands_accept_shift_only_terminal_events() { assert_eq!(textarea.cursor(), "alpha\nb".len()); } +#[test] +fn buffer_jumps_target_first_non_blank_and_support_operators() { + let mut textarea = vim_textarea(" first\n second\n third", /*cursor*/ 2); + keys(&mut textarea, "G"); + assert_eq!(textarea.cursor(), " first\n second\n ".len()); + keys(&mut textarea, "gg"); + assert_eq!(textarea.cursor(), 2); + + let mut textarea = vim_textarea("first\nsecond\nthird", "first\n".len()); + keys(&mut textarea, "dG"); + assert_eq!(textarea.text(), "first\n"); + + let mut textarea = vim_textarea("first\nsecond\nthird", "first\n".len()); + keys(&mut textarea, "dgg"); + assert_eq!(textarea.text(), "third"); + + let mut textarea = vim_textarea("one\ntwo\nthree\nfour", "one\n".len()); + keys(&mut textarea, "dggp"); + assert_eq!(textarea.text(), "three\none\ntwo\nfour"); + + let mut textarea = vim_textarea("one\ntwo\nthree", "one\n".len()); + keys(&mut textarea, "yGp"); + assert_eq!(textarea.text(), "one\ntwo\ntwo\nthree\nthree"); + + let mut textarea = vim_textarea("one\ntwo\nthree", "one\n".len()); + keys(&mut textarea, "cG"); + assert_eq!(textarea.vim_mode_label(), Some("Insert")); +} + #[test] fn pending_replacement_owns_escape_before_turn_interruption() { let mut textarea = vim_textarea("alpha", /*cursor*/ 0); @@ -539,7 +592,7 @@ fn dot_repeat_has_visual_snapshot_coverage() { fn find_and_navigation_have_visual_snapshot_coverage() { let mut textarea = vim_textarea("alpha beta\ngamma delta", /*cursor*/ 0); let mut states = Vec::new(); - for command in ["tb", "fb", "Ta", "Fa"] { + for command in ["tb", "fb", "Ta", "Fa", "G", "gg"] { keys(&mut textarea, command); states.push(format!( "{command}: {}\n{}^", @@ -560,5 +613,10 @@ fn find_and_navigation_have_visual_snapshot_coverage() { Fa: alpha beta\ngamma delta ^ + G: alpha beta\ngamma delta + ^ + + gg: alpha beta\ngamma delta + ^ "###); } diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 25da976a15..1e4438b649 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -190,6 +190,8 @@ pub(crate) struct VimNormalKeymap { pub(crate) find_backward: Vec, pub(crate) till_forward: Vec, pub(crate) till_backward: Vec, + pub(crate) jump_top: Vec, + pub(crate) jump_bottom: Vec, pub(crate) delete_char: Vec, pub(crate) replace_char: Vec, pub(crate) repeat_last_change: Vec, @@ -227,6 +229,8 @@ pub(crate) struct VimOperatorKeymap { pub(crate) motion_find_backward: Vec, pub(crate) motion_till_forward: Vec, pub(crate) motion_till_backward: Vec, + pub(crate) motion_jump_top: Vec, + pub(crate) motion_jump_bottom: Vec, pub(crate) select_inner_text_object: Vec, pub(crate) select_around_text_object: Vec, pub(crate) cancel: Vec, @@ -558,7 +562,15 @@ impl RuntimeKeymap { /// parsing `TuiKeymap`, because doing so would ignore explicit user /// unbindings and conflict diagnostics. pub(crate) fn defaults() -> Self { - Self::built_in_defaults() + static DEFAULTS: std::sync::OnceLock = std::sync::OnceLock::new(); + + DEFAULTS + .get_or_init(|| { + Self::from_config(&TuiKeymap::default()).unwrap_or_else(|error| { + panic!("built-in keymap defaults must be valid: {error}") + }) + }) + .clone() } /// Resolve a runtime keymap from config, applying precedence and validation. @@ -728,6 +740,8 @@ impl RuntimeKeymap { 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), + jump_top: resolve_local!(keymap, defaults, vim_normal, jump_top), + jump_bottom: resolve_local!(keymap, defaults, vim_normal, jump_bottom), delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char), replace_char: resolve_local!(keymap, defaults, vim_normal, replace_char), repeat_last_change: resolve_local!(keymap, defaults, vim_normal, repeat_last_change), @@ -829,6 +843,14 @@ impl RuntimeKeymap { keymap.vim_normal.till_backward.as_ref(), vim_normal.till_backward.as_slice(), ), + ( + keymap.vim_normal.jump_top.as_ref(), + vim_normal.jump_top.as_slice(), + ), + ( + keymap.vim_normal.jump_bottom.as_ref(), + vim_normal.jump_bottom.as_slice(), + ), ( keymap.vim_normal.delete_char.as_ref(), vim_normal.delete_char.as_slice(), @@ -924,6 +946,14 @@ impl RuntimeKeymap { keymap.vim_normal.till_backward.as_ref(), &mut vim_normal.till_backward, ), + ( + keymap.vim_normal.jump_top.as_ref(), + &mut vim_normal.jump_top, + ), + ( + keymap.vim_normal.jump_bottom.as_ref(), + &mut vim_normal.jump_bottom, + ), ] { if configured.is_none() { bindings.retain(|binding| { @@ -982,6 +1012,8 @@ impl RuntimeKeymap { vim_operator, motion_till_backward ), + motion_jump_top: resolve_local!(keymap, defaults, vim_operator, motion_jump_top), + motion_jump_bottom: resolve_local!(keymap, defaults, vim_operator, motion_jump_bottom), select_inner_text_object: resolve_local!( keymap, defaults, @@ -1058,6 +1090,14 @@ impl RuntimeKeymap { keymap.vim_operator.motion_till_backward.as_ref(), vim_operator.motion_till_backward.as_slice(), ), + ( + keymap.vim_operator.motion_jump_top.as_ref(), + vim_operator.motion_jump_top.as_slice(), + ), + ( + keymap.vim_operator.motion_jump_bottom.as_ref(), + vim_operator.motion_jump_bottom.as_slice(), + ), ( keymap.vim_operator.cancel.as_ref(), vim_operator.cancel.as_slice(), @@ -1091,6 +1131,14 @@ impl RuntimeKeymap { keymap.vim_operator.motion_till_backward.as_ref(), &mut vim_operator.motion_till_backward, ), + ( + keymap.vim_operator.motion_jump_top.as_ref(), + &mut vim_operator.motion_jump_top, + ), + ( + keymap.vim_operator.motion_jump_bottom.as_ref(), + &mut vim_operator.motion_jump_bottom, + ), ] { if configured.is_none() { bindings.retain(|binding| { @@ -1468,6 +1516,11 @@ impl RuntimeKeymap { find_backward: default_bindings![shift(KeyCode::Char('f'))], till_forward: default_bindings![plain(KeyCode::Char('t'))], till_backward: default_bindings![shift(KeyCode::Char('t'))], + jump_top: default_bindings![], + jump_bottom: default_bindings![ + shift(KeyCode::Char('g')), + plain(KeyCode::Char('G')) + ], delete_char: default_bindings![plain(KeyCode::Char('x'))], replace_char: default_bindings![plain(KeyCode::Char('r'))], repeat_last_change: default_bindings![plain(KeyCode::Char('.'))], @@ -1506,6 +1559,11 @@ impl RuntimeKeymap { motion_find_backward: default_bindings![shift(KeyCode::Char('f'))], motion_till_forward: default_bindings![plain(KeyCode::Char('t'))], motion_till_backward: default_bindings![shift(KeyCode::Char('t'))], + motion_jump_top: default_bindings![], + motion_jump_bottom: default_bindings![ + shift(KeyCode::Char('g')), + plain(KeyCode::Char('G')) + ], 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)], @@ -2011,6 +2069,8 @@ impl RuntimeKeymap { ("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()), + ("jump_top", self.vim_normal.jump_top.as_slice()), + ("jump_bottom", self.vim_normal.jump_bottom.as_slice()), ("delete_char", self.vim_normal.delete_char.as_slice()), ("replace_char", self.vim_normal.replace_char.as_slice()), ( @@ -2095,6 +2155,14 @@ impl RuntimeKeymap { "motion_till_backward", self.vim_operator.motion_till_backward.as_slice(), ), + ( + "motion_jump_top", + self.vim_operator.motion_jump_top.as_slice(), + ), + ( + "motion_jump_bottom", + self.vim_operator.motion_jump_bottom.as_slice(), + ), ( "select_inner_text_object", self.vim_operator.select_inner_text_object.as_slice(), @@ -3144,6 +3212,7 @@ mod tests { fn configured_legacy_vim_bindings_prune_new_navigation_defaults() { let mut keymap = TuiKeymap::default(); keymap.vim_normal.move_left = Some(one("f")); + keymap.vim_operator.motion_left = Some(one("g")); keymap.vim_normal.move_right = Some(one("t")); keymap.vim_normal.move_up = Some(one("shift-f")); keymap.vim_operator.motion_right = Some(one("shift-t")); @@ -3152,6 +3221,7 @@ mod tests { assert_eq!(runtime.vim_normal.find_forward, Vec::new()); assert_eq!(runtime.vim_normal.find_backward, Vec::new()); + assert_eq!(runtime.vim_operator.motion_jump_top, Vec::new()); assert_eq!(runtime.vim_normal.till_forward, Vec::new()); assert_eq!(runtime.vim_operator.motion_till_backward, Vec::new()); } diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index 91796d2e5d..3ea0aaff8a 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -289,6 +289,8 @@ define_runtime_action_bindings! { find_backward, till_forward, till_backward, + jump_top, + jump_bottom, delete_char, replace_char, repeat_last_change, @@ -318,6 +320,8 @@ define_runtime_action_bindings! { motion_find_backward, motion_till_forward, motion_till_backward, + motion_jump_top, + motion_jump_bottom, select_inner_text_object, select_around_text_object, cancel, diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index 61749ac6d2..a47464c3e6 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -131,6 +131,42 @@ or a two-stroke chord such as `ctrl-x ctrl-t`.", .configured_specs .push((action, configured_specs)); } + + let g = crate::key_hint::plain(KeyCode::Char('g')); + let jump_top = KeyChord { + prefix: g, + completion: g, + }; + for action in keymap_action_ids().filter(|action| { + matches!( + (action.context, action.action), + (KeymapContext::VimNormal, "jump_top") + | (KeymapContext::VimOperator, "motion_jump_top") + ) + }) { + if effective_configured_binding(keymap, action).is_some() + || keymap_chords.bindings.iter().any(|configured| { + action.context.overlaps(configured.action.context) + && configured.chord == jump_top + }) + || keymap_action_ids() + .filter(|configured| action.context.overlaps(configured.context)) + .filter_map(|configured| effective_configured_binding(keymap, configured)) + .flat_map(KeybindingsSpec::specs) + .any(|spec| { + parse_keybinding(spec.as_str()) + .is_some_and(|binding| binding.parts() == g.parts()) + }) + { + continue; + } + + keymap_chords.bindings.push(RuntimeChordBinding { + action, + chord: jump_top, + spec: "g g".to_string(), + }); + } Ok(keymap_chords) } @@ -162,7 +198,17 @@ or a two-stroke chord such as `ctrl-x ctrl-t`.", }; } - super::primary_binding(bindings).map(crate::key_hint::ShortcutHint::Single) + super::primary_binding(bindings) + .map(crate::key_hint::ShortcutHint::Single) + .or_else(|| { + self.bindings + .iter() + .find(|binding| binding.action == action) + .map(|binding| crate::key_hint::ShortcutHint::Chord { + prefix: binding.chord.prefix, + completion: binding.chord.completion, + }) + }) } } diff --git a/codex-rs/tui/src/keymap/chords_tests.rs b/codex-rs/tui/src/keymap/chords_tests.rs index dd0138696b..588e7ee7d6 100644 --- a/codex-rs/tui/src/keymap/chords_tests.rs +++ b/codex-rs/tui/src/keymap/chords_tests.rs @@ -91,6 +91,84 @@ fn resolves_chords_for_actions_in_different_contexts() { } } +#[test] +fn default_vim_jump_top_uses_contextual_gg_chords() { + let runtime = RuntimeKeymap::defaults(); + let g = key_event(key_hint::plain(KeyCode::Char('g'))); + + for (context, action, target) in [ + ( + KeymapContext::VimNormal, + "jump_top", + runtime.vim_normal.jump_top.as_slice(), + ), + ( + KeymapContext::VimOperator, + "motion_jump_top", + runtime.vim_operator.motion_jump_top.as_slice(), + ), + ] { + let action_id = keymap_action_id(context.config_name(), action).expect("known Vim action"); + assert!(runtime.chords.bindings.iter().any(|binding| { + binding.action == action_id + && binding.chord.prefix == key_hint::plain(KeyCode::Char('g')) + && binding.chord.completion == key_hint::plain(KeyCode::Char('g')) + })); + assert!(matches!( + runtime.primary_hint(context, action), + Some(crate::key_hint::ShortcutHint::Chord { .. }) + )); + + let mut matcher = KeyChordMatcher::default(); + let contexts = KeymapContextSet::new(context); + assert!(matches!( + matcher.advance(g, &runtime.chords, contexts, Instant::now()), + KeyChordMatch::Pending(_) + )); + let KeyChordMatch::Completed(event) = + matcher.advance(g, &runtime.chords, contexts, Instant::now()) + else { + panic!("gg must dispatch the active Vim jump action"); + }; + assert!(target.is_pressed(event)); + } +} + +#[test] +fn default_vim_jump_chords_yield_to_configured_singles_and_chords() { + let mut config = TuiKeymap::default(); + config.vim_normal.move_line_start = Some(binding("g g")); + config.vim_operator.motion_line_start = Some(binding("g")); + + let runtime = RuntimeKeymap::from_config(&config).expect("configured legacy bindings win"); + + assert!(runtime.vim_normal.jump_top.is_empty()); + assert!(runtime.vim_operator.motion_jump_top.is_empty()); + assert!( + runtime + .vim_operator + .motion_line_start + .is_pressed(key_event(key_hint::plain(KeyCode::Char('g')))) + ); +} + +#[test] +fn vim_jump_top_supports_single_and_custom_chord_remaps() { + let mut config = TuiKeymap::default(); + config.vim_normal.jump_top = Some(binding("home")); + config.vim_operator.motion_jump_top = Some(binding("ctrl-x g")); + + let runtime = RuntimeKeymap::from_config(&config).expect("custom Vim jumps are valid"); + + assert!(runtime.vim_normal.jump_top.is_pressed(KeyCode::Home.into())); + let action = keymap_action_id("vim_operator", "motion_jump_top").expect("known Vim action"); + assert!(runtime.chords.bindings.iter().any(|binding| { + binding.action == action + && binding.chord.prefix == key_hint::ctrl(KeyCode::Char('x')) + && binding.chord.completion == key_hint::plain(KeyCode::Char('g')) + })); +} + #[test] fn composer_chord_inherits_global_fallback() { let mut config = TuiKeymap::default(); diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index ed9ce919e9..57528f4a4c 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -564,7 +564,8 @@ pub(crate) fn active_binding_specs( context: &str, action: &str, ) -> Result, String> { - if let Some(action_id) = keymap_action_id(context, action) + let action_id = keymap_action_id(context, action); + if let Some(action_id) = action_id && let Some(specs) = runtime_keymap.chords.configured_specs(action_id) { return Ok(specs.to_vec()); @@ -573,6 +574,16 @@ pub(crate) fn active_binding_specs( let bindings = bindings_for_action(runtime_keymap, context, action).ok_or_else(|| { format!("Unknown keymap action `{context}.{action}`. Reopen /keymap and choose an action.") })?; + if let Some(action_id) = action_id + && let Some(crate::key_hint::ShortcutHint::Chord { prefix, completion }) = + runtime_keymap.chords.primary_hint(action_id, bindings) + { + return Ok(vec![format!( + "{} {}", + binding_to_config_key_spec(prefix)?, + binding_to_config_key_spec(completion)? + )]); + } bindings .iter() .map(|binding| binding_to_config_key_spec(*binding)) @@ -1037,6 +1048,15 @@ mod tests { #[test] fn picker_unbound_tab_lists_default_unbound_actions() { let runtime = RuntimeKeymap::defaults(); + for (context, action) in [ + ("vim_normal", "jump_top"), + ("vim_operator", "motion_jump_top"), + ] { + assert_eq!( + active_binding_specs(&runtime, context, action).expect("native Vim chord"), + ["g g"] + ); + } let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); let unbound_tab = selection_tab(¶ms, KEYMAP_UNBOUND_TAB_ID); diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index b4adbc3c52..e8fec1fc76 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -140,6 +140,8 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("vim_normal", "Vim normal", "find_backward", "Find the previous character on the current line."), action("vim_normal", "Vim normal", "till_forward", "Stop before the next character on the current line."), action("vim_normal", "Vim normal", "till_backward", "Stop after the previous character on the current line."), + action("vim_normal", "Vim normal", "jump_top", "Jump to the first buffer line."), + action("vim_normal", "Vim normal", "jump_bottom", "Jump to the last buffer line."), action("vim_normal", "Vim normal", "delete_char", "Delete the character under the cursor."), action("vim_normal", "Vim normal", "replace_char", "Replace the character under the cursor."), action("vim_normal", "Vim normal", "repeat_last_change", "Repeat the last complete edit."), @@ -167,6 +169,8 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("vim_operator", "Vim operator", "motion_find_backward", "Operator motion to the previous character on the current line."), action("vim_operator", "Vim operator", "motion_till_forward", "Stop before the next character on the current line."), action("vim_operator", "Vim operator", "motion_till_backward", "Stop after the previous character on the current line."), + action("vim_operator", "Vim operator", "motion_jump_top", "Operator motion to the first buffer line."), + action("vim_operator", "Vim operator", "motion_jump_bottom", "Operator motion to the last buffer line."), 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."), @@ -302,6 +306,8 @@ pub(super) fn binding_slot<'a>( ("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", "jump_top") => Some(&mut keymap.vim_normal.jump_top), + ("vim_normal", "jump_bottom") => Some(&mut keymap.vim_normal.jump_bottom), ("vim_normal", "delete_char") => Some(&mut keymap.vim_normal.delete_char), ("vim_normal", "replace_char") => Some(&mut keymap.vim_normal.replace_char), ("vim_normal", "repeat_last_change") => Some(&mut keymap.vim_normal.repeat_last_change), @@ -329,6 +335,8 @@ pub(super) fn binding_slot<'a>( ("vim_operator", "motion_find_backward") => Some(&mut keymap.vim_operator.motion_find_backward), ("vim_operator", "motion_till_forward") => Some(&mut keymap.vim_operator.motion_till_forward), ("vim_operator", "motion_till_backward") => Some(&mut keymap.vim_operator.motion_till_backward), + ("vim_operator", "motion_jump_top") => Some(&mut keymap.vim_operator.motion_jump_top), + ("vim_operator", "motion_jump_bottom") => Some(&mut keymap.vim_operator.motion_jump_bottom), ("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), 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 fbc49b6b84..5ed3c668fb 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. - 127 actions, 1 customized, 4 unbound. + 131 actions, 1 customized, 4 unbound. [All] Common Customized (1) Unbound (4) App Composer Editor Vim Navigation Agents 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 b04c78abd4..6689f447b1 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. - 128 actions, 0 customized, 5 unbound. + 132 actions, 0 customized, 5 unbound. [All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents 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 a63f05b418..e2ff90f225 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 (127 selectable) +tab: All (131 selectable) tab: Common (20 selectable) tab: Customized (0) (0 selectable) tab: Unbound (4) (4 selectable) tab: App (14 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) -tab: Vim (58 selectable) +tab: Vim (62 selectable) tab: Navigation (20 selectable) tab: Agents (5 selectable) tab: Approval (8 selectable) diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap index 9ef42945c9..4f822ff698 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 127 actions, 1 customized, 4 unbound. + 131 actions, 1 customized, 4 unbound. [All] Common Customized (1) Unbound (4) App Composer Editor Vim Navigation Agents Approval Debug 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 0f060d2baa..e8099d1e16 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. - 127 actions, 0 customized, 4 unbound. + 131 actions, 0 customized, 4 unbound. [All] Common Customized (0) Unbound (4) App Composer Editor Vim Navigation Agents 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 127df665a2..95a54489c4 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. - 127 actions, 0 customized, 4 unbound. + 131 actions, 0 customized, 4 unbound. [All] Common Customized (0) Unbound (4) App Composer Editor Vim Navigation Agents Approval Debug