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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
144
internal/app/keymatch.go
Normal file
144
internal/app/keymatch.go
Normal 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"
|
||||
}
|
||||
56
internal/app/keymatch_test.go
Normal file
56
internal/app/keymatch_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMatchCtrlK(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
chunk string
|
||||
offset int
|
||||
wantMatch bool
|
||||
wantAdvance int
|
||||
}{
|
||||
{"legacy lone byte", "\x0b", 0, true, 1},
|
||||
{"legacy followed by text", "\x0bx", 0, true, 1},
|
||||
{"kitty plain Ctrl-K", "\x1b[107;5u", 0, true, 8},
|
||||
{"kitty with press event", "\x1b[107;5:1u", 0, true, 10},
|
||||
{"kitty with key release", "\x1b[107;5:3u", 0, false, 0},
|
||||
{"kitty with extra shift", "\x1b[107;6u", 0, false, 0},
|
||||
{"kitty no modifier", "\x1b[107u", 0, false, 0},
|
||||
{"kitty wrong key", "\x1b[108;5u", 0, false, 0},
|
||||
{"kitty with associated text trailing group", "\x1b[107;5;107u", 0, true, 12},
|
||||
{"modifyOtherKeys Ctrl-K", "\x1b[27;5;107~", 0, true, 11},
|
||||
{"modifyOtherKeys wrong mods", "\x1b[27;6;107~", 0, false, 0},
|
||||
{"unrelated CSI", "\x1b[A", 0, false, 0},
|
||||
{"plain ascii", "k", 0, false, 0},
|
||||
{"empty", "", 0, false, 0},
|
||||
{"incomplete CSI", "\x1b[107;5", 0, false, 0},
|
||||
{"offset past legacy", "x\x0b", 1, true, 1},
|
||||
{"offset past kitty prefix", "x\x1b[107;5u", 1, true, 8},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, adv := matchCtrlK([]byte(tc.chunk), tc.offset)
|
||||
if got != tc.wantMatch || adv != tc.wantAdvance {
|
||||
t.Fatalf("matchCtrlK(%q, %d) = (%v, %d); want (%v, %d)",
|
||||
tc.chunk, tc.offset, got, adv, tc.wantMatch, tc.wantAdvance)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchCtrlKConsecutive(t *testing.T) {
|
||||
// Two kitty Ctrl-K sequences back to back, the chord case.
|
||||
chunk := []byte("\x1b[107;5u\x1b[107;5u")
|
||||
hit, adv := matchCtrlK(chunk, 0)
|
||||
if !hit || adv != 8 {
|
||||
t.Fatalf("first: hit=%v adv=%d", hit, adv)
|
||||
}
|
||||
hit2, adv2 := matchCtrlK(chunk, adv)
|
||||
if !hit2 || adv2 != 8 {
|
||||
t.Fatalf("second: hit=%v adv=%d", hit2, adv2)
|
||||
}
|
||||
if adv+adv2 != len(chunk) {
|
||||
t.Fatalf("expected to cover the whole chunk, got %d/%d", adv+adv2, len(chunk))
|
||||
}
|
||||
}
|
||||
@@ -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 +
|
||||
|
||||
108
internal/app/palette_input_test.go
Normal file
108
internal/app/palette_input_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/harrybrwn/patterm/internal/preset"
|
||||
)
|
||||
|
||||
func newTestPalette() *paletteState {
|
||||
return newPalette(nil, "", preset.Set{})
|
||||
}
|
||||
|
||||
func TestPaletteIgnoresKittyReleaseEvent(t *testing.T) {
|
||||
// A kitty key-release for Ctrl-K. With the legacy handler this looked
|
||||
// like ESC followed by `[`, which fell through to cancel.
|
||||
p := newTestPalette()
|
||||
chunk := []byte("\x1b[107;5:3u")
|
||||
action, done, adv := p.handleInput(chunk, 0)
|
||||
if done {
|
||||
t.Fatalf("release event closed palette: action=%+v", action)
|
||||
}
|
||||
if adv != len(chunk) {
|
||||
t.Fatalf("advance %d, want %d", adv, len(chunk))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaletteEscViaKittyCancels(t *testing.T) {
|
||||
p := newTestPalette()
|
||||
chunk := []byte("\x1b[27u")
|
||||
action, done, adv := p.handleInput(chunk, 0)
|
||||
if !done || action.kind != "cancel" {
|
||||
t.Fatalf("Esc via CSI u didn't cancel: action=%+v done=%v", action, done)
|
||||
}
|
||||
if adv != len(chunk) {
|
||||
t.Fatalf("advance %d, want %d", adv, len(chunk))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaletteBareEscCancels(t *testing.T) {
|
||||
p := newTestPalette()
|
||||
action, done, adv := p.handleInput([]byte{0x1b}, 0)
|
||||
if !done || action.kind != "cancel" {
|
||||
t.Fatalf("bare ESC didn't cancel: action=%+v done=%v", action, done)
|
||||
}
|
||||
if adv != 1 {
|
||||
t.Fatalf("advance %d, want 1", adv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaletteKittyArrowsNavigate(t *testing.T) {
|
||||
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}, {Name: "c"}}
|
||||
p := newPalette(nil, "", preset.Set{Agents: pr})
|
||||
if p.cursor != 0 {
|
||||
t.Fatalf("initial cursor %d", p.cursor)
|
||||
}
|
||||
// Kitty functional Down arrow.
|
||||
_, _, adv := p.handleInput([]byte("\x1b[57353u"), 0)
|
||||
if adv != 8 {
|
||||
t.Fatalf("advance %d", adv)
|
||||
}
|
||||
if p.cursor != 1 {
|
||||
t.Fatalf("cursor %d after Down, want 1", p.cursor)
|
||||
}
|
||||
// Kitty functional Up arrow.
|
||||
_, _, _ = p.handleInput([]byte("\x1b[57352u"), 0)
|
||||
if p.cursor != 0 {
|
||||
t.Fatalf("cursor %d after Up, want 0", p.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaletteLegacyArrowsStillWork(t *testing.T) {
|
||||
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}}
|
||||
p := newPalette(nil, "", preset.Set{Agents: pr})
|
||||
_, _, adv := p.handleInput([]byte("\x1b[B"), 0)
|
||||
if adv != 3 {
|
||||
t.Fatalf("advance %d", adv)
|
||||
}
|
||||
if p.cursor != 1 {
|
||||
t.Fatalf("cursor %d, want 1", p.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaletteKittyEnterAccepts(t *testing.T) {
|
||||
pr := []*preset.Preset{{Name: "x"}}
|
||||
p := newPalette(nil, "", preset.Set{Agents: pr})
|
||||
action, done, _ := p.handleInput([]byte("\x1b[13u"), 0)
|
||||
if !done || action.kind != "spawn-agent" {
|
||||
t.Fatalf("Enter via CSI u didn't accept: action=%+v done=%v", action, done)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaletteKittyBackspace(t *testing.T) {
|
||||
p := newTestPalette()
|
||||
p.query = []rune("hello")
|
||||
_, _, _ = p.handleInput([]byte("\x1b[127u"), 0)
|
||||
if string(p.query) != "hell" {
|
||||
t.Fatalf("query %q after backspace", string(p.query))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaletteLegacyPrintableTypes(t *testing.T) {
|
||||
p := newTestPalette()
|
||||
_, _, _ = p.handleInput([]byte("a"), 0)
|
||||
_, _, _ = p.handleInput([]byte("b"), 0)
|
||||
if string(p.query) != "ab" {
|
||||
t.Fatalf("query %q", string(p.query))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user