mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #435
fix(terminal): replay a pane capture at the geometry it was taken at
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
---
|
||||
"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 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. Against a 50-row
|
||||
pane, a 30-row terminal rendered 28 of a 45-line command and drew the surviving
|
||||
frame twice. A narrower terminal damages the same frame a second way: each row
|
||||
is painted out to the pane's own width, so the browser wraps every painted row,
|
||||
and the wrap on the last one scrolls the whole frame up by a row.
|
||||
|
||||
Nothing in the response said what geometry the frame was built for, so the
|
||||
client could not detect either case. 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`. Both fields are
|
||||
absent unless the response really carries a capture, since a body that was never
|
||||
positioned has no geometry to describe. When a captured pane is taller or wider
|
||||
than the terminal, or the size that produced the capture did not survive the
|
||||
load, `selectSession` replays once at the size that stuck.
|
||||
|
||||
That comparison runs on a visible-frame response only. A full-history response
|
||||
is linear scrollback closed by a relative cursor move, and a byte-history
|
||||
response carries no row alignment at all, so a size mismatch damages neither and
|
||||
a replay repairs neither. The distinction matters because the first load of
|
||||
every non-shell session per page takes the full-history path, where a replay
|
||||
would capture the whole tmux scrollback a second time.
|
||||
|
||||
Two guards keep the replay to the one pass that can converge. `resizeRetry` caps
|
||||
it at a single attempt, so two competing fits cannot trade replays forever. A
|
||||
pane already drawing at the size the client just requested is left alone, which
|
||||
is the signature of a clamp rather than a race: `getTerminalDimensions()` floors
|
||||
at 40x10 while `fitAddon.fit()` does not, so a terminal narrower than 40 columns
|
||||
or shorter than 10 rows reports a pane permanently bigger than itself and would
|
||||
otherwise replay on every tab switch without ever converging.
|
||||
|
||||
One case is still reported rather than repaired. A pane can be 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, so it costs the one capped
|
||||
attempt and the frame is shown as it is. Repairing it means deciding who owns
|
||||
the pane size while a desktop claim is live, which is a policy question this
|
||||
does not touch. The reported geometry still helps, because the client can see
|
||||
the mismatch at all rather than being blind to it.
|
||||
@@ -28,6 +28,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/terminal-copy-shortcut.test.ts',
|
||||
'test/terminal-keycode229-recovery.browser.test.ts',
|
||||
'test/capture-load-window.browser.test.ts',
|
||||
'test/capture-geometry-retry.browser.test.ts',
|
||||
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
||||
];
|
||||
|
||||
|
||||
@@ -159,6 +159,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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3482,6 +3482,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
)
|
||||
);
|
||||
// Report the size the pane was really drawing at. The visible-frame path
|
||||
// below addresses every row absolutely, so a consumer whose terminal is
|
||||
// shorter than this piles the overflow rows onto its last line and loses
|
||||
// the rows it overwrote. The full-history path instead ends in a RELATIVE
|
||||
// cursor move, which costs it nothing when the two sizes disagree, so the
|
||||
// geometry is reported there for diagnosis rather than for repair. 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.
|
||||
|
||||
+187
-22
@@ -549,6 +549,12 @@ class CodemanApp {
|
||||
// repaint-mode CLI pane, where tmux keeps no history of its own). The pull is
|
||||
// refused for those and retried far more slowly — see _maybeRefetchFullHistory.
|
||||
this._fullHistoryRepullUseless = new Set();
|
||||
// Sessions where the geometry replay has already been tried and did NOT
|
||||
// converge, so the pane is one this browser cannot size. Mirrors the Set
|
||||
// above: `resizeRetry` caps the recursion inside one select, and this is
|
||||
// what stops a fresh select from paying for the same answer again — see
|
||||
// the geometry gate in selectSession.
|
||||
this._geometryRetryUseless = new Set();
|
||||
this.terminalLoadStates = new Map(); // Map<sessionId, { generation, phase }>
|
||||
this.respawnStatus = {};
|
||||
this.respawnTimers = {}; // Track timed respawn timers
|
||||
@@ -5783,28 +5789,7 @@ class CodemanApp {
|
||||
if (ta) ta.dispatchEvent(new CompositionEvent('compositionend', { data: '' }));
|
||||
}
|
||||
} catch {}
|
||||
// Flush local echo text to PTY before switching tabs.
|
||||
// Send as a single batch (no Enter) so it lands in the session's readline
|
||||
// input buffer — avoids "old text resent on Enter" and overlay render bugs.
|
||||
// Track flushed length so _render() offsets the overlay correctly even before
|
||||
// the PTY echo arrives in the terminal buffer.
|
||||
if (this.activeSessionId) {
|
||||
const echoText = this._localEchoOverlay?.pendingText || '';
|
||||
// Include buffer-detected flushed text (from Tab completion, etc.)
|
||||
// so it's preserved across tab switches.
|
||||
const existingFlushed = this._localEchoOverlay?.getFlushed()?.count || 0;
|
||||
const existingFlushedText = this._localEchoOverlay?.getFlushed()?.text || '';
|
||||
if (echoText) {
|
||||
this._sendInputAsync(this.activeSessionId, echoText);
|
||||
}
|
||||
const totalOffset = existingFlushed + echoText.length;
|
||||
if (totalOffset > 0) {
|
||||
if (!this._flushedOffsets) this._flushedOffsets = new Map();
|
||||
if (!this._flushedTexts) this._flushedTexts = new Map();
|
||||
this._flushedOffsets.set(this.activeSessionId, totalOffset);
|
||||
this._flushedTexts.set(this.activeSessionId, existingFlushedText + echoText);
|
||||
}
|
||||
}
|
||||
this._flushLocalEchoTo(this.activeSessionId);
|
||||
this._localEchoOverlay?.clear();
|
||||
// Predictions are ephemeral + already sent: nothing to save/restore
|
||||
// across a tab switch (unlike the buffer overlay's setFlushed machinery)
|
||||
@@ -5819,6 +5804,45 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the local-echo overlay's unsent text to `sessionId` before anything
|
||||
* clears it, and record what has now been flushed so `_render()` offsets the
|
||||
* overlay correctly even before the PTY echo comes back.
|
||||
*
|
||||
* On a touch device the characters the user has typed live ONLY here until
|
||||
* Enter — they have never reached the PTY — so whoever clears the overlay
|
||||
* owes them a flush first. It is sent as one batch with no Enter, so it lands
|
||||
* in the session's readline buffer rather than submitting a line the user has
|
||||
* not finished.
|
||||
*
|
||||
* ⚠️ The session is a PARAMETER because the two callers are looking at
|
||||
* different ones. `_cleanupPreviousSession` flushes to the tab being left,
|
||||
* which is still `activeSessionId` when it runs. The `forceReload` branch in
|
||||
* `selectSession` flushes to the tab being RELOADED, and must do it before it
|
||||
* nulls `activeSessionId`: reading the field after that null is what silently
|
||||
* dropped the text, since the guard here then saw no session and the
|
||||
* unconditional `clear()` that follows took the characters with it.
|
||||
* @param {string|null} sessionId
|
||||
*/
|
||||
_flushLocalEchoTo(sessionId) {
|
||||
if (!sessionId) return;
|
||||
const echoText = this._localEchoOverlay?.pendingText || '';
|
||||
// Include buffer-detected flushed text (from Tab completion, etc.)
|
||||
// so it's preserved across tab switches.
|
||||
const existingFlushed = this._localEchoOverlay?.getFlushed()?.count || 0;
|
||||
const existingFlushedText = this._localEchoOverlay?.getFlushed()?.text || '';
|
||||
if (echoText) {
|
||||
this._sendInputAsync(sessionId, echoText);
|
||||
}
|
||||
const totalOffset = existingFlushed + echoText.length;
|
||||
if (totalOffset > 0) {
|
||||
if (!this._flushedOffsets) this._flushedOffsets = new Map();
|
||||
if (!this._flushedTexts) this._flushedTexts = new Map();
|
||||
this._flushedOffsets.set(sessionId, totalOffset);
|
||||
this._flushedTexts.set(sessionId, existingFlushedText + echoText);
|
||||
}
|
||||
}
|
||||
|
||||
_resetTerminalForReplay() {
|
||||
this.terminal.reset();
|
||||
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
||||
@@ -6093,6 +6117,13 @@ class CodemanApp {
|
||||
this._loadBufferQueue = null;
|
||||
this._terminalRefreshOwner = null;
|
||||
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
|
||||
// Anything typed but not yet submitted lives in the local-echo overlay and
|
||||
// has never reached the PTY. `_cleanupPreviousSession` below flushes it,
|
||||
// but only for a session it can still see, and the null on the next line
|
||||
// hides this one from it. Flush first or the characters are cleared
|
||||
// unread. The geometry replay re-enters here with no gesture behind it,
|
||||
// so on a touch device this fires while the user is still typing.
|
||||
this._flushLocalEchoTo(sessionId);
|
||||
this.activeSessionId = null;
|
||||
}
|
||||
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
|
||||
@@ -6272,6 +6303,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;
|
||||
@@ -6520,6 +6555,75 @@ 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?.();
|
||||
// Only a visible-frame capture positions its rows absolutely, and 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
|
||||
// (`formatCursorRestore`), 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. Replaying either at
|
||||
// a different size repairs nothing, and the full-history replay costs a
|
||||
// second whole-scrollback capture to learn that. Since the first select
|
||||
// of every non-shell session per page takes the full-history path, an
|
||||
// ungated comparison fires most often on the one response it cannot help.
|
||||
const framePositionsRowsAbsolutely = data.source === 'mux-visible';
|
||||
const sizeMovedUnderLoad =
|
||||
framePositionsRowsAbsolutely &&
|
||||
!!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. A pane WIDER than this terminal damages the same frame a
|
||||
// second way: `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. Both happen 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.
|
||||
//
|
||||
// An ABSENT field is not a fit. It means the capture reported no geometry
|
||||
// at all, so nothing was positioned and there is nothing to repair.
|
||||
const capturedTallerThanTerminal =
|
||||
framePositionsRowsAbsolutely &&
|
||||
Number.isFinite(data.captureRows) &&
|
||||
data.captureRows > (this.terminal?.rows || 0);
|
||||
const capturedWiderThanTerminal =
|
||||
framePositionsRowsAbsolutely &&
|
||||
Number.isFinite(data.captureCols) &&
|
||||
data.captureCols > (this.terminal?.cols || 0);
|
||||
// The retry replays at `dimsAfterLoad`, so it can only change what is on
|
||||
// screen if the pane was drawing at some OTHER size. When the reported
|
||||
// geometry already IS that size, the second pass captures the identical
|
||||
// frame and pays a full reload to do it: another fetch, another
|
||||
// `_resetTerminalForReplay()` and chunked rewrite (a visible re-flash),
|
||||
// and, because it goes through `forceReload`, a dropped and reopened
|
||||
// WebSocket plus a deleted xterm snapshot.
|
||||
//
|
||||
// That equality is the signature of a CLAMP rather than a race.
|
||||
// `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` does
|
||||
// not, so a terminal narrower than 40 columns or shorter than 10 rows
|
||||
// reports a pane permanently bigger than itself, and every select would
|
||||
// retry without ever converging. A race never produces this equality: its
|
||||
// whole premise is that the pane was still at the size we asked it to
|
||||
// leave. The other non-converging case, `Session.resize` declining a
|
||||
// small viewport while a desktop claim is live, does not produce it
|
||||
// either — that pane sits at the DESKTOP's size — so it still costs the
|
||||
// one capped attempt, and stopping it needs the pane-ownership policy
|
||||
// this does not touch.
|
||||
const captureMatchesRequestedSize =
|
||||
!!dimsAfterLoad && data.captureCols === dimsAfterLoad.cols && data.captureRows === dimsAfterLoad.rows;
|
||||
|
||||
// Defer secondary panel updates so they don't block the main thread
|
||||
// after terminal content is already visible.
|
||||
@@ -6620,6 +6724,67 @@ 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`);
|
||||
// Remember whether the replay was worth it, because `resizeRetry` only
|
||||
// caps the recursion INSIDE one select and says nothing about the next
|
||||
// one. A pane this browser cannot size — one whose resize `Session.resize`
|
||||
// declines while a desktop claim is live, or one a second tmux client is
|
||||
// also holding — reports the same mismatch on every select, so without a
|
||||
// memo the diagnosis is paid for again on every tab switch, forever: two
|
||||
// fetches per select rather than one. Each extra pass costs a second
|
||||
// `capture-pane`, which is `execSync` and blocks the server's event loop,
|
||||
// plus a reset and chunked rewrite, a discarded snapshot and cache entry,
|
||||
// and a dropped and reopened WebSocket.
|
||||
//
|
||||
// A retry pass that STILL does not fit is the proof, since the retry ran
|
||||
// at the size that stuck and the pane ignored it. Geometry that fits
|
||||
// clears the memo, so a pane that becomes sizeable again (the desktop tab
|
||||
// closes, the claim goes idle) is repaired on the next select. The race
|
||||
// case is untouched: it converges on its first attempt, so it never
|
||||
// reaches the branch that latches.
|
||||
const capturedGeometryFits =
|
||||
framePositionsRowsAbsolutely &&
|
||||
Number.isFinite(data.captureRows) &&
|
||||
!capturedTallerThanTerminal &&
|
||||
!capturedWiderThanTerminal;
|
||||
if (capturedGeometryFits) {
|
||||
this._geometryRetryUseless?.delete(sessionId);
|
||||
} else if (options?.resizeRetry && (capturedTallerThanTerminal || capturedWiderThanTerminal)) {
|
||||
(this._geometryRetryUseless ||= new Set()).add(sessionId);
|
||||
}
|
||||
// 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 || capturedWiderThanTerminal) &&
|
||||
!captureMatchesRequestedSize &&
|
||||
!this._geometryRetryUseless?.has(sessionId) &&
|
||||
!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.
|
||||
//
|
||||
// UNREACHABLE as written, and kept for the invariant rather than the
|
||||
// branch. A `useFullHistory` pass sends `full=1`, and the route answers
|
||||
// `full=1` with `mux-full-history` or `history`, never `mux-visible`
|
||||
// (see the source ladder in session-routes.ts), so the gate above
|
||||
// already rules out every pass that consumed the flag. Do not read this
|
||||
// line as evidence that a page load retries: it does not, and the test
|
||||
// suite pins that it does not.
|
||||
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;
|
||||
|
||||
@@ -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 {
|
||||
@@ -2632,14 +2633,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;
|
||||
@@ -2785,6 +2788,25 @@ 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.
|
||||
//
|
||||
// BOTH FIELDS ARE ABSENT unless this response really carries a capture,
|
||||
// and that is the honest answer rather than a gap to paper over. Two
|
||||
// separate things can leave a frame unpositioned. The cursor query is
|
||||
// what produces the absolute addressing in the first place, so a capture
|
||||
// that lost it returned a raw frame with no row positioning in it. And a
|
||||
// capture can report geometry and STILL hand back nothing: the
|
||||
// full-history path returns '' for a pane holding nothing visible, which
|
||||
// drops `source` to `history` while `capturedGeometry` is already
|
||||
// written, so the geometry has to be suppressed HERE rather than trusted
|
||||
// to be missing. Naming a size for a body that is the byte stream would
|
||||
// describe a frame that was never drawn and invite the client to repair
|
||||
// damage that does not exist.
|
||||
captureCols: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.cols : undefined,
|
||||
captureRows: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.rows : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* @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 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.
|
||||
* Without the fix the first assertion below sees one fetch instead of two.
|
||||
*
|
||||
* Port: 3252 (capture geometry retry)
|
||||
*
|
||||
* Run: npx vitest run --config config/vitest.browser.config.ts test/capture-geometry-retry.browser.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3252;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true); // testMode
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.stop();
|
||||
}, 30_000);
|
||||
|
||||
/** A visible-frame capture: one absolutely-addressed paint per row. */
|
||||
function paneSnapshot(rows: number): string {
|
||||
const parts: string[] = [];
|
||||
for (let row = 1; row <= rows; row++) parts.push(`\x1b[${row};1Hprobe-row-${row}`);
|
||||
parts.push(`\x1b[${rows};6H`);
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve every terminal fetch from a stub reporting `captureRows`, counting the
|
||||
* fetches. The real route needs live tmux to produce a mismatched frame.
|
||||
*
|
||||
* `source` is DERIVED from the request the way the real route derives it: a
|
||||
* `full=1` request whose capture came back is `mux-full-history`, and every
|
||||
* other one is `mux-visible`. The route cannot answer `full=1` with
|
||||
* `mux-visible`, so a stub that did would stage a combination production never
|
||||
* produces, and a test resting on it would prove nothing about production. A
|
||||
* test that needs some other source passes it explicitly and says why.
|
||||
*/
|
||||
async function stubTerminal(
|
||||
page: Page,
|
||||
captureRows: number,
|
||||
counter: { n: number; urls: string[] },
|
||||
options: { source?: string; captureCols?: number } = {}
|
||||
) {
|
||||
const captureCols = options.captureCols ?? 200;
|
||||
await page.route('**/api/sessions/*/terminal*', async (route) => {
|
||||
const url = route.request().url();
|
||||
counter.n += 1;
|
||||
counter.urls.push(url);
|
||||
const source = options.source ?? (url.includes('full=1') ? 'mux-full-history' : 'mux-visible');
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
terminalBuffer: paneSnapshot(captureRows),
|
||||
status: 'idle',
|
||||
fullSize: 1024,
|
||||
retainedBytes: 1024,
|
||||
truncated: false,
|
||||
truncationReason: null,
|
||||
source,
|
||||
captureCols,
|
||||
captureRows,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* As `stubTerminal`, but reading its geometry from a holder the test can change
|
||||
* between selects. That is what lets one case watch a pane stop fitting and
|
||||
* start fitting again, which a stub fixed at construction cannot show.
|
||||
*/
|
||||
async function stubTerminalDynamic(
|
||||
page: Page,
|
||||
counter: { n: number; urls: string[] },
|
||||
state: { captureRows: number; captureCols: number }
|
||||
) {
|
||||
await page.route('**/api/sessions/*/terminal*', async (route) => {
|
||||
const url = route.request().url();
|
||||
counter.n += 1;
|
||||
counter.urls.push(url);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
terminalBuffer: paneSnapshot(state.captureRows),
|
||||
status: 'idle',
|
||||
fullSize: 1024,
|
||||
retainedBytes: 1024,
|
||||
truncated: false,
|
||||
truncationReason: null,
|
||||
source: url.includes('full=1') ? 'mux-full-history' : 'mux-visible',
|
||||
captureCols: state.captureCols,
|
||||
captureRows: state.captureRows,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer every fetch with the geometry the client itself is asking for, read
|
||||
* live from the page. That is the clamp signature: `getTerminalDimensions()`
|
||||
* floors at 40x10 while `fitAddon.fit()` does not, so a small enough viewport
|
||||
* makes the pane permanently bigger than the terminal at a size the client
|
||||
* requested itself.
|
||||
*/
|
||||
async function stubTerminalAtRequestedSize(page: Page, counter: { n: number; urls: string[] }) {
|
||||
await page.route('**/api/sessions/*/terminal*', async (route) => {
|
||||
counter.n += 1;
|
||||
counter.urls.push(route.request().url());
|
||||
const dims = await page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as unknown as { app: { getTerminalDimensions?: () => { cols: number; rows: number } | null } }
|
||||
).app.getTerminalDimensions?.() ?? null
|
||||
);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
terminalBuffer: paneSnapshot(dims?.rows ?? 10),
|
||||
status: 'idle',
|
||||
fullSize: 1024,
|
||||
retainedBytes: 1024,
|
||||
truncated: false,
|
||||
truncationReason: null,
|
||||
source: 'mux-visible',
|
||||
captureCols: dims?.cols,
|
||||
captureRows: dims?.rows,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 });
|
||||
// xterm is loaded from /vendor, so the terminal appears a beat after the app.
|
||||
// Without it `app.terminal.rows` reads 0 and every height comparison below
|
||||
// would pass vacuously.
|
||||
await page.waitForFunction(() => (window as unknown as { app?: { terminal?: unknown } }).app?.terminal, null, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
return page.evaluate(async () => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp', name: 'capture-geometry-test' }),
|
||||
});
|
||||
const body = await res.json();
|
||||
return body.data?.session?.id ?? body.data?.id ?? body.id;
|
||||
});
|
||||
}
|
||||
|
||||
/** The terminal is sized by the first select, so this only reads after one. */
|
||||
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 }) => {
|
||||
const app = (window as unknown as { app: { selectSession: (id: string, o?: object) => Promise<void> } }).app;
|
||||
await app.selectSession(sid, opts);
|
||||
},
|
||||
{ sid: sessionId, opts: options }
|
||||
);
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend the per-page full-history allowance and forget what it cost. Every
|
||||
* geometry comparison below runs on a `mux-visible` response, and the route
|
||||
* only produces one for a request sent WITHOUT `full=1`, so reaching that shape
|
||||
* means not being the first select of the page — which is what a tab switch is.
|
||||
*/
|
||||
async function consumeFullHistory(
|
||||
page: Page,
|
||||
sessionId: string,
|
||||
counter: { n: number; urls: string[] }
|
||||
): Promise<void> {
|
||||
await select(page, sessionId);
|
||||
counter.n = 0;
|
||||
counter.urls.length = 0;
|
||||
}
|
||||
|
||||
async function closeSession(page: Page, sessionId: string): Promise<void> {
|
||||
await page.evaluate(
|
||||
(sid: string) => fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).then(() => undefined),
|
||||
sessionId
|
||||
);
|
||||
}
|
||||
|
||||
describe('a capture bigger than the terminal', () => {
|
||||
let context: BrowserContext;
|
||||
let page: Page;
|
||||
|
||||
afterAll(async () => {
|
||||
await context?.close();
|
||||
});
|
||||
|
||||
it('replays once when the captured pane is taller, and stops at one retry', async () => {
|
||||
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
page = await context.newPage();
|
||||
const sessionId = await openSession(page);
|
||||
expect(sessionId).toBeTruthy();
|
||||
|
||||
// 200 rows is taller than any terminal this viewport can produce, so the
|
||||
// trigger is the captured height alone and not a size that moved.
|
||||
const fetches = { n: 0, urls: [] as string[] };
|
||||
await stubTerminal(page, 200, fetches);
|
||||
// A tab switch is where a visible-frame response arrives, so that is what
|
||||
// this measures. The first select of the page takes the full-history path
|
||||
// and is covered by its own case below.
|
||||
await consumeFullHistory(page, sessionId, fetches);
|
||||
await select(page, sessionId, { forceReload: true });
|
||||
|
||||
// The terminal is sized by that select, so the premise is checkable now.
|
||||
expect(await terminalRows(page)).toBeLessThan(200);
|
||||
// One original load plus exactly one retry. `resizeRetry` caps it there:
|
||||
// the retry's own response reports the same mismatch, so an uncapped
|
||||
// implementation would loop.
|
||||
expect(fetches.n).toBe(2);
|
||||
|
||||
await closeSession(page, sessionId);
|
||||
await context.close();
|
||||
}, 60_000);
|
||||
|
||||
it('retries at the same scope the first pass used, not a wider one', async () => {
|
||||
// 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 must take
|
||||
// the tail too; clearing the flag unconditionally would upgrade it into a
|
||||
// fresh multi-megabyte scrollback capture the user never asked for.
|
||||
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);
|
||||
|
||||
// First select: a fresh session, so this one pulls full history. It does
|
||||
// NOT retry, because the geometry comparison runs on a visible-frame
|
||||
// response and a `full=1` request cannot produce one.
|
||||
await select(page, sessionId);
|
||||
expect(fetches.n).toBe(1);
|
||||
expect(fetches.urls.filter((u) => u.includes('full=1'))).toHaveLength(1);
|
||||
|
||||
// Re-select the SAME session. `selectSession` early-returns on an already
|
||||
// active session unless forceReload is set, and forceReload is the shape a
|
||||
// tab switch back to this session takes: `_fullHistoryLoaded` still holds
|
||||
// it, so neither this pass nor its retry asks for full history again.
|
||||
await select(page, sessionId, { forceReload: true });
|
||||
const tabSwitchUrls = fetches.urls.slice(1);
|
||||
expect(tabSwitchUrls.length).toBe(2);
|
||||
expect(tabSwitchUrls.filter((u) => u.includes('full=1'))).toHaveLength(0);
|
||||
|
||||
await closeSession(page, sessionId);
|
||||
await context.close();
|
||||
}, 60_000);
|
||||
|
||||
it('does not replay when the captured pane fits the terminal', async () => {
|
||||
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
page = await context.newPage();
|
||||
const sessionId = await openSession(page);
|
||||
|
||||
// Five rows is shorter than any terminal this viewport can produce, so the
|
||||
// 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, { captureCols: 40 });
|
||||
await consumeFullHistory(page, sessionId, fetches);
|
||||
await select(page, sessionId, { forceReload: true });
|
||||
|
||||
expect(await terminalRows(page)).toBeGreaterThan(5);
|
||||
expect(fetches.n).toBe(1);
|
||||
|
||||
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 consumeFullHistory(page, sessionId, fetches);
|
||||
await select(page, sessionId, { forceReload: true });
|
||||
|
||||
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, { 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);
|
||||
|
||||
it('replays once per session, not once per tab switch, when it cannot converge', async () => {
|
||||
// `resizeRetry` caps the recursion inside ONE select and says nothing about
|
||||
// the next one, so a pane this browser cannot size reported the same
|
||||
// mismatch on every select and bought the same failed repair every time:
|
||||
// two fetches per tab switch for the life of the page. That is the case the
|
||||
// description calls "every time rather than occasionally", a phone whose
|
||||
// resize is declined while a desktop claim is live, and it is not the only
|
||||
// one — any pane Codeman cannot size lands there, a second tmux client
|
||||
// attached to it included. Each wasted pass costs another `capture-pane`,
|
||||
// which is `execSync` on the server's event loop, plus a reset and rewrite,
|
||||
// a discarded snapshot, and a dropped and reopened WebSocket.
|
||||
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[] };
|
||||
const pane = { captureRows: 200, captureCols: 200 };
|
||||
await stubTerminalDynamic(page, fetches, pane);
|
||||
await consumeFullHistory(page, sessionId, fetches);
|
||||
|
||||
// First tab switch: one load, one replay, and the replay does not fit
|
||||
// either, which is the proof that this pane ignores the size it is given.
|
||||
await select(page, sessionId, { forceReload: true });
|
||||
expect(fetches.n).toBe(2);
|
||||
|
||||
// Every switch after it pays once. Unlatched this reads 4 then 6.
|
||||
await select(page, sessionId, { forceReload: true });
|
||||
expect(fetches.n).toBe(3);
|
||||
await select(page, sessionId, { forceReload: true });
|
||||
expect(fetches.n).toBe(4);
|
||||
|
||||
// The memo has to lift when the pane becomes sizeable again, or closing the
|
||||
// desktop tab that was holding it would leave this session permanently
|
||||
// unrepaired. A frame that fits clears it...
|
||||
pane.captureRows = 5;
|
||||
pane.captureCols = 40;
|
||||
await select(page, sessionId, { forceReload: true });
|
||||
expect(fetches.n).toBe(5);
|
||||
|
||||
// ...so the next genuine mismatch is diagnosed again.
|
||||
pane.captureRows = 200;
|
||||
pane.captureCols = 200;
|
||||
await select(page, sessionId, { forceReload: true });
|
||||
expect(fetches.n).toBe(7);
|
||||
|
||||
await closeSession(page, sessionId);
|
||||
await context.close();
|
||||
}, 60_000);
|
||||
|
||||
it('hands over text typed but not yet submitted before it replays', async () => {
|
||||
// On a touch device the characters the user has typed live ONLY in the
|
||||
// local-echo overlay until Enter; they have never reached the PTY. The
|
||||
// replay re-enters `selectSession` with `forceReload` on the session that
|
||||
// is still active, and that branch used to null `activeSessionId` before
|
||||
// `_cleanupPreviousSession` ran, so the flush there saw no session and the
|
||||
// unconditional `clear()` afterwards took the characters with it. Nothing
|
||||
// the user did triggered that: the replay fires on its own the moment a
|
||||
// tab switch finishes, which is exactly when someone typing into a
|
||||
// still-loading terminal has text in the overlay.
|
||||
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);
|
||||
await consumeFullHistory(page, sessionId, fetches);
|
||||
|
||||
// Headless chromium reports `isTouchDevice()` false even with `hasTouch`,
|
||||
// so the overlay would stay off and the whole case would pass vacuously.
|
||||
// The setting is what `_updateLocalEchoState()` reads, so it survives the
|
||||
// recompute that every select runs; the flag is forced too, for the window
|
||||
// before the next recompute. Record what crosses into the delivery layer,
|
||||
// which is the seam the text failed to cross.
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
app: {
|
||||
_localEchoEnabled: boolean;
|
||||
_sendInputAsync: (id: string, text: string, opts?: unknown) => void;
|
||||
terminal?: { focus: () => void };
|
||||
loadAppSettingsFromStorage: () => Record<string, unknown>;
|
||||
};
|
||||
__sentInputs: { id: string; text: string }[];
|
||||
};
|
||||
const settings = w.app.loadAppSettingsFromStorage();
|
||||
settings.localEchoEnabled = true;
|
||||
localStorage.setItem('codeman-app-settings', JSON.stringify(settings));
|
||||
w.app._localEchoEnabled = true;
|
||||
w.__sentInputs = [];
|
||||
const original = w.app._sendInputAsync.bind(w.app);
|
||||
w.app._sendInputAsync = (id: string, text: string, opts?: unknown) => {
|
||||
w.__sentInputs.push({ id, text });
|
||||
return original(id, text, opts);
|
||||
};
|
||||
w.app.terminal?.focus();
|
||||
});
|
||||
|
||||
await page.keyboard.type('hello-unsent');
|
||||
// The premise: the characters really are sitting in the overlay, unsent.
|
||||
// Without this the case would pass on a build where typing goes straight
|
||||
// to the PTY and there is nothing to lose.
|
||||
const pendingBefore = await page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { app: { _localEchoOverlay?: { pendingText: string } } }).app._localEchoOverlay
|
||||
?.pendingText ?? ''
|
||||
);
|
||||
expect(pendingBefore).toBe('hello-unsent');
|
||||
|
||||
// The captured pane is taller than the terminal, so this select replays.
|
||||
await select(page, sessionId, { forceReload: true });
|
||||
expect(fetches.n).toBe(2);
|
||||
|
||||
const sent = await page.evaluate(
|
||||
() => (window as unknown as { __sentInputs: { id: string; text: string }[] }).__sentInputs
|
||||
);
|
||||
expect(sent.map((s) => s.text)).toContain('hello-unsent');
|
||||
expect(sent.find((s) => s.text === 'hello-unsent')?.id).toBe(sessionId);
|
||||
|
||||
await closeSession(page, sessionId);
|
||||
await context.close();
|
||||
}, 60_000);
|
||||
|
||||
it('does not replay a pane already at the size the client asked for', async () => {
|
||||
// `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` does
|
||||
// not, so a viewport this small leaves the terminal shorter than the size
|
||||
// the client itself requests, and the pane obligingly draws at the floored
|
||||
// size. The captured height then exceeds the terminal's forever. A replay
|
||||
// cannot converge, because it re-requests the same floored size and
|
||||
// captures the same frame, so without the equality guard this retries on
|
||||
// every tab switch for the life of the page.
|
||||
context = await browser.newContext({ viewport: { width: 320, height: 200 } });
|
||||
page = await context.newPage();
|
||||
const sessionId = await openSession(page);
|
||||
|
||||
const fetches = { n: 0, urls: [] as string[] };
|
||||
await stubTerminalAtRequestedSize(page, fetches);
|
||||
await consumeFullHistory(page, sessionId, fetches);
|
||||
await select(page, sessionId, { forceReload: true });
|
||||
|
||||
// The premise: the floor really does bind here. Without this the case
|
||||
// would pass on any viewport, proving nothing.
|
||||
const requested = await page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as unknown as { app: { getTerminalDimensions?: () => { cols: number; rows: number } | null } }
|
||||
).app.getTerminalDimensions?.() ?? null
|
||||
);
|
||||
expect(requested).not.toBeNull();
|
||||
expect(requested!.rows).toBeGreaterThan(await terminalRows(page));
|
||||
|
||||
expect(fetches.n).toBe(1);
|
||||
|
||||
await closeSession(page, sessionId);
|
||||
await context.close();
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -775,7 +775,85 @@ 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('omits the geometry when the capture reports none', async () => {
|
||||
// The cursor query can fail, and a byte-history response never captures
|
||||
// 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);
|
||||
|
||||
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).toBeUndefined();
|
||||
expect(body.data.captureRows).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits the geometry when the capture reported a size but returned nothing', async () => {
|
||||
// A capture can report geometry and still hand back no frame. The
|
||||
// full-history path writes `capturedGeometry` from the cursor query, then
|
||||
// returns '' for a pane holding nothing visible, which drops the source
|
||||
// to `history` with the geometry already recorded. Reporting it there
|
||||
// would name a size for a body that is the byte stream.
|
||||
harness.ctx._session.terminalBuffer = 'byte history only';
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
|
||||
(_name: string, opts?: { capturedGeometry?: { cols: number; rows: number } }) => {
|
||||
if (opts) opts.capturedGeometry = { cols: 100, rows: 50 };
|
||||
return '';
|
||||
}
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`,
|
||||
});
|
||||
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.source).toBe('history');
|
||||
expect(body.data.captureCols).toBeUndefined();
|
||||
expect(body.data.captureRows).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── COD-47: full tmux scrollback replay on full page reload ──
|
||||
@@ -985,7 +1063,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 +1126,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 +1203,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 +1225,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 +1256,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 () => {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatCursorRestore, hasVisibleContent } from '../src/tmux-manager.js';
|
||||
import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent } from '../src/tmux-manager.js';
|
||||
|
||||
describe('tmux full-history pane capture (COD-47)', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
||||
@@ -121,3 +121,53 @@ describe('hasVisibleContent', () => {
|
||||
expect(hasVisibleContent('\x1b[m \x1b[0m\n\x1b[m x \x1b[0m')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the geometry a capture reports back', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
||||
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
|
||||
const methodEnd = source.indexOf('captureActivePaneBuffer(muxName: string', methodStart);
|
||||
const methodBody = source.slice(methodStart, methodEnd);
|
||||
|
||||
it('writes the pane size onto the caller options before either replay path returns', () => {
|
||||
// IS_TEST_MODE no-ops execSync, so assert from source (same approach as the
|
||||
// capture-flag tests above). The write must precede the fullHistory branch:
|
||||
// both paths return from inside it, and a caller that got no geometry
|
||||
// cannot tell a mismatched frame from a matching one.
|
||||
const write = methodBody.indexOf('opts.capturedGeometry = { cols: geometry.cols, rows: geometry.rows }');
|
||||
// Anchor on the REPLAY branch, not the earlier `if (fullHistory)` that only
|
||||
// sizes the exec buffer.
|
||||
const replayBranch = methodBody.indexOf('if (!geometry) return normalizeScrollbackEol(');
|
||||
const visibleReturn = methodBody.indexOf('if (geometry) return formatPaneSnapshot(');
|
||||
expect(write).toBeGreaterThan(-1);
|
||||
expect(replayBranch).toBeGreaterThan(-1);
|
||||
expect(visibleReturn).toBeGreaterThan(-1);
|
||||
expect(write).toBeLessThan(replayBranch);
|
||||
expect(write).toBeLessThan(visibleReturn);
|
||||
});
|
||||
|
||||
it('reports nothing when the cursor query gave no geometry', () => {
|
||||
// `queryPaneCursor` returns null on a failed or nonsensical query, and the
|
||||
// snapshot repaint is skipped in that case. Reporting a size anyway would
|
||||
// describe a frame that was never positioned.
|
||||
expect(methodBody).toContain('if (opts && geometry)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('why a capture has to report its height', () => {
|
||||
it('a snapshot addresses rows the receiving terminal may not have', () => {
|
||||
// formatPaneSnapshot positions every row absolutely. A terminal shorter
|
||||
// than the pane clamps each address past its own height onto its last
|
||||
// line, so the overflow rows overwrite one another and the rows underneath
|
||||
// are lost. Nothing in the escape sequence tells the client this happened —
|
||||
// hence captureRows on the response.
|
||||
const lines = Array.from({ length: 50 }, (_, i) => `row-${i + 1}`);
|
||||
// cursorX 5 keeps the trailing cursor-restore move (`\x1b[50;6H`) out of the
|
||||
// `;1H` row-paint match below, so the count is row paints alone.
|
||||
const snapshot = formatPaneSnapshot(lines, { cols: 100, rows: 50, cursorX: 5, cursorY: 49 });
|
||||
const addressed = [...snapshot.matchAll(/\x1b\[(\d+);1H/g)].map((m) => Number(m[1]));
|
||||
|
||||
expect(Math.max(...addressed)).toBe(50);
|
||||
// A 30-row terminal cannot honour 20 of those addresses.
|
||||
expect(addressed.filter((row) => row > 30)).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user