Skip to content

Beam Jump - Lightning Fast Vim style navigation#45387

Open
xipeng-jin wants to merge 12 commits intozed-industries:mainfrom
xipeng-jin:feat/beam-jump
Open

Beam Jump - Lightning Fast Vim style navigation#45387
xipeng-jin wants to merge 12 commits intozed-industries:mainfrom
xipeng-jin:feat/beam-jump

Conversation

@xipeng-jin
Copy link
Contributor

@xipeng-jin xipeng-jin commented Dec 19, 2025

Closes #14801
Closes #4930

Summary

Beam Jump is a viewport-first, directionless “type-to-target” jump mode for Zed’s Vim mode that supports arbitrary-length patterns with low-noise visuals and no surprising navigation while typing.

How to use

  • Press s to enter Beam Jump (cursor stays put; mode is directionless).

  • Type a pattern (any length):

    • len == 1: show hollow outlines for eligible 1-char matches in the viewport (no labels, no jump).

    • len ≥ 2:

      • V > 1 (multiple viewport matches): show hollow outlines + 1 or 2 char labels; type a label to jump.
      • V == 1: show a single outline (no label); press Enter to commit the jump.
      • V == 0: press Enter to run a global forward find with wrap (the only global navigation trigger).
  • Cancel anytime with Esc / Backspace / Tab (consumed; no Vim action pass through).

Key invariant / guarantees

  • Viewport-only matching while typing; no implicit global navigation.
  • Cross-cursor matches are excluded; for len ≥ 2, viewport candidates are non-overlapping (canonical set used for labels + jumps).
  • Label keys are collision-safe (characters that could extend the pattern are excluded); ; / , are pattern characters while active and are never label keys.
  • Non-text keys cancel + consume (no accidental Vim command execution).
  • After a successful jump, the full pattern is persisted for Vim repeat (; / , outside Beam Jump) behavior.

Safety / UX

  • If pattern_len ≥ 1 and viewport has zero matches, an idle-cancel timer (1.5s) cleans up the session.
  • If viewport bounds can’t be computed at entry, treat viewport matches as always empty (no whole-buffer fallback); Enter at len ≥ 2 still performs the global forward find (wrap).

Release Notes

  • Added optional Beam Jump mode for Vim Sneak (s): arbitrary-length pattern input with viewport-first matching and minimal visual overlays.
  • Introduced labeled jumps (1–2 characters) for multiple viewport matches; labels are designed to avoid conflicts with continued typing.
  • Added explicit commit behavior: when a unique viewport match exists, press Enter to jump (no auto-jump while typing).
  • Added repeatable global navigation via Enter-triggered forward find with wrap when there are no viewport matches (pattern length ≥ 2).
  • Improved safety/cancellation: Esc/Backspace/Tab/non-text keys cancel and are consumed, preventing accidental Vim command passthrough during an active session.
  • Added idle auto-cancel (1.5s) when no viewport matches exist, preventing lingering “ghost” sessions.

@cla-bot cla-bot bot added the cla-signed The user has signed the Contributor License Agreement label Dec 19, 2025
@xipeng-jin xipeng-jin marked this pull request as draft December 19, 2025 18:17
@P1n3appl3 P1n3appl3 self-requested a review December 19, 2025 18:47
Copy link

@ecstatic-morse ecstatic-morse left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not part of Zed, just a user who is excited about this functionality finally landing. I tried this locally and it looks great!

I pretty much exclusively use easy motion's bidirectional mode, so it's a little disappointing to see that part missing. It doesn't work as nicely with ;, but it's faster than having to decide between forward and backward before starting a jump.

];

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum BeamJumpDirection {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You probably want to reuse workspace::searchable::Direction for this.

Comment on lines +9 to +13
const BASE_LABEL_CHARS: &[char] = &[
'f', 'j', 'd', 'k', 's', 'l', 'a', 'g', 'h', 'r', 'u', 'e', 'i', 'o', 'w', 'm', 'n', 'c', 'v',
'x', 'z', 'p', 'q', 'y', 't', 'b',
];

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to let the user configure this in case they don't use QWERTY.

Comment on lines +2826 to +2958
fn reverse_beam_jump_direction(direction: BeamJumpDirection) -> BeamJumpDirection {
match direction {
BeamJumpDirection::Forward => BeamJumpDirection::Backward,
BeamJumpDirection::Backward => BeamJumpDirection::Forward,
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already a method on workspace::searchable::Direction

Comment on lines 715 to 819
if VimSettings::get_global(cx).beam_jump {
vim.start_beam_jump(BeamJumpDirection::Forward, action.first_char, window, cx);
}
Copy link

@ecstatic-morse ecstatic-morse Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's kind of strange to reuse the PushSneak actions here. It means the user can't one mapping for sneak and another for beam jump. Is there a reason you can't introduce new actions just for beam jump?

@HeeneFZ
Copy link

HeeneFZ commented Jan 9, 2026

Is anyone actively following this PR? This feature is great, and I'd really like to integrate it into Zed.

@RO03M
Copy link

RO03M commented Jan 9, 2026

maybe a noob question, is this feature going to be available outside of vim/helix mode? activating it with a shortcut when outside of vim/helix, like we do with goto line would be awesome

@xipeng-jin
Copy link
Contributor Author

@HeeneFZ , Yes, we are actively cooking this feature now. This feature will need more carefully review since we want it to be the most ergonomic and also be able to seamless integrated into Zed settings. Stay tuned!

@xipeng-jin
Copy link
Contributor Author

Hi @RO03M , thanks for asking. This is a good question. I did think the same way to make this feature outside of vim/helix mode and more globally. We can activate it with a shortcut, like control-s. This will definitely help all the users benefit from this feature to be able to navigate around the code faster and more efficient. But I don't think we will push this outside the vim mode for this initial PR for now. The reason is I want this PR to be minimal and focus on the core feature only, after we rolling out the initial version and then we can definitely add more on top of it. Thanks

@wuchunpeng777
Copy link

Is there a demo video for this feature? I've also created a similar feature and would like to see how well it's implemented and how complete it is. If it's well-done and has great functionality, it would be great if it could be merged into Zed. it's an essential requirement for Vimer users.

@xipeng-jin xipeng-jin force-pushed the feat/beam-jump branch 2 times, most recently from c42f50c to 2f10283 Compare January 30, 2026 01:26
- Remove direction state from Beam Jump session (`s` only, `S` becomes substitute-line)
- Match across entire viewport, not split by cursor
- Assign labels by cursor proximity (closest matches get easiest labels)
- Defer global search to `;`/`,` repeat keys
- Add tests for deferred global search and arbitrary-length pattern repeat
Patch Plan
- Stop “non-extending => PassThrough”; add viewport-miss auto-global
  jump + no-global-match pass-through.
- Rewrite test_beam_jump_passthrough_key; add two regression tests for
  auto-global + no-global-match.
  Code Changes
- Removed the !can_extend_pattern_with(...) => PassThrough behavior;
  non-label keys always extend the pattern.
- When extending an existing pattern (was_pattern_extension) and
  viewport matches drop to 0, immediately compute a global jump:
  - Forward match exists ⇒ jump forward (count=1).
  - No forward matches but global matches exist ⇒ jump to topmost match
    (implemented via backward jump with count = matches_before_cursor).
  - No global matches ⇒ clear_pattern() + PassThrough.
- Added clear_pattern, auto_global_jump, and helpers plus
  match_pattern_at to avoid rendering Beam Jump overlays during the
  transition (reuses the existing Jump→Motion::BeamJumpFind path).
  Tests
- Updated: (test_beam_jump_passthrough_key) now asserts viewport-miss
  triggers immediate global jump + highlights cleared + pattern persists
  for ;.
- Added: (test_beam_jump_auto_global_search_on_viewport_miss) covers the
  user repro (viewport has ab but no abc; buffer elsewhere has abc), and
  best-effort asserts the cursor is visible after the jump.
- Added:
  (test_beam_jump_auto_global_no_matches_passthrough_and_clears_pattern)
  asserts cancel+passthrough on zero global matches and that last_find
  is restored (so repeats don’t persist a non-existent pattern).
Patch Details
`beam_jump.rs`
- Add pending_commit + per-session session_id; change
  BeamJumpState::on_typed_char to enter pending-commit on V==1 (no
  immediate jump) while keeping existing V==0 auto-global behavior
  intact.
- Added `BEAM_JUMP_PENDING_COMMIT_TIMEOUT` (default
  Duration::from_millis(700)).
- Fixed “no global matches” on pattern extension to cancel + clear
  pattern + consume key (no Vim action runs)
- Removed the Beam Jump “pass-through keystroke” path entirely so vim.rs
  can’t dispatch the typed key on cancel
  `vim.rs`
- Render pending-commit as a single hollow highlight with label `;`,
  treat `;` as “commit unique” only when `pending_commit && V==1 &&
  pattern_len>=2`, otherwise keep `;`/`,` as global navigation.
- Schedule a 100ms timer via cx.background_executor().timer(...) that
  commits the pending jump if (session_id, pending_commit_id) still
  matches; reuse apply_beam_jump_jump for all jump paths to keep
  visuals/scrolling consistent.
  `test.rs`
- Add pending-commit tests + assert `;` is never a label in
  multi-candidate mode.
- Updated regression test to assert the last key is consumed (no cursor
  move) and ; repeat uses the prior last_find (pattern cleared)
Patch Plan
- Update viewport candidate generation in `scan_first_char` to drop
  matches where start < cursor < end.
- Update incremental refinement in `extend_matches` to drop any
  candidate whose extended end would cross the cursor.
  Tests Added
- test_beam_jump_excludes_cross_cursor_match_from_pending_commit ensures
  a would-be pending-commit target crossing the cursor is not
  rendered/labelled.
- test_beam_jump_extension_drops_cross_cursor_match ensures extension
  removes the cross-cursor candidate while keeping the valid one.
**What Changed**
- `Editor::set_beam_jump_highlights` now checks whether incoming
  highlights are already sorted by `range.start` and only calls
  `sort_by_key` when they aren’t.

**Call Path + Ordering Invariant**
- Beam Jump typing updates: `Vim::beam_jump_input` → build highlights
  from `state.matches` → `editor.set_beam_jump_highlights(...)`
  (`crates/vim/src/vim.rs:1697`).
- `BeamJumpState.matches` is already in increasing `start` order because
  it’s built by scanning left-to-right (`scan_first_char`,
  `crates/vim/src/beam_jump.rs:302`) and later filtered in-place without
  reordering (`extend_matches`, `crates/vim/src/beam_jump.rs:333`).
- Other callsites: `set_beam_jump_highlights` is only called from
  `crates/vim/src/vim.rs` in this tree.

**Why This Preserves Correctness**
- Sorted inputs (Beam Jump steady-state) preserve identical ordering; we
  just skip redundant work.
- Unsorted inputs remain safe: we still sort when the monotonicity check
  fails, preserving the sorted-by-start invariant required by
  `beam_jump_highlights_in_range`’s `partition_point` logic.

**Complexity (M = number of highlights)**
- Before: always `O(M log M)` per keystroke (unconditional sort).
- After: `O(M)` per keystroke for already-sorted input (cheap adjacent
  scan), with `O(M log M)` only when the input is actually unsorted.
**What Changed**
- Store Beam Jump highlights as `Arc<Vec<BeamJumpHighlight>>` in
  `Editor`, so the layout path can `clone()` the `Arc` (no allocation)
  and then slice it for the visible range.
- `layout_beam_jump_cursors` now range-filters and iterates over the
  borrowed slice from the cloned `Arc`, removing the per-frame
  `to_vec()` allocation.

**Why visuals are identical**
- The visible subset is computed with the same partition_point
  predicates as `Editor::beam_jump_highlights_in_range`, and iteration
  order is unchanged; cursor/label layout code is otherwise untouched.

**Allocations eliminated**
- Removes the per-layout `Vec` allocation + element clones from
  layout_beam_jump_cursors; now it’s `Arc clone + slice` iteration.
**Fix**
- Inserted the de-overlap step in `BeamJumpState::push_pattern_char`
  immediately after `extend_matches`, so the filtered `self.matches` is
  what drives highlights, label assignment, and `start -> (direction,
  count)` mapping (`crates/vim/src/beam_jump.rs:191`).
- Implemented `retain_non_overlapping_matches_in_start_order` as an
  in-place linear `retain` filter using `BeamJumpMatch.end` as the “real
  end” (`crates/vim/src/beam_jump.rs:364`); this is already the true
  match end because `extend_matches` maintains it as the pattern grows.

**Patch**
- call `self.retain_non_overlapping_matches_in_start_order()` after
  `extend_matches`.
- added `retain_non_overlapping_matches_in_start_order` (keeps `m` iff
  `m.start >= last_kept_end`, updates `last_kept_end = m.end`).
- added `test_beam_jump_filters_overlapping_viewport_matches`.

**Regression / Evidence**
- New test: `crates/vim/src/test.rs:1780` (`aaaaa`, pattern `aa`)
  asserts only non-overlapping viewport candidates exist (starts 1 and
  3), and selecting either label jumps correctly.
- Before: viewport candidates included overlapping starts (1,2,3), but
  the last labeled match could be unreachable because
  `beam_jump_find_forward` skips overlaps via `search_start = match_end`
  (`crates/vim/src/motion.rs:2982`).
- After: viewport candidates/labels match the reachable counted-search
  set; no “displayed but unreachable” labels.
matches

- **What was wrong:** With viewport candidates now de-overlapped,
  `BeamJumpState::direction_and_count_for_start`
  (`crates/vim/src/beam_jump.rs:539`) can produce a `(Backward, count)`
  that assumes non-overlapping indexing, but `beam_jump_find_backward`
  was still *counting overlapping matches* via `search_end =
  last_char_start` (`crates/vim/src/motion.rs:3087` in the pre-fix code
  path). This makes label jumps land on a different (hidden) overlapping
  match.
- **Repro test added:**
  `test_beam_jump_filters_overlapping_viewport_matches_backward`
  (`crates/vim/src/test.rs:1870`) with `"aaaaˇa"` + pattern `"aa"`.
  Before the fix, selecting the label for the first visible match
  incorrectly jumped to `aˇaaaa` instead of `ˇaaaaa`.
- **Fix:** In `beam_jump_find_backward`, when `search_range.is_some()`
  (viewport-scoped Beam Jump jumps), count matches using
  **non-overlapping enumeration** by scanning forward from `range_start`
  to `search_end`, advancing `offset = match_end`, and selecting the
  `times`th-from-last match (`crates/vim/src/motion.rs:3064`). The old
  overlapping-counting logic remains for `search_range == None`.
**What Changed**
- Store the pending-commit timer Task on Vim and drop/replace it on
  reschedule/clear so fast typing doesn’t accumulate detached timers
  (while keeping the existing id-guard correctness checks).
- Old behavior: `Vim::schedule_beam_jump_pending_commit_timer` spawned
  and `.detach()` a task per “continue”.
- New behavior: the task is stored in
  `Vim::beam_jump_pending_commit_task` and is cleared/replaced before
  scheduling, ensuring only one pending-commit timer task is live at a
  time.
- Correctness guards unchanged: `(session_id, pending_commit_id)` checks
  remain in `Vim::commit_beam_jump_pending_commit`.
- Remove pending-commit debounce and any auto-global navigation while typing
- Treat `;`/`,` as pattern characters during Beam Jump; `Enter` is the only navigation trigger
  - `Enter` cancels on short patterns / multiple viewport matches
  - commits unique viewport match or runs global forward find-with-wrap when V==0
- Make viewport capture strict: if visible rows are unavailable, use an empty viewport (sticky V==0)
- Add 1.5s idle-cancel timer for active sessions with `pattern_len >= 1 && V == 0` (coalesced task, guarded by session/id)
- Preserve RepeatFind semantics/state: persist pattern on successful jumps, restore prior repeat state on cancel/no-match
- Track overlapping candidates separately from canonical matches to avoid losing matches during extension
- Update/expand vim tests to cover the new key handling, label rules, wrap search, and timer behavior
- Route both interceptor/observer through one handler and harden the
  activation-key guard to prevent drift.
@xipeng-jin xipeng-jin marked this pull request as ready for review January 30, 2026 22:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed The user has signed the Contributor License Agreement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flash.nvim style search in a document. easymotion vim

5 participants