Files
patterm/internal/app/palette.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

536 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"fmt"
"strings"
"unicode/utf8"
"github.com/hjbdev/patterm/internal/preset"
)
// paletteAction is what the palette returns when the user picks an item.
type paletteAction struct {
// kind: "spawn-agent" | "spawn-process" | "switch" | "kill" | "quit" | "cancel"
kind string
// For spawn-*, the preset to launch.
preset *preset.Preset
// For "switch" and "kill", the target child id.
childID string
}
type paletteItem struct {
label string
hint string
action paletteAction
}
// paletteState is the in-memory model for the overlay. SPEC §4: a
// single fuzzy-searchable list of commands scoped to the current focus.
type paletteState struct {
query []rune
cursor int
children []*Child
focused string
presets preset.Set
items []paletteItem
}
func newPalette(children []*Child, focused string, presets preset.Set) *paletteState {
p := &paletteState{children: children, focused: focused, presets: presets}
p.rebuild()
return p
}
func (p *paletteState) rebuild() {
all := p.allItems()
q := strings.ToLower(string(p.query))
if q == "" {
p.items = all
} else {
p.items = p.items[:0]
for _, it := range all {
if fuzzyMatch(strings.ToLower(it.label+" "+it.hint), q) {
p.items = append(p.items, it)
}
}
}
if p.cursor >= len(p.items) {
p.cursor = len(p.items) - 1
}
if p.cursor < 0 {
p.cursor = 0
}
}
func (p *paletteState) allItems() []paletteItem {
var out []paletteItem
// Preset commands first — SPEC §4 calls these out as the primary
// way to spawn anything. One entry per file under presets/.
for _, pr := range p.presets.Agents {
out = append(out, paletteItem{
label: "Spawn agent: " + pr.Name,
hint: strings.Join(pr.Argv, " "),
action: paletteAction{kind: "spawn-agent", preset: pr},
})
}
for _, pr := range p.presets.Processes {
out = append(out, paletteItem{
label: "Run process: " + pr.Name,
hint: strings.Join(pr.Argv, " "),
action: paletteAction{kind: "spawn-process", preset: pr},
})
}
// Switch / Kill entries — one per existing child.
for _, c := range p.children {
label := "Switch to " + c.Name
hint := strings.Join(c.Argv, " ")
if c.ID == p.focused {
label = "• " + label + " (current)"
}
if c.Status() != StatusRunning {
label = label + " [" + string(c.Status()) + "]"
}
out = append(out, paletteItem{
label: label,
hint: hint,
action: paletteAction{kind: "switch", childID: c.ID},
})
}
for _, c := range p.children {
if c.Status() != StatusRunning {
continue
}
out = append(out, paletteItem{
label: "Kill " + c.Name,
hint: "SIGTERM " + strings.Join(c.Argv, " "),
action: paletteAction{kind: "kill", childID: c.ID},
})
}
out = append(out, paletteItem{
label: "Quit",
hint: "exit patterm; SIGTERM every child",
action: paletteAction{kind: "quit"},
})
return out
}
func fuzzyMatch(hay, needle string) bool {
if needle == "" {
return true
}
hi := 0
for _, r := range needle {
idx := strings.IndexRune(hay[hi:], r)
if idx < 0 {
return false
}
hi += idx + utf8.RuneLen(r)
}
return true
}
// kitty functional keycodes for arrows.
const (
kittyKeyUp = 57352
kittyKeyDown = 57353
)
// peekArrowEvent classifies the CSI sequence at chunk[i:] as Up ('U'),
// Down ('D'), or none (0) and returns the byte length of that sequence.
// Used by the palette input loop to suppress duplicate adjacent
// arrow events some terminals emit for a single physical keypress
// (either two legacy `CSI B` in a row, or a legacy + kitty pair).
func peekArrowEvent(chunk []byte, i int) (nav byte, advance int) {
if i >= len(chunk) || chunk[i] != 0x1b {
return 0, 0
}
n := csiLen(chunk, i)
if n == 0 {
return 0, 0
}
final := chunk[i+n-1]
switch final {
case 'A':
return 'U', n
case 'B':
return 'D', n
case 'u':
k, ok := decodeCSIu(string(chunk[i+2 : i+n-1]))
if !ok || k.event != 1 {
return 0, n
}
switch k.key {
case kittyKeyUp:
return 'U', n
case kittyKeyDown:
return 'D', n
}
}
return 0, 0
}
// 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 {
if n := csiLen(chunk, i); n > 0 {
return p.handleCSI(chunk[i+2:i+n-1], chunk[i+n-1], n)
}
// Bare ESC (no CSI follow-up): cancel.
return paletteAction{kind: "cancel"}, true, 1
}
switch b {
case '\r', '\n':
return p.accept(), true, 1
case 0x7f, 0x08:
p.backspace()
case 0x15: // Ctrl-U
p.clearQuery()
case 0x0e: // Ctrl-N
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:
if b >= 0x20 && b < 0x7f {
p.query = append(p.query, rune(b))
p.rebuild()
}
}
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. Layout is a rounded box with a
// title bar, query line, divider, item list, divider, and footer.
// The caller is responsible for the screen clear before the first
// render.
func (p *paletteState) render(out writeFlusher, cols, rows int) {
if cols < 32 {
cols = 32
}
if rows < 10 {
rows = 10
}
width := cols - 8
if width > 72 {
width = 72
}
if width < 40 {
width = cols - 2
}
if width < 32 {
width = 32
}
leftPad := (cols - width) / 2
if leftPad < 1 {
leftPad = 1
}
content := width - 4 // visible cells between the " " padding on each side
var b strings.Builder
b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J")
row := 2
titleText := "patterm"
keyHint := "Ctrl-K"
// ╭─ patterm ─...─ Ctrl-K ─╮ uses: 3 + len(title) + 1 + dashes + 1 + len(hint) + 3
dashes := width - 3 - len(titleText) - 1 - 1 - len(keyHint) - 3
if dashes < 2 {
dashes = 2
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "╭─ " + styleActive + titleText + styleReset + styleBorder + " " +
strings.Repeat("─", dashes) + " " + styleHint + keyHint + styleReset + styleBorder + " ─╮" + styleReset)
row++
queryStr := string(p.query)
queryRow := row
qLen := utf8.RuneCountInString(queryStr)
qPad := content - 2 - qLen
if qPad < 0 {
qPad = 0
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + styleAccent + "" + styleReset + " " + queryStr +
strings.Repeat(" ", qPad) + " " + styleBorder + "│" + styleReset)
row++
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++
maxItems := rows - 6
if maxItems > 12 {
maxItems = 12
}
if maxItems < 1 {
maxItems = 1
}
start := 0
if p.cursor >= maxItems {
start = p.cursor - maxItems + 1
}
end := start + maxItems
if end > len(p.items) {
end = len(p.items)
}
for i := 0; i < maxItems; i++ {
moveTo(&b, row, leftPad)
if start+i >= end {
if len(p.items) == 0 && i == 0 {
msg := styleDim + "no matches" + styleReset
pad := content - 2 - 10
if pad < 0 {
pad = 0
}
b.WriteString(styleBorder + "│" + styleReset + " " + msg +
strings.Repeat(" ", pad) + " " + styleBorder + "│" + styleReset)
} else {
b.WriteString(styleBorder + "│" + styleReset + strings.Repeat(" ", width-2) +
styleBorder + "│" + styleReset)
}
row++
continue
}
it := p.items[start+i]
isSel := (start + i) == p.cursor
avail := content - 2 // 2 cells reserved for the selection indicator
label := it.label
hint := it.hint
labelLen := utf8.RuneCountInString(label)
hintLen := utf8.RuneCountInString(hint)
if labelLen > avail {
label = clipRunes(label, avail-1) + "…"
labelLen = utf8.RuneCountInString(label)
hint = ""
hintLen = 0
} else if hintLen > 0 {
gap := avail - labelLen - hintLen
if gap < 3 {
budget := avail - labelLen - 3
if budget > 1 {
hint = clipRunes(hint, budget-1) + "…"
hintLen = utf8.RuneCountInString(hint)
} else {
hint = ""
hintLen = 0
}
}
}
gap := avail - labelLen - hintLen
if gap < 0 {
gap = 0
}
var indicator, labelStr, hintStr string
if isSel {
indicator = styleAccent + "▎" + styleReset + " "
labelStr = styleBold + label + styleReset
} else {
indicator = " "
labelStr = label
}
if hint != "" {
hintStr = styleHint + hint + styleReset
}
b.WriteString(styleBorder + "│" + styleReset + " " + indicator + labelStr +
strings.Repeat(" ", gap) + hintStr + " " + styleBorder + "│" + styleReset)
row++
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++
footer := "↵ run · esc close · ↑↓ navigate"
fLen := utf8.RuneCountInString(footer)
fPad := content - fLen
if fPad < 0 {
fPad = 0
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + styleHint + footer + styleReset +
strings.Repeat(" ", fPad) + " " + styleBorder + "│" + styleReset)
row++
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "╰" + strings.Repeat("─", width-2) + "╯" + styleReset)
// Park the real terminal cursor at the end of the query so it
// blinks naturally in place of the old underscore stub.
moveTo(&b, queryRow, leftPad+4+qLen)
b.WriteString("\x1b[?25h")
_, _ = out.Write([]byte(b.String()))
_ = out.Flush()
}
func clipRunes(s string, n int) string {
if n <= 0 {
return ""
}
count := 0
for i := range s {
if count == n {
return s[:i]
}
count++
}
return s
}
type writeFlusher interface {
Write(p []byte) (int, error)
Flush() error
}
type writeFlusherBase interface {
Write(p []byte) (int, error)
}
type nopFlusher struct{ io writeFlusherBase }
func wrapWriter(w writeFlusherBase) writeFlusher { return nopFlusher{io: w} }
func (n nopFlusher) Write(p []byte) (int, error) { return n.io.Write(p) }
func (n nopFlusher) Flush() error { return nil }
func moveTo(b *strings.Builder, row, col int) {
fmt.Fprintf(b, "\x1b[%d;%dH", row, col)
}
func padRight(s string, width int) string {
w := width - visibleLen(s)
if w <= 0 {
return s
}
return s + strings.Repeat(" ", w)
}
func visibleLen(s string) int {
n := 0
in := false
for _, r := range s {
if r == 0x1b {
in = true
continue
}
if in {
if r == 'm' || r == 'H' {
in = false
}
continue
}
n++
}
return n
}
func countAnsi(s string) int {
return len(s) - visibleLen(s)
}