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

@@ -15,10 +15,10 @@ import (
cpty "github.com/creack/pty"
"golang.org/x/term"
"github.com/harrybrwn/patterm/internal/mcp"
"github.com/harrybrwn/patterm/internal/preset"
"github.com/harrybrwn/patterm/internal/scratchpad"
"github.com/harrybrwn/patterm/internal/trust"
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/scratchpad"
"github.com/hjbdev/patterm/internal/trust"
)
// Options configures a patterm run.
@@ -607,12 +607,34 @@ func (st *uiState) processStdin(chunk []byte) {
var pendingAction *paletteAction
// Tracks the last arrow direction and the byte offset immediately
// after its CSI sequence. Some terminals emit a duplicate adjacent
// arrow event for one physical keypress (legacy `CSI B` + kitty
// `CSI 57353 u`, or two of the same form back-to-back). We collapse
// those into a single navigation step. Any non-arrow byte resets the
// tracker so genuine consecutive presses across other input still
// register normally.
var lastNav byte
var lastNavEnd int
i := 0
for i < len(chunk) {
b := chunk[i]
// Palette mode swallows all bytes.
if st.palette != nil {
if nav, navLen := peekArrowEvent(chunk, i); nav != 0 {
if i == lastNavEnd && nav == lastNav {
i += navLen
continue
}
lastNav = nav
lastNavEnd = i + navLen
} else {
lastNav = 0
lastNavEnd = -1
}
action, done, adv := st.palette.handleInput(chunk, i)
if adv <= 0 {
adv = 1
@@ -662,6 +684,15 @@ func (st *uiState) processStdin(chunk []byte) {
func (st *uiState) openPaletteLocked() {
st.palette = newPalette(st.sess.Children(), st.focusedID, st.presets)
// Push a "no kitty flags" entry onto the host terminal's keyboard
// stack so palette input arrives in plain legacy form regardless of
// what the focused child pushed. Codex/ratatui enables kitty mode
// for its own PTY; that push gets forwarded to the host and leaves
// the host emitting arrow keys in multiple forms, which manifests
// as the palette double-stepping on Down/Up. Popped on close.
st.outMu.Lock()
_, _ = os.Stdout.WriteString("\x1b[>0u")
st.outMu.Unlock()
st.renderPaletteLocked()
}
@@ -673,6 +704,11 @@ func (st *uiState) closePalette(action paletteAction) {
st.mu.Lock()
st.palette = nil
st.mu.Unlock()
// Pair with the push in openPaletteLocked: restore whatever
// keyboard flags the focused child had configured.
st.outMu.Lock()
_, _ = os.Stdout.WriteString("\x1b[<u")
st.outMu.Unlock()
st.clearScreen()
switch action.kind {
@@ -761,19 +797,81 @@ func (st *uiState) flashTransient(msg string) {
// repaintFocused redraws the current focused child's screen snapshot.
// Callers must NOT hold st.mu — repaintFocused takes it
// briefly itself.
//
// We feed the emulator's VT serialization through the viewport
// renderer so SGR styling, alt-screen state, and the cursor position
// survive a focus switch. The plain-text path (renderScreenSnapshot)
// is kept as a fallback for environments where SerializeVT is
// unavailable (e.g. the nocgo stub).
func (st *uiState) repaintFocused() {
st.mu.Lock()
id := st.focusedID
renderer := st.renderer
layout := st.layoutLocked()
st.mu.Unlock()
if id == "" {
st.renderEmptyState()
return
}
// Ratatui (codex) and other diff-based renderers can drift between
// their internal "last frame" model and the emulator state when they
// run unfocused, leaving incremental updates that target the wrong
// cells after we replay. Nudge the focused child to redraw fully so
// its next frame matches what we just put on the host.
if c := st.sess.FindChild(id); c != nil && c.Status() == StatusRunning {
cols, rows := layout.childCols(), layout.childRows()
defer c.NudgeRedraw(cols, rows)
}
if renderer != nil {
if serialized, err := st.sess.SerializeChild(id); err == nil && len(serialized) > 0 {
// Reset host terminal state before replaying so leftover
// modes from the previously-focused child (DECSTBM,
// DECOM, SGR) don't distort the snapshot. The DECSTBM is
// pinned to the viewport region in host coordinates; the
// cursor parks at the viewport's top-left. The replayed
// SerializeVT may re-set these modes if the child
// configured them, which is fine — we're just guaranteeing
// a known starting baseline.
mainBottom := int(layout.statusRow) - statusRows
prelude := fmt.Sprintf(
"\x1b[0m\x1b[?6l\x1b[%d;%dr\x1b[?25h\x1b[%d;%dH",
int(layout.mainTop), mainBottom,
int(layout.mainTop), int(layout.mainLeft),
)
out := []byte(prelude)
out = append(out, renderer.Render(serialized)...)
// Ghostty's VT serialization emits the cursor CUP, then
// DECSTBM (which moves the cursor to region home as a
// documented side effect), then tab-stop setup using CHA
// (\x1b[NG) — which leaves the renderer's internal vr.col
// tracking pointing at the last tab-stop column, not
// where the cursor actually ended up. Re-emit the saved
// cursor as a child-space CUP through the renderer so
// (a) the host cursor lands at the right place and (b)
// the renderer's internal row/col tracking is brought
// back in sync with the host. Without this, subsequent
// relative moves (CSI C/D) and erase-line widths (CSI K
// uses vr.col) operate from a stale column and the input
// box gets drawn at the wrong width / row.
if _, cursor, err := st.sess.SnapshotChild(id); err == nil {
cup := fmt.Sprintf("\x1b[%d;%dH",
int(cursor.Row)+1, int(cursor.Col)+1)
out = append(out, renderer.Render([]byte(cup))...)
}
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(out)
return
}
}
text, cursor, err := st.sess.SnapshotChild(id)
if err != nil {
return
}
out := renderScreenSnapshot(text, cursor, st.layoutSnapshot())
out := renderScreenSnapshot(text, cursor, layout)
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(out)