fix: support kitty keyboard protocol for ctrl keybindings (#101)

Fixes #88

## Root cause

`MatchKey` treated `Ctrl+M` and the Enter key as the same raw byte
(`0x0d`). After editing the generated config, a `ctrl+m` binding could
shadow Enter, causing Return to toggle mode instead of executing the
selected command.

While investigating this, I also found that `MatchKey` only recognized
raw control bytes for `ctrl+<letter>` bindings. In Fish and terminals
that use the Kitty keyboard protocol (e.g. Kitty and Foot), those key
combinations may instead be sent as CSI sequences such as `\x1b[106;5u`,
preventing navigation bindings like `ctrl+j` / `ctrl+k` from being
matched.

## Changes

* `internal/config/keys.go`

* Reserve the Enter byte (`0x0d`) so it cannot be claimed by a `ctrl+m`
keybinding.
* Add `matchKittyCtrl()` to support Kitty keyboard protocol CSI
sequences for `ctrl+<letter>` bindings when the Ctrl modifier is
present.
* `root/wrapper.go`

* Extract `handleNavKey()` to remove duplicated navigation handling
logic.
* `internal/config/config_test.go`

* Add regression tests covering the Enter/`ctrl+m` fix and Kitty
keyboard protocol key sequences.

## Tested

* `go test ./...` — 201 passed
* Verified that Enter is no longer shadowed by `ctrl+m` after editing
the generated config.
* Verified that `ctrl+j` / `ctrl+k` navigation works correctly in Fish.

---------

Co-authored-by: verse91 <versedev.store@proton.me>
Co-authored-by: VERSE <sunnyone0901@gmail.com>
This commit is contained in:
Ashvin Singh
2026-08-02 14:40:55 +07:00
committed by GitHub
co-authored by verse91 VERSE
parent 6faa2e3274
commit d669e97423
3 changed files with 282 additions and 158 deletions
+48
View File
@@ -289,6 +289,14 @@ func TestMatchKey(t *testing.T) {
{[]byte{0x09}, "tab", true, 1},
{[]byte{0x0d}, "enter", true, 1},
{[]byte{0x0d}, "ctrl+r", false, 0},
{[]byte("\x1b[106;4u"), "ctrl+j", true, 8},
{[]byte("\x1b[106;5u"), "ctrl+j", true, 8},
{[]byte("\x1b[107;4u"), "ctrl+k", true, 8},
{[]byte("\x1b[107;12u"), "ctrl+k", true, 9},
{[]byte("\x1b[106;1u"), "ctrl+j", false, 0},
{[]byte("\x1b[97;4u"), "ctrl+a", true, 7},
{[]byte("\x1b[106;4U"), "ctrl+j", false, 0},
{[]byte("\x1b[106u"), "ctrl+j", false, 0},
}
for _, tt := range tests {
@@ -298,3 +306,43 @@ func TestMatchKey(t *testing.T) {
}
}
}
// TestMatchKey_EnterReserved verifies that the Enter key (0x0d '\\r') can never
// be claimed by another keybinding. In a raw terminal Ctrl+M and the
// Enter/Return key are byte-identical (both 0x0d), so a "ctrl+m" keybinding
// must not shadow Enter, otherwise line submission (the Enter key) breaks.
func TestMatchKey_EnterReserved(t *testing.T) {
tests := []struct {
name string
input []byte
expected string
matched bool
consumed int
}{
{"ctrl+m must not match the Enter byte", []byte{0x0d}, "ctrl+m", false, 0},
{"ctrl+m must not match Ctrl+M as a generic binding", []byte{0x0d}, "ctrl+m", false, 0},
// other ctrl keys are still distinguishable from Enter and keep working
{"ctrl+n unaffected", []byte{0x0e}, "ctrl+n", true, 1},
{"ctrl+p unaffected", []byte{0x10}, "ctrl+p", true, 1},
{"ctrl+j (0x0a) remains a distinct binding", []byte{0x0a}, "ctrl+j", true, 1},
}
for _, tt := range tests {
m, c := MatchKey(tt.input, tt.expected)
if m != tt.matched || c != tt.consumed {
t.Errorf("%s: MatchKey(%v, %q) = (%v, %d); want (%v, %d)", tt.name, tt.input, tt.expected, m, c, tt.matched, tt.consumed)
}
}
}
// TestMatchKey_NavKeybindingsNoLongerHijackEnter is a regression guard: with a
// user-configured navigation (or any) keybinding set to "ctrl+m", pressing the
// Enter key must NOT be swallowed by that keybinding check.
func TestMatchKey_NavKeybindingsNoLongerHijackEnter(t *testing.T) {
kb := []string{"ctrl+m", "ctrl+m", "<ctrl-m>", "CTRL+M"}
for _, expected := range kb {
if m, _ := MatchKey([]byte{0x0d}, expected); m {
t.Errorf("MatchKey(enter{0x0d}, %q) matched; Enter must remain reserved", expected)
}
}
}
+62
View File
@@ -1,6 +1,7 @@
package config
import (
"strconv"
"strings"
)
@@ -29,9 +30,25 @@ func MatchKey(input []byte, expected string) (matched bool, consumed int) {
char := expected[5]
if char >= 'a' && char <= 'z' {
targetByte := char - 'a' + 1
// 0x0d ('m') is the Carriage Return byte. In a raw terminal the
// Enter/Return key and Ctrl+M both arrive as 0x0d, so they are
// indistinguishable. Matching a "ctrl+m" keybinding here would let
// it shadow the Enter key and break line submission (the wrapper
// checks keybindings before its enter handler). Reserve this byte
// for Enter so a "ctrl+m" keybinding can never hijack it.
if targetByte == 0x0d {
return false, 0
}
if input[0] == targetByte {
return true, 1
}
// Some terminals (foot, kitty, etc.) send kitty keyboard protocol
// escape sequences for Ctrl+letter instead of raw control bytes:
// CSI <keycode> ; <modifiers> <action>
// where modifiers include Ctrl=4 and action='u' means press.
if matched, consumed := matchKittyCtrl(input, int(char)); matched {
return matched, consumed
}
}
}
@@ -75,6 +92,51 @@ func MatchKey(input []byte, expected string) (matched bool, consumed int) {
return false, 0
}
// matchKittyCtrl matches the kitty keyboard protocol CSI sequence for Ctrl+<letter>.
// Format: ESC [ <keycode> ; <modifiers> <action>
// For Ctrl+<letter>: keycode is the ASCII code of the letter, modifiers must have bit 2 (value 4) set, action is 'u'.
func matchKittyCtrl(input []byte, expectedASCII int) (matched bool, consumed int) {
if len(input) < 6 || input[0] != 0x1b || input[1] != '[' {
return false, 0
}
uIdx := -1
for i := 2; i < len(input); i++ {
if input[i] == 'u' {
uIdx = i
break
}
}
if uIdx == -1 {
return false, 0
}
body := string(input[2:uIdx])
parts := strings.Split(body, ";")
if len(parts) != 2 {
return false, 0
}
keycode, err := strconv.Atoi(parts[0])
if err != nil {
return false, 0
}
modifiers, err := strconv.Atoi(parts[1])
if err != nil {
return false, 0
}
if modifiers&4 == 0 {
return false, 0
}
if keycode != expectedASCII {
return false, 0
}
return true, uIdx + 1
}
// FormatKeyName takes a config key string like "ctrl+r" and formats it for UI display, e.g. "<Ctrl+R>".
func FormatKeyName(key string) string {
parts := strings.Split(key, "+")