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.
This commit is contained in:
2026-05-14 16:02:40 +01:00
parent cb3e51d568
commit 39a042bda8
22 changed files with 729 additions and 178 deletions

View File

@@ -5,7 +5,7 @@ import (
"strings"
"unicode/utf8"
"github.com/harrybrwn/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/preset"
)
// paletteAction is what the palette returns when the user picks an item.
@@ -141,6 +141,40 @@ const (
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
@@ -266,42 +300,63 @@ func (p *paletteState) cursorDown() {
}
}
// render draws the palette onto out. Geometry: title bar + filter line +
// items + footer, centred. The caller is responsible for the screen
// clear before the first render.
// 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 < 20 {
cols = 20
if cols < 32 {
cols = 32
}
if rows < 6 {
rows = 6
if rows < 10 {
rows = 10
}
width := cols - 4
if width > 80 {
width = 80
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
}
row := 2
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("\x1b[1;7m")
b.WriteString(padRight(" patterm — Ctrl-K", width))
b.WriteString("\x1b[0m")
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("\x1b[7m")
b.WriteString(padRight(" "+string(p.query)+"_", width))
b.WriteString("\x1b[0m")
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++
maxItems := rows - 6
@@ -319,43 +374,116 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) {
if end > len(p.items) {
end = len(p.items)
}
for i := start; i < end; i++ {
it := p.items[i]
for i := 0; i < maxItems; i++ {
moveTo(&b, row, leftPad)
if i == p.cursor {
b.WriteString("\x1b[7m")
} else {
b.WriteString("\x1b[0m")
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
}
line := " " + it.label
if it.hint != "" {
line += " \x1b[2m— " + it.hint + "\x1b[0m"
if i == p.cursor {
line += "\x1b[7m"
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
}
}
}
b.WriteString(padRight(line, width+countAnsi(line)))
b.WriteString("\x1b[0m")
row++
}
if len(p.items) == 0 {
moveTo(&b, row, leftPad)
b.WriteString("\x1b[2m no matches\x1b[0m")
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("\x1b[2m")
b.WriteString(padRight(" Enter to run · Esc to close · ↑↓ to navigate", width))
b.WriteString("\x1b[0m")
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++
moveTo(&b, 3, leftPad+4+utf8.RuneCountInString(string(p.query)))
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