Skip to content

feat: leader-based chord keybindings (kitty-style ctrl+w>x)#143

Merged
umputun merged 18 commits intomasterfrom
leader-chord-keybindings
Apr 23, 2026
Merged

feat: leader-based chord keybindings (kitty-style ctrl+w>x)#143
umputun merged 18 commits intomasterfrom
leader-chord-keybindings

Conversation

@umputun
Copy link
Copy Markdown
Owner

@umputun umputun commented Apr 23, 2026

Adds multi-key chord support to the keybindings system, kitty-style ctrl+w>x. Leaders are restricted to ctrl+* or alt+* 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.

map ctrl+w>x mark_reviewed      # accepted
map alt+t>n theme_select        # accepted
map g>g home                    # rejected: leader must be ctrl+ or alt+
map ctrl+z>a>b quit             # rejected: only 2 stages supported

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 keyState struct on Model holds chordPending and hint. The chord-second guard in handleKey runs 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 by Load()-time conflict resolution). Modal-entry paths (startSearch, startAnnotation, handleOverlayOpen) call a shared clearChordState() helper so chord and modal modes never coexist in normal flow.

Refactor required. Extracted dispatchAction(action, msg) from handleKey and split handleDiffNav / handleTreeNav into thin msg-receivers plus new handleDiffAction(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 recommends ctrl+* 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 in app/ui/model_test.go covering 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.

umputun added 17 commits April 23, 2026 00:07
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.
Copilot AI review requested due to automatic review settings April 23, 2026 06:45
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 23, 2026

Deploying revdiff with  Cloudflare Pages  Cloudflare Pages

Latest commit: ca3704d
Status: ✅  Deploy successful!
Preview URL: https://f7de6b14.revdiff.pages.dev
Branch Preview URL: https://leader-chord-keybindings.revdiff.pages.dev

View logs

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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/keymap to 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/ui key 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.

Comment thread app/keymap/keymap.go Outdated
Comment on lines +489 to +497
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})
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment thread app/ui/model.go
Comment on lines +899 to +915
// 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)
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

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).

Suggested change
// 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

Copilot uses AI. Check for mistakes.
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.
@umputun umputun merged commit c444b58 into master Apr 23, 2026
5 checks passed
@umputun umputun deleted the leader-chord-keybindings branch April 23, 2026 06:55
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