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)

View File

@@ -14,8 +14,8 @@ import (
"syscall"
"time"
pkgpty "github.com/harrybrwn/patterm/internal/pty"
"github.com/harrybrwn/patterm/internal/vt"
pkgpty "github.com/hjbdev/patterm/internal/pty"
"github.com/hjbdev/patterm/internal/vt"
)
// portRegex matches dev-server URLs of the form `http(s)://host:NNNN[/path]`
@@ -374,6 +374,23 @@ func (c *Child) signal(sig syscall.Signal) error {
return syscall.Kill(pid, sig)
}
// NudgeRedraw asks the child to throw away any diff-based render state
// and emit a full frame on the next tick. Used after a focus switch so
// ratatui/ink TUIs re-render coherently against the snapshot we just
// replayed. We toggle the PTY size by one row so the kernel reliably
// emits SIGWINCH (TIOCSWINSZ skips the signal if the size didn't
// change). The emulator is left alone — it already matches our intended
// size and the brief mismatch only affects what the child writes during
// the second redraw.
func (c *Child) NudgeRedraw(cols, rows uint16) {
pty := c.PTY()
if pty == nil || rows < 2 {
return
}
_ = pty.Resize(cols, rows-1)
_ = pty.Resize(cols, rows)
}
func (c *Child) markExited(err error) {
exitCode := int32(0)
st := StatusExited

View File

@@ -22,10 +22,11 @@ import (
// CSI commands.
type cursorShifter struct {
rowOffset int
childRows int // viewport height in child rows; used for DECSTBM resets
state shifterState
buf []byte // bytes accumulated in current escape sequence (incl. introducer)
csiPrefix []byte // private prefix bytes (?, >, =) after CSI
buf []byte // bytes accumulated in current escape sequence (incl. introducer)
csiPrefix []byte // private prefix bytes (?, >, =) after CSI
pending strings.Builder
}
@@ -44,12 +45,13 @@ const (
stSOSPMAPCEsc
)
func newCursorShifter(rowOffset int) *cursorShifter {
return &cursorShifter{rowOffset: rowOffset}
func newCursorShifter(rowOffset, childRows int) *cursorShifter {
return &cursorShifter{rowOffset: rowOffset, childRows: childRows}
}
func (cs *cursorShifter) SetRowOffset(off int) {
cs.rowOffset = off
func (cs *cursorShifter) SetGeometry(rowOffset, childRows int) {
cs.rowOffset = rowOffset
cs.childRows = childRows
}
// Shift consumes a chunk of PTY-master bytes, applies row offsets to
@@ -209,8 +211,19 @@ func (cs *cursorShifter) emitCSI() {
cs.pending.WriteString(strconv.Itoa(r))
cs.pending.WriteByte(final)
case 'r':
// DECSTBM: top;bot. Empty resets to full region; we still
// shift to keep the chrome row reserved.
// DECSTBM: top;bot. Empty params (\x1b[r) means "reset to the
// full screen" from the child's point of view — for us that's
// the viewport, not the host's full screen. Rewriting it as
// (1,1)+offset would produce \x1b[4;4r, a one-row region that
// causes catastrophic scroll-up of the replayed snapshot.
if len(paramsRaw) == 0 && cs.childRows > 0 {
cs.pending.WriteString("\x1b[")
cs.pending.WriteString(strconv.Itoa(cs.rowOffset + 1))
cs.pending.WriteByte(';')
cs.pending.WriteString(strconv.Itoa(cs.rowOffset + cs.childRows))
cs.pending.WriteByte(final)
return
}
top, bot, ok := parseTwoParams(paramsRaw)
if !ok {
cs.pending.Write(cs.buf)

View File

@@ -6,7 +6,7 @@ import (
)
func TestCursorShifterCUP(t *testing.T) {
cs := newCursorShifter(1)
cs := newCursorShifter(1, 36)
got := cs.Shift([]byte("\x1b[H"))
want := []byte("\x1b[2;1H")
if !bytes.Equal(got, want) {
@@ -15,7 +15,7 @@ func TestCursorShifterCUP(t *testing.T) {
}
func TestCursorShifterCUPRowCol(t *testing.T) {
cs := newCursorShifter(1)
cs := newCursorShifter(1, 36)
got := cs.Shift([]byte("\x1b[10;5H"))
if string(got) != "\x1b[11;5H" {
t.Fatalf("CUP 10;5: got %q", got)
@@ -23,7 +23,7 @@ func TestCursorShifterCUPRowCol(t *testing.T) {
}
func TestCursorShifterVPA(t *testing.T) {
cs := newCursorShifter(1)
cs := newCursorShifter(1, 36)
got := cs.Shift([]byte("\x1b[7d"))
if string(got) != "\x1b[8d" {
t.Fatalf("VPA 7: got %q", got)
@@ -31,15 +31,27 @@ func TestCursorShifterVPA(t *testing.T) {
}
func TestCursorShifterDECSTBM(t *testing.T) {
cs := newCursorShifter(1)
cs := newCursorShifter(1, 36)
got := cs.Shift([]byte("\x1b[2;20r"))
if string(got) != "\x1b[3;21r" {
t.Fatalf("DECSTBM: got %q", got)
}
}
// Empty DECSTBM (\x1b[r) is a reset request; without a viewport-aware
// fix it would default to (1,1) and shift to a one-row scrolling
// region — that's what was scrolling claude's content up after a
// focus switch from codex.
func TestCursorShifterDECSTBMEmptyResetsToViewport(t *testing.T) {
cs := newCursorShifter(3, 36) // mainTop=4, childRows=36
got := cs.Shift([]byte("\x1b[r"))
if string(got) != "\x1b[4;39r" {
t.Fatalf("empty DECSTBM reset: got %q want \\x1b[4;39r", got)
}
}
func TestCursorShifterPrivateCSIPassthrough(t *testing.T) {
cs := newCursorShifter(1)
cs := newCursorShifter(1, 36)
// Alt-screen toggle — private CSI.
got := cs.Shift([]byte("\x1b[?1049h"))
if string(got) != "\x1b[?1049h" {
@@ -48,7 +60,7 @@ func TestCursorShifterPrivateCSIPassthrough(t *testing.T) {
}
func TestCursorShifterSGRPassthrough(t *testing.T) {
cs := newCursorShifter(1)
cs := newCursorShifter(1, 36)
got := cs.Shift([]byte("\x1b[1;31mhello\x1b[0m"))
if string(got) != "\x1b[1;31mhello\x1b[0m" {
t.Fatalf("SGR: got %q", got)
@@ -56,7 +68,7 @@ func TestCursorShifterSGRPassthrough(t *testing.T) {
}
func TestCursorShifterStraddleChunks(t *testing.T) {
cs := newCursorShifter(1)
cs := newCursorShifter(1, 36)
a := cs.Shift([]byte("\x1b["))
b := cs.Shift([]byte("5;3H"))
got := string(a) + string(b)
@@ -66,7 +78,7 @@ func TestCursorShifterStraddleChunks(t *testing.T) {
}
func TestCursorShifterOSCNotRewritten(t *testing.T) {
cs := newCursorShifter(1)
cs := newCursorShifter(1, 36)
// OSC body containing what looks like a CSI cursor move — should
// NOT be rewritten.
in := []byte("\x1b]0;\x1b[5;3Htitle\x07")

View File

@@ -8,11 +8,11 @@ import (
"syscall"
"time"
"github.com/harrybrwn/patterm/internal/mcp"
"github.com/harrybrwn/patterm/internal/preset"
"github.com/harrybrwn/patterm/internal/scratchpad"
"github.com/harrybrwn/patterm/internal/trust"
pkgvt "github.com/harrybrwn/patterm/internal/vt"
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/scratchpad"
"github.com/hjbdev/patterm/internal/trust"
pkgvt "github.com/hjbdev/patterm/internal/vt"
)
// attentionSink is implemented by uiState to surface

View File

@@ -9,7 +9,7 @@ import (
"sync"
"time"
"github.com/harrybrwn/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/preset"
)
// Launcher knows how to turn a preset into a running child. Both the

View File

@@ -3,7 +3,7 @@ package app
import (
"testing"
"github.com/harrybrwn/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/preset"
)
func TestTerminalLayoutWideUsesMainViewport(t *testing.T) {
@@ -14,10 +14,10 @@ func TestTerminalLayoutWideUsesMainViewport(t *testing.T) {
if l.childCols() != 92 {
t.Fatalf("child cols: got %d want 92", l.childCols())
}
if l.childRows() != 38 {
t.Fatalf("child rows: got %d want 38", l.childRows())
if l.childRows() != 36 {
t.Fatalf("child rows: got %d want 36", l.childRows())
}
if l.mainTop != 2 || l.statusRow != 40 {
if l.mainTop != 4 || l.statusRow != 40 {
t.Fatalf("unexpected vertical chrome: mainTop=%d statusRow=%d", l.mainTop, l.statusRow)
}
}
@@ -30,8 +30,8 @@ func TestTerminalLayoutNarrowHidesSidebar(t *testing.T) {
if l.childCols() != 38 {
t.Fatalf("child cols: got %d want 38", l.childCols())
}
if l.childRows() != 10 {
t.Fatalf("child rows: got %d want 10", l.childRows())
if l.childRows() != 8 {
t.Fatalf("child rows: got %d want 8", l.childRows())
}
}
@@ -46,13 +46,13 @@ func TestSpawnSizingUsesViewportDimensions(t *testing.T) {
l := newTerminalLayout(120, 40)
launcher := NewLauncher(nil, "", l.childCols(), l.childRows())
cols, rows := launcher.size()
if cols != 92 || rows != 38 {
t.Fatalf("launcher size: got %dx%d want 92x38", cols, rows)
if cols != 92 || rows != 36 {
t.Fatalf("launcher size: got %dx%d want 92x36", cols, rows)
}
host := newToolHost(nil, nil, nil, preset.Set{}, nil, l.childCols(), l.childRows())
cols, rows = host.size()
if cols != 92 || rows != 38 {
t.Fatalf("tool host size: got %dx%d want 92x38", cols, rows)
if cols != 92 || rows != 36 {
t.Fatalf("tool host size: got %dx%d want 92x36", cols, rows)
}
}

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

View File

@@ -3,7 +3,7 @@ package app
import (
"testing"
"github.com/harrybrwn/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/preset"
)
func newTestPalette() *paletteState {
@@ -106,3 +106,34 @@ func TestPaletteLegacyPrintableTypes(t *testing.T) {
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)
}
})
}
}

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"strings"
"github.com/harrybrwn/patterm/internal/vt"
"github.com/hjbdev/patterm/internal/vt"
)
func renderScreenSnapshot(text string, cursor vt.CursorState, layout terminalLayout) []byte {

View File

@@ -1,33 +1,41 @@
package app
import (
"fmt"
"strings"
"testing"
"github.com/harrybrwn/patterm/internal/vt"
"github.com/hjbdev/patterm/internal/vt"
)
func TestRenderScreenSnapshotClipsRowsToViewport(t *testing.T) {
layout := newTerminalLayout(20, 5)
// hostRows=8 leaves three rows of viewport once the 3-row tab bar
// and 1-row status line are reserved.
layout := newTerminalLayout(20, 8)
mainTop := int(layout.mainTop)
got := string(renderScreenSnapshot("abcdefghijklmnopqrstuvwxy\nsecond", vt.CursorState{}, layout))
if strings.Contains(got, "uvwxy") {
t.Fatalf("line leaked past viewport width: %q", got)
}
if !strings.Contains(got, "\x1b[2;1Habcdefghijklmnopqrst") {
first := fmt.Sprintf("\x1b[%d;1Habcdefghijklmnopqrst", mainTop)
if !strings.Contains(got, first) {
t.Fatalf("first row not drawn at viewport top: %q", got)
}
if !strings.Contains(got, "\x1b[3;1Hsecond ") {
second := fmt.Sprintf("\x1b[%d;1Hsecond ", mainTop+1)
if !strings.Contains(got, second) {
t.Fatalf("second row not padded in viewport: %q", got)
}
if !strings.Contains(got, "\x1b[4;1H ") {
blank := fmt.Sprintf("\x1b[%d;1H ", mainTop+2)
if !strings.Contains(got, blank) {
t.Fatalf("blank viewport row not cleared: %q", got)
}
}
func TestRenderScreenSnapshotPlacesCursorInsideViewport(t *testing.T) {
layout := newTerminalLayout(20, 5)
layout := newTerminalLayout(20, 8)
got := string(renderScreenSnapshot("abc", vt.CursorState{Col: 2, Row: 1, Visible: true}, layout))
if !strings.HasSuffix(got, "\x1b[?25h\x1b[3;3H") {
t.Fatalf("cursor not placed inside viewport: %q", got)
want := fmt.Sprintf("\x1b[?25h\x1b[%d;3H", int(layout.mainTop)+1)
if !strings.HasSuffix(got, want) {
t.Fatalf("cursor not placed inside viewport: %q (want suffix %q)", got, want)
}
}

View File

@@ -15,7 +15,7 @@ import (
"syscall"
"time"
"github.com/harrybrwn/patterm/internal/vt"
"github.com/hjbdev/patterm/internal/vt"
)
// Session is the in-memory state for the running patterm process.

View File

@@ -36,96 +36,112 @@ func (st *uiState) drawSidebar() {
maxRow := int(layout.statusRow) - statusRows
var b strings.Builder
// Border column at left-1: a single vertical pipe.
for r := 1; r <= maxRow; r++ {
fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[2m│\x1b[0m", r, left-1)
fmt.Fprintf(&b, "\x1b[%d;%dH%s│%s", r, left-1, styleBorder, styleReset)
}
row := 1
writeLine := func(s string, style string) {
// write paints one styled line into the sidebar column band and pads
// it out to `width` cells. Content may carry inline SGR escapes —
// visibleLen ignores them when computing padding.
write := func(content string) {
if row > maxRow {
return
}
if len(s) > width {
s = s[:width]
pad := width - visibleLen(content)
if pad < 0 {
pad = 0
}
fmt.Fprintf(&b, "\x1b[%d;%dH%s%s\x1b[0m\x1b[K", row, left, style, padRight(s, width))
fmt.Fprintf(&b, "\x1b[%d;%dH%s%s%s\x1b[K",
row, left, content, strings.Repeat(" ", pad), styleReset)
row++
}
writeHeader := func(text string) {
write(" " + styleActive + text + styleReset)
write(" " + styleBorder + strings.Repeat("─", width-2) + styleReset)
}
statusGlyph := func(c *Child, focused bool) string {
if c.Status() != StatusRunning {
return styleDim + "○" + styleReset
}
if focused {
return styleAccent + "●" + styleReset
}
return styleHint + "●" + styleReset
}
writeLine(" Session tree", "\x1b[1m")
writeLine(strings.Repeat("─", width-1), "\x1b[2m")
writeHeader("Session tree")
children := visibleSessionTree(st.sess.Children(), focus)
if len(children) == 0 {
writeLine(" (empty)", "\x1b[2m")
write(" " + styleDim + "(empty)" + styleReset)
}
for _, c := range children {
glyph := "◉"
marker := " "
if c.ID == focus {
marker = "▶ "
if row > maxRow {
break
}
indent := ""
if c.ParentID != "" {
indent = " "
}
line := fmt.Sprintf(" %s%s%s %s", marker, indent, glyph, c.Name)
style := ""
if c.ID == focus {
style = "\x1b[1m"
focused := c.ID == focus
glyph := statusGlyph(c, focused)
var line string
if focused {
line = " " + styleAccent + "▎" + styleReset + " " + indent + glyph + " " +
styleBold + c.Name + styleReset
} else {
line = " " + indent + glyph + " " + styleHint + c.Name + styleReset
}
writeLine(line, style)
write(line)
}
// Scratchpads list — pick the most-recently-modified one as the
// preview target. SPEC §4.
var previewName string
if row+2 <= maxRow {
row++
writeLine(" Scratchpads", "\x1b[1m")
writeLine(strings.Repeat("─", width-1), "\x1b[2m")
write("")
writeHeader("Scratchpads")
entries, err := st.pads.List()
if err == nil {
if len(entries) == 0 {
writeLine(" (none)", "\x1b[2m")
}
var newest string
var newestTS string
for _, e := range entries {
if e.ModifiedAt > newestTS {
newestTS = e.ModifiedAt
newest = e.Name
write(" " + styleDim + "(none)" + styleReset)
} else {
var newestTS string
for _, e := range entries {
if e.ModifiedAt > newestTS {
newestTS = e.ModifiedAt
previewName = e.Name
}
}
}
previewName = newest
for _, e := range entries {
if row > maxRow {
break
for _, e := range entries {
if row > maxRow {
break
}
var line string
if e.Name == previewName {
line = " " + styleAccent + "▎" + styleReset + " " +
styleBold + e.Name + styleReset
} else {
line = " " + styleHint + e.Name + styleReset
}
write(line)
}
marker := " "
style := ""
if e.Name == previewName {
marker = " ▸ "
style = "\x1b[1m"
}
writeLine(marker+e.Name, style)
}
}
}
// Preview pane at the bottom of the rail. Reserve up to 8 rows.
// Preview pane: dim file content under a thin divider.
if previewName != "" && row+2 <= maxRow {
row++
writeLine(strings.Repeat("─", width-1), "\x1b[2m")
writeLine(" "+previewName, "\x1b[1m")
write("")
write(" " + styleBorder + strings.Repeat("─", width-2) + styleReset)
write(" " + styleActive + previewName + styleReset)
content, _, err := st.pads.Read(previewName)
if err == nil {
for _, line := range strings.Split(content, "\n") {
if row > maxRow {
break
}
writeLine(" "+line, "\x1b[2m")
write(" " + styleDim + line + styleReset)
}
}
}
@@ -133,7 +149,7 @@ func (st *uiState) drawSidebar() {
// Blank-fill any rows the rail content didn't cover so stale
// content from a previous redraw doesn't linger.
for row <= maxRow {
writeLine("", "")
write("")
}
st.outMu.Lock()

14
internal/app/style.go Normal file
View File

@@ -0,0 +1,14 @@
package app
// Shared SGR style sequences used by the palette, tab bar, sidebar, and
// status line so all the chrome reads with a consistent look. 256-color
// codes degrade to "no color" on terminals that don't support them.
const (
styleReset = "\x1b[0m"
styleBold = "\x1b[1m"
styleDim = "\x1b[2m"
styleBorder = "\x1b[38;5;240m"
styleAccent = "\x1b[38;5;75m"
styleHint = "\x1b[38;5;244m"
styleActive = "\x1b[1;38;5;253m"
)

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())
}

View File

@@ -37,7 +37,7 @@ const (
func newViewportRenderer(l terminalLayout) *viewportRenderer {
return &viewportRenderer{
shifter: newCursorShifter(int(l.mainTop) - 1),
shifter: newCursorShifter(int(l.mainTop)-1, int(l.childRows())),
layout: l,
row: 1,
col: 1,
@@ -48,7 +48,7 @@ func (vr *viewportRenderer) SetLayout(l terminalLayout) {
vr.mu.Lock()
defer vr.mu.Unlock()
vr.layout = l
vr.shifter.SetRowOffset(int(l.mainTop) - 1)
vr.shifter.SetGeometry(int(l.mainTop)-1, int(l.childRows()))
}
func (vr *viewportRenderer) Render(in []byte) []byte {

View File

@@ -8,7 +8,7 @@ import (
func TestViewportRendererShiftsCursor(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[H")))
if got != "\x1b[2;1H" {
if got != "\x1b[4;1H" {
t.Fatalf("CUP home: got %q", got)
}
}
@@ -22,7 +22,9 @@ func TestViewportRendererSwallowsAltScreenToggles(t *testing.T) {
}
func TestViewportRendererClearScreenIsViewportOnly(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(20, 5))
// hostRows=7 leaves three viewport rows after the 3-row tab bar and
// 1-row status reservation.
vr := newViewportRenderer(newTerminalLayout(20, 7))
got := string(vr.Render([]byte("\x1b[2J")))
if strings.Contains(got, "\x1b[2J") {
t.Fatalf("host clear-screen leaked through: %q", got)
@@ -30,7 +32,7 @@ func TestViewportRendererClearScreenIsViewportOnly(t *testing.T) {
if strings.Count(got, " ") != 3 {
t.Fatalf("clear rows: got %q", got)
}
if !strings.Contains(got, "\x1b[2;1H") || !strings.Contains(got, "\x1b[4;1H") {
if !strings.Contains(got, "\x1b[4;1H") || !strings.Contains(got, "\x1b[6;1H") {
t.Fatalf("clear did not target viewport rows: %q", got)
}
}