fix(terminal): replay a pane capture at the geometry it was taken at

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. The overflow rows then overwrite one another, and
the rows underneath are lost. Replaying a real 50-row capture into a 30-row
terminal rendered 28 lines of a 45-line command and drew the frame twice.

Nothing in the response said what height the frame was built for, so the client
could not detect this. A capture now reports the geometry it was really taken at
through `capturedGeometry` on `PaneCaptureOptions`, and the terminal response
carries it as `captureCols` and `captureRows`. When the captured pane is taller
than the terminal, or the size that produced the capture did not survive the
load, `selectSession` replays once at the size that stuck. `resizeRetry` caps
that at one attempt, so two competing fits cannot trade replays forever.

The retry re-arms the full-history flag only when the pass that ran had consumed
it. A tab switch takes the bounded tail, so its retry takes the tail too:
clearing the flag unconditionally would upgrade that switch into a fresh
scrollback capture the user never asked for, which the route's own comments put
at tens of megabytes.

What this repairs is a capture that won a race against the resize meant to
precede it. It does not repair a capture whose pane was too tall because
`Session.resize` declined the resize outright, which it does for a small
viewport while a desktop viewport's size claim is live. The retry re-sends the
same declined resize and captures the same pane, and `resizeRetry` then stops
it. Repairing that means changing who owns the pane size, which is a policy
question this does not touch. The reported geometry still helps there, because
the client can see the mismatch at all rather than being blind to it.

Follows #395, #396 and #397, which fixed the other ways the replayed frame and
the terminal could disagree.

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 3cdb4bf42e
commit 3edf9aae2f
11 changed files with 462 additions and 14 deletions
+74 -6
View File
@@ -775,7 +775,60 @@ describe('session-routes', () => {
body.data.terminalBuffer.indexOf('visible tmux pane only')
);
// No ?full=1 → visible-frame capture (no fullHistory opts).
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(
harness.ctx._session.muxName,
expect.not.objectContaining({ fullHistory: true })
);
});
// ── The geometry a capture was taken at ──
//
// A visible-frame capture repaints each row at an absolute position
// (`\x1b[<row>;1H`). A terminal with fewer rows than the pane clamps every
// address past its own height onto its last line, so the overflow rows
// overwrite each other and the rows they land on are lost. The client can
// only notice that if the response says what height the frame was built
// for, which is what captureRows/captureCols carry.
it('reports the geometry the capture was really taken at', async () => {
harness.ctx._session.terminalBuffer = '';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
(_name: string, opts?: { capturedGeometry?: { cols: number; rows: number } }) => {
// Stand in for TmuxManager, which fills this from the pane itself.
if (opts) opts.capturedGeometry = { cols: 100, rows: 50 };
return 'visible frame';
}
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
const body = JSON.parse(res.body);
expect(body.data.source).toBe('mux-visible');
expect(body.data.captureCols).toBe(100);
expect(body.data.captureRows).toBe(50);
});
it('falls back to the session 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.
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',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
const body = JSON.parse(res.body);
expect(body.data.source).toBe('history');
expect(body.data.captureCols).toBe(111);
expect(body.data.captureRows).toBe(44);
});
// ── COD-47: full tmux scrollback replay on full page reload ──
@@ -985,7 +1038,10 @@ describe('session-routes', () => {
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
// Tail/tab-switch must NOT request fullHistory (undefined opts).
expect(captureSpy).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
expect(captureSpy).toHaveBeenCalledWith(
harness.ctx._session.muxName,
expect.not.objectContaining({ fullHistory: true })
);
expect(body.data.terminalBuffer).toContain('visible frame only');
expect(body.data.terminalBuffer).not.toContain('FULL_HISTORY_SHOULD_NOT_APPEAR');
expect(body.data.source).toBe('mux-visible');
@@ -1045,7 +1101,10 @@ describe('session-routes', () => {
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
body.data.terminalBuffer.indexOf('visible tmux pane only')
);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(
harness.ctx._session.muxName,
expect.not.objectContaining({ fullHistory: true })
);
});
it('preserves one-time OAuth authorization URLs in Codex TUI replay history', async () => {
@@ -1119,7 +1178,10 @@ describe('session-routes', () => {
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
body.data.terminalBuffer.indexOf('visible tmux pane only')
);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(
harness.ctx._session.muxName,
expect.not.objectContaining({ fullHistory: true })
);
});
it('uses live mux pane capture only when the accumulated buffer is empty', async () => {
@@ -1138,7 +1200,10 @@ describe('session-routes', () => {
const body = JSON.parse(res.body);
expect(body.data.terminalBuffer).toContain('visible restored tmux pane');
expect(body.data.terminalBuffer).toContain('› current prompt');
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(
harness.ctx._session.muxName,
expect.not.objectContaining({ fullHistory: true })
);
});
it('returns error for unknown session', async () => {
@@ -1166,7 +1231,10 @@ describe('session-routes', () => {
expect(buf).toContain('\x1b[H\x1b[2J');
expect(buf).toContain('LIVE-PANE-FRAME');
expect(buf.indexOf('history-bytes')).toBeLessThan(buf.indexOf('LIVE-PANE-FRAME'));
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(
harness.ctx._session.muxName,
expect.not.objectContaining({ fullHistory: true })
);
});
it('falls back to the byte history when no live pane buffer is available', async () => {