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 intonushell: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 intonushell:mainfrom
benvansleen:feat/multiple-key-combo-support

Conversation

@benvansleen
Copy link

@benvansleen benvansleen commented Jan 28, 2026

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
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
Copy link

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.

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.

2 participants