Skip to content

feat: Allow for user-defined "chord" / multi-key keybindings (and correct vi motions when targeting <space> char)#1016

Open
benvansleen wants to merge 8 commits into
nushell:mainfrom
benvansleen:feat/multiple-key-combo-support
Open

feat: Allow for user-defined "chord" / multi-key keybindings (and correct vi motions when targeting <space> char)#1016
benvansleen wants to merge 8 commits into
nushell:mainfrom
benvansleen:feat/multiple-key-combo-support

Conversation

@benvansleen

@benvansleen benvansleen commented Jan 28, 2026

Copy link
Copy Markdown

I care a great deal about nushell's vi mode support. I got myself addicted to "jj" to exit vi normal mode in basically all cli tools with a vi mode -- without it, I feel like I've lost my fingers!

A while back, I proposed #670. Since reedline does not support multi-key "chords," I special-cased logic into vi/mod.rs to listen for repeated keypresses in insert mode for a designated "exit-insert-mode" trigger.

The feedback led to a broader discussion. The main gist (as I understood it at the time) was that we should not special-case this logic; ideally, we would generalize this functionality to enable other kinds of user-defined key chords.

In the meantime, I've been maintaining a personal reedline fork with this special-case logic. It's fulfilled my needs, but is kind of a PITA. So: I thought why not take another stab?

Full disclosure: I've been trying to experiment with LSP-informed LLM code generation for languages with well-developed type systems (eg through something like rust LSP w/ opencode). The first draft of this PR was predominantly LLM generated, but I have reviewed the changes & am test-driving this as my daily-driver shell.

User-facing changes

  • Update ReedlineEvent:ViChangeMode handling to better mimic vi/vim behavior (eg when moving from insert -> normal modes, the cursor should move left 1 char)
  • Editors (both emacs and vi) now track a sequence state of keypresses
    • When a chord prefix is entered, the editor now holds this input awaiting the next key in the chord
    • If no key is pressed (after a configurable timeout) or the chord is not successfully completed, the buffered key combinations are flushed to the line
  • (Tangentially related bugfix) vi mode parsing is modified s.t. pending commands (eg. f, t, d) always consume the next character as a motion target -- even when it's a space
    • Right now, f<space> does not behave as expected in nushell; it basically inserts a space at b.o.l. and jacks up the undo buffer

What does this achieve for nushell?

in nu-cli/src/reedline_config.rs, you could:

    let mut insert_keybindings = default_vi_insert_keybindings();
    insert_keybindings.add_sequence_binding(
        vec![
            KeyCombination {
                modifier: KeyModifiers::NONE,
                key_code: KeyCode::Char('j'),
            },
            KeyCombination {
                modifier: KeyModifiers::NONE,
                key_code: KeyCode::Char('j'),
            },
        ],
        ReedlineEvent::ViChangeMode("normal".into()),
    );

Obviously, this would be better configured as part of the user's nushell startup script. If this PR is approved / we like this direction, I'll submit work on the nushell side to allow for configuring $env.config.keybindings accordingly.

How does it work?

                        +-------------------------+
                        |        Keybindings      |
                        |-------------------------|
                        | bindings (single-key)   |
                        | sequence_bindings       |
                        +-----------+-------------+
                                    |
                                    | sequence_match(&[KeyCombination])
                                    v
+-------------------------+   process_combo()   +----------------------+
|     KeySequenceState    |------------------------------->|  SequenceResolution  |
|-------------------------|                                |----------------------|
| buffer: Vec<KeyCombination>                              | events: Vec<ReedlineEvent>
| pending_exact: Option<(usize, ReedlineEvent)>            | combos: Vec<KeyCombination>
+-------------------------+                                +----------+-----------+
                                                                      |
                                                                      | into_event(fallback)
                                                                      v
                                                             +------------------+
                                                             |  ReedlineEvent   |
                                                             | (None / Edit /   |
                                                             |  Multiple / ...) |
                                                             +------------------+

Key event flow (Emacs / Vi):
KeyEvent -> normalize -> KeyCombination
-> KeySequenceState.process_combo(...)
-> SequenceResolution.into_event(|combo| fallback(combo))
-> ReedlineEvent returned to engine
Notes:

  • pending_exact holds an exact match that is also a prefix of a longer sequence.
    If the longer sequence fails, we emit the saved event and keep trailing keys.
  • SequenceResolution.events = matched sequences
  • SequenceResolution.combos = raw keys to replay through fallback
  • into_event combines both into a single ReedlineEvent
    Vi specifics:
  • fallback(combo) routes into vi parser when a command is pending,
    so combos can be re-fed into vi’s grammar instead of becoming edits.
    Timeout path:
    Engine timeout -> EditMode.flush_pending_sequence()
    -> KeySequenceState.flush_with_combos()
    -> SequenceResolution.into_event(...)
    -> ReedlineEvent
Walking through an example!

Scenario: insert mode, sequence binding j jViChangeMode("normal".into())
Initial state:

  • KeySequenceState.buffer = []
  • pending_exact = None
  • sequence_bindings contains [j, j] -> ViChangeMode("normal".into())
Step 1: user presses j

process_combo([j])
  buffer = [j]
  sequence_match([j]) -> Prefix (matches the start of [j,j])
  resolution:
    events = []
    combos = []
  buffer not empty → pending state is implicit
into_event(fallback) -> None
Result:
- No event yet; editor waits for more input.

Step 2: user presses j again

process_combo([j, j])
  buffer = [j, j]
  sequence_match([j, j]) -> Exact
  resolution:
    events = [ViChangeMode("normal".into())]
    combos = []
  buffer cleared
into_event(fallback) -> ViChangeMode("normal".into())

Result:

  • ViChangeMode("normal".into()) is emitted immediately.
  • Engine handles it: clears selection/menus, switches mode to normal, repaints, and (if coming from insert) moves cursor left.
    Step 3: If the user doesn’t press another key
  • The engine’s timeout will flush any pending sequence.
  • Since the buffer is empty after the exact match, nothing else happens.
    Failure/timeout example:
  • If user presses a single j and waits beyond the timeout:
    • flush_with_combos() returns combos = [j].
    • into_event(fallback) maps that combo through the normal insert fallback, inserting j.

@benvansleen

Copy link
Copy Markdown
Author

Am experimenting with the nushell-side of things for configuring in config.nu.

Currently, keychord configuration looks like this:

$env.config.keybindings ++= [
  {
    name: "jj_normal"
    modifier: "none"
    keycode: [ "char_j", "char_j" ]
    mode: "vi_insert"
    event: { send: "vichangemode", mode: "normal" }
  }
  {
    name: "save_buffer"
    modifier: "control"
    keycode: [ "char_x", "char_s" ]
    mode: "emacs"
    event: { send: "submit" }
  }
  {
    name: "mixed_mods"
    modifier: "none"
    keycode: [
      { modifier: "alt", keycode: "char_w" }
      { modifier: "none", keycode: "char_j" }
    ]
    mode: "emacs"
    event: { send: "openeditor" }
  }
]

How do we feel about it?

@benvansleen benvansleen changed the title Allow for user-defined "chord" / multi-key keybindings (and correct vi motions when targeting <space> char) feat: Allow for user-defined "chord" / multi-key keybindings (and correct vi motions when targeting <space> char) Jan 29, 2026
@ttiurani

ttiurani commented Feb 23, 2026

Copy link
Copy Markdown

I'm not in a position to review this, but just want to say that this is what's keeping me from using nushell – I've have a serious jk dependency and can't live without this feature.

Edit. Actually I just quit my jk addiction and use caps lock as esc. Have been a happy nushell user for a few months now.

@kronberger-droid

Copy link
Copy Markdown
Collaborator

Well this is a massive Change, I just overflew it.
But you essentially fully rewrote/changed both Emacs and vi parse_event() functions without any test harness (I recently added one for vi).
How would you even know you got no regressions from this?

I understand that this is important to you and I am already thinking about a way how to make repeated/pending keys part of the editor such that vi/Emacs/etc. don't have to re-implement them every time.

Thanks, but as long as I can't make sure parse_event() has no regressions and the conflicts are resolved I can't review or land this.

@benvansleen

Copy link
Copy Markdown
Author

Very fair feedback. I've been dogfooding my fork of nushell using this branch for ~4-5 months now.

If you feel this approach is valuable for the project, I'd be more than happy to revive this pr & ensure it's well-tested (incl. using your new Vi harness). Otherwise, I'm perfectly happy maintaining my personal fork. Totally your call!

@kronberger-droid

Copy link
Copy Markdown
Collaborator

@benvansleen
Hmm yeah, if you want we could try to land something which fits the future direction.

My idea:
The KeySequenceState/SequenceResolution machinery is currently duplicated into both Emacs and Vi.
Could we lift it into a single shared SequenceResolver in the edit_mode layer that each mode just embeds as a field?
Same behavior, but the recognizer exists once and any future mode (helix, etc.) reuses it for free.
The mode then only supplies the fallback/interpretation.
Keep the engine-side flush_pending_sequence timeout where it is, since that part has to stay there.

It might be cleanest to close this in favor of a fresh PR for the refactor, happy to look at it as a draft.

@kronberger-droid

Copy link
Copy Markdown
Collaborator

Heads up
@reubeno's #989 is going after the same thing from the other side, and I think you two teaming up beats either PR alone.
The big strength in yours is the runtime: the stateful sequencer, the timeout flush, the vi fallback.
#989 doesn't have any of that, but it does store bindings as a trie in Keybindings instead of a flat table, which feels like the more future-proof home for the recognizer (room for f-style capture and counts later).

Could the resolver sit on top of that trie rather than the flat list?
One shared SequenceResolver, deduped out of the editors, driving the trie and flushing on the engine timeout.

Worth coordinating with @reubeno?

@benvansleen

Copy link
Copy Markdown
Author

Would be happy to! Think a trie is a great idea. Should have time to take a closer look at #989 this weekend.

@reubeno

reubeno commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

That sounds great, thanks @benvansleen!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants