Spaces:
Running
Running
File size: 10,673 Bytes
12a7e8c ca9928f 12a7e8c ca9928f 12a7e8c 290f6a9 12a7e8c ca9928f 12a7e8c 290f6a9 1be81ac fd5428e 290f6a9 fd5428e 290f6a9 fd5428e 1be81ac fd5428e 1be81ac 290f6a9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 | /**
* 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('</div>');
if (first < 0 || last <= first) return [];
const inner = html.slice(first + 1, last);
const ansi = inner
.replace(/<div style="([^"]*)">/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;
}
|