Agent Manager Claude Opus 5 commited on
Commit
ca9928f
·
1 Parent(s): 8333ae6

Resize: archive the rows a shrink pushes off, once it is clear they are not a copy

Browse files

The 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 CHANGED
@@ -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) |
server/resize.test.mjs CHANGED
@@ -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('\x1b[H\x1b[2J', () => term.resize(m.cols, m.rows));
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:
server/src/runner.js CHANGED
@@ -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. Rows that fall off the top are DROPPED rather
167
- * than archivedsee carryScreen for why that is the point.
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. Returns the ANSI
195
- * to feed after the resize, or null when reflow should be left alone (see
196
- * host.repaints).
 
 
 
 
 
 
 
 
 
197
  */
198
  function carryScreen(host, cols, rows) {
199
  if (!RESIZE_CARRY || !host.repaints) return null;
200
- let ansi = null;
 
201
  try {
202
- ansi = snapshotToAnsi(fitSnapshot(host.vt.snapshot({ includeCells: true }), cols, rows));
 
203
  } catch { return null; }
204
- try { host.vt.feed('\x1b[H\x1b[2J'); } catch { return null; }
205
- return ansi;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- let ansi = carried;
233
- if (carried) { try { host.vt.feed(carried); } catch {} }
 
 
 
 
 
 
 
 
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 (!host.repaints && host.resizedAt) {
790
- if (Date.now() - host.resizedAt > 250) host.resizedAt = 0;
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
- if (host.resizeBytes > (host.cols * host.rows) / 4) { host.repaints = true; host.resizedAt = 0; }
 
 
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 allsettleArchive 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();
server/src/snapshot.js CHANGED
@@ -70,13 +70,69 @@ function sgrFor(cell) {
70
  return out;
71
  }
72
 
73
- export function snapshotToAnsi(snap) {
 
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 cells = grid[row];
90
- // Everything after the last drawn cell is already blank from the erase, and
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`;
web/src/components/TerminalPane.tsx CHANGED
@@ -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 cleared its screen before reflowing and a
419
- // repaint is already on the wire, so clear ours too xterm rewraps
420
- // the outgoing screen into scrollback on resize (harder than the
421
- // grid does: 119 lines against 80 over one drag), and that copy is
422
- // the duplication. Resizing inside the write callback is what keeps
423
- // it ordered against the repaint that follows.
424
- if (m.clear) term.write('\x1b[H\x1b[2J', () => { try { term.resize(m.cols, m.rows); } catch { /* ignore */ } });
 
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);