Files
patterm/internal/app/palette_input_test.go
Harry Bayliss 05f92a3ed0 Add context-aware items to the command palette
When opened with Ctrl-K, the palette now prepends entries for whatever
is currently focused:

- Focused scratchpad: Delete / Rename (inline form) / Edit (fire-and-
  forget zed launch with stdio detached so the TUI is not suspended).
- Focused agent: Rename (inline form) / Close.
- Focused process: Rename / Delete (drops the entry; SIGKILL if alive)
  / Stop (SIGTERM, keep entry) / Restart (same argv).

The rename UX is a single-field inline form that mirrors the existing
spawn-process form, so the modal-input contract is unchanged.
scratchpad.Store grows Delete / Rename / Path so the palette can act
on a pad file by name. focusedPad is plumbed onto uiState ahead of the
scratchpad-focus UI work; until that lands it stays empty and the
scratchpad-context entries simply never surface.

Tested with palette_context_test.go and a new rename_process_via_palette
harness scenario.
2026-05-15 00:51:07 +01:00

217 lines
6.4 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)
}
}
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)
}
})
}
}