Spaces:
Running
Running
Agent Manager commited on
Commit ·
93a8ea7
1
Parent(s): 1c8bc4b
Replace repaint tails without growing history
Browse files- server/resize.test.mjs +8 -7
- server/src/history-store.js +2 -2
- server/src/runner.js +54 -33
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,
|
| 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 |
-
|
| 214 |
const narrow = 'newer live turn\n';
|
| 215 |
-
const
|
| 216 |
-
|
| 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 |
-
|
| 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 =
|
| 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 |
-
/**
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 313 |
-
committedArchive =
|
| 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 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
archive = terminalArchive(host.vt, source);
|
| 389 |
-
}
|
| 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:
|
| 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);
|