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"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/harrybrwn/patterm/internal/app"
|
"github.com/hjbdev/patterm/internal/app"
|
||||||
"github.com/harrybrwn/patterm/internal/mcp"
|
"github.com/hjbdev/patterm/internal/mcp"
|
||||||
"github.com/harrybrwn/patterm/internal/projectkey"
|
"github.com/hjbdev/patterm/internal/projectkey"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/harrybrwn/patterm/internal/pty"
|
"github.com/hjbdev/patterm/internal/pty"
|
||||||
"github.com/harrybrwn/patterm/internal/vt"
|
"github.com/hjbdev/patterm/internal/vt"
|
||||||
|
|
||||||
cpty "github.com/creack/pty"
|
cpty "github.com/creack/pty"
|
||||||
"golang.org/x/term"
|
"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
|
go 1.26.3
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ import (
|
|||||||
cpty "github.com/creack/pty"
|
cpty "github.com/creack/pty"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
|
|
||||||
"github.com/harrybrwn/patterm/internal/mcp"
|
"github.com/hjbdev/patterm/internal/mcp"
|
||||||
"github.com/harrybrwn/patterm/internal/preset"
|
"github.com/hjbdev/patterm/internal/preset"
|
||||||
"github.com/harrybrwn/patterm/internal/scratchpad"
|
"github.com/hjbdev/patterm/internal/scratchpad"
|
||||||
"github.com/harrybrwn/patterm/internal/trust"
|
"github.com/hjbdev/patterm/internal/trust"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Options configures a patterm run.
|
// Options configures a patterm run.
|
||||||
@@ -607,12 +607,34 @@ func (st *uiState) processStdin(chunk []byte) {
|
|||||||
|
|
||||||
var pendingAction *paletteAction
|
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
|
i := 0
|
||||||
for i < len(chunk) {
|
for i < len(chunk) {
|
||||||
b := chunk[i]
|
b := chunk[i]
|
||||||
|
|
||||||
// Palette mode swallows all bytes.
|
// Palette mode swallows all bytes.
|
||||||
if st.palette != nil {
|
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)
|
action, done, adv := st.palette.handleInput(chunk, i)
|
||||||
if adv <= 0 {
|
if adv <= 0 {
|
||||||
adv = 1
|
adv = 1
|
||||||
@@ -662,6 +684,15 @@ func (st *uiState) processStdin(chunk []byte) {
|
|||||||
|
|
||||||
func (st *uiState) openPaletteLocked() {
|
func (st *uiState) openPaletteLocked() {
|
||||||
st.palette = newPalette(st.sess.Children(), st.focusedID, st.presets)
|
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()
|
st.renderPaletteLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,6 +704,11 @@ func (st *uiState) closePalette(action paletteAction) {
|
|||||||
st.mu.Lock()
|
st.mu.Lock()
|
||||||
st.palette = nil
|
st.palette = nil
|
||||||
st.mu.Unlock()
|
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()
|
st.clearScreen()
|
||||||
|
|
||||||
switch action.kind {
|
switch action.kind {
|
||||||
@@ -761,19 +797,81 @@ func (st *uiState) flashTransient(msg string) {
|
|||||||
// repaintFocused redraws the current focused child's screen snapshot.
|
// repaintFocused redraws the current focused child's screen snapshot.
|
||||||
// Callers must NOT hold st.mu — repaintFocused takes it
|
// Callers must NOT hold st.mu — repaintFocused takes it
|
||||||
// briefly itself.
|
// 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() {
|
func (st *uiState) repaintFocused() {
|
||||||
st.mu.Lock()
|
st.mu.Lock()
|
||||||
id := st.focusedID
|
id := st.focusedID
|
||||||
|
renderer := st.renderer
|
||||||
|
layout := st.layoutLocked()
|
||||||
st.mu.Unlock()
|
st.mu.Unlock()
|
||||||
if id == "" {
|
if id == "" {
|
||||||
st.renderEmptyState()
|
st.renderEmptyState()
|
||||||
return
|
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)
|
text, cursor, err := st.sess.SnapshotChild(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := renderScreenSnapshot(text, cursor, st.layoutSnapshot())
|
out := renderScreenSnapshot(text, cursor, layout)
|
||||||
st.outMu.Lock()
|
st.outMu.Lock()
|
||||||
defer st.outMu.Unlock()
|
defer st.outMu.Unlock()
|
||||||
_, _ = os.Stdout.Write(out)
|
_, _ = os.Stdout.Write(out)
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
pkgpty "github.com/harrybrwn/patterm/internal/pty"
|
pkgpty "github.com/hjbdev/patterm/internal/pty"
|
||||||
"github.com/harrybrwn/patterm/internal/vt"
|
"github.com/hjbdev/patterm/internal/vt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// portRegex matches dev-server URLs of the form `http(s)://host:NNNN[/path]`
|
// 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)
|
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) {
|
func (c *Child) markExited(err error) {
|
||||||
exitCode := int32(0)
|
exitCode := int32(0)
|
||||||
st := StatusExited
|
st := StatusExited
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
// CSI commands.
|
// CSI commands.
|
||||||
type cursorShifter struct {
|
type cursorShifter struct {
|
||||||
rowOffset int
|
rowOffset int
|
||||||
|
childRows int // viewport height in child rows; used for DECSTBM resets
|
||||||
|
|
||||||
state shifterState
|
state shifterState
|
||||||
buf []byte // bytes accumulated in current escape sequence (incl. introducer)
|
buf []byte // bytes accumulated in current escape sequence (incl. introducer)
|
||||||
@@ -44,12 +45,13 @@ const (
|
|||||||
stSOSPMAPCEsc
|
stSOSPMAPCEsc
|
||||||
)
|
)
|
||||||
|
|
||||||
func newCursorShifter(rowOffset int) *cursorShifter {
|
func newCursorShifter(rowOffset, childRows int) *cursorShifter {
|
||||||
return &cursorShifter{rowOffset: rowOffset}
|
return &cursorShifter{rowOffset: rowOffset, childRows: childRows}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *cursorShifter) SetRowOffset(off int) {
|
func (cs *cursorShifter) SetGeometry(rowOffset, childRows int) {
|
||||||
cs.rowOffset = off
|
cs.rowOffset = rowOffset
|
||||||
|
cs.childRows = childRows
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shift consumes a chunk of PTY-master bytes, applies row offsets to
|
// 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.WriteString(strconv.Itoa(r))
|
||||||
cs.pending.WriteByte(final)
|
cs.pending.WriteByte(final)
|
||||||
case 'r':
|
case 'r':
|
||||||
// DECSTBM: top;bot. Empty resets to full region; we still
|
// DECSTBM: top;bot. Empty params (\x1b[r) means "reset to the
|
||||||
// shift to keep the chrome row reserved.
|
// 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)
|
top, bot, ok := parseTwoParams(paramsRaw)
|
||||||
if !ok {
|
if !ok {
|
||||||
cs.pending.Write(cs.buf)
|
cs.pending.Write(cs.buf)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestCursorShifterCUP(t *testing.T) {
|
func TestCursorShifterCUP(t *testing.T) {
|
||||||
cs := newCursorShifter(1)
|
cs := newCursorShifter(1, 36)
|
||||||
got := cs.Shift([]byte("\x1b[H"))
|
got := cs.Shift([]byte("\x1b[H"))
|
||||||
want := []byte("\x1b[2;1H")
|
want := []byte("\x1b[2;1H")
|
||||||
if !bytes.Equal(got, want) {
|
if !bytes.Equal(got, want) {
|
||||||
@@ -15,7 +15,7 @@ func TestCursorShifterCUP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCursorShifterCUPRowCol(t *testing.T) {
|
func TestCursorShifterCUPRowCol(t *testing.T) {
|
||||||
cs := newCursorShifter(1)
|
cs := newCursorShifter(1, 36)
|
||||||
got := cs.Shift([]byte("\x1b[10;5H"))
|
got := cs.Shift([]byte("\x1b[10;5H"))
|
||||||
if string(got) != "\x1b[11;5H" {
|
if string(got) != "\x1b[11;5H" {
|
||||||
t.Fatalf("CUP 10;5: got %q", got)
|
t.Fatalf("CUP 10;5: got %q", got)
|
||||||
@@ -23,7 +23,7 @@ func TestCursorShifterCUPRowCol(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCursorShifterVPA(t *testing.T) {
|
func TestCursorShifterVPA(t *testing.T) {
|
||||||
cs := newCursorShifter(1)
|
cs := newCursorShifter(1, 36)
|
||||||
got := cs.Shift([]byte("\x1b[7d"))
|
got := cs.Shift([]byte("\x1b[7d"))
|
||||||
if string(got) != "\x1b[8d" {
|
if string(got) != "\x1b[8d" {
|
||||||
t.Fatalf("VPA 7: got %q", got)
|
t.Fatalf("VPA 7: got %q", got)
|
||||||
@@ -31,15 +31,27 @@ func TestCursorShifterVPA(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCursorShifterDECSTBM(t *testing.T) {
|
func TestCursorShifterDECSTBM(t *testing.T) {
|
||||||
cs := newCursorShifter(1)
|
cs := newCursorShifter(1, 36)
|
||||||
got := cs.Shift([]byte("\x1b[2;20r"))
|
got := cs.Shift([]byte("\x1b[2;20r"))
|
||||||
if string(got) != "\x1b[3;21r" {
|
if string(got) != "\x1b[3;21r" {
|
||||||
t.Fatalf("DECSTBM: got %q", got)
|
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) {
|
func TestCursorShifterPrivateCSIPassthrough(t *testing.T) {
|
||||||
cs := newCursorShifter(1)
|
cs := newCursorShifter(1, 36)
|
||||||
// Alt-screen toggle — private CSI.
|
// Alt-screen toggle — private CSI.
|
||||||
got := cs.Shift([]byte("\x1b[?1049h"))
|
got := cs.Shift([]byte("\x1b[?1049h"))
|
||||||
if string(got) != "\x1b[?1049h" {
|
if string(got) != "\x1b[?1049h" {
|
||||||
@@ -48,7 +60,7 @@ func TestCursorShifterPrivateCSIPassthrough(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCursorShifterSGRPassthrough(t *testing.T) {
|
func TestCursorShifterSGRPassthrough(t *testing.T) {
|
||||||
cs := newCursorShifter(1)
|
cs := newCursorShifter(1, 36)
|
||||||
got := cs.Shift([]byte("\x1b[1;31mhello\x1b[0m"))
|
got := cs.Shift([]byte("\x1b[1;31mhello\x1b[0m"))
|
||||||
if string(got) != "\x1b[1;31mhello\x1b[0m" {
|
if string(got) != "\x1b[1;31mhello\x1b[0m" {
|
||||||
t.Fatalf("SGR: got %q", got)
|
t.Fatalf("SGR: got %q", got)
|
||||||
@@ -56,7 +68,7 @@ func TestCursorShifterSGRPassthrough(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCursorShifterStraddleChunks(t *testing.T) {
|
func TestCursorShifterStraddleChunks(t *testing.T) {
|
||||||
cs := newCursorShifter(1)
|
cs := newCursorShifter(1, 36)
|
||||||
a := cs.Shift([]byte("\x1b["))
|
a := cs.Shift([]byte("\x1b["))
|
||||||
b := cs.Shift([]byte("5;3H"))
|
b := cs.Shift([]byte("5;3H"))
|
||||||
got := string(a) + string(b)
|
got := string(a) + string(b)
|
||||||
@@ -66,7 +78,7 @@ func TestCursorShifterStraddleChunks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCursorShifterOSCNotRewritten(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
|
// OSC body containing what looks like a CSI cursor move — should
|
||||||
// NOT be rewritten.
|
// NOT be rewritten.
|
||||||
in := []byte("\x1b]0;\x1b[5;3Htitle\x07")
|
in := []byte("\x1b]0;\x1b[5;3Htitle\x07")
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/harrybrwn/patterm/internal/mcp"
|
"github.com/hjbdev/patterm/internal/mcp"
|
||||||
"github.com/harrybrwn/patterm/internal/preset"
|
"github.com/hjbdev/patterm/internal/preset"
|
||||||
"github.com/harrybrwn/patterm/internal/scratchpad"
|
"github.com/hjbdev/patterm/internal/scratchpad"
|
||||||
"github.com/harrybrwn/patterm/internal/trust"
|
"github.com/hjbdev/patterm/internal/trust"
|
||||||
pkgvt "github.com/harrybrwn/patterm/internal/vt"
|
pkgvt "github.com/hjbdev/patterm/internal/vt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// attentionSink is implemented by uiState to surface
|
// attentionSink is implemented by uiState to surface
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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
|
// Launcher knows how to turn a preset into a running child. Both the
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/harrybrwn/patterm/internal/preset"
|
"github.com/hjbdev/patterm/internal/preset"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTerminalLayoutWideUsesMainViewport(t *testing.T) {
|
func TestTerminalLayoutWideUsesMainViewport(t *testing.T) {
|
||||||
@@ -14,10 +14,10 @@ func TestTerminalLayoutWideUsesMainViewport(t *testing.T) {
|
|||||||
if l.childCols() != 92 {
|
if l.childCols() != 92 {
|
||||||
t.Fatalf("child cols: got %d want 92", l.childCols())
|
t.Fatalf("child cols: got %d want 92", l.childCols())
|
||||||
}
|
}
|
||||||
if l.childRows() != 38 {
|
if l.childRows() != 36 {
|
||||||
t.Fatalf("child rows: got %d want 38", l.childRows())
|
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)
|
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 {
|
if l.childCols() != 38 {
|
||||||
t.Fatalf("child cols: got %d want 38", l.childCols())
|
t.Fatalf("child cols: got %d want 38", l.childCols())
|
||||||
}
|
}
|
||||||
if l.childRows() != 10 {
|
if l.childRows() != 8 {
|
||||||
t.Fatalf("child rows: got %d want 10", l.childRows())
|
t.Fatalf("child rows: got %d want 8", l.childRows())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,13 +46,13 @@ func TestSpawnSizingUsesViewportDimensions(t *testing.T) {
|
|||||||
l := newTerminalLayout(120, 40)
|
l := newTerminalLayout(120, 40)
|
||||||
launcher := NewLauncher(nil, "", l.childCols(), l.childRows())
|
launcher := NewLauncher(nil, "", l.childCols(), l.childRows())
|
||||||
cols, rows := launcher.size()
|
cols, rows := launcher.size()
|
||||||
if cols != 92 || rows != 38 {
|
if cols != 92 || rows != 36 {
|
||||||
t.Fatalf("launcher size: got %dx%d want 92x38", cols, rows)
|
t.Fatalf("launcher size: got %dx%d want 92x36", cols, rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
host := newToolHost(nil, nil, nil, preset.Set{}, nil, l.childCols(), l.childRows())
|
host := newToolHost(nil, nil, nil, preset.Set{}, nil, l.childCols(), l.childRows())
|
||||||
cols, rows = host.size()
|
cols, rows = host.size()
|
||||||
if cols != 92 || rows != 38 {
|
if cols != 92 || rows != 36 {
|
||||||
t.Fatalf("tool host size: got %dx%d want 92x38", cols, rows)
|
t.Fatalf("tool host size: got %dx%d want 92x36", cols, rows)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"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.
|
// paletteAction is what the palette returns when the user picks an item.
|
||||||
@@ -141,6 +141,40 @@ const (
|
|||||||
kittyKeyDown = 57353
|
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
|
// handleInput consumes one keystroke from chunk[i:] and updates palette
|
||||||
// state. advance is how many bytes the keystroke occupies (1 for legacy
|
// state. advance is how many bytes the keystroke occupies (1 for legacy
|
||||||
// keys, longer for CSI sequences). Returning done=true tells the caller
|
// 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 +
|
// render draws the palette onto out. Layout is a rounded box with a
|
||||||
// items + footer, centred. The caller is responsible for the screen
|
// title bar, query line, divider, item list, divider, and footer.
|
||||||
// clear before the first render.
|
// The caller is responsible for the screen clear before the first
|
||||||
|
// render.
|
||||||
func (p *paletteState) render(out writeFlusher, cols, rows int) {
|
func (p *paletteState) render(out writeFlusher, cols, rows int) {
|
||||||
if cols < 20 {
|
if cols < 32 {
|
||||||
cols = 20
|
cols = 32
|
||||||
}
|
}
|
||||||
if rows < 6 {
|
if rows < 10 {
|
||||||
rows = 6
|
rows = 10
|
||||||
}
|
}
|
||||||
width := cols - 4
|
width := cols - 8
|
||||||
if width > 80 {
|
if width > 72 {
|
||||||
width = 80
|
width = 72
|
||||||
}
|
}
|
||||||
if width < 40 {
|
if width < 40 {
|
||||||
width = cols - 2
|
width = cols - 2
|
||||||
}
|
}
|
||||||
|
if width < 32 {
|
||||||
|
width = 32
|
||||||
|
}
|
||||||
leftPad := (cols - width) / 2
|
leftPad := (cols - width) / 2
|
||||||
if leftPad < 1 {
|
if leftPad < 1 {
|
||||||
leftPad = 1
|
leftPad = 1
|
||||||
}
|
}
|
||||||
row := 2
|
content := width - 4 // visible cells between the " " padding on each side
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J")
|
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)
|
moveTo(&b, row, leftPad)
|
||||||
b.WriteString("\x1b[1;7m")
|
b.WriteString(styleBorder + "╭─ " + styleActive + titleText + styleReset + styleBorder + " " +
|
||||||
b.WriteString(padRight(" patterm — Ctrl-K", width))
|
strings.Repeat("─", dashes) + " " + styleHint + keyHint + styleReset + styleBorder + " ─╮" + styleReset)
|
||||||
b.WriteString("\x1b[0m")
|
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++
|
row++
|
||||||
|
|
||||||
moveTo(&b, row, leftPad)
|
moveTo(&b, row, leftPad)
|
||||||
b.WriteString("\x1b[7m")
|
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
|
||||||
b.WriteString(padRight(" › "+string(p.query)+"_", width))
|
|
||||||
b.WriteString("\x1b[0m")
|
|
||||||
row++
|
row++
|
||||||
|
|
||||||
maxItems := rows - 6
|
maxItems := rows - 6
|
||||||
@@ -319,43 +374,116 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) {
|
|||||||
if end > len(p.items) {
|
if end > len(p.items) {
|
||||||
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)
|
moveTo(&b, row, leftPad)
|
||||||
if i == p.cursor {
|
if start+i >= end {
|
||||||
b.WriteString("\x1b[7m")
|
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 {
|
} else {
|
||||||
b.WriteString("\x1b[0m")
|
b.WriteString(styleBorder + "│" + styleReset + strings.Repeat(" ", width-2) +
|
||||||
|
styleBorder + "│" + styleReset)
|
||||||
}
|
}
|
||||||
line := " " + it.label
|
|
||||||
if it.hint != "" {
|
|
||||||
line += " \x1b[2m— " + it.hint + "\x1b[0m"
|
|
||||||
if i == p.cursor {
|
|
||||||
line += "\x1b[7m"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.WriteString(padRight(line, width+countAnsi(line)))
|
|
||||||
b.WriteString("\x1b[0m")
|
|
||||||
row++
|
row++
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if len(p.items) == 0 {
|
|
||||||
moveTo(&b, row, leftPad)
|
it := p.items[start+i]
|
||||||
b.WriteString("\x1b[2m no matches\x1b[0m")
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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++
|
row++
|
||||||
}
|
}
|
||||||
|
|
||||||
moveTo(&b, row, leftPad)
|
moveTo(&b, row, leftPad)
|
||||||
b.WriteString("\x1b[2m")
|
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
|
||||||
b.WriteString(padRight(" Enter to run · Esc to close · ↑↓ to navigate", width))
|
row++
|
||||||
b.WriteString("\x1b[0m")
|
|
||||||
|
|
||||||
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")
|
b.WriteString("\x1b[?25h")
|
||||||
|
|
||||||
_, _ = out.Write([]byte(b.String()))
|
_, _ = out.Write([]byte(b.String()))
|
||||||
_ = out.Flush()
|
_ = 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 {
|
type writeFlusher interface {
|
||||||
Write(p []byte) (int, error)
|
Write(p []byte) (int, error)
|
||||||
Flush() error
|
Flush() error
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/harrybrwn/patterm/internal/preset"
|
"github.com/hjbdev/patterm/internal/preset"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestPalette() *paletteState {
|
func newTestPalette() *paletteState {
|
||||||
@@ -106,3 +106,34 @@ func TestPaletteLegacyPrintableTypes(t *testing.T) {
|
|||||||
t.Fatalf("query %q", string(p.query))
|
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"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/harrybrwn/patterm/internal/vt"
|
"github.com/hjbdev/patterm/internal/vt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func renderScreenSnapshot(text string, cursor vt.CursorState, layout terminalLayout) []byte {
|
func renderScreenSnapshot(text string, cursor vt.CursorState, layout terminalLayout) []byte {
|
||||||
|
|||||||
@@ -1,33 +1,41 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/harrybrwn/patterm/internal/vt"
|
"github.com/hjbdev/patterm/internal/vt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRenderScreenSnapshotClipsRowsToViewport(t *testing.T) {
|
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))
|
got := string(renderScreenSnapshot("abcdefghijklmnopqrstuvwxy\nsecond", vt.CursorState{}, layout))
|
||||||
if strings.Contains(got, "uvwxy") {
|
if strings.Contains(got, "uvwxy") {
|
||||||
t.Fatalf("line leaked past viewport width: %q", got)
|
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)
|
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)
|
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)
|
t.Fatalf("blank viewport row not cleared: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderScreenSnapshotPlacesCursorInsideViewport(t *testing.T) {
|
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))
|
got := string(renderScreenSnapshot("abc", vt.CursorState{Col: 2, Row: 1, Visible: true}, layout))
|
||||||
if !strings.HasSuffix(got, "\x1b[?25h\x1b[3;3H") {
|
want := fmt.Sprintf("\x1b[?25h\x1b[%d;3H", int(layout.mainTop)+1)
|
||||||
t.Fatalf("cursor not placed inside viewport: %q", got)
|
if !strings.HasSuffix(got, want) {
|
||||||
|
t.Fatalf("cursor not placed inside viewport: %q (want suffix %q)", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/harrybrwn/patterm/internal/vt"
|
"github.com/hjbdev/patterm/internal/vt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Session is the in-memory state for the running patterm process.
|
// Session is the in-memory state for the running patterm process.
|
||||||
|
|||||||
@@ -36,96 +36,112 @@ func (st *uiState) drawSidebar() {
|
|||||||
maxRow := int(layout.statusRow) - statusRows
|
maxRow := int(layout.statusRow) - statusRows
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
// Border column at left-1: a single vertical pipe.
|
|
||||||
for r := 1; r <= maxRow; r++ {
|
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
|
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 {
|
if row > maxRow {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(s) > width {
|
pad := width - visibleLen(content)
|
||||||
s = s[:width]
|
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++
|
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")
|
writeHeader("Session tree")
|
||||||
writeLine(strings.Repeat("─", width-1), "\x1b[2m")
|
|
||||||
|
|
||||||
children := visibleSessionTree(st.sess.Children(), focus)
|
children := visibleSessionTree(st.sess.Children(), focus)
|
||||||
if len(children) == 0 {
|
if len(children) == 0 {
|
||||||
writeLine(" (empty)", "\x1b[2m")
|
write(" " + styleDim + "(empty)" + styleReset)
|
||||||
}
|
}
|
||||||
for _, c := range children {
|
for _, c := range children {
|
||||||
glyph := "◉"
|
if row > maxRow {
|
||||||
marker := " "
|
break
|
||||||
if c.ID == focus {
|
|
||||||
marker = "▶ "
|
|
||||||
}
|
}
|
||||||
indent := ""
|
indent := ""
|
||||||
if c.ParentID != "" {
|
if c.ParentID != "" {
|
||||||
indent = " "
|
indent = " "
|
||||||
}
|
}
|
||||||
line := fmt.Sprintf(" %s%s%s %s", marker, indent, glyph, c.Name)
|
focused := c.ID == focus
|
||||||
style := ""
|
glyph := statusGlyph(c, focused)
|
||||||
if c.ID == focus {
|
var line string
|
||||||
style = "\x1b[1m"
|
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
|
// Scratchpads list — pick the most-recently-modified one as the
|
||||||
// preview target. SPEC §4.
|
// preview target. SPEC §4.
|
||||||
var previewName string
|
var previewName string
|
||||||
if row+2 <= maxRow {
|
if row+2 <= maxRow {
|
||||||
row++
|
write("")
|
||||||
writeLine(" Scratchpads", "\x1b[1m")
|
writeHeader("Scratchpads")
|
||||||
writeLine(strings.Repeat("─", width-1), "\x1b[2m")
|
|
||||||
entries, err := st.pads.List()
|
entries, err := st.pads.List()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
writeLine(" (none)", "\x1b[2m")
|
write(" " + styleDim + "(none)" + styleReset)
|
||||||
}
|
} else {
|
||||||
var newest string
|
|
||||||
var newestTS string
|
var newestTS string
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.ModifiedAt > newestTS {
|
if e.ModifiedAt > newestTS {
|
||||||
newestTS = e.ModifiedAt
|
newestTS = e.ModifiedAt
|
||||||
newest = e.Name
|
previewName = e.Name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
previewName = newest
|
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if row > maxRow {
|
if row > maxRow {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
marker := " "
|
var line string
|
||||||
style := ""
|
|
||||||
if e.Name == previewName {
|
if e.Name == previewName {
|
||||||
marker = " ▸ "
|
line = " " + styleAccent + "▎" + styleReset + " " +
|
||||||
style = "\x1b[1m"
|
styleBold + e.Name + styleReset
|
||||||
|
} else {
|
||||||
|
line = " " + styleHint + e.Name + styleReset
|
||||||
|
}
|
||||||
|
write(line)
|
||||||
}
|
}
|
||||||
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 {
|
if previewName != "" && row+2 <= maxRow {
|
||||||
row++
|
write("")
|
||||||
writeLine(strings.Repeat("─", width-1), "\x1b[2m")
|
write(" " + styleBorder + strings.Repeat("─", width-2) + styleReset)
|
||||||
writeLine(" "+previewName, "\x1b[1m")
|
write(" " + styleActive + previewName + styleReset)
|
||||||
content, _, err := st.pads.Read(previewName)
|
content, _, err := st.pads.Read(previewName)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, line := range strings.Split(content, "\n") {
|
for _, line := range strings.Split(content, "\n") {
|
||||||
if row > maxRow {
|
if row > maxRow {
|
||||||
break
|
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
|
// Blank-fill any rows the rail content didn't cover so stale
|
||||||
// content from a previous redraw doesn't linger.
|
// content from a previous redraw doesn't linger.
|
||||||
for row <= maxRow {
|
for row <= maxRow {
|
||||||
writeLine("", "")
|
write("")
|
||||||
}
|
}
|
||||||
|
|
||||||
st.outMu.Lock()
|
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"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"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
|
// drawTabBar renders the top tab strip across the full host width. The
|
||||||
// children (ParentID == ""); the focused tab is highlighted. The PTY
|
// strip has three rows: labels (with horizontal padding), a dim
|
||||||
// region begins at row 2.
|
// 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() {
|
func (st *uiState) drawTabBar() {
|
||||||
st.mu.Lock()
|
st.mu.Lock()
|
||||||
palOpen := st.palette != nil
|
palOpen := st.palette != nil
|
||||||
@@ -21,6 +26,9 @@ func (st *uiState) drawTabBar() {
|
|||||||
}
|
}
|
||||||
layout := st.layoutSnapshot()
|
layout := st.layoutSnapshot()
|
||||||
width := int(layout.childCols())
|
width := int(layout.childCols())
|
||||||
|
if width < 8 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var sessions []*Child
|
var sessions []*Child
|
||||||
for _, c := range st.sess.Children() {
|
for _, c := range st.sess.Children() {
|
||||||
@@ -29,42 +37,124 @@ func (st *uiState) drawTabBar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
type tabRect struct {
|
||||||
b.WriteString("\x1b[1;1H")
|
startCol int
|
||||||
cur := 0
|
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 {
|
for _, c := range sessions {
|
||||||
label := c.Name
|
label := c.Name
|
||||||
seg := " " + label + " "
|
labelW := utf8.RuneCountInString(label)
|
||||||
if cur+len(seg) > width-2 {
|
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
|
break
|
||||||
}
|
}
|
||||||
if c.ID == focus {
|
label = clipRunes(label, avail-1) + "…"
|
||||||
b.WriteString("\x1b[7m")
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
} else {
|
||||||
b.WriteString("\x1b[2m")
|
b.WriteString(styleHint)
|
||||||
}
|
}
|
||||||
b.WriteString(seg)
|
b.WriteString(strings.Repeat(" ", tabPad))
|
||||||
b.WriteString("\x1b[0m")
|
b.WriteString(t.label)
|
||||||
cur += len(seg)
|
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 = ""
|
||||||
}
|
}
|
||||||
// "+" hint at end.
|
|
||||||
hint := "+"
|
|
||||||
if cur > 0 {
|
|
||||||
hint = " +"
|
|
||||||
}
|
}
|
||||||
if cur+len(hint) <= width {
|
padR := t.width - utf8.RuneCountInString(sub)
|
||||||
b.WriteString("\x1b[2m")
|
if padR < 0 {
|
||||||
b.WriteString(hint)
|
padR = 0
|
||||||
b.WriteString("\x1b[0m")
|
|
||||||
cur += len(hint)
|
|
||||||
}
|
}
|
||||||
// Fill the rest of the tab-bar row so stale chars don't linger.
|
fmt.Fprintf(&b, "\x1b[2;%dH%s%s%s%s",
|
||||||
if width-cur > 0 {
|
t.startCol, styleDim, sub, strings.Repeat(" ", padR), styleReset)
|
||||||
b.WriteString(strings.Repeat(" ", width-cur))
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "+ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if leadingPad > 0 {
|
||||||
|
fmt.Fprintf(&b, "\x1b[3;1H%s%s%s",
|
||||||
|
styleBorder, strings.Repeat("─", leadingPad), styleReset)
|
||||||
}
|
}
|
||||||
|
|
||||||
st.outMu.Lock()
|
st.outMu.Lock()
|
||||||
defer st.outMu.Unlock()
|
defer st.outMu.Unlock()
|
||||||
// Save cursor, paint, restore.
|
|
||||||
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", b.String())
|
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", b.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const (
|
|||||||
|
|
||||||
func newViewportRenderer(l terminalLayout) *viewportRenderer {
|
func newViewportRenderer(l terminalLayout) *viewportRenderer {
|
||||||
return &viewportRenderer{
|
return &viewportRenderer{
|
||||||
shifter: newCursorShifter(int(l.mainTop) - 1),
|
shifter: newCursorShifter(int(l.mainTop)-1, int(l.childRows())),
|
||||||
layout: l,
|
layout: l,
|
||||||
row: 1,
|
row: 1,
|
||||||
col: 1,
|
col: 1,
|
||||||
@@ -48,7 +48,7 @@ func (vr *viewportRenderer) SetLayout(l terminalLayout) {
|
|||||||
vr.mu.Lock()
|
vr.mu.Lock()
|
||||||
defer vr.mu.Unlock()
|
defer vr.mu.Unlock()
|
||||||
vr.layout = l
|
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 {
|
func (vr *viewportRenderer) Render(in []byte) []byte {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
func TestViewportRendererShiftsCursor(t *testing.T) {
|
func TestViewportRendererShiftsCursor(t *testing.T) {
|
||||||
vr := newViewportRenderer(newTerminalLayout(120, 40))
|
vr := newViewportRenderer(newTerminalLayout(120, 40))
|
||||||
got := string(vr.Render([]byte("\x1b[H")))
|
got := string(vr.Render([]byte("\x1b[H")))
|
||||||
if got != "\x1b[2;1H" {
|
if got != "\x1b[4;1H" {
|
||||||
t.Fatalf("CUP home: got %q", got)
|
t.Fatalf("CUP home: got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,9 @@ func TestViewportRendererSwallowsAltScreenToggles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestViewportRendererClearScreenIsViewportOnly(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")))
|
got := string(vr.Render([]byte("\x1b[2J")))
|
||||||
if strings.Contains(got, "\x1b[2J") {
|
if strings.Contains(got, "\x1b[2J") {
|
||||||
t.Fatalf("host clear-screen leaked through: %q", got)
|
t.Fatalf("host clear-screen leaked through: %q", got)
|
||||||
@@ -30,7 +32,7 @@ func TestViewportRendererClearScreenIsViewportOnly(t *testing.T) {
|
|||||||
if strings.Count(got, " ") != 3 {
|
if strings.Count(got, " ") != 3 {
|
||||||
t.Fatalf("clear rows: got %q", got)
|
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)
|
t.Fatalf("clear did not target viewport rows: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"syscall"
|
"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
|
// JSON-RPC error codes used by the patterm MCP surface. -32700..-32600
|
||||||
|
|||||||
Reference in New Issue
Block a user