Handle kitty keyboard protocol input for Ctrl-K and palette

Codex (and other ratatui-based children) pushes kitty keyboard flags
onto the host terminal, so Ctrl-K arrives as `\x1b[107;5u` instead of
0x0B and the palette open never fired. With "report event types" also
on, the release event `\x1b[107;5:3u` followed the press and tripped
the palette's "unknown ESC sequence → cancel" branch, making the
palette flash and close.

Add a small CSI scanner / kitty CSI u decoder and use them in two
places: matchCtrlK now accepts the legacy byte, the kitty CSI u form,
and xterm modifyOtherKeys; the palette's input handler consumes whole
CSI sequences, ignores non-press events, and decodes Enter/Esc/
Backspace/arrows/Ctrl-U-N-P in their kitty forms. Ctrl-K Ctrl-K
forwards the raw matched bytes so nested TUIs that asked for kitty
input still receive kitty input.
This commit is contained in:
2026-05-14 14:46:21 +01:00
parent 55c6c93086
commit cb3e51d568
5 changed files with 436 additions and 93 deletions

144
internal/app/keymatch.go Normal file
View File

@@ -0,0 +1,144 @@
package app
import (
"strconv"
"strings"
)
// csiLen returns the byte length of the CSI sequence starting at
// chunk[i], or 0 if chunk[i:] doesn't begin a complete CSI. A CSI is
// ESC '[' followed by parameter bytes (0x30..0x3F), intermediate bytes
// (0x20..0x2F), and one final byte (0x40..0x7E).
func csiLen(chunk []byte, i int) int {
if i+1 >= len(chunk) || chunk[i] != 0x1b || chunk[i+1] != '[' {
return 0
}
end := i + 2
for end < len(chunk) && chunk[end] >= 0x30 && chunk[end] <= 0x3F {
end++
}
for end < len(chunk) && chunk[end] >= 0x20 && chunk[end] <= 0x2F {
end++
}
if end >= len(chunk) {
return 0
}
if final := chunk[end]; final < 0x40 || final > 0x7E {
return 0
}
return end - i + 1
}
// csiuKey is the decoded form of a CSI u key event. key is the kitty
// keycode (the unshifted unicode codepoint for character keys, or a
// kitty functional-key constant). mods is the kitty modifier value
// (1 + bitfield: shift=1, alt=2, ctrl=4, super=8, …). event is the
// event type (1=press, 2=repeat, 3=release).
type csiuKey struct {
key int
mods int
event int
}
// decodeCSIu parses the parameter string of a `CSI ... u` sequence.
// The kitty shape is:
//
// <key>[:<shifted>[:<base>]] [;<mods>[:<event>][;<text>...]]
//
// Unspecified groups default to mods=1, event=1.
func decodeCSIu(params string) (csiuKey, bool) {
parts := strings.SplitN(params, ";", 3)
keyGroup := parts[0]
if i := strings.IndexByte(keyGroup, ':'); i >= 0 {
keyGroup = keyGroup[:i]
}
if keyGroup == "" {
return csiuKey{}, false
}
key, err := strconv.Atoi(keyGroup)
if err != nil {
return csiuKey{}, false
}
mods, event := 1, 1
if len(parts) > 1 {
modGroup := parts[1]
eventGroup := ""
if i := strings.IndexByte(modGroup, ':'); i >= 0 {
eventGroup = modGroup[i+1:]
modGroup = modGroup[:i]
}
if modGroup != "" {
m, err := strconv.Atoi(modGroup)
if err != nil {
return csiuKey{}, false
}
mods = m
}
if eventGroup != "" {
e, err := strconv.Atoi(eventGroup)
if err != nil {
return csiuKey{}, false
}
event = e
}
}
return csiuKey{key: key, mods: mods, event: event}, true
}
// matchCtrlK reports whether chunk[i:] starts with a Ctrl-K keystroke
// in any of the encodings we accept on input, and returns the number of
// bytes consumed.
//
// Three encodings are recognised:
//
// - Legacy: the single byte 0x0B.
// - Kitty keyboard CSI u: ESC '[' 107 ';' 5 'u' (optionally with sub-
// parameters and trailing groups, see [kitty]). The kitty protocol
// fires when a child PTY pushes it onto the host terminal's flag
// stack; codex/ratatui does this on startup, which is what motivated
// this matcher.
// - xterm modifyOtherKeys: ESC '[' 27 ';' 5 ';' 107 '~'.
//
// Only an unmodified Ctrl-K (modifier value exactly 5 — i.e. Ctrl with
// no Shift/Alt/Meta) and a key-press event (event-type 1 or omitted)
// match. That mirrors the legacy 0x0B byte, which only fires on plain
// Ctrl-K too.
//
// [kitty]: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
func matchCtrlK(chunk []byte, i int) (matched bool, advance int) {
if i >= len(chunk) {
return false, 0
}
if chunk[i] == keyCtrlK {
return true, 1
}
n := csiLen(chunk, i)
if n == 0 {
return false, 0
}
final := chunk[i+n-1]
params := string(chunk[i+2 : i+n-1])
switch final {
case 'u':
k, ok := decodeCSIu(params)
if ok && k.key == 107 && k.mods == 5 && k.event == 1 {
return true, n
}
case '~':
if isModifyOtherKeysCtrlK(params) {
return true, n
}
}
return false, 0
}
// isModifyOtherKeysCtrlK parses xterm's CSI 27;<mods>;<key>~ form.
func isModifyOtherKeysCtrlK(params string) bool {
parts := strings.Split(params, ";")
if len(parts) != 3 {
return false
}
return parts[0] == "27" && parts[1] == "5" && parts[2] == "107"
}