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") {