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

Replace repaint tails without growing history

Browse files
server/resize.test.mjs CHANGED
@@ -15,7 +15,7 @@ import {
15
  TERMINAL_HISTORY_VERSION, createTerminalHistoryCheckpoint, loadTerminalHistory,
16
  traceHistoryLines,
17
  } from './src/history-store.js';
18
- import { mergeRepaintArchive, repaintArchiveHistory } 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-'));
@@ -210,17 +210,18 @@ try {
210
  check('a wide repaint merges recovered turns into one full archive',
211
  archive === wide && archive.split('❯ hi there').length - 1 === 1);
212
  check('a wide repaint needs no duplicate scrollback',
213
- repaintArchiveHistory(archive, wide).length === 0);
214
  const narrow = 'newer live turn\n';
215
- const narrowHistory = repaintArchiveHistory(
216
- mergeRepaintArchive(archive, narrow), narrow,
217
- ).map((line) => line.text).join('\n');
218
  check('a narrow repaint restores the archive prefix above the viewport',
219
  narrowHistory.includes('Claude welcome frame') && narrowHistory.includes('❯ hi there'));
220
  check('a repaint replaces a volatile old footer after its transcript overlap',
221
- mergeRepaintArchive('deep history\nshared transcript\n[old 160x40]\n',
222
- 'shared transcript\n[new 90x24]\n')
223
  === 'deep history\nshared transcript\n[new 90x24]\n');
 
 
224
  }
225
 
226
  const up = await waitFor(async () => {
 
15
  TERMINAL_HISTORY_VERSION, createTerminalHistoryCheckpoint, loadTerminalHistory,
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-'));
 
210
  check('a wide repaint merges recovered turns into one full archive',
211
  archive === wide && archive.split('❯ hi there').length - 1 === 1);
212
  check('a wide repaint needs no duplicate scrollback',
213
+ repaintArchiveView(archive, wide).history.length === 0);
214
  const narrow = 'newer live turn\n';
215
+ const narrowView = repaintArchiveView(archive, narrow);
216
+ const narrowHistory = narrowView.history.map((line) => line.text).join('\n');
 
217
  check('a narrow repaint restores the archive prefix above the viewport',
218
  narrowHistory.includes('Claude welcome frame') && narrowHistory.includes('❯ hi there'));
219
  check('a repaint replaces a volatile old footer after its transcript overlap',
220
+ repaintArchiveView('deep history\nshared transcript\n[old 160x40]\n',
221
+ 'shared transcript\n[new 90x24]\n').archive
222
  === 'deep history\nshared transcript\n[new 90x24]\n');
223
+ check('zooming wide again exposes the archive without growing it',
224
+ repaintArchiveView(narrowView.archive, wide).archive === wide);
225
  }
226
 
227
  const up = await waitFor(async () => {
server/src/history-store.js CHANGED
@@ -5,14 +5,14 @@ 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 = 5;
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, 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 ? {
 
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 ? {
server/src/runner.js CHANGED
@@ -223,6 +223,23 @@ function visibleRows(vt) {
223
  try { return vt.getVisibleText().split('\n').map((text) => ({ text })); } catch { return []; }
224
  }
225
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  /**
227
  * Merge a primary-screen repaint into the fullest transcript seen so far.
228
  *
@@ -242,23 +259,6 @@ export function mergeRepaintArchive(base, repaint) {
242
  for (let i = text.length - 1; i >= 0; i--) result += text[i];
243
  return result;
244
  };
245
- const longestPrefixOccurrence = (pattern, text) => {
246
- const prefix = new Array(pattern.length).fill(0);
247
- for (let i = 1, matched = 0; i < pattern.length; i++) {
248
- while (matched && pattern[i] !== pattern[matched]) matched = prefix[matched - 1];
249
- if (pattern[i] === pattern[matched]) matched++;
250
- prefix[i] = matched;
251
- }
252
- let matched = 0, best = 0, bestEnd = -1;
253
- for (let i = 0; i < text.length; i++) {
254
- while (matched && text[i] !== pattern[matched]) matched = prefix[matched - 1];
255
- if (text[i] === pattern[matched]) matched++;
256
- if (matched > best) { best = matched; bestEnd = i; }
257
- if (matched === pattern.length) matched = prefix[matched - 1];
258
- }
259
- return { length: best, end: bestEnd };
260
- };
261
-
262
  // Shape 1: an archive suffix is revealed after a freshly painted header.
263
  const suffix = longestPrefixOccurrence(
264
  reverse(base.slice(-repaint.length)), reverse(repaint),
@@ -277,11 +277,26 @@ export function mergeRepaintArchive(base, repaint) {
277
  + repaint.slice(repaintOffset + suffix.length);
278
  }
279
 
280
- /** The archive prefix not represented by the repaint's visible styled grid. */
281
- export function repaintArchiveHistory(archive, visible) {
282
- const history = visible && archive.endsWith(visible)
283
- ? archive.slice(0, archive.length - visible.length) : archive;
284
- return logicalHistory(history);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  }
286
 
287
  function terminalArchive(vt, snap) {
@@ -309,15 +324,14 @@ function finishCapturedGrid(host, txn) {
309
  try {
310
  const visible = visibleRows(txn.vt);
311
  const visibleText = logicalText(visible, txn.cols);
312
- const repaint = logicalText([...(snap.scrollbackLines || []), ...visible], txn.cols);
313
- committedArchive = mergeRepaintArchive(txn.archive, repaint);
314
- const history = repaintArchiveHistory(committedArchive, visibleText);
315
  replacement = ghostty.createTerminal({
316
  cols: txn.cols,
317
  rows: txn.rows,
318
  scrollbackLimit: SCROLLBACK_BYTES,
319
  });
320
- replacement.feed(snapshotToRestoreAnsi({ ...snap, scrollbackLines: history }));
321
  } catch (error) {
322
  console.error('[runner] resize capture commit', error && error.message);
323
  try { replacement?.dispose(); } catch {}
@@ -376,18 +390,23 @@ function startCapturedGrid(host, cols, rows, seed = null, resizePty = true) {
376
  const previous = host.resizeCapture;
377
  let archive = seed?.history
378
  ? logicalText(seed.history, seed.historyCols || host.cols) : host.repaintArchive;
 
379
  if (previous) {
380
  archive = previous.archive;
 
381
  host.resizeCapture = null;
382
  clearCaptureTimers(previous);
383
  try { previous.vt.dispose(); } catch {}
384
  }
385
- if (archive == null) {
386
- try {
387
- const source = host.vt.snapshot({ includeScrollback: true });
388
  archive = terminalArchive(host.vt, source);
389
- } catch { return false; }
390
- }
 
 
 
391
 
392
  let scratch;
393
  try {
@@ -403,6 +422,7 @@ function startCapturedGrid(host, cols, rows, seed = null, resizePty = true) {
403
  cols,
404
  rows,
405
  archive: archive || '',
 
406
  sawData: false,
407
  idleTimer: null,
408
  maxTimer: null,
@@ -482,16 +502,17 @@ function armTraceHydration(host) {
482
  // into one archive rather than appending either presentation verbatim.
483
  const startup = terminalArchive(host.vt, snap);
484
  const archive = mergeRepaintArchive(logicalText(recovered, snap.cols), startup);
 
485
  replacement = ghostty.createTerminal({
486
  cols: host.cols, rows: host.rows, scrollbackLimit: SCROLLBACK_BYTES,
487
  });
488
  replacement.feed(snapshotToRestoreAnsi({
489
  ...snap,
490
- scrollbackLines: repaintArchiveHistory(archive, visibleText),
491
  }));
492
  const previous = host.vt;
493
  host.vt = replacement;
494
- host.repaintArchive = archive;
495
  notifyGrid(host, true);
496
  const committed = host.vt.snapshot({ includeCells: true, includeScrollback: true });
497
  const ansi = snapshotToRestoreAnsi(committed);
 
223
  try { return vt.getVisibleText().split('\n').map((text) => ({ text })); } catch { return []; }
224
  }
225
 
226
+ function longestPrefixOccurrence(pattern, text) {
227
+ const prefix = new Array(pattern.length).fill(0);
228
+ for (let i = 1, matched = 0; i < pattern.length; i++) {
229
+ while (matched && pattern[i] !== pattern[matched]) matched = prefix[matched - 1];
230
+ if (pattern[i] === pattern[matched]) matched++;
231
+ prefix[i] = matched;
232
+ }
233
+ let matched = 0, best = 0, bestEnd = -1;
234
+ for (let i = 0; i < text.length; i++) {
235
+ while (matched && text[i] !== pattern[matched]) matched = prefix[matched - 1];
236
+ if (text[i] === pattern[matched]) matched++;
237
+ if (matched > best) { best = matched; bestEnd = i; }
238
+ if (matched === pattern.length) matched = prefix[matched - 1];
239
+ }
240
+ return { length: best, end: bestEnd };
241
+ }
242
+
243
  /**
244
  * Merge a primary-screen repaint into the fullest transcript seen so far.
245
  *
 
259
  for (let i = text.length - 1; i >= 0; i--) result += text[i];
260
  return result;
261
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  // Shape 1: an archive suffix is revealed after a freshly painted header.
263
  const suffix = longestPrefixOccurrence(
264
  reverse(base.slice(-repaint.length)), reverse(repaint),
 
277
  + repaint.slice(repaintOffset + suffix.length);
278
  }
279
 
280
+ /**
281
+ * Replace the archive's old visible tail with a new repaint.
282
+ *
283
+ * Resize output is presentation, never appended terminal output. Match the
284
+ * longest prefix of the new visible grid inside the archive; everything before
285
+ * that point is the hidden prefix, and the new grid replaces everything after
286
+ * it (including dimension-dependent status/footer text).
287
+ */
288
+ export function repaintArchiveView(archive, visible, fallbackHistory = '') {
289
+ if (!visible) return { archive, history: logicalHistory(archive) };
290
+ const leading = visible.match(/^(?:[ \t]*\n)*/)?.[0].length || 0;
291
+ const candidate = visible.slice(leading);
292
+ const match = candidate ? longestPrefixOccurrence(candidate, archive) : { length: 0, end: -1 };
293
+ const minimum = Math.min(12, candidate.trim().length, archive.trim().length);
294
+ let history = fallbackHistory;
295
+ if (match.length >= minimum && minimum > 0) {
296
+ const archiveOffset = match.end - match.length + 1;
297
+ history = archive.slice(0, archiveOffset);
298
+ }
299
+ return { archive: history + visible, history: logicalHistory(history) };
300
  }
301
 
302
  function terminalArchive(vt, 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);
328
+ committedArchive = view.archive;
 
329
  replacement = ghostty.createTerminal({
330
  cols: txn.cols,
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 {}
 
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;
396
+ fallbackHistory = previous.fallbackHistory;
397
  host.resizeCapture = null;
398
  clearCaptureTimers(previous);
399
  try { previous.vt.dispose(); } catch {}
400
  }
401
+ try {
402
+ const source = host.vt.snapshot({ includeScrollback: true });
403
+ if (archive == null) {
404
  archive = terminalArchive(host.vt, source);
405
+ }
406
+ if (fallbackHistory == null) {
407
+ fallbackHistory = logicalText(source.scrollbackLines || [], source.cols);
408
+ }
409
+ } catch { return false; }
410
 
411
  let scratch;
412
  try {
 
422
  cols,
423
  rows,
424
  archive: archive || '',
425
+ fallbackHistory: fallbackHistory || '',
426
  sawData: false,
427
  idleTimer: null,
428
  maxTimer: null,
 
502
  // into one archive rather than appending either presentation verbatim.
503
  const startup = terminalArchive(host.vt, snap);
504
  const archive = mergeRepaintArchive(logicalText(recovered, snap.cols), startup);
505
+ const view = repaintArchiveView(archive, visibleText);
506
  replacement = ghostty.createTerminal({
507
  cols: host.cols, rows: host.rows, scrollbackLimit: SCROLLBACK_BYTES,
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);