/** * Turn a libghostty-vt snapshot into an ANSI repaint. * * Deliberately renderer-agnostic: the output is just bytes, so the same restore * works for xterm.js today and anything else that speaks VT later. That * portability is the argument for holding the grid server-side: the browser * stays a dumb renderer, and a reattaching client is repainted from our own * grid instead of asking the agent's TUI to redraw itself. * * snapshot() omits blank cells entirely, so gaps are painted as spaces. */ /** * snapshot() resolves palette colours to hex against libghostty's OWN default * palette, so blindly emitting truecolor would repaint a restored screen in * ghostty's greens and yellows instead of the theme the browser renders live * with. Probe the library for its palette once, then emit indexed SGR whenever a * colour came from it — the client re-maps those through its own theme, and only * genuinely truecolor cells stay pinned. */ let paletteIndex = null; export function buildPaletteIndex(createTerminal) { if (paletteIndex) return paletteIndex; const map = new Map(); try { const probe = createTerminal({ cols: 16, rows: 16, scrollbackLimit: 0 }); let out = ''; for (let i = 0; i < 256; i++) { if (i % 16 === 0 && i > 0) out += '\r\n'; out += `\x1b[38;5;${i}mX`; } probe.feed(`${out}\x1b[0m`); for (const cell of probe.snapshot({ includeCells: true }).cells || []) { const idx = cell.row * 16 + cell.col; // A cell with no explicit foreground rendered as the default, which has no // stable index to map back to — leave those to truecolor. if (idx < 256 && cell.foreground && !map.has(cell.foreground)) map.set(cell.foreground, idx); } probe.dispose(); } catch { /* fall through to truecolor-only */ } paletteIndex = map; return map; } const hexToRgb = (hex) => { const h = hex.replace('#', ''); if (h.length !== 6) return null; const n = Number.parseInt(h, 16); if (Number.isNaN(n)) return null; return `${(n >> 16) & 255};${(n >> 8) & 255};${n & 255}`; }; function colorSgr(hex, layer) { const idx = paletteIndex && paletteIndex.get(hex); if (idx !== undefined) return `\x1b[${layer};5;${idx}m`; const rgb = hexToRgb(hex); return rgb ? `\x1b[${layer};2;${rgb}m` : ''; } function sgrFor(cell) { let out = ''; if (cell.bold) out += '\x1b[1m'; if (cell.italic) out += '\x1b[3m'; if (cell.underline) out += '\x1b[4m'; if (cell.foreground) out += colorSgr(cell.foreground, 38); if (cell.background) out += colorSgr(cell.background, 48); return out; } /** Cells indexed by row, since snapshot() returns them as a flat sparse list. */ function cellGrid(snap) { const grid = Array.from({ length: snap.rows }, () => new Array(snap.cols).fill(null)); for (const cell of snap.cells || []) { if (cell.row >= 0 && cell.row < snap.rows && cell.col >= 0 && cell.col < snap.cols) { grid[cell.row][cell.col] = cell; } } return grid; } /** * One row as styled text, with no cursor positioning. Everything after the last * drawn cell is dropped: trailing spaces are the bulk of a mostly-empty agent * screen, and the caller has either just erased the line or is about to end it. */ function renderRow(cells, cols) { let last = -1; for (let col = cols - 1; col >= 0; col--) { if (cells[col] && (cells[col].text !== ' ' || cells[col].background)) { last = col; break; } } if (last < 0) return ''; let out = ''; let style = ''; let col = 0; while (col <= last) { const cell = cells[col]; if (!cell) { if (style) { out += '\x1b[0m'; style = ''; } out += ' '; col += 1; continue; } const next = sgrFor(cell); if (next !== style) { out += '\x1b[0m' + next; style = next; } out += cell.text; col += Math.max(1, cell.width || 1); } if (style) out += '\x1b[0m'; return out; } export function snapshotToAnsi(snap) { const grid = cellGrid(snap); // Normalise the receiver's active buffer first. Reset attributes so nothing // leaks in from what was showing, and hide the cursor so the repaint doesn't // strobe across the screen. let out = snap.isAltScreen ? '\x1b[?1049h' : '\x1b[?1049l'; out += '\x1b[?25l\x1b[0m\x1b[H\x1b[2J'; for (let row = 0; row < snap.rows; row++) { const line = renderRow(grid[row], snap.cols); if (line) out += `\x1b[${row + 1};1H` + line; } out += `\x1b[0m\x1b[${snap.cursorRow + 1};${snap.cursorCol + 1}H`; out += '\x1b[?25h'; return out; } // Snapshot history is plain terminal text. Count its display columns closely // enough to know how many visual rows it occupies when restored at a different // width; using string.length loses wrapped rows (and then the screen repaint // overwrites them). Combining marks are zero-width, common CJK/emoji ranges are // two columns, and everything else is one. export function textColumns(text) { let width = 0; for (const char of text) { const cp = char.codePointAt(0); if (cp === 0x200d || cp === 0xfe0e || cp === 0xfe0f || /\p{Mark}/u.test(char)) continue; const wide = cp >= 0x1100 && ( cp <= 0x115f || cp === 0x2329 || cp === 0x232a || (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) || (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe10 && cp <= 0xfe19) || (cp >= 0xfe30 && cp <= 0xfe6f) || (cp >= 0xff00 && cp <= 0xff60) || (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x1f300 && cp <= 0x1faff) || (cp >= 0x20000 && cp <= 0x3fffd) ); width += wide ? 2 : 1; } return width; } function htmlEntities(text) { return text .replace(/&#x([0-9a-f]+);/gi, (_, value) => String.fromCodePoint(Number.parseInt(value, 16))) .replace(/&#(\d+);/g, (_, value) => String.fromCodePoint(Number.parseInt(value, 10))) .replace(/</g, '<').replace(/>/g, '>') .replace(/"/g, '"').replace(/'|'/g, "'") .replace(/&/g, '&'); } function cssColor(style, property, layer) { const escaped = property.replace('-', '\\-'); const palette = style.match(new RegExp(`(?:^|;)${escaped}: var\\(--vt-palette-(\\d+)\\)`)); if (palette) return `${layer};5;${palette[1]}`; const rgb = style.match(new RegExp(`(?:^|;)${escaped}: rgb\\((\\d+), (\\d+), (\\d+)\\)`)); return rgb ? `${layer};2;${rgb[1]};${rgb[2]};${rgb[3]}` : ''; } function htmlStyleSgr(style) { const codes = []; const foreground = cssColor(style, 'color', 38); const background = cssColor(style, 'background-color', 48); if (foreground) codes.push(foreground); if (background) codes.push(background); if (style.includes('font-weight: bold')) codes.push('1'); if (style.includes('opacity: 0.5')) codes.push('2'); if (style.includes('font-style: italic')) codes.push('3'); if (/text-decoration-line:[^;]*underline/.test(style)) codes.push('4'); if (/text-decoration-line:[^;]*blink/.test(style)) codes.push('5'); if (style.includes('filter: invert(100%)')) codes.push('7'); if (/text-decoration-line:[^;]*line-through/.test(style)) codes.push('9'); if (/text-decoration-line:[^;]*overline/.test(style)) codes.push('53'); return codes.length ? `\x1b[${codes.join(';')}m` : ''; } const hasAnsiStyle = (text) => text.replace(/\x1b\[0m/g, '').includes('\x1b['); /** Convert Ghostty's deterministic debug HTML into one ANSI string per visual row. */ function htmlAnsiRows(html) { if (typeof html !== 'string') return []; const first = html.indexOf('>'); const last = html.lastIndexOf(''); if (first < 0 || last <= first) return []; const inner = html.slice(first + 1, last); const ansi = inner .replace(/
/g, (_, style) => htmlStyleSgr(style)) .replace(/<\/div>/g, '\x1b[0m'); return htmlEntities(ansi).split('\n'); } /** Styled visual rows for the complete primary grid, including scrollback. */ export function styledTerminalRows(snap, html) { const plain = [...(snap.scrollbackLines || []), ...(snap.visibleLines || [])]; const ansi = htmlAnsiRows(html); while (ansi.length < plain.length && !(plain[ansi.length]?.text || '')) ansi.push(''); if (ansi.length !== plain.length) return plain.map((line) => ({ text: line.text || '' })); return plain.map((line, index) => { const text = line.text || ''; const rendered = ansi[index] || ''; return hasAnsiStyle(rendered) ? { text, ansi: `${rendered}\x1b[0m` } : { text }; }); } function joinStyledRows(rows, cols) { const lines = []; let text = ''; let ansi = ''; for (const row of rows) { text += row.text || ''; ansi += row.ansi || row.text || ''; if (textColumns(row.text || '') < cols) { lines.push(hasAnsiStyle(ansi) ? { text, ansi: `${ansi}\x1b[0m` } : { text }); text = ''; ansi = ''; } } if (text || ansi) lines.push(hasAnsiStyle(ansi) ? { text, ansi: `${ansi}\x1b[0m` } : { text }); return lines; } /** Styled logical lines, joining visual rows that were soft-wrapped. */ export function styledLogicalLines(snap, html) { return joinStyledRows(styledTerminalRows(snap, html), snap.cols); } /** Parse Ghostty's formatted state once when both visual and logical keys are needed. */ export function styledSnapshotLines(snap, html) { const rows = styledTerminalRows(snap, html); return { rows, logical: joinStyledRows(rows, snap.cols) }; } /** * Rebuild a fresh viewer from Ghostty's canonical state. * * The binding currently exposes styled cells only for the visible screen, while * scrollback is plain text. That is still a much stronger restore boundary than * replaying a truncated raw PTY byte stream: it includes every retained history * row, is already reflowed to the current geometry, and cannot begin halfway * through an escape sequence. * * A line becomes scrollback only after it leaves the visible grid. Print the * history on the primary screen, then scroll the remaining visible history rows * off before painting the authoritative current screen. */ export function snapshotToRestoreAnsi(snap) { const history = (snap.scrollbackLines || []).map((line) => ({ text: line.text || '', ansi: typeof line.ansi === 'string' ? line.ansi : '', })); let out = '\x1b[?1049l\x1b[?25l\x1b[0m\x1b[H\x1b[2J'; if (history.length) { out += history.map((line) => line.ansi || line.text).join('\r\n'); const visualRows = history.reduce((total, line) => total + Math.max(1, Math.ceil(textColumns(line.text) / Math.max(1, snap.cols))), 0); out += `\x1b[${snap.rows};1H` + '\r\n'.repeat(Math.min(visualRows, snap.rows)); } out += snapshotToAnsi(snap); return out; }