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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user