Files
patterm/internal/app/palette_input_test.go
Harry Bayliss 39a042bda8 Polish chrome and rework tab-switch repaint
Module renamed github.com/harrybrwn/patterm → github.com/hjbdev/patterm
across imports.

Chrome:
- Palette redrawn with rounded box-drawing borders, accent left-bar
  for the selected item, dim hints, and a separator-aware footer.
- Tab bar grew from 1 row to 3: labels with breathing room, a dim
  argv subtitle truncated to each tab's width, and an accent thick
  underline for the focused tab with a faint divider extending across
  the rest of the host width. Layout, viewport-renderer, and screen-
  renderer tests updated for the new mainTop.
- Sidebar reuses the same palette: accent section headers, `▎`
  selection marker, `●`/`○` status glyphs, dim previews.
- Shared SGR constants moved into internal/app/style.go.

Palette input:
- Adjacent duplicate arrow events (legacy `\x1b[B` + kitty
  `\x1b[57353u` for one keypress, or two of the same form) are now
  collapsed via peekArrowEvent + chunk-level dedupe in processStdin.
- On open, push `\x1b[>0u` onto the host's kitty keyboard stack so
  palette input is in plain legacy mode regardless of what the child
  pushed (codex/ratatui pushes its own flags which had been leaking
  to the host). Popped on close.

Tab-switch repaint (repaintFocused):
- Use the emulator's SerializeVT bytes (with SGR / cursor / DECSTBM
  / tabstops) instead of plain text, fed through the per-focused
  viewport renderer so the shifter translates row positions.
- Prelude resets host SGR / DECOM / DECSTBM (pinned to viewport) /
  cursor visibility before the replay, so leftover modes from the
  previously-focused child don't distort the new snapshot.
- Re-emit the saved cursor as a child-space CUP after the
  serialized bytes so the host cursor lands at the emulator's
  actual position (overriding DECSTBM's home side-effect and the
  tabstop-setup CHA sequences) AND the renderer's vr.row/vr.col
  get re-synced via trackCSI.
- cursorShifter now carries childRows and rewrites empty
  `\x1b[r` to `\x1b[<mainTop>;<mainBottom>r` (host coords) — the
  default (1,1) shifted to (4,4) was producing a one-row scrolling
  region that scroll-exploded the replay.
- After the snapshot lands, nudge the focused child with a one-row
  PTY winsize toggle so the kernel emits SIGWINCH and ratatui-style
  TUIs throw away their diff state and emit a fresh frame.

Codex still renders incorrectly after a focus switch; see TODO.md
"Switch-back render divergence" for the deep investigation handoff.
2026-05-14 16:02:40 +01:00

140 lines
3.9 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))
}
}
// 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)
}
})
}
}