Fix styled switch-back repaint

This commit is contained in:
2026-05-14 17:20:23 +01:00
parent d5ee50fa65
commit 36e738b5c6
12 changed files with 423 additions and 62 deletions

View File

@@ -215,7 +215,9 @@ type uiState struct {
// renderer confines focused-child live output to the main viewport.
// A fresh renderer is allocated per focused child so partial-escape
// state cannot bleed between panes.
renderer *viewportRenderer
renderer *viewportRenderer
repaintNextPTY string
repaintNextPTYBudget int
// attention is the latest request_human_attention surfaced via MCP;
// rendered in the status line until cleared.
@@ -350,15 +352,33 @@ func (st *uiState) OnChildExited(c *Child) {
// disabled only around the replay so long styled runs cannot wrap into
// the right rail.
func (st *uiState) OnPTYOut(childID string, chunk []byte) {
layout := st.layoutSnapshot()
st.mu.Lock()
focus := st.focusedID
palOpen := st.palette != nil
renderer := st.renderer
forceRepaint := focus == childID && st.repaintNextPTY == childID && st.repaintNextPTYBudget > 0
if forceRepaint {
renderer = newViewportRenderer(layout)
st.renderer = renderer
st.repaintNextPTYBudget--
if st.repaintNextPTYBudget == 0 {
st.repaintNextPTY = ""
}
}
st.mu.Unlock()
if palOpen || focus != childID || renderer == nil {
return
}
out := renderer.Render(chunk)
var out []byte
if forceRepaint {
out = st.renderFocusedSnapshot(childID, renderer, layout)
if len(out) == 0 {
return
}
} else {
out = renderer.Render(chunk)
}
st.outMu.Lock()
_, _ = os.Stdout.Write([]byte("\x1b[?7l"))
_, _ = os.Stdout.Write(out)
@@ -798,11 +818,11 @@ func (st *uiState) flashTransient(msg string) {
// 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).
// We replay the emulator's padded grid snapshot rather than its VT
// serialization. SerializeVT can preserve style, but for diff-based TUIs
// we've seen it replay stale prompt layout that no longer matches the
// emulator grid; the padded snapshot is the source of truth for visible
// cells.
func (st *uiState) repaintFocused() {
st.mu.Lock()
id := st.focusedID
@@ -824,16 +844,28 @@ func (st *uiState) repaintFocused() {
defer c.NudgeRedraw(cols, rows)
}
out := st.renderFocusedSnapshot(id, renderer, layout)
if len(out) == 0 {
return
}
st.mu.Lock()
if st.focusedID == id {
st.repaintNextPTY = id
st.repaintNextPTYBudget = 8
}
st.mu.Unlock()
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(out)
}
func (st *uiState) renderFocusedSnapshot(id string, renderer *viewportRenderer, layout terminalLayout) []byte {
text, cursor, err := st.sess.SnapshotChild(id)
if err != nil {
return nil
}
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.
if styled, err := st.sess.StyledSnapshotChild(id); err == nil && len(styled) > 0 {
mainBottom := int(layout.statusRow) - statusRows
prelude := fmt.Sprintf(
"\x1b[0m\x1b[?6l\x1b[%d;%dr\x1b[?25h\x1b[%d;%dH",
@@ -841,40 +873,19 @@ func (st *uiState) repaintFocused() {
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
out = append(out, renderer.ClearViewport()...)
out = append(out, renderer.Render(styled)...)
cup := fmt.Sprintf("\x1b[%d;%dH", int(cursor.Row)+1, int(cursor.Col)+1)
out = append(out, renderer.Render([]byte(cup))...)
return out
}
}
text, cursor, err := st.sess.SnapshotChild(id)
if err != nil {
return
}
out := renderScreenSnapshot(text, cursor, layout)
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(out)
if renderer != nil {
cup := fmt.Sprintf("\x1b[%d;%dH", int(cursor.Row)+1, int(cursor.Col)+1)
out = append(out, renderer.Render([]byte(cup))...)
}
return out
}
func (st *uiState) requestExit() {

View File

@@ -379,9 +379,10 @@ func (c *Child) signal(sig syscall.Signal) error {
// 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.
// change), then send SIGWINCH explicitly for TUIs that miss or coalesce
// the size-toggled signal. 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 {
@@ -389,6 +390,7 @@ func (c *Child) NudgeRedraw(cols, rows uint16) {
}
_ = pty.Resize(cols, rows-1)
_ = pty.Resize(cols, rows)
_ = c.signal(syscall.SIGWINCH)
}
func (c *Child) markExited(err error) {

View File

@@ -428,6 +428,18 @@ func (s *Session) SerializeChild(id string) ([]byte, error) {
return em.SerializeVT()
}
func (s *Session) StyledSnapshotChild(id string) ([]byte, error) {
c := s.FindChild(id)
if c == nil {
return nil, fmt.Errorf("no such child %q", id)
}
em := c.Emulator()
if em == nil {
return nil, fmt.Errorf("child %q has no emulator", id)
}
return em.StyledScreenVT()
}
func (s *Session) SnapshotChild(id string) (string, vt.CursorState, error) {
c := s.FindChild(id)
if c == nil {

View File

@@ -61,6 +61,12 @@ func (vr *viewportRenderer) Render(in []byte) []byte {
return []byte(vr.pending.String())
}
func (vr *viewportRenderer) ClearViewport() []byte {
vr.mu.Lock()
defer vr.mu.Unlock()
return []byte(vr.clearViewport())
}
func (vr *viewportRenderer) feed(b byte) {
switch vr.state {
case vpNormal:
@@ -191,7 +197,7 @@ func (vr *viewportRenderer) clearViewport() string {
var b strings.Builder
b.WriteString("\x1b7")
for r := uint16(0); r < vr.layout.childRows(); r++ {
fmt.Fprintf(&b, "\x1b[%d;%dH%s", int(vr.layout.mainTop+r), int(vr.layout.mainLeft), strings.Repeat(" ", int(vr.layout.childCols())))
fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX", int(vr.layout.mainTop+r), int(vr.layout.mainLeft), int(vr.layout.childCols()))
}
b.WriteString("\x1b8")
return b.String()

View File

@@ -29,7 +29,7 @@ func TestViewportRendererClearScreenIsViewportOnly(t *testing.T) {
if strings.Contains(got, "\x1b[2J") {
t.Fatalf("host clear-screen leaked through: %q", got)
}
if strings.Count(got, " ") != 3 {
if strings.Count(got, "\x1b[20X") != 3 {
t.Fatalf("clear rows: got %q", got)
}
if !strings.Contains(got, "\x1b[4;1H") || !strings.Contains(got, "\x1b[6;1H") {

View File

@@ -3,6 +3,7 @@ package harness
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
@@ -74,6 +75,43 @@ func runStep(s *Session, step Step, results map[string]json.RawMessage) error {
return fmt.Errorf("screen does not contain %q:\n%s", step.Contains, screen)
}
return nil
case "assert_not_contains":
screen, err := s.Screen()
if err != nil {
return err
}
if strings.Contains(screen, step.Contains) {
return fmt.Errorf("screen contains %q:\n%s", step.Contains, screen)
}
return nil
case "mark_raw":
if step.SaveAs == "" {
return fmt.Errorf("mark_raw requires save_as")
}
raw, err := json.Marshal(s.RawOffset())
if err != nil {
return err
}
results[step.SaveAs] = raw
return nil
case "assert_raw_since_regex":
raw, ok := results[step.From]
if !ok {
return fmt.Errorf("no saved result %q", step.From)
}
var offset int
if err := json.Unmarshal(raw, &offset); err != nil {
return fmt.Errorf("saved result %q is not a raw offset: %w", step.From, err)
}
re, err := regexp.Compile(step.Regex)
if err != nil {
return err
}
b := s.RawSince(offset)
if !re.Match(b) {
return fmt.Errorf("raw output since %q does not match %q:\n%s", step.From, step.Regex, string(b))
}
return nil
case "assert_regex":
return s.WaitForRegex(step.Regex, timeoutMS(step.TimeoutMS))
case "wait_text":

View File

@@ -0,0 +1,35 @@
{
"name": "switch_replay_clears_viewport",
"cols": 80,
"rows": 24,
"scripts": [
{
"name": "blanktop",
"body": "#!/bin/sh\nprintf '\\033[2;1HFIRST-ROW-TWO\\n'\nsleep 5\n"
}
],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": { "kind": "command", "argv": ["blanktop"], "name": "first" },
"save_as": "first"
},
{ "type": "wait_text", "contains": "FIRST-ROW-TWO", "timeout_ms": 5000 },
{
"type": "mcp_call",
"method": "spawn_process",
"params": { "kind": "command", "argv": ["sh", "-lc", "echo SECOND READY; sleep 5"], "name": "second" },
"save_as": "second"
},
{ "type": "wait_text", "contains": "SECOND READY", "timeout_ms": 5000 },
{
"type": "mcp_call",
"method": "select_process",
"params": { "process_id": "{{first.process_id}}" }
},
{ "type": "wait_stable", "timeout_ms": 5000 },
{ "type": "assert_contains", "contains": "FIRST-ROW-TWO" },
{ "type": "assert_not_contains", "contains": "SECOND READY" }
]
}

View File

@@ -0,0 +1,36 @@
{
"name": "switch_replay_preserves_color",
"cols": 80,
"rows": 24,
"scripts": [
{
"name": "color-frame",
"body": "#!/bin/sh\nprintf '\\033[31mREDMARK\\033[0m\\n'\nsleep 5\n"
}
],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": { "kind": "command", "argv": ["color-frame"], "name": "color" },
"save_as": "color"
},
{ "type": "wait_text", "contains": "REDMARK", "timeout_ms": 5000 },
{
"type": "mcp_call",
"method": "spawn_process",
"params": { "kind": "command", "argv": ["sh", "-lc", "echo SECOND READY; sleep 5"], "name": "second" },
"save_as": "second"
},
{ "type": "wait_text", "contains": "SECOND READY", "timeout_ms": 5000 },
{ "type": "mark_raw", "save_as": "before_switch_back" },
{
"type": "mcp_call",
"method": "select_process",
"params": { "process_id": "{{color.process_id}}" }
},
{ "type": "wait_stable", "timeout_ms": 5000 },
{ "type": "assert_contains", "contains": "REDMARK" },
{ "type": "assert_raw_since_regex", "from": "before_switch_back", "regex": "\u001b\\[[0-9;]*38;2;[^m]*mREDMARK" }
]
}

View File

@@ -262,3 +262,23 @@ func (s *Session) rawBytes() []byte {
copy(out, s.bytes)
return out
}
func (s *Session) RawOffset() int {
s.bytesMu.Lock()
defer s.bytesMu.Unlock()
return len(s.bytes)
}
func (s *Session) RawSince(offset int) []byte {
s.bytesMu.Lock()
defer s.bytesMu.Unlock()
if offset < 0 {
offset = 0
}
if offset > len(s.bytes) {
offset = len(s.bytes)
}
out := make([]byte, len(s.bytes)-offset)
copy(out, s.bytes[offset:])
return out
}

View File

@@ -45,6 +45,12 @@ type Emulator interface {
// frame" for newly-attached clients.
SerializeVT() ([]byte, error)
// StyledScreenVT returns the active screen's visible cell grid as VT
// bytes with SGR styling and child-space cursor movement, but without
// terminal modes, scroll regions, tabstops, or formatter cursor side
// effects.
StyledScreenVT() ([]byte, error)
// Cursor returns cursor position and visibility on the active screen.
Cursor() (CursorState, error)

View File

@@ -121,6 +121,7 @@ import (
"fmt"
"runtime"
"runtime/cgo"
"strings"
"sync"
"sync/atomic"
"unsafe"
@@ -309,6 +310,199 @@ func (e *GhosttyEmulator) SerializeVT() ([]byte, error) {
return C.GoBytes(unsafe.Pointer(buf), C.int(n)), nil
}
type styledCellSGR struct {
fgSet, bgSet bool
fgR, fgG, fgB uint8
bgR, bgG, bgB uint8
bold, italic, faint, blink, inverse, invisible, strikethrough, overline bool
underline int
}
func (s styledCellSGR) equal(o styledCellSGR) bool {
return s == o
}
func sgrSeq(s styledCellSGR) string {
var b strings.Builder
b.WriteString("\x1b[0")
if s.bold {
b.WriteString(";1")
}
if s.faint {
b.WriteString(";2")
}
if s.italic {
b.WriteString(";3")
}
if s.underline != 0 {
b.WriteString(";4")
}
if s.blink {
b.WriteString(";5")
}
if s.inverse {
b.WriteString(";7")
}
if s.invisible {
b.WriteString(";8")
}
if s.strikethrough {
b.WriteString(";9")
}
if s.overline {
b.WriteString(";53")
}
if s.fgSet {
fmt.Fprintf(&b, ";38;2;%d;%d;%d", s.fgR, s.fgG, s.fgB)
}
if s.bgSet {
fmt.Fprintf(&b, ";48;2;%d;%d;%d", s.bgR, s.bgG, s.bgB)
}
b.WriteByte('m')
return b.String()
}
func (e *GhosttyEmulator) StyledScreenVT() ([]byte, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return nil, errors.New("vt: emulator closed")
}
var state C.GhosttyRenderState
if rc := C.ghostty_render_state_new(nil, &state); rc != C.GHOSTTY_SUCCESS {
return nil, fmt.Errorf("vt: render_state_new failed: %s", ghosttyResultStr(rc))
}
defer C.ghostty_render_state_free(state)
if rc := C.ghostty_render_state_update(state, e.term); rc != C.GHOSTTY_SUCCESS {
return nil, fmt.Errorf("vt: render_state_update failed: %s", ghosttyResultStr(rc))
}
var rows C.uint16_t
if rc := C.ghostty_render_state_get(state, C.GHOSTTY_RENDER_STATE_DATA_ROWS, unsafe.Pointer(&rows)); rc != C.GHOSTTY_SUCCESS {
return nil, fmt.Errorf("vt: render_state rows failed: %s", ghosttyResultStr(rc))
}
var iter C.GhosttyRenderStateRowIterator
if rc := C.ghostty_render_state_row_iterator_new(nil, &iter); rc != C.GHOSTTY_SUCCESS {
return nil, fmt.Errorf("vt: row_iterator_new failed: %s", ghosttyResultStr(rc))
}
defer C.ghostty_render_state_row_iterator_free(iter)
if rc := C.ghostty_render_state_get(state, C.GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR, unsafe.Pointer(&iter)); rc != C.GHOSTTY_SUCCESS {
return nil, fmt.Errorf("vt: render_state row iterator failed: %s", ghosttyResultStr(rc))
}
var cells C.GhosttyRenderStateRowCells
if rc := C.ghostty_render_state_row_cells_new(nil, &cells); rc != C.GHOSTTY_SUCCESS {
return nil, fmt.Errorf("vt: row_cells_new failed: %s", ghosttyResultStr(rc))
}
defer C.ghostty_render_state_row_cells_free(cells)
var out strings.Builder
for row := 0; row < int(rows) && C.ghostty_render_state_row_iterator_next(iter); row++ {
if rc := C.ghostty_render_state_row_get(iter, C.GHOSTTY_RENDER_STATE_ROW_DATA_CELLS, unsafe.Pointer(&cells)); rc != C.GHOSTTY_SUCCESS {
return nil, fmt.Errorf("vt: render_state row cells failed: %s", ghosttyResultStr(rc))
}
rowCells := make([]struct {
text string
sgr styledCellSGR
draw bool
}, 0, int(e.cols))
lastDraw := -1
for col := 0; col < int(e.cols) && C.ghostty_render_state_row_cells_next(cells); col++ {
var cell C.GhosttyCell
_ = C.ghostty_render_state_row_cells_get(cells, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW, unsafe.Pointer(&cell))
var wide C.GhosttyCellWide
_ = C.ghostty_cell_get(cell, C.GHOSTTY_CELL_DATA_WIDE, unsafe.Pointer(&wide))
if wide == C.GHOSTTY_CELL_WIDE_SPACER_TAIL || wide == C.GHOSTTY_CELL_WIDE_SPACER_HEAD {
rowCells = append(rowCells, struct {
text string
sgr styledCellSGR
draw bool
}{})
continue
}
var style C.GhosttyStyle
style.size = C.size_t(unsafe.Sizeof(style))
_ = C.ghostty_render_state_row_cells_get(cells, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_STYLE, unsafe.Pointer(&style))
sgr := styledCellSGR{
bold: bool(style.bold),
italic: bool(style.italic),
faint: bool(style.faint),
blink: bool(style.blink),
inverse: bool(style.inverse),
invisible: bool(style.invisible),
strikethrough: bool(style.strikethrough),
overline: bool(style.overline),
underline: int(style.underline),
}
var fg C.GhosttyColorRgb
if rc := C.ghostty_render_state_row_cells_get(cells, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR, unsafe.Pointer(&fg)); rc == C.GHOSTTY_SUCCESS {
sgr.fgSet, sgr.fgR, sgr.fgG, sgr.fgB = true, uint8(fg.r), uint8(fg.g), uint8(fg.b)
}
var bg C.GhosttyColorRgb
if rc := C.ghostty_render_state_row_cells_get(cells, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_BG_COLOR, unsafe.Pointer(&bg)); rc == C.GHOSTTY_SUCCESS {
sgr.bgSet, sgr.bgR, sgr.bgG, sgr.bgB = true, uint8(bg.r), uint8(bg.g), uint8(bg.b)
}
var graphemeLen C.uint32_t
_ = C.ghostty_render_state_row_cells_get(cells, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN, unsafe.Pointer(&graphemeLen))
text := ""
if graphemeLen > 0 {
buf := make([]C.uint32_t, int(graphemeLen))
_ = C.ghostty_render_state_row_cells_get(cells, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF, unsafe.Pointer(&buf[0]))
rs := make([]rune, len(buf))
for i, r := range buf {
rs[i] = rune(r)
}
text = string(rs)
}
draw := text != "" || sgr.bgSet
if draw {
lastDraw = col
if text == "" {
text = " "
}
}
rowCells = append(rowCells, struct {
text string
sgr styledCellSGR
draw bool
}{text: text, sgr: sgr, draw: draw})
}
if lastDraw < 0 {
continue
}
fmt.Fprintf(&out, "\x1b[%d;1H", row+1)
cur := styledCellSGR{}
out.WriteString("\x1b[0m")
for col := 0; col <= lastDraw && col < len(rowCells); col++ {
cell := rowCells[col]
if !cell.draw {
if !cur.equal(styledCellSGR{}) {
cur = styledCellSGR{}
out.WriteString("\x1b[0m")
}
out.WriteByte(' ')
continue
}
if !cell.sgr.equal(cur) {
cur = cell.sgr
out.WriteString(sgrSeq(cur))
}
out.WriteString(cell.text)
}
out.WriteString("\x1b[0m")
}
return []byte(out.String()), nil
}
func (e *GhosttyEmulator) Cursor() (CursorState, error) {
e.mu.Lock()
defer e.mu.Unlock()

View File

@@ -15,16 +15,17 @@ func NewGhosttyEmulator(cols, rows uint16) (*GhosttyEmulator, error) {
return nil, errors.New("vt: built with -tags nocgo; libghostty-vt is unavailable")
}
func (e *GhosttyEmulator) Write(p []byte) (int, error) { return 0, errStub }
func (e *GhosttyEmulator) Resize(cols, rows uint16) error { return errStub }
func (e *GhosttyEmulator) Size() (uint16, uint16) { return 0, 0 }
func (e *GhosttyEmulator) PlainText() (string, error) { return "", errStub }
func (e *GhosttyEmulator) ScreenText() (string, error) { return "", errStub }
func (e *GhosttyEmulator) SerializeVT() ([]byte, error) { return nil, errStub }
func (e *GhosttyEmulator) Cursor() (CursorState, error) { return CursorState{}, errStub }
func (e *GhosttyEmulator) ActiveScreen() (Screen, error) { return 0, errStub }
func (e *GhosttyEmulator) OnWritePTY(fn func([]byte)) {}
func (e *GhosttyEmulator) Close() error { return nil }
func (e *GhosttyEmulator) Write(p []byte) (int, error) { return 0, errStub }
func (e *GhosttyEmulator) Resize(cols, rows uint16) error { return errStub }
func (e *GhosttyEmulator) Size() (uint16, uint16) { return 0, 0 }
func (e *GhosttyEmulator) PlainText() (string, error) { return "", errStub }
func (e *GhosttyEmulator) ScreenText() (string, error) { return "", errStub }
func (e *GhosttyEmulator) SerializeVT() ([]byte, error) { return nil, errStub }
func (e *GhosttyEmulator) StyledScreenVT() ([]byte, error) { return nil, errStub }
func (e *GhosttyEmulator) Cursor() (CursorState, error) { return CursorState{}, errStub }
func (e *GhosttyEmulator) ActiveScreen() (Screen, error) { return 0, errStub }
func (e *GhosttyEmulator) OnWritePTY(fn func([]byte)) {}
func (e *GhosttyEmulator) Close() error { return nil }
var errStub = errors.New("vt: built with -tags nocgo")