From 36e738b5c679cf50c6ce189104d942c3e1ff2419 Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Thu, 14 May 2026 17:20:23 +0100 Subject: [PATCH] Fix styled switch-back repaint --- internal/app/app.go | 105 +++++----- internal/app/child.go | 8 +- internal/app/session.go | 12 ++ internal/app/viewport_renderer.go | 8 +- internal/app/viewport_renderer_test.go | 2 +- internal/harness/runner.go | 38 ++++ .../switch_replay_clears_viewport.json | 35 ++++ .../switch_replay_preserves_color.json | 36 ++++ internal/harness/session.go | 20 ++ internal/vt/emulator.go | 6 + internal/vt/ghostty.go | 194 ++++++++++++++++++ internal/vt/ghostty_nocgo.go | 21 +- 12 files changed, 423 insertions(+), 62 deletions(-) create mode 100644 internal/harness/scenarios/switch_replay_clears_viewport.json create mode 100644 internal/harness/scenarios/switch_replay_preserves_color.json diff --git a/internal/app/app.go b/internal/app/app.go index 68788b3..e5daa64 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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() { diff --git a/internal/app/child.go b/internal/app/child.go index 76d22ae..7c814d3 100644 --- a/internal/app/child.go +++ b/internal/app/child.go @@ -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) { diff --git a/internal/app/session.go b/internal/app/session.go index 5f4a2f7..31658ce 100644 --- a/internal/app/session.go +++ b/internal/app/session.go @@ -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 { diff --git a/internal/app/viewport_renderer.go b/internal/app/viewport_renderer.go index 6422760..349f0b6 100644 --- a/internal/app/viewport_renderer.go +++ b/internal/app/viewport_renderer.go @@ -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() diff --git a/internal/app/viewport_renderer_test.go b/internal/app/viewport_renderer_test.go index 84aa1c7..4cfbc2f 100644 --- a/internal/app/viewport_renderer_test.go +++ b/internal/app/viewport_renderer_test.go @@ -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") { diff --git a/internal/harness/runner.go b/internal/harness/runner.go index 2adc015..7a0a70b 100644 --- a/internal/harness/runner.go +++ b/internal/harness/runner.go @@ -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": diff --git a/internal/harness/scenarios/switch_replay_clears_viewport.json b/internal/harness/scenarios/switch_replay_clears_viewport.json new file mode 100644 index 0000000..87041f7 --- /dev/null +++ b/internal/harness/scenarios/switch_replay_clears_viewport.json @@ -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" } + ] +} diff --git a/internal/harness/scenarios/switch_replay_preserves_color.json b/internal/harness/scenarios/switch_replay_preserves_color.json new file mode 100644 index 0000000..5cf1d72 --- /dev/null +++ b/internal/harness/scenarios/switch_replay_preserves_color.json @@ -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" } + ] +} diff --git a/internal/harness/session.go b/internal/harness/session.go index da56e60..4acb83a 100644 --- a/internal/harness/session.go +++ b/internal/harness/session.go @@ -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 +} diff --git a/internal/vt/emulator.go b/internal/vt/emulator.go index 9949c05..667d3f3 100644 --- a/internal/vt/emulator.go +++ b/internal/vt/emulator.go @@ -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) diff --git a/internal/vt/ghostty.go b/internal/vt/ghostty.go index 4923965..c5a548a 100644 --- a/internal/vt/ghostty.go +++ b/internal/vt/ghostty.go @@ -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() diff --git a/internal/vt/ghostty_nocgo.go b/internal/vt/ghostty_nocgo.go index ebf21d2..38738b7 100644 --- a/internal/vt/ghostty_nocgo.go +++ b/internal/vt/ghostty_nocgo.go @@ -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")