From 39a042bda85ffcbc6d3d13992acd5fb4dc039f1e Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Thu, 14 May 2026 16:02:40 +0100 Subject: [PATCH] Polish chrome and rework tab-switch repaint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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[;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. --- TODO.md | 122 +++++++++++++++ cmd/patterm/main.go | 6 +- cmd/spike/main.go | 4 +- go.mod | 2 +- internal/app/app.go | 108 ++++++++++++- internal/app/child.go | 21 ++- internal/app/cursorshift.go | 29 +++- internal/app/cursorshift_test.go | 28 +++- internal/app/host.go | 10 +- internal/app/launch.go | 2 +- internal/app/layout_test.go | 20 +-- internal/app/palette.go | 208 ++++++++++++++++++++----- internal/app/palette_input_test.go | 33 +++- internal/app/screen_renderer.go | 2 +- internal/app/screen_renderer_test.go | 24 ++- internal/app/session.go | 2 +- internal/app/sidebar.go | 112 +++++++------ internal/app/style.go | 14 ++ internal/app/tabbar.go | 146 +++++++++++++---- internal/app/viewport_renderer.go | 4 +- internal/app/viewport_renderer_test.go | 8 +- internal/mcp/tools.go | 2 +- 22 files changed, 729 insertions(+), 178 deletions(-) create mode 100644 TODO.md create mode 100644 internal/app/style.go diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..8742472 --- /dev/null +++ b/TODO.md @@ -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 ` 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 4–5 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[;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[;r\x1b[?25h\x1b[;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[;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: + ``` + \x1b[0m\x1b[;H\x1b[;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. diff --git a/cmd/patterm/main.go b/cmd/patterm/main.go index a720739..6f55d0d 100644 --- a/cmd/patterm/main.go +++ b/cmd/patterm/main.go @@ -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() { diff --git a/cmd/spike/main.go b/cmd/spike/main.go index de1d50e..a3999c5 100644 --- a/cmd/spike/main.go +++ b/cmd/spike/main.go @@ -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" diff --git a/go.mod b/go.mod index 776a895..5e0e902 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/harrybrwn/patterm +module github.com/hjbdev/patterm go 1.26.3 diff --git a/internal/app/app.go b/internal/app/app.go index aa4e086..68788b3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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[ 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) diff --git a/internal/app/child.go b/internal/app/child.go index f9bb748..76d22ae 100644 --- a/internal/app/child.go +++ b/internal/app/child.go @@ -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 diff --git a/internal/app/cursorshift.go b/internal/app/cursorshift.go index 8ef46e7..86120d8 100644 --- a/internal/app/cursorshift.go +++ b/internal/app/cursorshift.go @@ -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) diff --git a/internal/app/cursorshift_test.go b/internal/app/cursorshift_test.go index a873abc..759db37 100644 --- a/internal/app/cursorshift_test.go +++ b/internal/app/cursorshift_test.go @@ -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") diff --git a/internal/app/host.go b/internal/app/host.go index 40020c7..3272922 100644 --- a/internal/app/host.go +++ b/internal/app/host.go @@ -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 diff --git a/internal/app/launch.go b/internal/app/launch.go index 93c6178..2d3a708 100644 --- a/internal/app/launch.go +++ b/internal/app/launch.go @@ -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 diff --git a/internal/app/layout_test.go b/internal/app/layout_test.go index 8a5a26e..5466432 100644 --- a/internal/app/layout_test.go +++ b/internal/app/layout_test.go @@ -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) } } diff --git a/internal/app/palette.go b/internal/app/palette.go index ed7b6b1..90f5695 100644 --- a/internal/app/palette.go +++ b/internal/app/palette.go @@ -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 diff --git a/internal/app/palette_input_test.go b/internal/app/palette_input_test.go index 4d6a799..8204007 100644 --- a/internal/app/palette_input_test.go +++ b/internal/app/palette_input_test.go @@ -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) + } + }) + } +} diff --git a/internal/app/screen_renderer.go b/internal/app/screen_renderer.go index 345f8d4..fff8882 100644 --- a/internal/app/screen_renderer.go +++ b/internal/app/screen_renderer.go @@ -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 { diff --git a/internal/app/screen_renderer_test.go b/internal/app/screen_renderer_test.go index be9edbe..9d3d3d6 100644 --- a/internal/app/screen_renderer_test.go +++ b/internal/app/screen_renderer_test.go @@ -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) } } diff --git a/internal/app/session.go b/internal/app/session.go index c4ef63a..5f4a2f7 100644 --- a/internal/app/session.go +++ b/internal/app/session.go @@ -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. diff --git a/internal/app/sidebar.go b/internal/app/sidebar.go index 7a419b3..fe32ebd 100644 --- a/internal/app/sidebar.go +++ b/internal/app/sidebar.go @@ -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() diff --git a/internal/app/style.go b/internal/app/style.go new file mode 100644 index 0000000..f6b984f --- /dev/null +++ b/internal/app/style.go @@ -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" +) diff --git a/internal/app/tabbar.go b/internal/app/tabbar.go index 006d9af..fb87e0c 100644 --- a/internal/app/tabbar.go +++ b/internal/app/tabbar.go @@ -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()) } diff --git a/internal/app/viewport_renderer.go b/internal/app/viewport_renderer.go index 9e90061..6422760 100644 --- a/internal/app/viewport_renderer.go +++ b/internal/app/viewport_renderer.go @@ -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 { diff --git a/internal/app/viewport_renderer_test.go b/internal/app/viewport_renderer_test.go index b48d97b..84aa1c7 100644 --- a/internal/app/viewport_renderer_test.go +++ b/internal/app/viewport_renderer_test.go @@ -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) } } diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 527248a..91b1a4b 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -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