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:
122
TODO.md
Normal file
122
TODO.md
Normal 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 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[<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.
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
2
go.mod
@@ -1,4 +1,4 @@
|
||||
module github.com/harrybrwn/patterm
|
||||
module github.com/hjbdev/patterm
|
||||
|
||||
go 1.26.3
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
14
internal/app/style.go
Normal 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"
|
||||
)
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user