Spaces:
Running
Resize: archive the rows a shrink pushes off, once it is clear they are not a copy
Browse filesThe carry fixed the duplication by clearing the screen before the reflow, and in
doing so quietly ate history: the rows that no longer fitted were dropped, so
every zoom-in cost up to a screenful of scrollback and a pane could no longer
scroll all the way up. Reported from the dev Space, reproduced at 30 lines gone
over six zoom steps.
Archiving them instead brought the duplication straight back — 82 duplicated
tokens — because for a repainting TUI those rows ARE the screen it is about to
reprint. The two failures are the same question with opposite answers, and it
cannot be answered at resize time: it depends on what the app does next.
So it is answered afterwards. The rows go into limbo, and 250ms later
settleArchive looks at whether a screenful arrived: if the app repainted, they
were a copy and are discarded; if nothing came — a shell's log, a TUI that
exited, an agent sitting idle — they were the only copy and are printed into
scrollback with the screen painted back over them. The same verdict updates
host.repaints, so a session whose TUI exited stops being treated as one instead
of carrying a stale guess forever.
Archiving needs rows printed and scrolled off, which took two attempts: a newline
on the last row moves the TOP row into scrollback, not the one just printed, so
the first version archived blank lines and then painted over the real ones.
snapshot.js grows rowsToAnsi for this, and snapshotToAnsi now shares its per-row
rendering instead of duplicating it.
Verified with the browser's own emulator attached to the real server: six zoom
steps lose nothing from the grid and duplicate nothing in either, against 63 and
101 duplicated tokens with AM_RESIZE_CARRY=0.
Two losses in that test are NOT ours, and the test says so rather than pretending:
bash redraws its prompt on every SIGWINCH and, when wrapped, draws over the rows
above it, and xterm's own reflow drops about three lines over a cycle this
violent. Both reproduce identically with the carry disabled. The grid — which is
what a reattach repaints from — is exact.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- README.md +1 -0
- server/resize.test.mjs +57 -1
- server/src/runner.js +95 -17
- server/src/snapshot.js +59 -30
- web/src/components/TerminalPane.tsx +8 -7
|
@@ -148,6 +148,7 @@ conversations.
|
|
| 148 |
| `AM_REPLAY_BYTES` | `262144` | PTY bytes replayed to a reattaching browser |
|
| 149 |
| `AM_RESIZE_SETTLE_MS` | `120` | Quiet period before a resize is applied to the PTY |
|
| 150 |
| `AM_RESIZE_CARRY` | `1` | `0` reflows on resize like a plain terminal (duplicates scrollback) |
|
|
|
|
| 151 |
| `ANTHROPIC_API_KEY` | — | Claude Code / opencode / Hermes (Space **secret**) |
|
| 152 |
| `OPENAI_API_KEY` / `CODEX_API_KEY` | — | Codex (Space secret) |
|
| 153 |
| `GEMINI_API_KEY` | — | Gemini CLI (Space secret) |
|
|
|
|
| 148 |
| `AM_REPLAY_BYTES` | `262144` | PTY bytes replayed to a reattaching browser |
|
| 149 |
| `AM_RESIZE_SETTLE_MS` | `120` | Quiet period before a resize is applied to the PTY |
|
| 150 |
| `AM_RESIZE_CARRY` | `1` | `0` reflows on resize like a plain terminal (duplicates scrollback) |
|
| 151 |
+
| `AM_RESIZE_ARCHIVE_MS` | `250` | Grace period for an app to repaint before rows a shrink pushed off are archived |
|
| 152 |
| `ANTHROPIC_API_KEY` | — | Claude Code / opencode / Hermes (Space **secret**) |
|
| 153 |
| `OPENAI_API_KEY` / `CODEX_API_KEY` | — | Codex (Space secret) |
|
| 154 |
| `GEMINI_API_KEY` | — | Gemini CLI (Space secret) |
|
|
@@ -91,7 +91,7 @@ function view(id, cols, rows, mirror = false) {
|
|
| 91 |
v.frames.push(m);
|
| 92 |
if (term && (m.t === 'grid' || m.t === 'restore') && m.cols > 0 && m.rows > 0
|
| 93 |
&& (term.cols !== m.cols || term.rows !== m.rows)) {
|
| 94 |
-
if (m.clear) term.write('
|
| 95 |
else term.resize(m.cols, m.rows);
|
| 96 |
}
|
| 97 |
} else {
|
|
@@ -261,6 +261,62 @@ try {
|
|
| 261 |
await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
|
| 262 |
}
|
| 263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
// --- the screen survives a resize the app ignores ------------------------
|
| 265 |
// Clearing before the reflow is only safe because the screen is re-painted
|
| 266 |
// afterwards. An app that never answers SIGWINCH is the case that proves it:
|
|
|
|
| 91 |
v.frames.push(m);
|
| 92 |
if (term && (m.t === 'grid' || m.t === 'restore') && m.cols > 0 && m.rows > 0
|
| 93 |
&& (term.cols !== m.cols || term.rows !== m.rows)) {
|
| 94 |
+
if (m.clear) term.write('', () => term.resize(m.cols, m.rows));
|
| 95 |
else term.resize(m.cols, m.rows);
|
| 96 |
}
|
| 97 |
} else {
|
|
|
|
| 261 |
await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
|
| 262 |
}
|
| 263 |
|
| 264 |
+
// --- scrolling back still reaches everything -----------------------------
|
| 265 |
+
// The first version of the carry cleared the screen and dropped the rows that
|
| 266 |
+
// no longer fitted, so every zoom-in quietly ate history: a 40->24 shrink took
|
| 267 |
+
// 16 lines with it and the pane could no longer scroll all the way up. Rows
|
| 268 |
+
// that fall off the top have to be ARCHIVED, not discarded.
|
| 269 |
+
if (!Headless) {
|
| 270 |
+
console.log('SKIP scrollback checks (@xterm/headless not installed)');
|
| 271 |
+
} else {
|
| 272 |
+
const id = await session('resize history');
|
| 273 |
+
const v = await view(id, 120, 40, true);
|
| 274 |
+
v.resize(120, 40);
|
| 275 |
+
await sleep(900);
|
| 276 |
+
// Teach the host that this session repaints, so the carry path is the one
|
| 277 |
+
// under test, then leave a real log behind — that is what must survive.
|
| 278 |
+
v.type(`node ${FIXTURE}\r`);
|
| 279 |
+
await sleep(1500);
|
| 280 |
+
v.resize(118, 38);
|
| 281 |
+
await sleep(800);
|
| 282 |
+
v.type('\x03');
|
| 283 |
+
await sleep(800);
|
| 284 |
+
// The trailing blank lines are load-bearing. Bash redraws its prompt on every
|
| 285 |
+
// SIGWINCH and, when that prompt is wrapped, draws over the rows above it —
|
| 286 |
+
// verified identical with AM_RESIZE_CARRY=0, so it is bash's doing and not the
|
| 287 |
+
// resize path's. Without the padding this test would be asserting something no
|
| 288 |
+
// terminal delivers.
|
| 289 |
+
v.type('for i in $(seq 1 200); do echo "hist-$i"; done; echo; echo; echo\r');
|
| 290 |
+
await sleep(2000);
|
| 291 |
+
|
| 292 |
+
const missing = (text) => {
|
| 293 |
+
const gone = [];
|
| 294 |
+
for (let i = 1; i <= 200; i++) if (!new RegExp(`hist-${i}(?!\\d)`).test(text)) gone.push(i);
|
| 295 |
+
return gone;
|
| 296 |
+
};
|
| 297 |
+
const before = missing(await v.screenText());
|
| 298 |
+
check('the log is complete before zooming', before.length === 0, `${before.length} lines missing`);
|
| 299 |
+
|
| 300 |
+
for (const [c, r] of [[100, 30], [90, 24], [100, 30], [110, 34], [120, 40], [90, 24]]) {
|
| 301 |
+
v.resize(c, r);
|
| 302 |
+
await sleep(400);
|
| 303 |
+
}
|
| 304 |
+
await sleep(1200);
|
| 305 |
+
// The grid is the authority and has to be exact.
|
| 306 |
+
const gridGone = missing(await gridText(id));
|
| 307 |
+
check('zooming loses no scrollback in the grid', gridGone.length === 0,
|
| 308 |
+
gridGone.length ? `${gridGone.length} lines gone, e.g. hist-${gridGone.slice(0, 5).join(', hist-')}` : '');
|
| 309 |
+
// xterm's own reflow drops a couple of lines over a cycle this violent no
|
| 310 |
+
// matter what we do — AM_RESIZE_CARRY=0 loses three in the same run, from the
|
| 311 |
+
// same region — so this is a bound, not a promise. A reattach repaints the
|
| 312 |
+
// pane from the grid, which is why the grid check above is the strict one.
|
| 313 |
+
const after = missing(await v.screenText());
|
| 314 |
+
check('zooming loses no more browser scrollback than plain reflow does', after.length <= 4,
|
| 315 |
+
after.length ? `${after.length} lines gone, e.g. hist-${after.slice(0, 5).join(', hist-')}` : 'none');
|
| 316 |
+
v.close();
|
| 317 |
+
await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST' });
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
// --- the screen survives a resize the app ignores ------------------------
|
| 321 |
// Clearing before the reflow is only safe because the screen is re-painted
|
| 322 |
// afterwards. An app that never answers SIGWINCH is the case that proves it:
|
|
@@ -6,7 +6,7 @@ import { remoteState, setPaused } from './remote.js';
|
|
| 6 |
import { cliById, WORKSPACES_DIR, isRemote } from './config.js';
|
| 7 |
import { update, list } from './sessions.js';
|
| 8 |
import { captureOpencodeSession } from './traces.js';
|
| 9 |
-
import { buildPaletteIndex, snapshotToAnsi } from './snapshot.js';
|
| 10 |
|
| 11 |
// libghostty-vt ships prebuilts for linux x64/arm64 and macOS arm64. Loading it
|
| 12 |
// is guarded so a platform without a prebuilt still boots and says so, rather
|
|
@@ -70,6 +70,9 @@ const RESIZE_SETTLE_MS = Number(process.env.AM_RESIZE_SETTLE_MS || 120);
|
|
| 70 |
// plain terminal does, duplication and all. Kept because carrying the screen by
|
| 71 |
// hand is the one part of a resize that makes an assumption about the app.
|
| 72 |
const RESIZE_CARRY = process.env.AM_RESIZE_CARRY !== '0';
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
const hosts = new Map(); // session id -> host
|
| 75 |
|
|
@@ -163,8 +166,8 @@ function effectiveGrid(host) {
|
|
| 163 |
* Re-frame a snapshot for a different geometry, so it can be painted into one.
|
| 164 |
*
|
| 165 |
* Bottom-anchored on a shrink, like a terminal: the cursor and the newest rows
|
| 166 |
-
* are what a user is looking at.
|
| 167 |
-
*
|
| 168 |
*/
|
| 169 |
function fitSnapshot(snap, cols, rows) {
|
| 170 |
const drop = Math.max(0, snap.rows - rows);
|
|
@@ -191,18 +194,76 @@ function fitSnapshot(snap, cols, rows) {
|
|
| 191 |
*
|
| 192 |
* So the screen is cleared BEFORE the reflow, which leaves it nothing to archive,
|
| 193 |
* and re-painted after: snapshotToAnsi positions every row absolutely and emits
|
| 194 |
-
* no newline, so the paint itself cannot wrap or scroll either.
|
| 195 |
-
*
|
| 196 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
*/
|
| 198 |
function carryScreen(host, cols, rows) {
|
| 199 |
if (!RESIZE_CARRY || !host.repaints) return null;
|
| 200 |
-
let
|
|
|
|
| 201 |
try {
|
| 202 |
-
|
|
|
|
| 203 |
} catch { return null; }
|
| 204 |
-
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
}
|
| 207 |
|
| 208 |
/**
|
|
@@ -225,16 +286,28 @@ function applyGrid(host) {
|
|
| 225 |
host.cols = cols;
|
| 226 |
host.rows = rows;
|
| 227 |
const carried = carryScreen(host, cols, rows);
|
|
|
|
|
|
|
|
|
|
| 228 |
try { host.vt.resize(cols, rows); } catch {}
|
| 229 |
try { host.pty.resize(cols, rows); } catch {}
|
| 230 |
host.resizedAt = Date.now();
|
| 231 |
host.resizeBytes = 0;
|
| 232 |
-
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
else { try { ansi = snapshotToAnsi(host.vt.snapshot({ includeCells: true })); } catch {} }
|
| 235 |
for (const sub of host.subs) {
|
| 236 |
// `carried` doubles as the flag: the viewer's own emulator has to skip its
|
| 237 |
-
// reflow exactly when we skipped ours, or it archives what we did not
|
|
|
|
| 238 |
sub.onGrid(cols, rows, host.subs.size > 1, host.subs.size, !!carried);
|
| 239 |
if (ansi) sub.onData(ansi);
|
| 240 |
}
|
|
@@ -767,6 +840,8 @@ export function ensureRunning(session, cols = 120, rows = 34) {
|
|
| 767 |
repaints: !isShell,
|
| 768 |
resizedAt: 0,
|
| 769 |
resizeBytes: 0,
|
|
|
|
|
|
|
| 770 |
subs: new Set(),
|
| 771 |
sizes: new Map(), // sub -> the grid that viewer can display
|
| 772 |
pendingSizes: new Set(), // subs whose request hasn't been answered yet
|
|
@@ -786,13 +861,15 @@ export function ensureRunning(session, cols = 120, rows = 34) {
|
|
| 786 |
// rewrapped copy reflow just archived was redundant. Latch that: the next
|
| 787 |
// resize carries the screen across by hand instead (see carryScreen). This is
|
| 788 |
// what catches `vim` or a hand-typed `claude` inside a shell session.
|
| 789 |
-
if (
|
| 790 |
-
if (Date.now() - host.resizedAt >
|
| 791 |
else {
|
| 792 |
host.resizeBytes += chunk.length;
|
| 793 |
// A quarter screen of bytes is far more than a shell prompt redrawing
|
| 794 |
-
// itself and far less than a TUI frame.
|
| 795 |
-
|
|
|
|
|
|
|
| 796 |
}
|
| 797 |
}
|
| 798 |
|
|
@@ -816,6 +893,7 @@ export function ensureRunning(session, cols = 120, rows = 34) {
|
|
| 816 |
term.onExit(() => {
|
| 817 |
hosts.delete(session.id);
|
| 818 |
if (host.gridTimer) { clearTimeout(host.gridTimer); host.gridTimer = null; }
|
|
|
|
| 819 |
try { vt.dispose(); } catch {}
|
| 820 |
for (const sub of host.subs) sub.onExit();
|
| 821 |
host.subs.clear();
|
|
|
|
| 6 |
import { cliById, WORKSPACES_DIR, isRemote } from './config.js';
|
| 7 |
import { update, list } from './sessions.js';
|
| 8 |
import { captureOpencodeSession } from './traces.js';
|
| 9 |
+
import { buildPaletteIndex, rowsToAnsi, snapshotToAnsi } from './snapshot.js';
|
| 10 |
|
| 11 |
// libghostty-vt ships prebuilts for linux x64/arm64 and macOS arm64. Loading it
|
| 12 |
// is guarded so a platform without a prebuilt still boots and says so, rather
|
|
|
|
| 70 |
// plain terminal does, duplication and all. Kept because carrying the screen by
|
| 71 |
// hand is the one part of a resize that makes an assumption about the app.
|
| 72 |
const RESIZE_CARRY = process.env.AM_RESIZE_CARRY !== '0';
|
| 73 |
+
// How long an app gets to answer SIGWINCH before the rows a shrink pushed off the
|
| 74 |
+
// screen are treated as history rather than as a copy (see settleArchive).
|
| 75 |
+
const ARCHIVE_SETTLE_MS = Number(process.env.AM_RESIZE_ARCHIVE_MS || 250);
|
| 76 |
|
| 77 |
const hosts = new Map(); // session id -> host
|
| 78 |
|
|
|
|
| 166 |
* Re-frame a snapshot for a different geometry, so it can be painted into one.
|
| 167 |
*
|
| 168 |
* Bottom-anchored on a shrink, like a terminal: the cursor and the newest rows
|
| 169 |
+
* are what a user is looking at. The rows that fall off the top are not in here
|
| 170 |
+
* at all — settleArchive decides what becomes of those.
|
| 171 |
*/
|
| 172 |
function fitSnapshot(snap, cols, rows) {
|
| 173 |
const drop = Math.max(0, snap.rows - rows);
|
|
|
|
| 194 |
*
|
| 195 |
* So the screen is cleared BEFORE the reflow, which leaves it nothing to archive,
|
| 196 |
* and re-painted after: snapshotToAnsi positions every row absolutely and emits
|
| 197 |
+
* no newline, so the paint itself cannot wrap or scroll either.
|
| 198 |
+
*
|
| 199 |
+
* The rows that no longer fit cannot be settled here, which took two tries to
|
| 200 |
+
* see. Dropping them ate real history — a 40->24 shrink lost 16 lines a user
|
| 201 |
+
* could previously scroll back to. Archiving them brought the duplication
|
| 202 |
+
* straight back, because for a repainting TUI those rows ARE the screen it is
|
| 203 |
+
* about to reprint. Whether they are redundant depends on what the app does
|
| 204 |
+
* NEXT, so they are handed to settleArchive to decide once that is known.
|
| 205 |
+
*
|
| 206 |
+
* Returns { pre, ansi, dropped }: bytes for before the resize and after it (both
|
| 207 |
+
* of which every viewer needs too) plus the rows in limbo. Null when reflow
|
| 208 |
+
* should be left alone (see host.repaints).
|
| 209 |
*/
|
| 210 |
function carryScreen(host, cols, rows) {
|
| 211 |
if (!RESIZE_CARRY || !host.repaints) return null;
|
| 212 |
+
let snap;
|
| 213 |
+
let ansi;
|
| 214 |
try {
|
| 215 |
+
snap = host.vt.snapshot({ includeCells: true });
|
| 216 |
+
ansi = snapshotToAnsi(fitSnapshot(snap, cols, rows));
|
| 217 |
} catch { return null; }
|
| 218 |
+
|
| 219 |
+
const drop = Math.max(0, snap.rows - rows);
|
| 220 |
+
const pre = '\x1b[0m\x1b[H\x1b[2J';
|
| 221 |
+
try { host.vt.feed(pre); } catch { return null; }
|
| 222 |
+
return { pre, ansi, dropped: drop ? rowsToAnsi(snap, 0, drop) : [] };
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
/**
|
| 226 |
+
* Put lines into scrollback without disturbing the screen.
|
| 227 |
+
*
|
| 228 |
+
* Printing is the only way in — there is no API for "append to history" — so the
|
| 229 |
+
* lines are printed at the top and then pushed off it, and the screen is painted
|
| 230 |
+
* back from the grid afterwards. Costs one repaint, which is why it only happens
|
| 231 |
+
* when nothing else repainted.
|
| 232 |
+
*/
|
| 233 |
+
function archiveLines(host, lines) {
|
| 234 |
+
let restore;
|
| 235 |
+
try { restore = snapshotToAnsi(host.vt.snapshot({ includeCells: true })); } catch { return; }
|
| 236 |
+
let bytes = '\x1b[0m\x1b[H\x1b[2J\x1b[1;1H' + lines.join('\r\n');
|
| 237 |
+
// A newline on the LAST row is what moves a row into scrollback, and it moves
|
| 238 |
+
// the top row — not the one just printed. Enough of them to clear the screen
|
| 239 |
+
// archives every line, whether they all fitted on it or not.
|
| 240 |
+
bytes += `\x1b[0m\x1b[${host.rows};1H` + '\r\n'.repeat(Math.min(lines.length, host.rows));
|
| 241 |
+
bytes += restore;
|
| 242 |
+
try { host.vt.feed(bytes); } catch { return; }
|
| 243 |
+
for (const sub of host.subs) sub.onData(bytes);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
/**
|
| 247 |
+
* Decide what the rows a shrink pushed off the screen were: a copy, or history.
|
| 248 |
+
*
|
| 249 |
+
* A screenful of output within the window means the app answered SIGWINCH by
|
| 250 |
+
* reprinting its screen, so those rows are about to appear again and archiving
|
| 251 |
+
* them is what filled the scrollback with duplicates. Silence means they were the
|
| 252 |
+
* only copy — a shell's log, a TUI that exited, an agent sitting idle — and
|
| 253 |
+
* dropping them is what stopped a pane from scrolling all the way up.
|
| 254 |
+
*
|
| 255 |
+
* Deciding afterwards is the point: at resize time this is not yet knowable, and
|
| 256 |
+
* both guesses are wrong for somebody. The verdict also updates host.repaints, so
|
| 257 |
+
* a session that stops repainting (its TUI exited) stops being treated as one.
|
| 258 |
+
*/
|
| 259 |
+
function settleArchive(host) {
|
| 260 |
+
host.archiveTimer = null;
|
| 261 |
+
const pending = host.pendingArchive;
|
| 262 |
+
host.pendingArchive = null;
|
| 263 |
+
if (!pending) return;
|
| 264 |
+
const repainted = host.resizeBytes > (host.cols * host.rows) / 4;
|
| 265 |
+
host.repaints = repainted;
|
| 266 |
+
if (!repainted && pending.length) archiveLines(host, pending);
|
| 267 |
}
|
| 268 |
|
| 269 |
/**
|
|
|
|
| 286 |
host.cols = cols;
|
| 287 |
host.rows = rows;
|
| 288 |
const carried = carryScreen(host, cols, rows);
|
| 289 |
+
// Viewers get the erase BEFORE they resize, in the same order the grid saw it,
|
| 290 |
+
// so their emulators skip the same reflow ours did.
|
| 291 |
+
if (carried) for (const sub of host.subs) sub.onData(carried.pre);
|
| 292 |
try { host.vt.resize(cols, rows); } catch {}
|
| 293 |
try { host.pty.resize(cols, rows); } catch {}
|
| 294 |
host.resizedAt = Date.now();
|
| 295 |
host.resizeBytes = 0;
|
| 296 |
+
if (carried) {
|
| 297 |
+
// Oldest first: a second resize before the verdict lands adds to the same
|
| 298 |
+
// batch rather than replacing it.
|
| 299 |
+
host.pendingArchive = [...(host.pendingArchive || []), ...carried.dropped];
|
| 300 |
+
if (host.archiveTimer) clearTimeout(host.archiveTimer);
|
| 301 |
+
host.archiveTimer = setTimeout(() => settleArchive(host), ARCHIVE_SETTLE_MS);
|
| 302 |
+
if (host.archiveTimer.unref) host.archiveTimer.unref();
|
| 303 |
+
}
|
| 304 |
+
let ansi = carried ? carried.ansi : null;
|
| 305 |
+
if (carried) { try { host.vt.feed(carried.ansi); } catch {} }
|
| 306 |
else { try { ansi = snapshotToAnsi(host.vt.snapshot({ includeCells: true })); } catch {} }
|
| 307 |
for (const sub of host.subs) {
|
| 308 |
// `carried` doubles as the flag: the viewer's own emulator has to skip its
|
| 309 |
+
// reflow exactly when we skipped ours, or it archives what we did not — and it
|
| 310 |
+
// must not resize until the bytes above have been parsed at the OLD size.
|
| 311 |
sub.onGrid(cols, rows, host.subs.size > 1, host.subs.size, !!carried);
|
| 312 |
if (ansi) sub.onData(ansi);
|
| 313 |
}
|
|
|
|
| 840 |
repaints: !isShell,
|
| 841 |
resizedAt: 0,
|
| 842 |
resizeBytes: 0,
|
| 843 |
+
pendingArchive: null,
|
| 844 |
+
archiveTimer: null,
|
| 845 |
subs: new Set(),
|
| 846 |
sizes: new Map(), // sub -> the grid that viewer can display
|
| 847 |
pendingSizes: new Set(), // subs whose request hasn't been answered yet
|
|
|
|
| 861 |
// rewrapped copy reflow just archived was redundant. Latch that: the next
|
| 862 |
// resize carries the screen across by hand instead (see carryScreen). This is
|
| 863 |
// what catches `vim` or a hand-typed `claude` inside a shell session.
|
| 864 |
+
if (host.resizedAt) {
|
| 865 |
+
if (Date.now() - host.resizedAt > ARCHIVE_SETTLE_MS + 150) host.resizedAt = 0;
|
| 866 |
else {
|
| 867 |
host.resizeBytes += chunk.length;
|
| 868 |
// A quarter screen of bytes is far more than a shell prompt redrawing
|
| 869 |
+
// itself and far less than a TUI frame. For a session that reflowed
|
| 870 |
+
// plainly there is no verdict pending, so latch it here instead — this is
|
| 871 |
+
// what catches `vim` or a hand-typed `claude` in a shell pane.
|
| 872 |
+
if (!host.repaints && host.resizeBytes > (host.cols * host.rows) / 4) host.repaints = true;
|
| 873 |
}
|
| 874 |
}
|
| 875 |
|
|
|
|
| 893 |
term.onExit(() => {
|
| 894 |
hosts.delete(session.id);
|
| 895 |
if (host.gridTimer) { clearTimeout(host.gridTimer); host.gridTimer = null; }
|
| 896 |
+
if (host.archiveTimer) { clearTimeout(host.archiveTimer); host.archiveTimer = null; }
|
| 897 |
try { vt.dispose(); } catch {}
|
| 898 |
for (const sub of host.subs) sub.onExit();
|
| 899 |
host.subs.clear();
|
|
@@ -70,13 +70,69 @@ function sgrFor(cell) {
|
|
| 70 |
return out;
|
| 71 |
}
|
| 72 |
|
| 73 |
-
|
|
|
|
| 74 |
const grid = Array.from({ length: snap.rows }, () => new Array(snap.cols).fill(null));
|
| 75 |
for (const cell of snap.cells || []) {
|
| 76 |
if (cell.row >= 0 && cell.row < snap.rows && cell.col >= 0 && cell.col < snap.cols) {
|
| 77 |
grid[cell.row][cell.col] = cell;
|
| 78 |
}
|
| 79 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
// Normalise the buffer first: a byte replay can leave the receiver on the
|
| 82 |
// alternate screen (or off it), and the repaint alone would then land on the
|
|
@@ -86,35 +142,8 @@ export function snapshotToAnsi(snap) {
|
|
| 86 |
out += '\x1b[?25l\x1b[0m\x1b[H\x1b[2J';
|
| 87 |
|
| 88 |
for (let row = 0; row < snap.rows; row++) {
|
| 89 |
-
const
|
| 90 |
-
|
| 91 |
-
// trailing spaces are the bulk of a mostly-empty agent screen.
|
| 92 |
-
let last = -1;
|
| 93 |
-
for (let col = snap.cols - 1; col >= 0; col--) {
|
| 94 |
-
if (cells[col] && (cells[col].text !== ' ' || cells[col].background)) { last = col; break; }
|
| 95 |
-
}
|
| 96 |
-
if (last < 0) continue;
|
| 97 |
-
|
| 98 |
-
out += `\x1b[${row + 1};1H`;
|
| 99 |
-
let style = '';
|
| 100 |
-
let col = 0;
|
| 101 |
-
while (col <= last) {
|
| 102 |
-
const cell = cells[col];
|
| 103 |
-
if (!cell) {
|
| 104 |
-
if (style) { out += '\x1b[0m'; style = ''; }
|
| 105 |
-
out += ' ';
|
| 106 |
-
col += 1;
|
| 107 |
-
continue;
|
| 108 |
-
}
|
| 109 |
-
const next = sgrFor(cell);
|
| 110 |
-
if (next !== style) {
|
| 111 |
-
out += '\x1b[0m' + next;
|
| 112 |
-
style = next;
|
| 113 |
-
}
|
| 114 |
-
out += cell.text;
|
| 115 |
-
col += Math.max(1, cell.width || 1);
|
| 116 |
-
}
|
| 117 |
-
if (style) out += '\x1b[0m';
|
| 118 |
}
|
| 119 |
|
| 120 |
out += `\x1b[0m\x1b[${snap.cursorRow + 1};${snap.cursorCol + 1}H`;
|
|
|
|
| 70 |
return out;
|
| 71 |
}
|
| 72 |
|
| 73 |
+
/** Cells indexed by row, since snapshot() returns them as a flat sparse list. */
|
| 74 |
+
function cellGrid(snap) {
|
| 75 |
const grid = Array.from({ length: snap.rows }, () => new Array(snap.cols).fill(null));
|
| 76 |
for (const cell of snap.cells || []) {
|
| 77 |
if (cell.row >= 0 && cell.row < snap.rows && cell.col >= 0 && cell.col < snap.cols) {
|
| 78 |
grid[cell.row][cell.col] = cell;
|
| 79 |
}
|
| 80 |
}
|
| 81 |
+
return grid;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/**
|
| 85 |
+
* One row as styled text, with no cursor positioning. Everything after the last
|
| 86 |
+
* drawn cell is dropped: trailing spaces are the bulk of a mostly-empty agent
|
| 87 |
+
* screen, and the caller has either just erased the line or is about to end it.
|
| 88 |
+
*/
|
| 89 |
+
function renderRow(cells, cols) {
|
| 90 |
+
let last = -1;
|
| 91 |
+
for (let col = cols - 1; col >= 0; col--) {
|
| 92 |
+
if (cells[col] && (cells[col].text !== ' ' || cells[col].background)) { last = col; break; }
|
| 93 |
+
}
|
| 94 |
+
if (last < 0) return '';
|
| 95 |
+
|
| 96 |
+
let out = '';
|
| 97 |
+
let style = '';
|
| 98 |
+
let col = 0;
|
| 99 |
+
while (col <= last) {
|
| 100 |
+
const cell = cells[col];
|
| 101 |
+
if (!cell) {
|
| 102 |
+
if (style) { out += '\x1b[0m'; style = ''; }
|
| 103 |
+
out += ' ';
|
| 104 |
+
col += 1;
|
| 105 |
+
continue;
|
| 106 |
+
}
|
| 107 |
+
const next = sgrFor(cell);
|
| 108 |
+
if (next !== style) {
|
| 109 |
+
out += '\x1b[0m' + next;
|
| 110 |
+
style = next;
|
| 111 |
+
}
|
| 112 |
+
out += cell.text;
|
| 113 |
+
col += Math.max(1, cell.width || 1);
|
| 114 |
+
}
|
| 115 |
+
if (style) out += '\x1b[0m';
|
| 116 |
+
return out;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/**
|
| 120 |
+
* Rows `from`..`to` as styled lines, ready to be PRINTED rather than placed —
|
| 121 |
+
* for putting rows into scrollback, where a line has to be written and scrolled
|
| 122 |
+
* off rather than positioned. Blank rows are kept as empty strings so the
|
| 123 |
+
* archived block keeps its shape.
|
| 124 |
+
*/
|
| 125 |
+
export function rowsToAnsi(snap, from, to) {
|
| 126 |
+
const grid = cellGrid(snap);
|
| 127 |
+
const out = [];
|
| 128 |
+
for (let row = Math.max(0, from); row < Math.min(snap.rows, to); row++) {
|
| 129 |
+
out.push(renderRow(grid[row], snap.cols));
|
| 130 |
+
}
|
| 131 |
+
return out;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
export function snapshotToAnsi(snap) {
|
| 135 |
+
const grid = cellGrid(snap);
|
| 136 |
|
| 137 |
// Normalise the buffer first: a byte replay can leave the receiver on the
|
| 138 |
// alternate screen (or off it), and the repaint alone would then land on the
|
|
|
|
| 142 |
out += '\x1b[?25l\x1b[0m\x1b[H\x1b[2J';
|
| 143 |
|
| 144 |
for (let row = 0; row < snap.rows; row++) {
|
| 145 |
+
const line = renderRow(grid[row], snap.cols);
|
| 146 |
+
if (line) out += `\x1b[${row + 1};1H` + line;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
}
|
| 148 |
|
| 149 |
out += `\x1b[0m\x1b[${snap.cursorRow + 1};${snap.cursorCol + 1}H`;
|
|
@@ -415,13 +415,14 @@ export default function TerminalPane({
|
|
| 415 |
// own geometry instead would garble the screen.
|
| 416 |
else if (m.t === 'grid' || m.t === 'restore') {
|
| 417 |
if (m.cols > 0 && m.rows > 0 && (term.cols !== m.cols || term.rows !== m.rows)) {
|
| 418 |
-
// m.clear: the backend
|
| 419 |
-
//
|
| 420 |
-
// the outgoing screen into scrollback
|
| 421 |
-
// grid does
|
| 422 |
-
// the duplication.
|
| 423 |
-
//
|
| 424 |
-
|
|
|
|
| 425 |
else try { term.resize(m.cols, m.rows); } catch { /* ignore */ }
|
| 426 |
}
|
| 427 |
setViewers(m.viewers > 1 ? m.viewers : 0);
|
|
|
|
| 415 |
// own geometry instead would garble the screen.
|
| 416 |
else if (m.t === 'grid' || m.t === 'restore') {
|
| 417 |
if (m.cols > 0 && m.rows > 0 && (term.cols !== m.cols || term.rows !== m.rows)) {
|
| 418 |
+
// m.clear: the backend already sent us the bytes that erase this
|
| 419 |
+
// screen and archive the rows about to fall off it, so skip our own
|
| 420 |
+
// reflow — xterm rewraps the outgoing screen into scrollback harder
|
| 421 |
+
// than the grid does (119 lines against 80 over one drag), and that
|
| 422 |
+
// copy is the duplication. The empty write is a barrier: writes are
|
| 423 |
+
// asynchronous, so resizing outside its callback would apply the new
|
| 424 |
+
// size to bytes that were written for the old one.
|
| 425 |
+
if (m.clear) term.write('', () => { try { term.resize(m.cols, m.rows); } catch { /* ignore */ } });
|
| 426 |
else try { term.resize(m.cols, m.rows); } catch { /* ignore */ }
|
| 427 |
}
|
| 428 |
setViewers(m.viewers > 1 ? m.viewers : 0);
|