Agent Manager commited on
Commit
fd5428e
·
1 Parent(s): 93a8ea7

Preserve styled terminal scrollback

Browse files
server/resize.test.mjs CHANGED
@@ -16,6 +16,7 @@ import {
16
  traceHistoryLines,
17
  } from './src/history-store.js';
18
  import { mergeRepaintArchive, repaintArchiveView } from './src/runner.js';
 
19
 
20
  const HERE = path.dirname(fileURLToPath(import.meta.url));
21
  const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'am-resize-'));
@@ -175,13 +176,31 @@ try {
175
 
176
  const checkpoint = createTerminalHistoryCheckpoint({
177
  directory, id: 'current', delayMs: 1,
178
- snapshot: () => ({ cols: 90, scrollbackLines: [{ text: 'clean' }] }),
 
 
179
  blocked: () => false,
180
  });
181
  checkpoint.flush();
182
  const wrote = await waitFor(() => loadTerminalHistory(directory, 'current')?.version
183
  === TERMINAL_HISTORY_VERSION);
184
  check('new checkpoints use the clean startup generation', wrote);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  }
186
 
187
  {
@@ -356,11 +375,12 @@ try {
356
  const resumed = await waitFor(async () => (await gridText(id)).includes('[fixture 150x40]'));
357
  await sleep(250);
358
  const restartedText = Headless ? await restarted.screenText() : '';
 
359
  check('host restart restores deep scrollback', resumed
360
  && (!Headless || restartedText.includes('history-0001')));
361
  check('host restart does not duplicate repaint content', !Headless
362
- || duplicateTokens(restartedText) === beforeBrowser,
363
- Headless ? `${duplicateTokens(restartedText)} duplicate tokens` : '');
364
  if (Headless) check('restored viewport remains scrollable', await restarted.scrollsBack());
365
  restarted.close();
366
  await stop(id);
 
16
  traceHistoryLines,
17
  } from './src/history-store.js';
18
  import { mergeRepaintArchive, repaintArchiveView } from './src/runner.js';
19
+ import { snapshotToRestoreAnsi, styledTerminalRows } from './src/snapshot.js';
20
 
21
  const HERE = path.dirname(fileURLToPath(import.meta.url));
22
  const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'am-resize-'));
 
176
 
177
  const checkpoint = createTerminalHistoryCheckpoint({
178
  directory, id: 'current', delayMs: 1,
179
+ snapshot: () => ({
180
+ cols: 90, scrollbackLines: [{ text: 'clean', ansi: '\x1b[38;5;2mclean\x1b[0m' }],
181
+ }),
182
  blocked: () => false,
183
  });
184
  checkpoint.flush();
185
  const wrote = await waitFor(() => loadTerminalHistory(directory, 'current')?.version
186
  === TERMINAL_HISTORY_VERSION);
187
  check('new checkpoints use the clean startup generation', wrote);
188
+ check('checkpoints retain validated scrollback styling',
189
+ loadTerminalHistory(directory, 'current')?.lines[0]?.ansi
190
+ === '\x1b[38;5;2mclean\x1b[0m');
191
+
192
+ const styled = styledTerminalRows({
193
+ cols: 20, rows: 1,
194
+ scrollbackLines: [{ row: 0, text: 'red history' }],
195
+ visibleLines: [{ row: 0, text: 'live' }],
196
+ }, '<div style="font-family: monospace; white-space: pre;"><div style="display: inline;color: var(--vt-palette-1);">red history</div>\nlive</div>');
197
+ const restore = snapshotToRestoreAnsi({
198
+ cols: 20, rows: 1, cursorRow: 0, cursorCol: 0, cells: [],
199
+ scrollbackLines: styled.slice(0, 1),
200
+ });
201
+ check('Ghostty HTML scrollback colors convert back to ANSI',
202
+ styled[0].ansi?.includes('\x1b[38;5;1m')
203
+ && restore.includes('\x1b[38;5;1mred history'));
204
  }
205
 
206
  {
 
375
  const resumed = await waitFor(async () => (await gridText(id)).includes('[fixture 150x40]'));
376
  await sleep(250);
377
  const restartedText = Headless ? await restarted.screenText() : '';
378
+ const restartDuplicates = Headless ? duplicateTokens(restartedText) : 0;
379
  check('host restart restores deep scrollback', resumed
380
  && (!Headless || restartedText.includes('history-0001')));
381
  check('host restart does not duplicate repaint content', !Headless
382
+ || restartDuplicates === beforeBrowser,
383
+ Headless ? `${restartDuplicates} duplicate tokens` : '');
384
  if (Headless) check('restored viewport remains scrollable', await restarted.scrollsBack());
385
  restarted.close();
386
  await stop(id);
server/src/history-store.js CHANGED
@@ -5,16 +5,24 @@ const fileFor = (directory, id) => path.join(
5
  directory, `${String(id).replace(/[^a-zA-Z0-9._-]/g, '_')}.json`,
6
  );
7
 
8
- export const TERMINAL_HISTORY_VERSION = 6;
 
 
 
9
 
10
  /** Load a plain-text Ghostty scrollback checkpoint, ignoring old/bad schemas. */
11
  export function loadTerminalHistory(directory, id) {
12
  try {
13
  const body = fs.readFileSync(fileFor(directory, id), 'utf8');
14
  const saved = JSON.parse(body);
15
- if (![1, 2, 3, 4, 5, TERMINAL_HISTORY_VERSION].includes(saved?.version)
16
  || !Number.isFinite(saved.cols) || !Array.isArray(saved.lines)) return null;
17
- const lines = saved.lines.filter((line) => typeof line === 'string').map((text) => ({ text }));
 
 
 
 
 
18
  return lines.length ? {
19
  version: saved.version, cols: Math.max(1, Math.round(saved.cols)), lines, body,
20
  } : null;
@@ -70,7 +78,10 @@ export function createTerminalHistoryCheckpoint({
70
  if (blocked()) { schedule(); return; }
71
  let snap;
72
  try { snap = snapshot(); } catch { return; }
73
- const lines = (snap.scrollbackLines || []).map((line) => line.text || '');
 
 
 
74
  pending = JSON.stringify({ version: TERMINAL_HISTORY_VERSION, cols: snap.cols, lines });
75
  writePending();
76
  };
 
5
  directory, `${String(id).replace(/[^a-zA-Z0-9._-]/g, '_')}.json`,
6
  );
7
 
8
+ export const TERMINAL_HISTORY_VERSION = 7;
9
+
10
+ const validAnsi = (ansi, text) => typeof ansi === 'string'
11
+ && ansi.replace(/\x1b\[[0-9;]*m/g, '') === text;
12
 
13
  /** Load a plain-text Ghostty scrollback checkpoint, ignoring old/bad schemas. */
14
  export function loadTerminalHistory(directory, id) {
15
  try {
16
  const body = fs.readFileSync(fileFor(directory, id), 'utf8');
17
  const saved = JSON.parse(body);
18
+ if (![1, 2, 3, 4, 5, 6, TERMINAL_HISTORY_VERSION].includes(saved?.version)
19
  || !Number.isFinite(saved.cols) || !Array.isArray(saved.lines)) return null;
20
+ const lines = saved.lines.flatMap((line) => {
21
+ if (typeof line === 'string') return [{ text: line }];
22
+ if (!line || typeof line.text !== 'string') return [];
23
+ return [validAnsi(line.ansi, line.text)
24
+ ? { text: line.text, ansi: line.ansi } : { text: line.text }];
25
+ });
26
  return lines.length ? {
27
  version: saved.version, cols: Math.max(1, Math.round(saved.cols)), lines, body,
28
  } : null;
 
78
  if (blocked()) { schedule(); return; }
79
  let snap;
80
  try { snap = snapshot(); } catch { return; }
81
+ const lines = (snap.scrollbackLines || []).map((line) => {
82
+ const text = line.text || '';
83
+ return validAnsi(line.ansi, text) ? { text, ansi: line.ansi } : text;
84
+ });
85
  pending = JSON.stringify({ version: TERMINAL_HISTORY_VERSION, cols: snap.cols, lines });
86
  writePending();
87
  };
server/src/runner.js CHANGED
@@ -6,7 +6,9 @@ import { remoteState, setPaused } from './remote.js';
6
  import { cliById, isRemote, STATE_DIR, WORKSPACES_DIR } from './config.js';
7
  import { update, list } from './sessions.js';
8
  import { captureOpencodeSession, readTrace } from './traces.js';
9
- import { buildPaletteIndex, snapshotToRestoreAnsi, textColumns } from './snapshot.js';
 
 
10
  import {
11
  createTerminalHistoryCheckpoint, loadTerminalHistory, TERMINAL_HISTORY_VERSION,
12
  traceHistoryLines,
@@ -303,6 +305,39 @@ function terminalArchive(vt, snap) {
303
  return logicalText([...(snap.scrollbackLines || []), ...visibleRows(vt)], snap.cols);
304
  }
305
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  /**
307
  * Commit one captured agent repaint without duplicating its overflow rows.
308
  *
@@ -322,6 +357,7 @@ function finishCapturedGrid(host, txn) {
322
  try { snap = txn.vt.snapshot({ includeCells: true, includeScrollback: true }); } catch {}
323
  if (snap) {
324
  try {
 
325
  const visible = visibleRows(txn.vt);
326
  const visibleText = logicalText(visible, txn.cols);
327
  const view = repaintArchiveView(txn.archive, visibleText, txn.fallbackHistory);
@@ -331,7 +367,9 @@ function finishCapturedGrid(host, txn) {
331
  rows: txn.rows,
332
  scrollbackLimit: SCROLLBACK_BYTES,
333
  });
334
- replacement.feed(snapshotToRestoreAnsi({ ...snap, scrollbackLines: view.history }));
 
 
335
  } catch (error) {
336
  console.error('[runner] resize capture commit', error && error.message);
337
  try { replacement?.dispose(); } catch {}
@@ -364,7 +402,7 @@ function finishCapturedGrid(host, txn) {
364
  // diverge after a narrow zoom, even when the server history is correct.
365
  notifyGrid(host, true);
366
  const committed = host.vt.snapshot({ includeCells: true, includeScrollback: true });
367
- const ansi = snapshotToRestoreAnsi(committed);
368
  for (const sub of host.subs) sub.onData(ansi);
369
  try { previous.dispose(); } catch {}
370
  sampleScreen(host);
@@ -389,7 +427,10 @@ function armCapturedGrid(host, txn) {
389
  function startCapturedGrid(host, cols, rows, seed = null, resizePty = true) {
390
  const previous = host.resizeCapture;
391
  let archive = seed?.history
392
- ? logicalText(seed.history, seed.historyCols || host.cols) : host.repaintArchive;
 
 
 
393
  let fallbackHistory = seed?.history ? archive : null;
394
  if (previous) {
395
  archive = previous.archive;
@@ -494,6 +535,7 @@ function armTraceHydration(host) {
494
  const currentText = visible.map((line) => line.text || '').join('\n');
495
  const recovered = traceHistoryLines(host.traceHistoryPage, currentText);
496
  host.traceHistoryPage = null;
 
497
 
498
  let replacement = null;
499
  try {
@@ -508,14 +550,14 @@ function armTraceHydration(host) {
508
  });
509
  replacement.feed(snapshotToRestoreAnsi({
510
  ...snap,
511
- scrollbackLines: view.history,
512
  }));
513
  const previous = host.vt;
514
  host.vt = replacement;
515
  host.repaintArchive = view.archive;
516
  notifyGrid(host, true);
517
  const committed = host.vt.snapshot({ includeCells: true, includeScrollback: true });
518
- const ansi = snapshotToRestoreAnsi(committed);
519
  for (const sub of host.subs) sub.onData(ansi);
520
  try { previous.dispose(); } catch {}
521
  sampleScreen(host);
@@ -1058,6 +1100,8 @@ export function ensureRunning(session, cols = 120, rows = 34) {
1058
  captureResize,
1059
  resizeCapture: null,
1060
  repaintArchive: null,
 
 
1061
  startupHistory: captureResize ? persistedHistory : null,
1062
  historyCheckpoint: null,
1063
  traceHistoryPage: null,
@@ -1076,9 +1120,15 @@ export function ensureRunning(session, cols = 120, rows = 34) {
1076
  delayMs: HISTORY_SAVE_MS,
1077
  snapshot: () => {
1078
  const snap = host.vt.snapshot({ includeScrollback: true });
1079
- if (!captureResize) return snap;
 
 
 
1080
  const archive = host.repaintArchive ?? terminalArchive(host.vt, snap);
1081
- return { cols: host.cols, scrollbackLines: logicalHistory(archive) };
 
 
 
1082
  },
1083
  blocked: () => !!host.resizeCapture,
1084
  persistedBody: persistedHistory?.body || null,
@@ -1098,6 +1148,7 @@ export function ensureRunning(session, cols = 120, rows = 34) {
1098
  startCapturedGrid(host, host.cols, host.rows, {
1099
  history: startup.lines,
1100
  historyCols: startup.cols,
 
1101
  }, false);
1102
  txn = host.resizeCapture;
1103
  }
@@ -1207,7 +1258,7 @@ export function attach(session, cols, rows) {
1207
  let snap;
1208
  try { snap = host.vt.snapshot({ includeCells: true, includeScrollback: true }); } catch { return null; }
1209
  return {
1210
- ansi: snapshotToRestoreAnsi(snap),
1211
  cols: snap.cols,
1212
  rows: snap.rows,
1213
  viewers: host.subs.size,
 
6
  import { cliById, isRemote, STATE_DIR, WORKSPACES_DIR } from './config.js';
7
  import { update, list } from './sessions.js';
8
  import { captureOpencodeSession, readTrace } from './traces.js';
9
+ import {
10
+ buildPaletteIndex, snapshotToRestoreAnsi, styledSnapshotLines, textColumns,
11
+ } from './snapshot.js';
12
  import {
13
  createTerminalHistoryCheckpoint, loadTerminalHistory, TERMINAL_HISTORY_VERSION,
14
  traceHistoryLines,
 
305
  return logicalText([...(snap.scrollbackLines || []), ...visibleRows(vt)], snap.cols);
306
  }
307
 
308
+ const MAX_HISTORY_STYLES = 50_000;
309
+
310
+ function learnHistoryStyles(host, vt, snap) {
311
+ if (!host.historyStyles || typeof vt.formatHtml !== 'function') return;
312
+ const visibleHasStyle = (snap.cells || []).some((cell) => cell.bold || cell.italic
313
+ || cell.underline || cell.foreground || cell.background);
314
+ if (!visibleHasStyle && host.historyStyles.size === 0) return;
315
+ let styled;
316
+ try { styled = styledSnapshotLines(snap, vt.formatHtml()); } catch { return; }
317
+ for (const line of [...styled.rows, ...styled.logical]) {
318
+ if (!line.text || !line.ansi) continue;
319
+ // Refresh insertion order so frequently repainted transcript rows survive
320
+ // the bounded cache while old one-off status lines age out.
321
+ host.historyStyles.delete(line.text);
322
+ host.historyStyles.set(line.text, line.ansi);
323
+ }
324
+ while (host.historyStyles.size > MAX_HISTORY_STYLES) {
325
+ host.historyStyles.delete(host.historyStyles.keys().next().value);
326
+ }
327
+ }
328
+
329
+ function withHistoryStyles(host, lines) {
330
+ return (lines || []).map((line) => {
331
+ const ansi = line.ansi || host.historyStyles?.get(line.text || '');
332
+ return ansi ? { ...line, ansi } : line;
333
+ });
334
+ }
335
+
336
+ function styledSnapshot(host, vt, snap) {
337
+ learnHistoryStyles(host, vt, snap);
338
+ return { ...snap, scrollbackLines: withHistoryStyles(host, snap.scrollbackLines) };
339
+ }
340
+
341
  /**
342
  * Commit one captured agent repaint without duplicating its overflow rows.
343
  *
 
357
  try { snap = txn.vt.snapshot({ includeCells: true, includeScrollback: true }); } catch {}
358
  if (snap) {
359
  try {
360
+ learnHistoryStyles(host, txn.vt, snap);
361
  const visible = visibleRows(txn.vt);
362
  const visibleText = logicalText(visible, txn.cols);
363
  const view = repaintArchiveView(txn.archive, visibleText, txn.fallbackHistory);
 
367
  rows: txn.rows,
368
  scrollbackLimit: SCROLLBACK_BYTES,
369
  });
370
+ replacement.feed(snapshotToRestoreAnsi({
371
+ ...snap, scrollbackLines: withHistoryStyles(host, view.history),
372
+ }));
373
  } catch (error) {
374
  console.error('[runner] resize capture commit', error && error.message);
375
  try { replacement?.dispose(); } catch {}
 
402
  // diverge after a narrow zoom, even when the server history is correct.
403
  notifyGrid(host, true);
404
  const committed = host.vt.snapshot({ includeCells: true, includeScrollback: true });
405
+ const ansi = snapshotToRestoreAnsi(styledSnapshot(host, host.vt, committed));
406
  for (const sub of host.subs) sub.onData(ansi);
407
  try { previous.dispose(); } catch {}
408
  sampleScreen(host);
 
427
  function startCapturedGrid(host, cols, rows, seed = null, resizePty = true) {
428
  const previous = host.resizeCapture;
429
  let archive = seed?.history
430
+ ? (seed.logical
431
+ ? `${seed.history.map((line) => line.text || '').join('\n')}\n`
432
+ : logicalText(seed.history, seed.historyCols || host.cols))
433
+ : host.repaintArchive;
434
  let fallbackHistory = seed?.history ? archive : null;
435
  if (previous) {
436
  archive = previous.archive;
 
535
  const currentText = visible.map((line) => line.text || '').join('\n');
536
  const recovered = traceHistoryLines(host.traceHistoryPage, currentText);
537
  host.traceHistoryPage = null;
538
+ learnHistoryStyles(host, host.vt, snap);
539
 
540
  let replacement = null;
541
  try {
 
550
  });
551
  replacement.feed(snapshotToRestoreAnsi({
552
  ...snap,
553
+ scrollbackLines: withHistoryStyles(host, view.history),
554
  }));
555
  const previous = host.vt;
556
  host.vt = replacement;
557
  host.repaintArchive = view.archive;
558
  notifyGrid(host, true);
559
  const committed = host.vt.snapshot({ includeCells: true, includeScrollback: true });
560
+ const ansi = snapshotToRestoreAnsi(styledSnapshot(host, host.vt, committed));
561
  for (const sub of host.subs) sub.onData(ansi);
562
  try { previous.dispose(); } catch {}
563
  sampleScreen(host);
 
1100
  captureResize,
1101
  resizeCapture: null,
1102
  repaintArchive: null,
1103
+ historyStyles: new Map((persistedHistory?.lines || [])
1104
+ .filter((line) => line.text && line.ansi).map((line) => [line.text, line.ansi])),
1105
  startupHistory: captureResize ? persistedHistory : null,
1106
  historyCheckpoint: null,
1107
  traceHistoryPage: null,
 
1120
  delayMs: HISTORY_SAVE_MS,
1121
  snapshot: () => {
1122
  const snap = host.vt.snapshot({ includeScrollback: true });
1123
+ learnHistoryStyles(host, host.vt, snap);
1124
+ if (!captureResize) {
1125
+ return { ...snap, scrollbackLines: withHistoryStyles(host, snap.scrollbackLines) };
1126
+ }
1127
  const archive = host.repaintArchive ?? terminalArchive(host.vt, snap);
1128
+ return {
1129
+ cols: host.cols,
1130
+ scrollbackLines: withHistoryStyles(host, logicalHistory(archive)),
1131
+ };
1132
  },
1133
  blocked: () => !!host.resizeCapture,
1134
  persistedBody: persistedHistory?.body || null,
 
1148
  startCapturedGrid(host, host.cols, host.rows, {
1149
  history: startup.lines,
1150
  historyCols: startup.cols,
1151
+ logical: true,
1152
  }, false);
1153
  txn = host.resizeCapture;
1154
  }
 
1258
  let snap;
1259
  try { snap = host.vt.snapshot({ includeCells: true, includeScrollback: true }); } catch { return null; }
1260
  return {
1261
+ ansi: snapshotToRestoreAnsi(styledSnapshot(host, host.vt, snap)),
1262
  cols: snap.cols,
1263
  rows: snap.rows,
1264
  viewers: host.subs.size,
server/src/snapshot.js CHANGED
@@ -162,6 +162,96 @@ export function textColumns(text) {
162
  return width;
163
  }
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  /**
166
  * Rebuild a fresh viewer from Ghostty's canonical state.
167
  *
@@ -176,12 +266,14 @@ export function textColumns(text) {
176
  * off before painting the authoritative current screen.
177
  */
178
  export function snapshotToRestoreAnsi(snap) {
179
- const history = (snap.scrollbackLines || []).map((line) => line.text || '');
 
 
180
  let out = '\x1b[?1049l\x1b[?25l\x1b[0m\x1b[H\x1b[2J';
181
  if (history.length) {
182
- out += history.join('\r\n');
183
  const visualRows = history.reduce((total, line) =>
184
- total + Math.max(1, Math.ceil(textColumns(line) / Math.max(1, snap.cols))), 0);
185
  out += `\x1b[${snap.rows};1H` + '\r\n'.repeat(Math.min(visualRows, snap.rows));
186
  }
187
  out += snapshotToAnsi(snap);
 
162
  return width;
163
  }
164
 
165
+ function htmlEntities(text) {
166
+ return text
167
+ .replace(/&#x([0-9a-f]+);/gi, (_, value) => String.fromCodePoint(Number.parseInt(value, 16)))
168
+ .replace(/&#(\d+);/g, (_, value) => String.fromCodePoint(Number.parseInt(value, 10)))
169
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>')
170
+ .replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'")
171
+ .replace(/&amp;/g, '&');
172
+ }
173
+
174
+ function cssColor(style, property, layer) {
175
+ const escaped = property.replace('-', '\\-');
176
+ const palette = style.match(new RegExp(`(?:^|;)${escaped}: var\\(--vt-palette-(\\d+)\\)`));
177
+ if (palette) return `${layer};5;${palette[1]}`;
178
+ const rgb = style.match(new RegExp(`(?:^|;)${escaped}: rgb\\((\\d+), (\\d+), (\\d+)\\)`));
179
+ return rgb ? `${layer};2;${rgb[1]};${rgb[2]};${rgb[3]}` : '';
180
+ }
181
+
182
+ function htmlStyleSgr(style) {
183
+ const codes = [];
184
+ const foreground = cssColor(style, 'color', 38);
185
+ const background = cssColor(style, 'background-color', 48);
186
+ if (foreground) codes.push(foreground);
187
+ if (background) codes.push(background);
188
+ if (style.includes('font-weight: bold')) codes.push('1');
189
+ if (style.includes('opacity: 0.5')) codes.push('2');
190
+ if (style.includes('font-style: italic')) codes.push('3');
191
+ if (/text-decoration-line:[^;]*underline/.test(style)) codes.push('4');
192
+ if (/text-decoration-line:[^;]*blink/.test(style)) codes.push('5');
193
+ if (style.includes('filter: invert(100%)')) codes.push('7');
194
+ if (/text-decoration-line:[^;]*line-through/.test(style)) codes.push('9');
195
+ if (/text-decoration-line:[^;]*overline/.test(style)) codes.push('53');
196
+ return codes.length ? `\x1b[${codes.join(';')}m` : '';
197
+ }
198
+
199
+ const hasAnsiStyle = (text) => text.replace(/\x1b\[0m/g, '').includes('\x1b[');
200
+
201
+ /** Convert Ghostty's deterministic debug HTML into one ANSI string per visual row. */
202
+ function htmlAnsiRows(html) {
203
+ if (typeof html !== 'string') return [];
204
+ const first = html.indexOf('>');
205
+ const last = html.lastIndexOf('</div>');
206
+ if (first < 0 || last <= first) return [];
207
+ const inner = html.slice(first + 1, last);
208
+ const ansi = inner
209
+ .replace(/<div style="([^"]*)">/g, (_, style) => htmlStyleSgr(style))
210
+ .replace(/<\/div>/g, '\x1b[0m');
211
+ return htmlEntities(ansi).split('\n');
212
+ }
213
+
214
+ /** Styled visual rows for the complete primary grid, including scrollback. */
215
+ export function styledTerminalRows(snap, html) {
216
+ const plain = [...(snap.scrollbackLines || []), ...(snap.visibleLines || [])];
217
+ const ansi = htmlAnsiRows(html);
218
+ while (ansi.length < plain.length && !(plain[ansi.length]?.text || '')) ansi.push('');
219
+ if (ansi.length !== plain.length) return plain.map((line) => ({ text: line.text || '' }));
220
+ return plain.map((line, index) => {
221
+ const text = line.text || '';
222
+ const rendered = ansi[index] || '';
223
+ return hasAnsiStyle(rendered) ? { text, ansi: `${rendered}\x1b[0m` } : { text };
224
+ });
225
+ }
226
+
227
+ function joinStyledRows(rows, cols) {
228
+ const lines = [];
229
+ let text = '';
230
+ let ansi = '';
231
+ for (const row of rows) {
232
+ text += row.text || '';
233
+ ansi += row.ansi || row.text || '';
234
+ if (textColumns(row.text || '') < cols) {
235
+ lines.push(hasAnsiStyle(ansi) ? { text, ansi: `${ansi}\x1b[0m` } : { text });
236
+ text = '';
237
+ ansi = '';
238
+ }
239
+ }
240
+ if (text || ansi) lines.push(hasAnsiStyle(ansi) ? { text, ansi: `${ansi}\x1b[0m` } : { text });
241
+ return lines;
242
+ }
243
+
244
+ /** Styled logical lines, joining visual rows that were soft-wrapped. */
245
+ export function styledLogicalLines(snap, html) {
246
+ return joinStyledRows(styledTerminalRows(snap, html), snap.cols);
247
+ }
248
+
249
+ /** Parse Ghostty's formatted state once when both visual and logical keys are needed. */
250
+ export function styledSnapshotLines(snap, html) {
251
+ const rows = styledTerminalRows(snap, html);
252
+ return { rows, logical: joinStyledRows(rows, snap.cols) };
253
+ }
254
+
255
  /**
256
  * Rebuild a fresh viewer from Ghostty's canonical state.
257
  *
 
266
  * off before painting the authoritative current screen.
267
  */
268
  export function snapshotToRestoreAnsi(snap) {
269
+ const history = (snap.scrollbackLines || []).map((line) => ({
270
+ text: line.text || '', ansi: typeof line.ansi === 'string' ? line.ansi : '',
271
+ }));
272
  let out = '\x1b[?1049l\x1b[?25l\x1b[0m\x1b[H\x1b[2J';
273
  if (history.length) {
274
+ out += history.map((line) => line.ansi || line.text).join('\r\n');
275
  const visualRows = history.reduce((total, line) =>
276
+ total + Math.max(1, Math.ceil(textColumns(line.text) / Math.max(1, snap.cols))), 0);
277
  out += `\x1b[${snap.rows};1H` + '\r\n'.repeat(Math.min(visualRows, snap.rows));
278
  }
279
  out += snapshotToAnsi(snap);