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

122
TODO.md Normal file
View File

@@ -0,0 +1,122 @@
- [ ] Switch-back rendering is wrong for diff-based TUIs (specifically codex / ratatui). Partial progress; deeper investigation needed — details below in "Switch-back render divergence".
- [ ] Killed agents are visible in the command palette. They shouldn't be.
- [ ] claude failed to connect to patterm mcp -32601
- [ ] codex doesn't show the patterm mcp at all
- [ ] opencode doesn't show the patterm mcp at all
- [ ] Open agents/processes should appear above the option to open a new one in the palette
- [ ] Some sort of macros in the command pallete would be nice, like if i type `sw <query>` it would only show the switch entries. Maybe we should have info text greyed out to show these macros.
---
## Switch-back render divergence
### Symptom
Switching focus to codex (and back to it again after another tab) leaves
codex's input box rendered wrong. The input text and the `` prompt
glyph appear on different rows. Typing more characters in codex makes
the box "grow" to 45 rows tall even though the content is one short
line. Claude (claude-code, ink-based) is mostly fine after the fixes
below; codex (Rust/ratatui) is not.
Initial spawn of codex looks correct. The bug only appears after a
focus switch off codex and then back.
### What's already fixed and committed
These actually helped; don't undo them blindly.
1. **`cursorShifter` empty-`\x1b[r` bug** (`internal/app/cursorshift.go`)
`\x1b[r` (reset DECSTBM) was being parsed as `(1,1)` and shifted to
`\x1b[4;4r`, producing a one-row scrolling region that scroll-exploded
the snapshot. Now rewrites empty params to `\x1b[<mainTop>;<mainBottom>r`
in host coords. `cursorShifter` carries `childRows` for this. Test:
`TestCursorShifterDECSTBMEmptyResetsToViewport`.
2. **Host-state reset prelude in `repaintFocused`** (`internal/app/app.go`)
— before replaying, write `\x1b[0m\x1b[?6l\x1b[<top>;<bot>r\x1b[?25h\x1b[<top>;<left>H`
directly to stdout to clear leftover SGR / DECOM / DECSTBM from the
previously-focused child.
3. **Use `SerializeVT` instead of plain text for the snapshot**
(`internal/app/app.go: repaintFocused`) — previously `repaintFocused`
used `SnapshotChild` (plain text, no SGR). Now it feeds
`SerializeChild` bytes through the per-focused-child viewport
renderer, preserving colors and cursor state.
4. **Re-emit cursor as a child-space CUP through the renderer**
`SerializeVT`'s output order is: content with CRLFs, `\x1b[0m`,
cursor CUP, **DECSTBM**, tabstops. DECSTBM has a documented side
effect of moving the cursor to the scrolling region's home, and the
trailing tabstop setup uses CHA (`\x1b[NG`) which leaves the
renderer's internal `vr.col` parked at the last tab-stop column.
Without a fixup the host cursor and the renderer's tracking both
drift. The current code re-emits the saved cursor as a child-space
`\x1b[<R+1>;<C+1>H` through the renderer, so the shifter writes the
right host CUP and `trackCSI` updates `vr.row`/`vr.col`.
5. **`NudgeRedraw` on the focused child after replay**
(`internal/app/child.go: NudgeRedraw`, called via `defer` in
`repaintFocused`) — toggles PTY winsize by one row and back to force
the kernel to emit `SIGWINCH`. Intent: make ratatui throw away its
internal "last frame" diff state and emit a full frame. After this
change the initial load and the post-interaction state of codex are
visually equivalent, but both are still wrong.
### What's still broken
After all of the above, codex's input box still draws with the input
text and the `` prompt on different rows, and "asdasdasdasd"-style
typing makes the box grow vertically instead of staying single-line.
Suspected causes, in rough order of likelihood:
- **The renderer is over-shifting some row-positioning sequence that
ghostty's `SerializeVT` emits but I haven't recognised.** Run the
probe pattern below to see what bytes go through. Pay special
attention to anything that targets rows after the DECSTBM is in
place, anything that uses DECOM, and any `\x1bD`/`\x1bM` (IND/RI)
which scroll within the region.
- **Ratatui's internal "previous_buffer" isn't actually getting reset
by `SIGWINCH`** in this PTY environment, or it's getting reset to a
size that doesn't match the emulator's. The one-row toggle in
`NudgeRedraw` might be a bad idea — try direct `kill(pid, SIGWINCH)`
with no size change (the kernel's `TIOCSWINSZ` skips SIGWINCH when
the size is unchanged, so we'd need to send the signal explicitly).
See `Child.signal` for the helper.
- **`childRows`/`childCols` reported via `TIOCGWINSZ` isn't what codex
expects.** If codex reads winsize at startup and caches it, our
`tabBarRows` change (1 → 3) might have left the cached size stale
in some path. Verify by spawning codex fresh after the chrome
change and confirming `stty size` inside codex matches
`layout.childCols()` × `layout.childRows()`.
### Investigation tools
- `internal/vt/probe_test.go` doesn't exist any more; recreate it to
print `SerializeVT` output for representative cases. The relevant
call is `(*GhosttyEmulator).SerializeVT()`. Confirmed shape:
```
<content with CRLFs>\x1b[0m\x1b[<r>;<c>H\x1b[<top>;<bot>r\x1b[3g\x1b[NG\x1bH...
```
- Add a debug tee around `viewportRenderer.Render` to log the raw
bytes codex emits **after** the snapshot replay. That will show
whether codex is emitting CUPs that target wrong rows (suggesting
its diff state is wrong) or whether it's emitting reasonable CUPs
and the renderer is mis-shifting them.
- The user said they're building a harness so agents can iterate on
this without manual screenshotting; once that exists, the diagnose
loop is: replay snapshot → capture host stdout → diff against
expected. Start with the simplest reproduction: spawn codex, switch
away, switch back, type one character, compare host bytes against a
golden file.
### Files touched (so the next agent knows what to read)
- `internal/app/app.go` — `repaintFocused`
- `internal/app/cursorshift.go` — DECSTBM handling, `childRows`
- `internal/app/viewport_renderer.go` — plumbing for `childRows`
- `internal/app/child.go` — `NudgeRedraw`
- `internal/app/cursorshift_test.go` — DECSTBM reset coverage
- Probe what `(*GhosttyEmulator).SerializeVT()` emits — that's the
source of truth for what we're replaying.

View File

@@ -15,9 +15,9 @@ import (
"fmt"
"os"
"github.com/harrybrwn/patterm/internal/app"
"github.com/harrybrwn/patterm/internal/mcp"
"github.com/harrybrwn/patterm/internal/projectkey"
"github.com/hjbdev/patterm/internal/app"
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/projectkey"
)
func main() {

View File

@@ -20,8 +20,8 @@ import (
"syscall"
"time"
"github.com/harrybrwn/patterm/internal/pty"
"github.com/harrybrwn/patterm/internal/vt"
"github.com/hjbdev/patterm/internal/pty"
"github.com/hjbdev/patterm/internal/vt"
cpty "github.com/creack/pty"
"golang.org/x/term"

2
go.mod
View File

@@ -1,4 +1,4 @@
module github.com/harrybrwn/patterm
module github.com/hjbdev/patterm
go 1.26.3

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

View File

@@ -6,7 +6,7 @@ import (
"fmt"
"syscall"
"github.com/harrybrwn/patterm/internal/scratchpad"
"github.com/hjbdev/patterm/internal/scratchpad"
)
// JSON-RPC error codes used by the patterm MCP surface. -32700..-32600