Skip to content

[keybindings] "alt"/"option" chords never match on standard (non-Kitty) terminals — should map to "meta" #843

Description

@gtrak

This issue was prepared with the help of an LLM (root-cause analysis and drafting), based on debugging a real config that stopped working. Environment/repro details below were confirmed on my own machine.

Environment

  • Hunk version: 0.19.0 (hunk --version)
  • Terminal: Windows console, SSH'd into a Linux box (TERM=xterm-256color on the remote side)
  • Config: ~/.config/hunk/config.toml, [keybindings] table

Summary

Any [keybindings] entry written with the alt/option modifier (e.g. "alt+n") is silently unreachable on a normal terminal. The key never fires the bound command, and there's no warning/notice that the binding is dead.

Steps to reproduce

  1. Add to ~/.config/hunk/config.toml:
    [keybindings]
    "hunk.review.pageDown" = ["pagedown", "alt+n"]
  2. Launch hunk diff over SSH from a Windows console (or really any terminal without Kitty keyboard protocol support).
  3. Press Alt+N.

Expected: triggers hunk.review.pageDown.
Actual: nothing happens.

Root cause

parseKeyChord (src/extension-api/keys.ts) maps the alt/option token to a distinct option boolean, separate from meta (which cmd/meta sets):

MODIFIER_TOKENS = {
  ctrl: "ctrl", control: "ctrl",
  meta: "meta", cmd: "meta", command: "meta",
  alt: "option", option: "option",
  shift: "shift",
};

So "alt+n" parses to { base: "n", option: true, meta: false }.

But the actual terminal key decoder (in the vendored @opentui/core keypress parser) never produces that combination for a physically-pressed Alt key on a plain ANSI terminal. When Alt+letter arrives as the classic 2-byte ESC+letter sequence, it's decoded via metaKeyCodeRe (/^\x1b([a-zA-Z0-9])$/) as:

key.meta = true;   // option stays false

And matchesKeyChord requires an exact match on both flags:

if (Boolean(key.ctrl) !== parsed.ctrl || Boolean(key.meta) !== parsed.meta) return false;
if (Boolean(key.option) !== parsed.option) return false;

parsed.option = true, parsed.meta = false from "alt+n" can never equal key.option = false, key.meta = true from the terminal, so the chord is dead.

(If the terminal does speak the Kitty keyboard protocol, Alt is decoded differently again — there it sets meta: true and option: true together, from key2.meta = mods.alt || mods.meta; key2.option = mods.alt;. That combination doesn't match a bare "alt+n" chord either, and doesn't match "meta+n" either — only "alt+meta+n" would.)

Net effect: there is no single documented chord string that reliably maps to "the Alt key" across terminal modes. Users have to guess/reverse-engineer which of alt+x, meta+x, or alt+meta+x will actually fire, depending on their terminal and whether Kitty protocol negotiated.

Suggested fix (one of)

  1. Make alt/option in the config grammar match on option || meta (i.e. treat a plain Alt press as satisfying an alt+-bound chord regardless of which single flag the decoder happened to set), rather than requiring an exact boolean match on both option and meta.
  2. Alternatively, document in docs/keybindings.md that plain-terminal Alt presses must be bound as meta+x, and Kitty-protocol Alt presses must be bound as alt+meta+x — but this pushes a decoder implementation detail onto every user's config and makes alt/option effectively useless as a config token on its own.
  3. At minimum, add a startup notice (like the existing "Ignored [keybindings] entries with unsupported values" notice) when a configured chord uses a modifier combination that can never be produced by the active key decoder, so this doesn't fail silently.

Workaround

Binding the same command to meta+x (for non-Kitty terminals) and alt+meta+x (for Kitty-protocol terminals) together works around it:

"hunk.review.pageDown" = ["pagedown", "meta+n", "alt+meta+n"]

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions