Six-phase sweep: section headers (Focused / Open / Spawn / Quit) with header-skip cursor; chip strip mirroring sw/sp/k macros, driven by Tab; unified Spawn verbs across agent / process / terminal / custom; dropped duplicate global Close list in favor of Ctrl-X inline close on a Switch row plus the [Close] chip; scored matching (prefix > word-boundary > substring > fuzzy) with matched-char highlighting; title bar surfaces focus subject; rename forms split long subject onto its own row; new Alt-1..9 quick-pick, Home/End, ? help overlay, and Ctrl-R relaunch toggle inside the spawn-process form. Scroll indicator and cursor/total counter round out the footer.
231 lines
6.8 KiB
Go
231 lines
6.8 KiB
Go
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)
|
|
}
|
|
}
|
|
|
|
// firstSelectable returns the lowest item index whose action is
|
|
// selectable (not a section header), or -1 if the palette has no
|
|
// selectable rows.
|
|
func firstSelectable(p *paletteState) int {
|
|
for i, it := range p.items {
|
|
if it.action.kind != "header" {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func TestPaletteKittyArrowsNavigate(t *testing.T) {
|
|
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}, {Name: "c"}}
|
|
p := newPalette(nil, "", "", preset.Set{Agents: pr})
|
|
first := firstSelectable(p)
|
|
if first < 0 || p.cursor != first {
|
|
t.Fatalf("initial cursor %d, want first selectable %d", p.cursor, first)
|
|
}
|
|
// Kitty functional Down arrow.
|
|
_, _, adv := p.handleInput([]byte("\x1b[57353u"), 0)
|
|
if adv != 8 {
|
|
t.Fatalf("advance %d", adv)
|
|
}
|
|
if p.cursor != first+1 {
|
|
t.Fatalf("cursor %d after Down, want %d", p.cursor, first+1)
|
|
}
|
|
// Kitty functional Up arrow.
|
|
_, _, _ = p.handleInput([]byte("\x1b[57352u"), 0)
|
|
if p.cursor != first {
|
|
t.Fatalf("cursor %d after Up, want %d", p.cursor, first)
|
|
}
|
|
}
|
|
|
|
func TestPaletteLegacyArrowsStillWork(t *testing.T) {
|
|
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}}
|
|
p := newPalette(nil, "", "", preset.Set{Agents: pr})
|
|
first := firstSelectable(p)
|
|
_, _, adv := p.handleInput([]byte("\x1b[B"), 0)
|
|
if adv != 3 {
|
|
t.Fatalf("advance %d", adv)
|
|
}
|
|
if p.cursor != first+1 {
|
|
t.Fatalf("cursor %d, want %d", p.cursor, first+1)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|