vim: Add Helix jump-to-word support to Vim mode (#55492)

Closes #55481

Adds Vim-mode access to the existing Helix jump-to-word overlay via `g
z`. We use `g z` because it is currently unassigned in Vim mode, while
`g w` is already used for rewrap.

Most of the implementation lives in `helix.rs` because the existing jump
overlay, label generation, and Helix/Vim modal behavior are currently
intertwined there. This keeps the change small and reuses the existing
navigation overlay logic instead of doing a broader refactor.

In Vim normal mode, jump labels behave like a cursor motion: selecting a
label moves the cursor to the start of the target word without selecting
it. In Vim visual mode, jump labels extend the selection like a Vim
word-start motion, preserving Vim’s inclusive visual-selection behavior.

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- Added Vim-mode jump-to-word navigation on `g z`.
This commit is contained in:
Mikhail Pertsev 2026-05-12 23:46:18 +02:00 committed by GitHub
parent 064a17fd12
commit 249f427f10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 131 additions and 7 deletions

View file

@ -983,10 +983,17 @@ impl Vim {
window: &mut Window,
cx: &mut Context<Self>,
) {
let behaviour = if self.mode.is_visual() {
HelixJumpBehaviour::Extend
} else {
HelixJumpBehaviour::Move
let behaviour = match self.mode {
// Vim normal mode treats jump-to-word as a cursor motion, while Helix
// normal mode treats the cursor as a single-character selection.
Mode::Normal => HelixJumpBehaviour::MoveToWordStart,
// Vim visual mode extends like a motion, so the cursor stops at the
// same word boundary as normal mode instead of selecting the word.
Mode::Visual | Mode::VisualLine | Mode::VisualBlock => {
HelixJumpBehaviour::ExtendToWordStart
}
Mode::HelixSelect => HelixJumpBehaviour::Extend,
_ => HelixJumpBehaviour::Move,
};
self.start_helix_jump(behaviour, window, cx);
}
@ -1709,7 +1716,7 @@ mod test {
use editor::{HighlightKey, MultiBufferOffset};
use gpui::{KeyBinding, UpdateGlobal, VisualTestContext};
use indoc::indoc;
use language::Point;
use language::{CursorShape, Point};
use project::FakeFs;
use search::{ProjectSearchView, project_search};
use serde_json::json;
@ -1718,7 +1725,7 @@ mod test {
use util::path;
use workspace::{DeploySearch, MultiWorkspace};
use super::HELIX_JUMP_LABEL_LIMIT;
use super::{HELIX_JUMP_LABEL_LIMIT, HelixJumpToWord};
use crate::{
HELIX_JUMP_OVERLAY_KEY, Vim, VimAddon,
state::{Mode, Operator},
@ -1772,7 +1779,11 @@ mod test {
}
fn jump_to_word(cx: &mut VimTestContext, target_word: &str) {
cx.simulate_keystrokes("g w");
jump_to_word_with_keystrokes(cx, "g w", target_word);
}
fn jump_to_word_with_keystrokes(cx: &mut VimTestContext, keystrokes: &str, target_word: &str) {
cx.simulate_keystrokes(keystrokes);
let label = helix_jump_label_for_word(cx, target_word);
@ -1782,6 +1793,16 @@ mod test {
cx.simulate_keystrokes(&format!("{first} {second}"));
}
fn bind_vim_jump_to_word(cx: &mut VimTestContext, keystrokes: &'static str) {
cx.update(|_, cx| {
cx.bind_keys([KeyBinding::new(
keystrokes,
HelixJumpToWord,
Some("vim_mode == normal || vim_mode == visual"),
)])
});
}
fn active_helix_jump_overlay_counts(cx: &mut VimTestContext) -> (usize, usize) {
let covered_text_range_count = cx.update_editor(|editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
@ -3468,6 +3489,70 @@ mod test {
assert_eq!(cx.active_operator(), None);
}
#[gpui::test]
async fn test_vim_jump_moves_to_target_word_start(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
bind_vim_jump_to_word(&mut cx, "g z");
cx.set_state("ˇone two three", Mode::Normal);
jump_to_word_with_keystrokes(&mut cx, "g z", "two");
cx.assert_state("one ˇtwo three", Mode::Normal);
assert_eq!(cx.active_operator(), None);
}
#[gpui::test]
async fn test_vim_jump_keeps_normal_cursor_shape(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
bind_vim_jump_to_word(&mut cx, "g z");
cx.update(|_, cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.vim.get_or_insert_default().cursor_shape =
Some(settings::CursorShapeSettings {
normal: Some(settings::CursorShape::Bar),
..Default::default()
});
});
});
});
cx.set_state("ˇone two three", Mode::Normal);
cx.simulate_keystrokes("g z");
assert!(
matches!(cx.active_operator(), Some(Operator::HelixJump { .. })),
"expected HelixJump operator to be active"
);
cx.update_editor(|editor, _, _| {
assert_eq!(editor.cursor_shape(), CursorShape::Bar);
});
}
#[gpui::test]
async fn test_vim_visual_jump_extends_selection(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
bind_vim_jump_to_word(&mut cx, "g z");
cx.set_state("one «twoˇ» three four", Mode::Visual);
jump_to_word_with_keystrokes(&mut cx, "g z", "three");
cx.assert_state("one «two tˇ»hree four", Mode::Visual);
assert_eq!(cx.active_operator(), None);
}
#[gpui::test]
async fn test_vim_visual_jump_extends_selection_backward(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
bind_vim_jump_to_word(&mut cx, "g z");
cx.set_state("one two «threeˇ» four", Mode::Visual);
jump_to_word_with_keystrokes(&mut cx, "g z", "one");
cx.assert_state("«ˇone two three» four", Mode::Visual);
assert_eq!(cx.active_operator(), None);
}
#[gpui::test]
async fn test_helix_jump_extends_selection_forward(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;

View file

@ -176,7 +176,9 @@ pub struct HelixJumpLabel {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HelixJumpBehaviour {
Move,
MoveToWordStart,
Extend,
ExtendToWordStart,
}
#[derive(Default, Clone, Debug)]

View file

@ -1362,6 +1362,10 @@ impl Vim {
Mode::Normal => {
if let Some(operator) = self.operator_stack.last() {
match operator {
// Vim jump labels are transient navigation, so keep the
// user's normal cursor shape while waiting for the label.
Operator::HelixJump { .. } => cursor_shape.normal,
// Navigation operators -> Block cursor
Operator::FindForward { .. }
| Operator::FindBackward { .. }
@ -1828,6 +1832,28 @@ impl Vim {
s.select_anchor_ranges([candidate.range.clone()])
});
}
HelixJumpBehaviour::MoveToWordStart => {
editor.change_selections(Default::default(), window, cx, |s| {
// Vim users expect jump labels to behave like motions, leaving
// normal mode at the label instead of selecting the word.
s.select_anchor_ranges([candidate.range.start..candidate.range.start])
});
}
HelixJumpBehaviour::ExtendToWordStart => {
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(&mut |map, selection| {
let word_start = candidate.range.start.to_display_point(map);
let tail = selection.tail();
if word_start >= tail {
selection
.set_head(motion::right(map, word_start, 1), SelectionGoal::None);
} else {
selection.set_head_tail(word_start, selection.end, SelectionGoal::None);
}
});
});
}
HelixJumpBehaviour::Extend => {
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(&mut |map, selection| {

View file

@ -540,6 +540,17 @@ The [Sneak motion](https://github.com/justinmk/vim-sneak) feature allows for qui
}
```
The Helix-style jump-to-word action shows jump labels at visible word starts. It has no default binding in Vim mode, but you can enable it by adding a keybinding to your keymap. This example uses `g w`, which matches the default Helix binding, but overrides Vim mode's default rewrap binding.
```json [keymap]
{
"context": "vim_mode == normal || vim_mode == visual",
"bindings": {
"g w": "vim::HelixJumpToWord"
}
}
```
The [vim-exchange](https://github.com/tommcdo/vim-exchange) feature does not have a default binding for visual mode, as the `shift-x` binding conflicts with the default `shift-x` binding for visual mode (`vim::VisualDeleteLine`). To assign the default vim-exchange binding, add the following keybinding to your keymap:
```json [keymap]