fix(terminal): compare capture geometry only on a visible-frame response

Only a visible-frame capture positions its rows absolutely, so only that frame
can be damaged by a terminal of the wrong size. A `full=1` body is linear
scrollback closed by a relative cursor move, which is relative precisely so the
browser's row count need not match the pane's, and a `history` body is the byte
stream, which carries no row alignment to protect. The geometry comparison ran
on all three, so it fired most often on the one response it cannot help:
`_fullHistoryLoaded` is empty on the first select of every non-shell session per
page, and a session whose pane a desktop tab holds too tall to ever fit then
paid a second whole-scrollback capture, reset and replay on every page load and
every first tab switch.

`framePositionsRowsAbsolutely` gates both the captured-geometry comparison and
`sizeMovedUnderLoad`. A size that moved under a byte-stream or scrollback replay
is healed by xterm's own reflow plus the SIGWINCH the trailing `sendResize`
already sends.

A pane WIDER than the terminal damages the same frame a second way, so
`captureCols` is now compared rather than only logged. `formatPaneSnapshot`
paints each row out to the pane's own width, so a narrower browser wraps every
painted row, and the wrap on the last one scrolls the whole frame up by a row.

The terminal response no longer falls back to `session.ptyCols`/`ptyRows` when
the capture reported no geometry. The cursor query is what produces the absolute
addressing in the first place, so a capture that lost it returned a raw frame
that was never positioned, and a byte-history response was never positioned
either. Naming the session's own PTY size there described a frame that does not
exist and invited a repair for damage that is not present. `_ptyCols` is also
written only by `resize()` while the PTY is spawned at the size queried from
tmux, so it can be wrong on its own terms. Both fields are now absent instead,
and the `Session` getters added for that fallback go with it.

Two browser cases cover the new behaviour and each fails without its fix: a
`mux-full-history` response with both dimensions mismatched asserts one fetch
(two without the gate), and a `mux-visible` response wider than the terminal
but short enough to fit asserts two (one without the width comparison).

Corrects a claim in the comment above `capturedGeometry` in tmux-manager.ts.
Both replay paths do not address rows absolutely; the full-history one ends in a
relative move, which is the whole reason the gate is right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Grundberg
2026-09-19 10:44:35 +02:00
co-authored by Claude Opus 5
parent 3edf9aae2f
commit 5cfb98fb8b
8 changed files with 165 additions and 63 deletions
+90 -11
View File
@@ -1,13 +1,20 @@
/**
* @fileoverview A capture drawn for a taller pane makes the client replay once.
* @fileoverview A capture drawn for a bigger pane makes the client replay once.
*
* A visible-frame capture repaints each row at an absolute position, counting
* up to the PANE's height. A terminal shorter than that clamps every address
* past its own height onto its last line, so the overflow rows overwrite one
* another and the rows underneath are lost. The client cannot see that from
* the escape sequence, so the terminal response reports the geometry the
* capture was taken at (`captureCols`/`captureRows`) and `selectSession`
* replays once at the size that stuck.
* up to the PANE's height and out to the PANE's width. A terminal shorter than
* that clamps every address past its own height onto its last line, so the
* overflow rows overwrite one another and the rows underneath are lost. A
* narrower terminal wraps every painted row, and the wrap on the last one
* scrolls the whole frame up by one. The client cannot see either from the
* escape sequence, so the terminal response reports the geometry the capture
* was taken at (`captureCols`/`captureRows`) and `selectSession` replays once
* at the size that stuck.
*
* The comparison runs on a `mux-visible` response ONLY. The other two sources
* position no rows absolutely, so a size mismatch damages neither and a replay
* repairs neither, and the last case here pins that the expensive one is left
* alone.
*
* These drive the REAL client in chromium and stub only the terminal endpoint,
* because the mismatch itself needs two viewports to stage against live tmux.
@@ -50,8 +57,19 @@ function paneSnapshot(rows: number): string {
/**
* Serve every terminal fetch from a stub reporting `captureRows`, counting the
* fetches. The real route needs live tmux to produce a mismatched frame.
*
* `source` and `captureCols` default to the visible-frame case, which is the
* only response whose rows are addressed absolutely and therefore the only one
* a replay can repair. A test that varies either says so.
*/
async function stubTerminal(page: Page, captureRows: number, counter: { n: number; urls: string[] }) {
async function stubTerminal(
page: Page,
captureRows: number,
counter: { n: number; urls: string[] },
options: { source?: string; captureCols?: number } = {}
) {
const source = options.source ?? 'mux-visible';
const captureCols = options.captureCols ?? 200;
await page.route('**/api/sessions/*/terminal*', async (route) => {
counter.n += 1;
counter.urls.push(route.request().url());
@@ -67,8 +85,8 @@ async function stubTerminal(page: Page, captureRows: number, counter: { n: numbe
retainedBytes: 1024,
truncated: false,
truncationReason: null,
source: 'mux-visible',
captureCols: 200,
source,
captureCols,
captureRows,
},
}),
@@ -76,6 +94,9 @@ async function stubTerminal(page: Page, captureRows: number, counter: { n: numbe
});
}
/** The widest terminal this suite's 1280px viewport can produce, with margin. */
const WIDER_THAN_ANY_TERMINAL_COLS = 500;
async function openSession(page: Page): Promise<string> {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
@@ -101,6 +122,11 @@ async function terminalRows(page: Page): Promise<number> {
return page.evaluate(() => (window as unknown as { app: { terminal?: { rows: number } } }).app.terminal?.rows ?? 0);
}
/** As above, for the width half of the comparison. */
async function terminalCols(page: Page): Promise<number> {
return page.evaluate(() => (window as unknown as { app: { terminal?: { cols: number } } }).app.terminal?.cols ?? 0);
}
async function select(page: Page, sessionId: string, options: object = {}): Promise<void> {
await page.evaluate(
async ({ sid, opts }) => {
@@ -190,7 +216,7 @@ describe('a capture taller than the terminal', () => {
// frame fits, nothing is clamped, and nothing needs repeating. A retry here
// would double the work of every tab switch.
const fetches = { n: 0, urls: [] as string[] };
await stubTerminal(page, 5, fetches);
await stubTerminal(page, 5, fetches, { captureCols: 40 });
await select(page, sessionId);
expect(await terminalRows(page)).toBeGreaterThan(5);
@@ -199,4 +225,57 @@ describe('a capture taller than the terminal', () => {
await closeSession(page, sessionId);
await context.close();
}, 60_000);
it('replays once when the captured pane is wider', async () => {
// A pane wider than the terminal damages the same frame a second way.
// `formatPaneSnapshot` paints every row out to the PANE's width, so a
// narrower browser wraps each painted row, and the wrap on the last row
// scrolls the whole frame up by one. The height here fits deliberately, so
// the width is the only thing that can trigger the replay.
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
page = await context.newPage();
const sessionId = await openSession(page);
const fetches = { n: 0, urls: [] as string[] };
await stubTerminal(page, 5, fetches, { captureCols: WIDER_THAN_ANY_TERMINAL_COLS });
await select(page, sessionId);
expect(await terminalRows(page)).toBeGreaterThan(5);
expect(await terminalCols(page)).toBeLessThan(WIDER_THAN_ANY_TERMINAL_COLS);
expect(fetches.n).toBe(2);
await closeSession(page, sessionId);
await context.close();
}, 60_000);
it('does not replay a full-history response, whatever geometry it reports', async () => {
// A `full=1` body is linear scrollback closed by a RELATIVE cursor move,
// which is relative precisely so the browser's row count need not match the
// pane's. A mismatch there is not damage and a replay cannot repair it, so
// the geometry comparison must not fire on it. This is the path that makes
// the gate worth having: `_fullHistoryLoaded` is empty on the first select
// of every non-shell session per page, so an ungated comparison would pull
// the entire tmux scrollback a second time on every page load and every
// first tab switch, for a session whose pane a desktop tab is holding too
// tall to ever fit.
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
page = await context.newPage();
const sessionId = await openSession(page);
const fetches = { n: 0, urls: [] as string[] };
await stubTerminal(page, 200, fetches, {
source: 'mux-full-history',
captureCols: WIDER_THAN_ANY_TERMINAL_COLS,
});
await select(page, sessionId);
// Both dimensions are mismatched, so height alone is not what spares it.
expect(await terminalRows(page)).toBeLessThan(200);
expect(await terminalCols(page)).toBeLessThan(WIDER_THAN_ANY_TERMINAL_COLS);
expect(fetches.n).toBe(1);
expect(fetches.urls.filter((u) => u.includes('full=1'))).toHaveLength(1);
await closeSession(page, sessionId);
await context.close();
}, 60_000);
});
+1 -12
View File
@@ -373,19 +373,8 @@ export class MockSession extends EventEmitter {
/** Stub for sendInput */
sendInput = vi.fn();
/**
* The geometry the pane is drawing at, which the terminal route reports on
* every response so a client can tell whether the frame fits its own
* terminal. The stubbed `resize` records it the way the real one does.
*/
ptyCols = 120;
ptyRows = 40;
/** Stub for resize */
resize = vi.fn((cols: number, rows: number) => {
this.ptyCols = cols;
this.ptyRows = rows;
});
resize = vi.fn();
/** Stubs for the desktop sizing claims used by resize arbitration */
claimDesktopSizing = vi.fn();
+7 -7
View File
@@ -811,14 +811,14 @@ describe('session-routes', () => {
expect(body.data.captureRows).toBe(50);
});
it('falls back to the session geometry when the capture reports none', async () => {
it('omits the geometry when the capture reports none', async () => {
// The cursor query can fail, and a byte-history response never captures
// at all. The session's own PTY size is the best answer available, and a
// missing field would read as "no mismatch" and suppress the client's
// repair.
// at all. Neither frame was positioned, so neither can be damaged by a
// terminal of the wrong size. Naming the session's own PTY size here
// would describe a geometry no frame was built for, and the client would
// read it as a mismatch worth replaying for.
harness.ctx._session.terminalBuffer = 'byte history only';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(() => null);
harness.ctx._session.resize(111, 44, { force: true });
const res = await harness.app.inject({
method: 'GET',
@@ -827,8 +827,8 @@ describe('session-routes', () => {
const body = JSON.parse(res.body);
expect(body.data.source).toBe('history');
expect(body.data.captureCols).toBe(111);
expect(body.data.captureRows).toBe(44);
expect(body.data.captureCols).toBeUndefined();
expect(body.data.captureRows).toBeUndefined();
});
// ── COD-47: full tmux scrollback replay on full page reload ──