package app import ( "testing" "github.com/hjbdev/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)) } } // "Spawn process…" is intercepted on accept: it switches the palette // into the form mode instead of closing it. Subsequent Enter on a // non-empty command line emits the submit action with relaunch reflecting // the checkbox state. func TestPaletteSpawnProcessFormFlow(t *testing.T) { p := newPalette(nil, "", preset.Set{}) // The "Spawn process…" entry is the only non-Quit item with an // empty preset list. Locate its index by scanning items. idx := -1 for i, it := range p.items { if it.action.kind == "spawn-process-form" { idx = i break } } if idx < 0 { t.Fatalf("no spawn-process-form item in palette items: %+v", p.items) } p.cursor = idx // Enter on the entry opens the form (done=false, mode flips). action, done, _ := p.handleInput([]byte("\r"), 0) if done { t.Fatalf("spawn-process-form accept closed palette: action=%+v", action) } if p.mode != paletteModeSpawnForm || p.form == nil { t.Fatalf("palette did not switch to form mode: mode=%v form=%v", p.mode, p.form) } // Type a command: "bun run dev". for _, b := range []byte("bun run dev") { _, _, _ = p.handleInput([]byte{b}, 0) } if string(p.form.cmd) != "bun run dev" { t.Fatalf("form cmd = %q", string(p.form.cmd)) } // Tab to the relaunch field, toggle with space. _, _, _ = p.handleInput([]byte{'\t'}, 0) if p.form.field != 1 { t.Fatalf("field after tab = %d, want 1", p.form.field) } _, _, _ = p.handleInput([]byte{' '}, 0) if !p.form.relaunch { t.Fatalf("relaunch toggle didn't stick") } // Enter submits. action, done, _ = p.handleInput([]byte("\r"), 0) if !done || action.kind != "spawn-process-submit" { t.Fatalf("submit didn't fire: action=%+v done=%v", action, done) } if action.command != "bun run dev" || !action.relaunch { t.Fatalf("submit payload = %+v", action) } } func TestPaletteSpawnProcessFormEmptyCommandCancels(t *testing.T) { p := newPalette(nil, "", preset.Set{}) p.mode = paletteModeSpawnForm p.form = &spawnProcessForm{} action, done, _ := p.handleInput([]byte("\r"), 0) if !done || action.kind != "cancel" { t.Fatalf("empty submit didn't cancel: action=%+v done=%v", action, done) } } func TestPaletteSpawnProcessFormEscCancels(t *testing.T) { p := newPalette(nil, "", preset.Set{}) p.mode = paletteModeSpawnForm p.form = &spawnProcessForm{cmd: []rune("x")} action, done, _ := p.handleInput([]byte{0x1b}, 0) if !done || action.kind != "cancel" { t.Fatalf("ESC didn't cancel form: action=%+v done=%v", action, done) } } // peekArrowEvent powers the chunk-level dedupe in processStdin. The // scenarios below cover the patterns we've actually seen terminals // emit for one physical Down press: a kitty press event, a legacy CSI // arrow, and the pair of the two adjacent. We assert classification // here so processStdin can rely on it. func TestPeekArrowEventClassifies(t *testing.T) { cases := []struct { name string in []byte wantNav byte wantLen int }{ {"legacy down", []byte("\x1b[B"), 'D', 3}, {"legacy up", []byte("\x1b[A"), 'U', 3}, {"kitty down press", []byte("\x1b[57353u"), 'D', 8}, {"kitty up press", []byte("\x1b[57352u"), 'U', 8}, {"kitty down release", []byte("\x1b[57353;1:3u"), 0, 12}, {"kitty enter", []byte("\x1b[13u"), 0, 0}, {"not a CSI", []byte("a"), 0, 0}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { nav, adv := peekArrowEvent(tc.in, 0) if nav != tc.wantNav || adv != tc.wantLen { t.Fatalf("got nav=%q len=%d, want nav=%q len=%d", nav, adv, tc.wantNav, tc.wantLen) } }) } }