feat: leader-based chord keybindings (kitty-style ctrl+w>x)#143
feat: leader-based chord keybindings (kitty-style ctrl+w>x)#143
Conversation
Extends parse() to recognize chord bindings like 'ctrl+w>x'. Leader must be a ctrl+ or alt+ combo; second stage is a single key. Three-stage chords and empty halves are rejected with a warn. Leader case is normalized (ctrl+/alt+ lowercased); second-stage case is preserved so ctrl+w>x and ctrl+w>X stay distinct. Both map and unmap accept chord keys.
Add IsChordLeader/chordPrefixes methods backed by a lazy cache on Keymap, invalidated on every Bind/Unbind. Add resolveConflicts() at the end of Load() that drops any standalone binding whose key is also a chord prefix so that pressing the leader always enters chord-pending state.
Lock in the behavior that chord keys stored as flat strings (ctrl+w>x) round-trip through Dump/parse and sort alphabetically alongside single-key bindings via KeysFor and HelpSections.
Pure refactor preparing for chord dispatch. handleKey's post-Resolve flow moves into dispatchAction; handleDiffNav and handleTreeNav become thin wrappers over handleDiffAction and handleTreeAction so keymap-resolved and chord-resolved paths share the same core.
Introduces keyState sub-struct (chordPending + hint) on Model and the handleChordSecond method that dispatches resolved chord actions through dispatchAction, surfaces "Unknown chord" on a miss, and cancels silently on esc. Wires the chord hint into transientHint() as the lowest-priority tier so in-flight reload/compact hints still win.
adds the dispatch guards that make leader-chord keybindings active end-to-end: the chord-second guard runs before handleModalKey so a pending chord's second key never leaks into a textinput, and the chord-first guard runs after Resolve so an unresolved key that is a registered chord leader transitions into pending state with a status-bar hint. covers entering pending state, modal coexistence defense-in-depth, reload precedence, and standalone-action fallthrough.
TestHandleKey_ChordPrecedence covers nine scenarios from the dispatch matrix: clean leader entry, bound/unbound second, esc cancel, leader sent twice (consumed as second-stage), and chord suppression while annotate/search/overlay/pending-reload modes are active. TestHandleKey_NonKeyMessagesPreserveChordState locks in the invariant that non-key messages routed through Update (window resize, stale files/blame/commits loads) cannot affect chord state.
All acceptance criteria for the leader-chord keybindings feature pass: parser/conflict/dispatch/modal-clear/layout-fallback/Dump/help-render are covered by existing unit and integration tests; full test suite green (coverage 90.8%, keymap 95.8%, ui 94.2%); linter clean.
Remove test-only pane-nav wrappers and route chord-resolved actions through the TOC without re-resolving from the raw key. - handleDiffNav/handleTreeNav wrappers had no production callers after the dispatchAction refactor; removed along with their delegation tests - handleTOCNav now takes a pre-resolved keymap.Action so chord-resolved TOC navigation no longer re-resolves from a synthesized second-stage key (which would silently drop the chord action) - drop unused tea.KeyMsg parameter from handleDiffAction / handleTreeAction / dispatchAction now that no downstream handler consults msg context - add regression test for chord -> TOC dispatch while TOC is focused
Replace invalid close_file action in chord keybinding examples with mark_reviewed across README, site docs, CLAUDE.md, and plugin reference copies so copy-paste examples bind successfully. Correct CLAUDE.md gotcha: dispatchAction takes a single action parameter, and the handleDiffNav/handleTreeNav wrappers described there never shipped — pane dispatch goes directly through handleDiffAction / handleTreeAction. Document chord keybindings in ARCHITECTURE.md app/keymap section so the subsystem is discoverable from the architecture overview.
macOS terminals default to composing special characters on Option (e.g. Option+T produces a dagger glyph), so alt+* leaders silently don't fire unless the terminal is configured to send Option as Meta/Alt. Document the fix for iTerm2, Terminal.app, Kitty, and Ghostty, and recommend ctrl+* leaders as the zero-config fallback.
Deploying revdiff with
|
| Latest commit: |
ca3704d
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://f7de6b14.revdiff.pages.dev |
| Branch Preview URL: | https://leader-chord-keybindings.revdiff.pages.dev |
There was a problem hiding this comment.
Pull request overview
Adds leader-based two-stage chord keybindings (kitty-style ctrl+w>x) to revdiff’s keymap and UI dispatch pipeline, exposed via user config, along with comprehensive tests and documentation updates.
Changes:
- Extend
app/keymapto parse/store 2-stage chord bindings, detect chord leaders with a lazy cache, resolve chords with non-Latin layout fallback, and drop standalone bindings that conflict with chord leaders. - Update
app/uikey dispatch to support chord-pending state + status hints, route chord-resolved actions through a unified dispatch path, and clear chord state on modal/overlay entry. - Document chord syntax and macOS Alt/Option terminal requirements across README/site/docs and mirrored plugin references; add a completed implementation plan.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
app/keymap/keymap.go |
Implements chord parsing, leader cache, ResolveChord, conflict resolution, and dump handling for chord keys. |
app/keymap/keymap_test.go |
Adds extensive unit coverage for chord parsing, conflict behavior, layout fallback, dumping, and cache invalidation. |
app/ui/model.go |
Adds chord state to the Model, integrates chord guards into handleKey, centralizes dispatch via dispatchAction, and clears chord state on overlay entry. |
app/ui/model_test.go |
Adds dispatch and chord precedence matrix tests (including overlay/search/annotate interactions). |
app/ui/diffnav.go |
Refactors pane navigation to accept pre-resolved actions (handleDiffAction/handleTreeAction/handleTOCNav) so chord actions don’t re-resolve from raw keys. |
app/ui/search.go |
Clears chord state on entering search mode. |
app/ui/annotate.go |
Clears chord state on entering annotation mode. |
app/ui/view.go |
Displays chord-related transient hints at lowest priority. |
README.md |
Documents chord syntax, conflict rule, layout behavior, and macOS Alt/Option configuration. |
site/docs.html |
Mirrors README chord documentation in the site docs. |
.claude-plugin/skills/revdiff/references/usage.md |
Adds chord docs to plugin reference (must remain byte-identical to codex copy). |
plugins/codex/skills/revdiff/references/usage.md |
Same chord docs as .claude-plugin copy. |
docs/ARCHITECTURE.md |
Documents chord storage/dispatch architecture and relevant keymap/UI internals. |
CLAUDE.md |
Adds a “gotcha” entry describing chord behavior/constraints and where state lives. |
docs/plans/completed/20260423-leader-chord-keybindings.md |
Adds the completed implementation plan/spec and rationale. |
app/ui/diffnav_test.go |
Trivial whitespace-only change. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if strings.Contains(rawKey, ">") { | ||
| key, ok := parseChordKey(rawKey, lineNum) | ||
| if !ok { | ||
| continue | ||
| } | ||
| maps = append(maps, mapEntry{key: key, action: action}) | ||
| continue | ||
| } | ||
| maps = append(maps, mapEntry{key: normalizeKey(rawKey), action: action}) |
There was a problem hiding this comment.
Chord parsing currently treats any key containing ">" as chord syntax, which makes it impossible to bind the literal ">" key in the user keybindings file (e.g. map > quit / unmap >). Consider special-casing rawKey == ">" (and possibly allowing escaped/aliased greater-than) so standalone ">" remains bindable while still supporting chord syntax.
| // cancels silently, an unbound second key surfaces an "Unknown chord" hint, | ||
| // and a resolved action flows through dispatchAction so chord-bound actions | ||
| // reach the same handlers as keymap-resolved single keys. The local copy of | ||
| // Model carries the cleared chord state back to bubbletea. | ||
| func (m Model) handleChordSecond(keyStr string) (tea.Model, tea.Cmd) { | ||
| prefix := m.keys.chordPending | ||
| m.keys.chordPending = "" | ||
| m.keys.hint = "" | ||
| if keyStr == "esc" { | ||
| return m, nil | ||
| } | ||
| action := m.keymap.ResolveChord(prefix, keyStr) | ||
| if action == "" { | ||
| m.keys.hint = "Unknown chord: " + prefix + ">" + keyStr | ||
| return m, nil | ||
| } | ||
| return m.dispatchAction(action) |
There was a problem hiding this comment.
handleChordSecond treats esc as an unconditional cancel before attempting ResolveChord. Since the parser allows bindings like map ctrl+w>esc ..., such chords can be configured but will never fire. Either reject ...>esc (and related aliases) at parse/load time with a warning, or allow esc to dispatch when explicitly bound (and pick a different cancel rule).
| // cancels silently, an unbound second key surfaces an "Unknown chord" hint, | |
| // and a resolved action flows through dispatchAction so chord-bound actions | |
| // reach the same handlers as keymap-resolved single keys. The local copy of | |
| // Model carries the cleared chord state back to bubbletea. | |
| func (m Model) handleChordSecond(keyStr string) (tea.Model, tea.Cmd) { | |
| prefix := m.keys.chordPending | |
| m.keys.chordPending = "" | |
| m.keys.hint = "" | |
| if keyStr == "esc" { | |
| return m, nil | |
| } | |
| action := m.keymap.ResolveChord(prefix, keyStr) | |
| if action == "" { | |
| m.keys.hint = "Unknown chord: " + prefix + ">" + keyStr | |
| return m, nil | |
| } | |
| return m.dispatchAction(action) | |
| // cancels silently when unbound, an unbound second key surfaces an "Unknown | |
| // chord" hint, and a resolved action flows through dispatchAction so | |
| // chord-bound actions reach the same handlers as keymap-resolved single | |
| // keys. The local copy of Model carries the cleared chord state back to | |
| // bubbletea. | |
| func (m Model) handleChordSecond(keyStr string) (tea.Model, tea.Cmd) { | |
| prefix := m.keys.chordPending | |
| m.keys.chordPending = "" | |
| m.keys.hint = "" | |
| action := m.keymap.ResolveChord(prefix, keyStr) | |
| if action != "" { | |
| return m.dispatchAction(action) | |
| } | |
| if keyStr == "esc" { | |
| return m, nil | |
| } | |
| m.keys.hint = "Unknown chord: " + prefix + ">" + keyStr | |
| return m, nil |
Two edge cases Copilot flagged on PR #143: 1. Standalone ">" key was unbindable — the chord gate was strings.Contains(rawKey, ">"), which treats a bare ">" as malformed chord syntax. Narrow the gate to also require rawKey != ">" so "map > quit" and "unmap >" work. 2. "map ctrl+w>esc foo" parsed and stored a binding that could never fire, because handleChordSecond treats esc as unconditional cancel before ResolveChord. Reject esc (and the "escape" alias) as a chord second-stage key at parse time with a warning, so the silent failure becomes loud and actionable. Matches the locked design decision that esc is reserved for cancel.
Adds multi-key chord support to the keybindings system, kitty-style
ctrl+w>x. Leaders are restricted toctrl+*oralt+*combos; second stage is any single key; depth is exactly 2. No default chord bindings ship — purely a user-customization feature exposed through~/.config/revdiff/keybindings.Why ctrl/alt-only leaders. Restricting leaders to modifier combos sidesteps every open question on #138 (vim-style
gg/yy/count-prefix support): non-Latin layout resolution doesn't apply to control combos, single-key-vs-prefix conflicts can't arise (no app action uses ctrl combos standalone), and the stale-prefix timeout debate is moot (esc-only cancel with a visible status-bar hint). Vim-style chords stay out of scope; #138 remains open.Parser behavior.
When both a standalone key and a chord sharing the same leader are bound, the chord wins and the standalone is dropped with a warning. ASCII second-stage keys get layout-resolve fallback so chords work on non-Latin layouts.
Dispatch. A new
keyStatestruct onModelholdschordPendingandhint. The chord-second guard inhandleKeyruns before modal/textinput handlers (defense-in-depth against coexistence); the chord-first guard runs after action resolution and only when no standalone action matches (guaranteed byLoad()-time conflict resolution). Modal-entry paths (startSearch,startAnnotation,handleOverlayOpen) call a sharedclearChordState()helper so chord and modal modes never coexist in normal flow.Refactor required. Extracted
dispatchAction(action, msg)fromhandleKeyand splithandleDiffNav/handleTreeNavinto thin msg-receivers plus newhandleDiffAction(action, msg)/handleTreeAction(action, msg)cores. This is necessary so chord-resolved actions flow through the same dispatch machinery as keymap-resolved actions without re-resolving the second-stage key via the standalone bindings.Docs. README, site/docs.html, and both skill reference copies (.claude-plugin + plugins/codex, byte-identical) document the chord syntax. A macOS note explains that
alt+*leaders require configuring the terminal to send Option as Meta/Alt (iTerm2, Terminal.app, Kitty, Ghostty), and recommendsctrl+*as the zero-config fallback.Testing. 75 new tests in
app/keymap/keymap_test.go(parser, conflict resolution, lazy index invalidation, ResolveChord with layout fallback, Dump round-trip) plus a full handleKey precedence matrix inapp/ui/model_test.gocovering chord + overlay, chord + search, chord + annotate, chord + pending-reload, esc cancel, unknown second key, and non-key messages preserving chord state. All tests pass; linter clean.Implementation followed the plan at
docs/plans/completed/20260423-leader-chord-keybindings.md.