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

@@ -135,51 +135,42 @@ func fuzzyMatch(hay, needle string) bool {
return true
}
func (p *paletteState) handleKey(b byte, peek []byte) (paletteAction, bool) {
// kitty functional keycodes for arrows.
const (
kittyKeyUp = 57352
kittyKeyDown = 57353
)
// handleInput consumes one keystroke from chunk[i:] and updates palette
// state. advance is how many bytes the keystroke occupies (1 for legacy
// keys, longer for CSI sequences). Returning done=true tells the caller
// the palette is finished and action describes what to do next.
//
// Recognised input includes both legacy byte forms and the kitty
// keyboard CSI u encoding that codex/ratatui pushes onto the terminal.
// Unknown CSI sequences (including release events from kitty flag 2)
// are consumed silently so they don't fall through to the ESC branch
// and accidentally cancel the palette.
func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, done bool, advance int) {
b := chunk[i]
if b == 0x1b {
// Pure Esc cancels; Esc [ A/B is up/down arrow.
if len(peek) >= 2 && peek[0] == '[' {
switch peek[1] {
case 'A':
p.cursor--
if p.cursor < 0 {
p.cursor = 0
}
return paletteAction{}, false
case 'B':
p.cursor++
if p.cursor >= len(p.items) {
p.cursor = len(p.items) - 1
}
return paletteAction{}, false
}
if n := csiLen(chunk, i); n > 0 {
return p.handleCSI(chunk[i+2:i+n-1], chunk[i+n-1], n)
}
return paletteAction{kind: "cancel"}, true
// Bare ESC (no CSI follow-up): cancel.
return paletteAction{kind: "cancel"}, true, 1
}
switch b {
case '\r', '\n':
if p.cursor >= 0 && p.cursor < len(p.items) {
return p.items[p.cursor].action, true
}
return paletteAction{kind: "cancel"}, true
return p.accept(), true, 1
case 0x7f, 0x08:
if len(p.query) > 0 {
p.query = p.query[:len(p.query)-1]
p.rebuild()
}
p.backspace()
case 0x15: // Ctrl-U
p.query = p.query[:0]
p.rebuild()
p.clearQuery()
case 0x0e: // Ctrl-N
p.cursor++
if p.cursor >= len(p.items) {
p.cursor = len(p.items) - 1
}
case 0x10: // Ctrl-P inside palette: cursor up.
p.cursor--
if p.cursor < 0 {
p.cursor = 0
}
p.cursorDown()
case 0x10: // Ctrl-P
p.cursorUp()
case 0x0b: // Ctrl-K inside palette is a no-op (would re-open); ignore.
case 0x16: // Ctrl-V literal-paste — ignore in palette.
default:
@@ -188,7 +179,91 @@ func (p *paletteState) handleKey(b byte, peek []byte) (paletteAction, bool) {
p.rebuild()
}
}
return paletteAction{}, false
return paletteAction{}, false, 1
}
func (p *paletteState) handleCSI(params []byte, final byte, n int) (paletteAction, bool, int) {
switch final {
case 'A':
p.cursorUp()
return paletteAction{}, false, n
case 'B':
p.cursorDown()
return paletteAction{}, false, n
case 'u':
k, ok := decodeCSIu(string(params))
if !ok || k.event != 1 {
// Repeat / release events, or malformed: ignore.
return paletteAction{}, false, n
}
switch k.key {
case 13: // Enter
return p.accept(), true, n
case 27: // Escape
return paletteAction{kind: "cancel"}, true, n
case 127, 8: // Backspace
p.backspace()
case kittyKeyUp:
p.cursorUp()
case kittyKeyDown:
p.cursorDown()
default:
// Ctrl-modified character keys.
if k.mods == 5 {
switch k.key {
case 'u':
p.clearQuery()
case 'n':
p.cursorDown()
case 'p':
p.cursorUp()
}
return paletteAction{}, false, n
}
// Unmodified printable ASCII typed via CSI u (flag 8): treat
// as a query keystroke.
if k.mods == 1 && k.key >= 0x20 && k.key < 0x7f {
p.query = append(p.query, rune(k.key))
p.rebuild()
}
}
return paletteAction{}, false, n
}
// Anything else (~, function keys, etc.): consume silently.
return paletteAction{}, false, n
}
func (p *paletteState) accept() paletteAction {
if p.cursor >= 0 && p.cursor < len(p.items) {
return p.items[p.cursor].action
}
return paletteAction{kind: "cancel"}
}
func (p *paletteState) backspace() {
if len(p.query) > 0 {
p.query = p.query[:len(p.query)-1]
p.rebuild()
}
}
func (p *paletteState) clearQuery() {
p.query = p.query[:0]
p.rebuild()
}
func (p *paletteState) cursorUp() {
p.cursor--
if p.cursor < 0 {
p.cursor = 0
}
}
func (p *paletteState) cursorDown() {
p.cursor++
if p.cursor >= len(p.items) {
p.cursor = len(p.items) - 1
}
}
// render draws the palette onto out. Geometry: title bar + filter line +