Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/fix-report-the-captured-pane-geometry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"aicodeman": patch
---

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, so the overflow rows overwrite one another
and the rows underneath are lost. Against a 50-row pane, a 30-row terminal
rendered 28 of a 45-line command and drew the surviving 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.

That repairs the case where a capture 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. The reported geometry still
helps there, because the client can see the mismatch at all.
1 change: 1 addition & 0 deletions config/test-suites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const BROWSER_TEST_GLOBS = [
'test/webgl-fallback.test.ts',
'test/terminal-copy-shortcut.test.ts',
'test/terminal-keycode229-recovery.browser.test.ts',
'test/capture-geometry-retry.browser.test.ts',
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
];

Expand Down
9 changes: 9 additions & 0 deletions src/mux-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,15 @@ export interface PaneCaptureOptions {
* the 1MB execSync default (ENOBUFS).
*/
maxCaptureBytes?: number;
/**
* Filled in by the implementation with the pane geometry the capture was
* really taken at, which is not always the geometry the caller last asked
* for: a resize and a capture can race, and a pane whose size a desktop
* viewport has claimed ignores a smaller client's resize outright. A
* visible-frame capture addresses every row absolutely, so a consumer
* rendering it needs the real height to know the frame fits.
*/
capturedGeometry?: { cols: number; rows: number };
}

/**
Expand Down
14 changes: 14 additions & 0 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3479,6 +3479,20 @@ export class Session extends EventEmitter {
private _ptyCols = 120;
private _ptyRows = 40;

/**
* The geometry the pane is currently drawing at. A caller that captures the
* pane needs this to report the size the frame was built for, and `resize`
* can decline a small viewport's request while a desktop claim is live, so
* the last size asked for is not always the size in force.
*/
get ptyCols(): number {
return this._ptyCols;
}

get ptyRows(): number {
return this._ptyRows;
}

/**
* Live WebSocket connections that have announced a desktop viewport for this
* session. While at least one is registered, small-viewport (mobile/tablet)
Expand Down
5 changes: 5 additions & 0 deletions src/tmux-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3467,6 +3467,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
)
);
// Report the size the pane was really drawing at. Both replay paths below
// address rows absolutely, so a consumer whose terminal is shorter than
// this piles every overflow row onto its last line and loses the rows it
// overwrote. Only the caller can see both sizes, so hand it this one.
if (opts && geometry) opts.capturedGeometry = { cols: geometry.cols, rows: geometry.rows };

if (fullHistory) {
// Without geometry there is no cursor move, so fall back to the old trim.
Expand Down
51 changes: 51 additions & 0 deletions src/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -6117,6 +6117,10 @@ class CodemanApp {
// sendResize is a no-op on the server when dims haven't changed, so
// calling it every tab switch is cheap.
const dimsChanged = await this.sendResize(sessionId, { forceHttp: true }).catch(() => false);
// The size the capture below will be taken against. The debounced resize
// handler can move the terminal again while the load runs, so this is a
// recorded value rather than a later read of `_lastResizeDims`.
const dimsAtCapture = this.getTerminalDimensions?.();
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
Expand Down Expand Up @@ -6348,6 +6352,29 @@ class CodemanApp {
// annoyance that disappear on the user's next keypress; data loss is not
// acceptable. Do NOT re-introduce Ctrl+L here.
this.sendResize(sessionId);
// sendResize fits synchronously before its first await, so this reads the
// size that survived the load rather than the one the capture was taken
// at. The two differ whenever the terminal was still settling.
const dimsAfterLoad = this.getTerminalDimensions?.();
const sizeMovedUnderLoad =
!!dimsAtCapture &&
!!dimsAfterLoad &&
(dimsAfterLoad.cols !== dimsAtCapture.cols || dimsAfterLoad.rows !== dimsAtCapture.rows);
// A capture positions every row absolutely, so a pane taller than this
// terminal writes its overflow rows onto the last line and loses the rows
// it overwrote. That happens when the capture wins a race against the
// resize meant to precede it, which is what the retry below repairs.
//
// It also happens when `Session.resize` DECLINED the resize, which it does
// for a small viewport while a desktop viewport's size claim is live. The
// retry cannot repair that one: it re-sends the same declined resize and
// captures the same too-tall pane. `resizeRetry` stops it after the one
// extra attempt, and the frame is shown as-is. Repairing that case means
// changing who owns the pane size, which is a policy question this does
// not touch. What the flag does buy there is that the client can SEE the
// mismatch at all, which it previously could not.
const capturedTallerThanTerminal =
Number.isFinite(data.captureRows) && data.captureRows > (this.terminal?.rows || 0);

// Defer secondary panel updates so they don't block the main thread
// after terminal content is already visible.
Expand Down Expand Up @@ -6448,6 +6475,30 @@ class CodemanApp {
this._clearTerminalLoadState(sessionId, selectGen);
_crashDiag.log(`SELECT_DONE: ${selectDoneMs.toFixed(0)}ms`);
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${selectDoneMs.toFixed(0)}ms`);
// What is on screen was drawn for a geometry this terminal does not have.
// Replaying once against the size that stuck is the only thing that
// repairs it: SIGWINCH reaches the CLI only on a real size change, and
// the pane is already at its final size, so no redraw is coming.
// `resizeRetry` caps this at one attempt, so two competing fits cannot
// trade replays forever.
if (
(sizeMovedUnderLoad || capturedTallerThanTerminal) &&
!options?.resizeRetry &&
!this._isStaleSelect(selectGen)
) {
_crashDiag.log(
`RESIZE_RETRY: capture ${data.captureCols}x${data.captureRows} vs terminal ` +
`${this.terminal?.cols}x${this.terminal?.rows}` +
(sizeMovedUnderLoad ? ' (size moved under load)' : '')
);
// Re-arm the full-history pull ONLY if this pass actually used one, so
// the retry replays the same content at the geometry that stuck. A pass
// that took the bounded tail must retry on the tail too: clearing the
// flag unconditionally would UPGRADE a tab switch into a fresh
// multi-megabyte scrollback capture it never asked for.
if (useFullHistory) this._fullHistoryLoaded.delete(sessionId);
await this.selectSession(sessionId, { auto: true, forceReload: true, resizeRetry: true });
}
} catch (err) {
if (this._isLoadingBuffer) this._finishBufferLoad(bufferLoadOwner);
this._restoringFlushedState = false;
Expand Down
23 changes: 17 additions & 6 deletions src/web/routes/session-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
type OmpConfig,
} from '../../types.js';
import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
import type { PaneCaptureOptions } from '../../mux-interface.js';
import { SseEvent } from '../sse-events.js';
import { webviewCapabilities } from '../../webview-capabilities.js';
import {
Expand Down Expand Up @@ -2591,14 +2592,16 @@ export function registerSessionRoutes(
// returns null when unavailable, in which case we fall back to history.
const muxName = session.muxName;
const captureStartedAt = performance.now();
// The visible path used to pass no options at all. It passes one now for a
// single reason: `capturedGeometry` comes BACK on it, and the response has
// to tell the client what size the frame it is about to render was built
// for. See PaneCaptureOptions.capturedGeometry.
const captureOpts: PaneCaptureOptions = isFullReload
? { fullHistory: true, historyLimitLines: tmuxHistoryLimit, maxCaptureBytes: terminalBufferMaxBytes }
: {};
const liveMuxBuffer =
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
? ctx.mux.captureActivePaneBuffer(
muxName,
isFullReload
? { fullHistory: true, historyLimitLines: tmuxHistoryLimit, maxCaptureBytes: terminalBufferMaxBytes }
: undefined
)
? ctx.mux.captureActivePaneBuffer(muxName, captureOpts)
: null;
const captureFinishedAt = performance.now();
const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
Expand Down Expand Up @@ -2744,6 +2747,14 @@ export function registerSessionRoutes(
// what existed before the cut. The gap is what the indicator reports.
retainedBytes: cleanBuffer.length,
source,
// The pane geometry this frame was drawn for. A visible-frame capture
// positions every row absolutely, so a client whose terminal has fewer
// rows than this overwrites its last line with the overflow and loses
// the rows underneath. The client compares these against its own size.
// Falls back to the session's own geometry when the capture reported
// none (cursor query failed, or the buffer came from byte history).
captureCols: captureOpts.capturedGeometry?.cols ?? session.ptyCols,
captureRows: captureOpts.capturedGeometry?.rows ?? session.ptyRows,
};
});

Expand Down
Loading