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

@@ -4,13 +4,18 @@ import (
"fmt"
"os"
"strings"
"unicode/utf8"
)
const tabBarRows = 1
// Three-row tab bar: labels row, subtitle row, underline row. The PTY
// viewport's top row is therefore mainTop == tabBarRows + 1.
const tabBarRows = 3
// drawTabBar renders SPEC §4's top tab bar at row 1. Tabs are top-level
// children (ParentID == ""); the focused tab is highlighted. The PTY
// region begins at row 2.
// drawTabBar renders the top tab strip across the full host width. The
// strip has three rows: labels (with horizontal padding), a dim
// subtitle showing each child's argv, and an underline that's thick +
// accent for the focused tab and faint for the rest. Subtitles are
// truncated with `…` to the tab's width.
func (st *uiState) drawTabBar() {
st.mu.Lock()
palOpen := st.palette != nil
@@ -21,6 +26,9 @@ func (st *uiState) drawTabBar() {
}
layout := st.layoutSnapshot()
width := int(layout.childCols())
if width < 8 {
return
}
var sessions []*Child
for _, c := range st.sess.Children() {
@@ -29,42 +37,124 @@ func (st *uiState) drawTabBar() {
}
}
var b strings.Builder
b.WriteString("\x1b[1;1H")
cur := 0
type tabRect struct {
startCol int
width int
label string
subtitle string
active bool
}
const (
leadingPad = 2 // host columns before the first tab
tabPad = 2 // spaces on each side of the label inside the tab
tabGap = 1 // gap columns between adjacent tabs
tailReserve = 8 // reserve room for the trailing "+ new" hint
)
tabs := make([]tabRect, 0, len(sessions))
cur := leadingPad + 1
for _, c := range sessions {
label := c.Name
seg := " " + label + " "
if cur+len(seg) > width-2 {
labelW := utf8.RuneCountInString(label)
tabW := labelW + tabPad*2
// If the tab won't fit, try truncating the label down to whatever
// space is left (label still has to leave room for "…").
if cur+tabW+tabGap+tailReserve > width+1 {
avail := width + 1 - cur - tabGap - tailReserve - tabPad*2
if avail < 3 {
break
}
label = clipRunes(label, avail-1) + "…"
labelW = utf8.RuneCountInString(label)
tabW = labelW + tabPad*2
tabs = append(tabs, tabRect{
startCol: cur, width: tabW,
label: label, subtitle: strings.Join(c.Argv, " "),
active: c.ID == focus,
})
cur += tabW + tabGap
break
}
if c.ID == focus {
b.WriteString("\x1b[7m")
tabs = append(tabs, tabRect{
startCol: cur, width: tabW,
label: label, subtitle: strings.Join(c.Argv, " "),
active: c.ID == focus,
})
cur += tabW + tabGap
}
var b strings.Builder
// Clear all three rows up front so a stale label from the previous
// frame can't bleed through.
b.WriteString("\x1b[1;1H\x1b[2K")
b.WriteString("\x1b[2;1H\x1b[2K")
b.WriteString("\x1b[3;1H\x1b[2K")
for _, t := range tabs {
// Row 1: label
fmt.Fprintf(&b, "\x1b[1;%dH", t.startCol)
if t.active {
b.WriteString(styleActive)
} else {
b.WriteString("\x1b[2m")
b.WriteString(styleHint)
}
b.WriteString(seg)
b.WriteString("\x1b[0m")
cur += len(seg)
b.WriteString(strings.Repeat(" ", tabPad))
b.WriteString(t.label)
b.WriteString(strings.Repeat(" ", tabPad))
b.WriteString(styleReset)
// Row 2: subtitle, truncated to tab width and dimmed.
sub := t.subtitle
if utf8.RuneCountInString(sub) > t.width {
if t.width > 1 {
sub = clipRunes(sub, t.width-1) + "…"
} else {
sub = ""
}
}
padR := t.width - utf8.RuneCountInString(sub)
if padR < 0 {
padR = 0
}
fmt.Fprintf(&b, "\x1b[2;%dH%s%s%s%s",
t.startCol, styleDim, sub, strings.Repeat(" ", padR), styleReset)
// Row 3: underline. Thick accent for the active tab, faint
// border for the rest.
fmt.Fprintf(&b, "\x1b[3;%dH", t.startCol)
if t.active {
b.WriteString(styleAccent)
b.WriteString(strings.Repeat("━", t.width))
} else {
b.WriteString(styleBorder)
b.WriteString(strings.Repeat("─", t.width))
}
b.WriteString(styleReset)
}
// "+" hint at end.
hint := "+"
if cur > 0 {
hint = " +"
// "+ new" hint at the end of the labels row, in dim.
if cur+3 <= width {
fmt.Fprintf(&b, "\x1b[1;%dH%s+ new%s", cur+1, styleDim, styleReset)
}
if cur+len(hint) <= width {
b.WriteString("\x1b[2m")
b.WriteString(hint)
b.WriteString("\x1b[0m")
cur += len(hint)
// Extend the faint underline across the rest of the host width so
// the tab strip reads as one continuous divider.
if cur <= width {
remain := width - cur + 1
if remain > 0 {
fmt.Fprintf(&b, "\x1b[3;%dH%s%s%s",
cur, styleBorder, strings.Repeat("─", remain), styleReset)
}
}
// Fill the rest of the tab-bar row so stale chars don't linger.
if width-cur > 0 {
b.WriteString(strings.Repeat(" ", width-cur))
if leadingPad > 0 {
fmt.Fprintf(&b, "\x1b[3;1H%s%s%s",
styleBorder, strings.Repeat("", leadingPad), styleReset)
}
st.outMu.Lock()
defer st.outMu.Unlock()
// Save cursor, paint, restore.
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", b.String())
}