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

View File

@@ -216,9 +216,6 @@ type uiState struct {
// A fresh renderer is allocated per focused child so partial-escape
// state cannot bleed between panes.
renderer *viewportRenderer
// passthrough: when true, the next keystroke is forwarded to the
// focused PTY untouched (SPEC §4 Ctrl-K Ctrl-K).
passthroughArmed bool
// attention is the latest request_human_attention surfaced via MCP;
// rendered in the status line until cleared.
@@ -614,31 +611,13 @@ func (st *uiState) processStdin(chunk []byte) {
for i < len(chunk) {
b := chunk[i]
// Passthrough armed: forward this byte literally regardless of
// what it is, then disarm.
if st.passthroughArmed {
forward = append(forward, b)
st.passthroughArmed = false
i++
continue
}
// Palette mode swallows all bytes.
if st.palette != nil {
var peek []byte
if i+1 < len(chunk) {
peek = chunk[i+1:]
}
action, done := st.palette.handleKey(b, peek)
if b == 0x1b && len(peek) >= 2 && peek[0] == '[' {
if peek[1] == 'A' || peek[1] == 'B' {
i += 3
} else {
i++
}
} else {
i++
action, done, adv := st.palette.handleInput(chunk, i)
if adv <= 0 {
adv = 1
}
i += adv
if done {
a := action
pendingAction = &a
@@ -650,42 +629,23 @@ func (st *uiState) processStdin(chunk []byte) {
// Ctrl-K is the reserved app-level binding. Two cases:
// - Ctrl-K then anything except Ctrl-K → open palette.
// - Ctrl-K Ctrl-K → arm passthrough; the next byte goes raw.
if b == keyCtrlK {
// Peek at the next byte if we have it.
next := byte(0)
haveNext := i+1 < len(chunk)
if haveNext {
next = chunk[i+1]
}
if haveNext && next == keyCtrlK {
// Chord: forward both Ctrl-K bytes literally. (Some
// nested TUIs expect Ctrl-K itself.)
// - Ctrl-K Ctrl-K → forward both keystrokes to the child raw.
//
// Ctrl-K is recognised in legacy (0x0B), kitty CSI u, and xterm
// modifyOtherKeys encodings — see matchCtrlK. The chord forwards
// the bytes the terminal actually emitted, so a child that asked
// for kitty input gets kitty input.
if hit, adv := matchCtrlK(chunk, i); hit {
if hit2, adv2 := matchCtrlK(chunk, i+adv); hit2 {
flushForward()
forward = append(forward, keyCtrlK, keyCtrlK)
forward = append(forward, chunk[i:i+adv+adv2]...)
flushForward()
i += 2
i += adv + adv2
continue
}
if !haveNext {
// Could be the first byte of a chord — arm and wait.
st.passthroughArmed = true
// But we also want palette-open on a lone Ctrl-K. Resolve
// by treating "Ctrl-K at end of read" as palette open;
// any subsequent Ctrl-K in the next read still has the
// chord semantics because passthroughArmed got set first.
// To match the spec's reading, simpler model: lone Ctrl-K
// in this read opens the palette.
st.passthroughArmed = false
flushForward()
st.openPaletteLocked()
i++
continue
}
// Ctrl-K followed by something that's not Ctrl-K → palette open.
flushForward()
st.openPaletteLocked()
i++
i += adv
continue
}