Merge the final checkup's tile fixes into the 1.40.0 release

From the read-only final review of the release, adversarially verified:
- a server clear frame refreshes a tile instead of blanking it, so a Claude session Run into the grid keeps its banner and transcript
- a tile refresh fetches first and resets in-stream, so the screen never goes blank while it waits
- Redraw on a tile sends the forced resize and reports only what it sent
- each tile caps its live-output backlog (4 MiB) and recovers dropped output with one bounded refresh
- the tile grid plan is marked merged

Gate green on the branch: static checks, 41 tile and split unit files (836 tests) and 7 browser files (36 tests).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 09:52:42 +02:00
10 changed files with 1081 additions and 164 deletions
File diff suppressed because one or more lines are too long
+9 -5
View File
@@ -1,8 +1,8 @@
# Tile Grid: Design Spec
**Status**: PR 1 (tile foundation) implemented on `feat/terminal-tile`; PR 2 (the grid) implemented on `feat/tile-grid`, both local only. Builds on `docs/split-pane-sessions-plan.md`; the split pane stays.
**Status**: Merged for the 1.40.0 release as #560 (the TerminalTile foundation) and #561 (the grid). Where the "As built" section below differs from this spec, As built is authoritative. Builds on `docs/split-pane-sessions-plan.md`; the split pane stays.
**Author**: Claude (planning session with the maintainer), 2026-10-06
**Branches**: PR 1 `feat/terminal-tile`, PR 2 `feat/tile-grid` stacked on it (worktrees `claudeman-tiles`, `claudeman-tilegrid`)
**Branches**: developed as PR 1 `feat/terminal-tile` and PR 2 `feat/tile-grid` stacked on it, both merged
**Scope**: v1 is fully designed here; follow-ups are named at the end and explicitly deferred.
## As built: where PR 2 differs from this spec
@@ -30,8 +30,11 @@ or settled a question the spec left open. The invariants as built are in
- **Zoom follows tmux.** Moving focus to another tile restores the grid; an automatic zoom
(window too small for the minimum tile) follows focus instead.
- **Tile loads are bounded** (`boundedLoad`), carry a fetch deadline covering the body (Pane
B too), and a refresh clears the screen at its turn in the queue, so a waiting tile keeps
its last frame.
B too), and a refresh fetches at its turn in the queue: the tile keeps its last frame
through its wait and its own round trip, and is reset with the queued in-stream `\x1bc`
only once the capture is in hand, right before the replay (never xterm's `clear()` before
the fetch). A failed, aborted or empty fetch writes nothing and resets nothing: the tile
keeps its last frame and every held live frame.
- **4009 lands on the Attach overlay**, and 4003/4004/4010 remove the tile.
- **Tile header buttons are 26px targets with 16 to 19px glyphs** (owner feedback: the
first build's 12px glyphs read as tiny next to the name), the size of the app header's own
@@ -408,7 +411,8 @@ against the live list without rebuilding tiles that are still alive.
### Gating
- Setting `showTileGridButton`, per device (in `displayKeys`, stripped from the
settings PUT, NOT in `SettingsUpdateSchema`), default OFF. Independent of
settings PUT, NOT in `SettingsUpdateSchema`), default ON on desktop and OFF on
handhelds (specified OFF; superseded, see "As built"). Independent of
`showSplitButton`, which is unchanged; a desk can show both buttons.
- Hidden below 1180 px by both a JS width check with a `matchMedia` listener and
a CSS `@media (max-width: 1179px)` backstop, exactly like the split button.
+294 -74
View File
@@ -49,7 +49,8 @@
*/
(function (global) {
// How long a scroll-to-top history pull may hold this pane's live output.
// How long a load may hold this pane's live output while it reads a bounded
// body: a scroll-to-top history pull, or a refresh of a bounded window.
const HISTORY_PULL_TIMEOUT_MS = 10000;
// How much of a replay is queued in xterm at once: a 1 MiB load goes in one
@@ -158,7 +159,19 @@
this._historyPullAt = 0;
this._historyPullUseless = false;
this._liveQueue = null;
this._liveQueueBytes = 0;
this._markerOwed = false;
// Live-output flow control (_writeLive, TerminalTile.LIVE_BACKLOG_BUDGET):
// code units written into this xterm and not yet parsed (each write's
// callback counts its own back down, unless a reset bumped `_liveEpoch`
// since), whether output was dropped and not yet recovered, when the last
// frame was dropped, and the debounced, bounded recovery refresh.
this._liveInFlight = 0;
this._liveEpoch = 0;
this._liveDropped = false;
this._liveDropAt = 0;
this._dropRecoveryTimer = null;
this._dropRecoveryAttempt = 0;
this._onWheel = null;
// `{ ws, lastRecvAt }`, registered with the app's input-socket map while
// this pane's socket is open, so the exactly-once input queue delivers this
@@ -506,7 +519,12 @@
this._lastSentDims = null;
this._registerInputSocket();
this._sendResize();
if (reconnected) this._refreshBuffer();
if (reconnected) {
// The gap already cost output, and the refresh below replaces the
// screen, so live-output accounting starts over with it.
this._resetLiveFlow();
this._refreshBuffer();
}
}
// Opens a replacement socket now instead of waiting out the backoff (for an
@@ -666,10 +684,11 @@
// a closed socket. Called from each load's own finally, just before
// _endBufferLoad() starts any trailing refresh.
_stampMarkerIfOwed() {
// A trailing refresh is about to clear() synchronously, while xterm parses
// a write() on a later tick: a marker written here would land in the
// freshly cleared buffer ABOVE that refresh's replay, a second, stale copy.
// The refresh re-owes the marker on a closed socket and stamps it itself.
// A trailing refresh is about to run, and it settles the marker itself:
// a replay's queued `\x1bc` would wipe one written here (it re-owes the
// marker on a closed socket and stamps it below the replay), and a refresh
// that writes nothing stamps the one still owed. Stamped here as well,
// there would be two marker writes for one close.
if (this._bufferRefreshPending && !this._destroyed) return;
const owed = this._markerOwed;
this._markerOwed = false;
@@ -683,11 +702,12 @@
}
// Fetches and writes the session's current scrollback. Used both by
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
// (above). The primary pane's own _onSessionNeedsRefresh (app.js) is
// scoped to `this.activeSessionId` and clears/rewrites the primary
// terminal, neither of which applies to this independent pane, so this is
// a standalone equivalent rather than a call into it.
// connect() (initial load) and by the refresh frames (`{t:'r'}`, `{t:'c'}`)
// and a reconnect (_refreshBuffer). The primary pane's own
// _onSessionNeedsRefresh (app.js) is scoped to `this.activeSessionId` and
// rewrites the primary terminal, neither of which applies to this
// independent pane, so this is a standalone equivalent rather than a call
// into it, in the primary's order (below).
//
// Mirrors the primary pane's own mode check (app.js's selectSession /
// _onSessionNeedsRefresh): a shell session can retain hundreds of
@@ -699,6 +719,21 @@
// wrapper (constants.js), which already prefixes CodemanBase, unlike the
// raw WebSocket URL above, which does not.
//
// A refresh replaces what the pane shows, in the primary pane's order
// (_onSessionNeedsRefresh, _resetTerminalForReplay): fetch FIRST, so the
// pane keeps its last frame through the round trip (and through a grid
// tile's wait in the load queue); then the queued in-stream `\x1bc`, never
// xterm's clear(): clear() is synchronous while write() is parsed on a later
// tick, so live bytes still queued would land after it and fuse into the
// snapshot, and it keeps the cursor's row, column, SGR and margins, so the
// capture (raw rows, no home) started wherever the cursor sat. Live frames
// from the response onward are held (`_liveQueue`, the primary's
// _finishBufferLoad `since` rule, as _pullHistory() holds them) and only
// those that arrived after it are written behind the replay. A failed,
// aborted or empty fetch writes nothing and resets nothing: the pane keeps
// its last frame and every held frame. The initial load needs none of
// this: it runs before the pane has a socket, onto a fresh xterm.
//
// Single-flight: the flag is held across the fetch AND the chunked write
// (writeChunked resolves after its last chunk), so two replays can never
// interleave their chunks into one terminal. A second call while one is
@@ -709,42 +744,60 @@
this._bufferLoading = true;
await this._runLoad(refresh ? 'refresh' : 'initial', async () => {
this._loadRunning = true;
let replayed = false;
let capturedAt = 0;
// A deadline covering the body as well as the headers (the primary
// pane's budgets, CodemanFetchDeadline): a capture that never answers
// would otherwise hold this pane's single-flight flag, and in the grid
// the one load queue every tile waits behind, forever. Re-armed once a
// refresh's headers land (below), so one signal carries both budgets.
const controller = global.AbortController ? new global.AbortController() : null;
let abortTimer = null;
const armDeadline = (ms) => {
if (!controller) return;
clearTimeout(abortTimer);
abortTimer = setTimeout(() => controller.abort(), ms);
};
try {
if (this._destroyed) return;
if (refresh) {
// Cleared at the load's turn, not when it was asked for: a grid tile
// waiting in the queue keeps its last frame instead of sitting blank.
this.terminal?.clear();
this._overflowRows = 0;
// The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue
// a trailing refresh behind a pull that the socket's close then
// interrupts), so a refresh on a closed socket owes it back once its
// replay is written.
if (this._wsClosed) this._markerOwed = true;
}
const shell = this.sessionMode === 'shell';
let query = shell ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
if (this.boundedLoad && !shell) query = `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`;
// A deadline covering the body as well as the headers (the primary
// pane's budgets, CodemanFetchDeadline): a capture that never answers
// would otherwise hold this pane's single-flight flag, and in the grid
// the one load queue every tile waits behind, forever.
const controller = global.AbortController ? new global.AbortController() : null;
this._loadAbort = controller;
const budget = global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000;
const timer = controller ? setTimeout(() => controller.abort(), budget) : null;
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000);
let payload;
try {
const res = await fetch(
`/api/sessions/${this.sessionId}/terminal?${query}`,
controller ? { signal: controller.signal } : undefined
);
if (refresh) {
// The response's arrival stands in for the instant tmux took the
// capture (see _pullHistory()). Frames from here on are news the
// capture cannot hold, so they wait for the replay. From now on
// live output IS held, so a bounded window's body (at most
// TERMINAL_TAIL_SIZE) gets the pull's short budget; an unbounded
// capture (the split's Pane B, up to 32 MB) keeps the request's.
capturedAt = performance.now();
this._openLiveQueue();
if (shell || this.boundedLoad) armDeadline(HISTORY_PULL_TIMEOUT_MS);
}
payload = (await res.json())?.data ?? {};
} finally {
clearTimeout(timer);
clearTimeout(abortTimer);
this._loadAbort = null;
}
if (payload.terminalBuffer && this.terminal) {
if (payload.terminalBuffer && this.terminal && !this._destroyed) {
if (refresh) {
this.terminal.write('\x1bc');
this._overflowRows = 0; // the reset leaves nothing above the screen
replayed = true;
// The reset wipes a "disconnected" marker (a `{t:'r'}` frame can
// queue a trailing refresh behind a pull that the socket's close
// then interrupts), so a refresh on a closed socket owes it back
// once its replay is written.
if (this._wsClosed) this._markerOwed = true;
}
await writeChunked(
this.terminal,
payload.terminalBuffer,
@@ -756,7 +809,15 @@
} catch {
/* Best-effort: live output still arrives once the socket connects. */
} finally {
clearTimeout(abortTimer);
this._loadAbort = null;
this._loadRunning = false;
// Before the flush: a refresh that recovered dropped output lets its
// held frames through.
if (refresh) this._settleDropRecovery({ replayed, capturedAt, timedOut: !!controller?.signal?.aborted });
// Held frames before the marker, so the marker stays the last thing on
// screen (see _pullHistory()).
this._flushLiveQueue(replayed ? capturedAt : 0);
this._stampMarkerIfOwed();
this._endBufferLoad();
}
@@ -800,29 +861,168 @@
}
}
// Live terminal output. Written straight through, except while a history
// pull is replaying: a capture is current only up to the instant tmux took
// it, so a frame arriving mid-replay is held with its arrival time and
// replayed behind the snapshot by _pullHistory() (the primary pane's
// _finishBufferLoad `since` rule), never written underneath it.
// Live terminal output. Written straight through, except while a refresh or
// a history pull is replaying: a capture is current only up to the instant
// tmux took it, so a frame arriving mid-replay is held with its arrival time
// and written behind the snapshot by that load's _flushLiveQueue() (the
// primary pane's _finishBufferLoad `since` rule), never underneath it.
// Held frames count against the same budget as unparsed ones: a pull or a
// refresh holds output for up to its body budget, and a flood meanwhile
// must not grow the queue without bound either.
_onLiveOutput(data) {
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data });
else this.terminal?.write(data);
if (!data) return;
if (this._liveQueue) {
if (this._liveQueueBytes + data.length > TerminalTile.LIVE_BACKLOG_BUDGET) {
this._noteLiveDrop();
return;
}
this._liveQueueBytes += data.length;
this._liveQueue.push({ at: performance.now(), data });
return;
}
this._writeLive(data);
}
// The server's `{t:'c'}` clear frame takes the same route as output, for the
// same reason: clearing straight away, mid-replay, would wipe the half-written
// snapshot and leave _pullHistory() measuring a buffer that is no longer the
// one it is restoring. Queued, it lands in order with the frames around it.
_openLiveQueue() {
this._liveQueue = [];
this._liveQueueBytes = 0;
}
// Releases the frames a load held (_liveQueue) and closes the queue. After a
// replay only those that arrived after the capture are news (`cutoff`, the
// response's arrival; earlier ones are already in it); with no replay
// (`cutoff` 0) every one is. Through _writeLive(), so they are counted (and
// a write that throws cannot skip the load's marker and trailing refresh).
_flushLiveQueue(cutoff) {
const queued = this._liveQueue ?? [];
this._liveQueue = null;
this._liveQueueBytes = 0;
for (const entry of queued) {
if (entry.at < cutoff) continue;
this._writeLive(entry.data);
}
}
// Writes one live frame into this xterm, under flow control. The server
// applies no backpressure (16 KB / 8 ms batches, never a bufferedAmount
// check), so a flood a tile cannot parse as fast as it arrives (a shell
// tile running `cat` on a huge log, `yes`) used to pile up in xterm's own
// write queue without bound, on a main thread six tiles share, until
// xterm's WriteBuffer throws past 50M code units and the frames were
// silently lost in onmessage's catch. The primary pane caps its queues
// and drops then recaptures (_onSessionTerminal, app.js); this is the
// tile's equivalent. Past TerminalTile.LIVE_BACKLOG_BUDGET unparsed, a
// frame is dropped and the tile stops writing until a refresh recaptures
// the screen (_scheduleDropRecovery): every byte after a hole is written
// onto a screen out of step with the PTY, which that refresh replaces
// anyway. A write that throws is the same drop, never a malformed frame.
_writeLive(data) {
const terminal = this.terminal;
if (!terminal || this._destroyed || !data) return;
if (this._liveDropped) {
this._noteLiveDrop();
return;
}
const n = data.length;
if (this._liveInFlight + n > TerminalTile.LIVE_BACKLOG_BUDGET) {
this._noteLiveDrop();
return;
}
const epoch = this._liveEpoch;
this._liveInFlight += n;
try {
terminal.write(data, () => {
if (epoch === this._liveEpoch) this._liveInFlight -= n;
});
} catch {
if (epoch === this._liveEpoch) this._liveInFlight -= n;
this._noteLiveDrop();
}
}
// A live frame was dropped. Marks the tile out of step and arms ONE
// recovery; later drops only move the stamp the recovery has to beat.
_noteLiveDrop() {
this._liveDropAt = performance.now();
if (this._liveDropped) return;
this._liveDropped = true;
this._scheduleDropRecovery();
}
// The primary pane's dropped-output recovery (_scheduleDroppedOutputRecovery,
// app.js), aimed at this tile: debounced by DROP_RECOVERY_DELAY_MS so a
// sustained flood collapses into one attempt, and run as an ordinary
// refresh, which is single-flight, bounded (`lines=`/`tail=`) and waits its
// turn in the grid's load queue. _settleDropRecovery() decides what the
// refresh it starts achieved.
_scheduleDropRecovery() {
if (this._dropRecoveryTimer || this._destroyed) return;
const delay = global.CodemanDroppedOutput?.DROP_RECOVERY_DELAY_MS ?? 2000;
this._dropRecoveryTimer = setTimeout(() => {
this._dropRecoveryTimer = null;
if (this._destroyed || !this._liveDropped) return;
this._dropRecoveryAttempt++;
this._refreshBuffer();
}, delay);
}
// A refresh finished while output was marked dropped. Recovered when its
// replay's capture was taken after the last dropped frame (the response's
// arrival, the cutoff every load uses): output flows again. Otherwise one
// more attempt, bounded by the primary pane's rule
// (shouldRetryDroppedOutputRecovery: DROP_RECOVERY_MAX_ATTEMPTS, and never
// after a capture cut off at its deadline, a stalled link); past that the
// flag is released so the tile is never left frozen, and it writes on, out
// of step, as every tile did before this existed.
_settleDropRecovery({ replayed, capturedAt, timedOut }) {
if (!this._liveDropped || this._destroyed) return;
if (replayed && capturedAt >= this._liveDropAt) {
this._liveDropped = false;
this._dropRecoveryAttempt = 0;
return;
}
// Another try is already on its way: the debounce, or a trailing refresh.
if (this._dropRecoveryTimer || this._bufferRefreshPending) return;
const retry =
global.CodemanDroppedOutput?.shouldRetryDroppedOutputRecovery?.({
repainted: false,
timedOut,
attempt: Math.max(0, this._dropRecoveryAttempt - 1),
stillActive: true,
}) === true;
if (retry) {
this._scheduleDropRecovery();
return;
}
this._liveDropped = false;
this._dropRecoveryAttempt = 0;
}
// Starts live-output accounting over (a reconnect, destroy): write callbacks
// still pending from before carry the old epoch and count nothing.
_resetLiveFlow() {
this._liveEpoch++;
this._liveInFlight = 0;
this._liveDropped = false;
this._dropRecoveryAttempt = 0;
clearTimeout(this._dropRecoveryTimer);
this._dropRecoveryTimer = null;
}
// The server's `{t:'c'}` frame, which is a refresh, not a wipe. Its one
// emitter (Session.startInteractive, session.ts) sends it once a fresh Claude
// pane first shows its prompt: the server has just trimmed its own buffer and
// means "refresh after startup". The primary pane refetches the capture and
// replays it (_onSessionClearTerminal, app.js), and while the grid is open
// that handler stands aside for the tiles. A bare xterm clear() here kept
// only the cursor's row and dropped the banner and every row above it, and an
// idle Claude never repaints static rows, so a Claude session Run into the
// grid (or Attached in a tile) sat there as a near-empty tile. So it takes
// the `{t:'r'}` route: single-flight, coalesced into one trailing refresh
// behind a load already running (a pull's held frames included), and paced
// by the grid's load queue.
_onLiveClear() {
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true });
else this._clearTerminal();
}
// A clear leaves no rows above the screen, the pane's own overflow included.
_clearTerminal() {
this.terminal?.clear();
this._overflowRows = 0;
this._refreshBuffer();
}
// Capture phase, because xterm's own wheel handler stopPropagation()s every
@@ -931,8 +1131,8 @@
}
// History rows in this xterm, for the paging gate: baseY less the rows this
// pane pushed up itself. Clamped, because a clear (Ctrl+L, a `{t:'c'}`
// frame) or an ED3/RIS in the stream drops rows behind this count's back.
// pane pushed up itself. Clamped, because a clear (Ctrl+L) or an ED3/RIS in
// the stream drops rows behind this count's back.
_localRows() {
const baseY = this.terminal?.buffer?.active?.baseY || 0;
this._overflowRows = Math.min(this._overflowRows, baseY);
@@ -1044,7 +1244,7 @@
// Opened only now: a frame from before the response is either replaced by
// the capture or written unchanged, so holding it for the round trip
// would buy nothing and freeze the pane for as long as the fetch took.
this._liveQueue = [];
this._openLiveQueue();
const payload = (await res.json())?.data;
clearTimeout(abortTimer);
const buffer = payload?.terminalBuffer;
@@ -1096,16 +1296,9 @@
clearTimeout(abortTimer);
this._loadAbort = null;
this._loadRunning = false;
const queued = this._liveQueue ?? [];
this._liveQueue = null;
// After a replay, only frames that arrived after the capture are news;
// earlier ones are already in it. With no replay, every held frame is.
const cutoff = replayed ? capturedAt : 0;
for (const entry of queued) {
if (entry.at < cutoff) continue;
if (entry.clear) this._clearTerminal();
else this.terminal?.write(entry.data);
}
this._flushLiveQueue(replayed ? capturedAt : 0);
// Settled after the queue flush so the marker is the last thing on
// screen: a close during the pull wrote nothing (_onSocketClosed() defers
// it while a load runs), and a replay's own `\x1bc` (flagged above) wipes
@@ -1128,12 +1321,13 @@
return `&lines=${this.scrollback + (this.terminal?.rows || 0)}`;
}
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
// frames in a row must not start two concurrent replays, each clearing
// the terminal under the other's chunked write. A refresh that arrives
// mid-replay is COALESCED into one trailing re-run rather than ignored:
// the in-flight fetch may predate the drop the new frame is reporting,
// and no further frame is coming to correct stale content.
// The refresh path (`{t:'r'}`, `{t:'c'}`, a reconnect): fetch, then reset
// in-stream and replay (_loadBuffer). Two refresh frames in a row must not
// start two concurrent replays, each resetting the terminal under the
// other's chunked write. A refresh that arrives mid-replay is COALESCED
// into one trailing re-run rather than ignored: the in-flight fetch may
// predate the drop the new frame is reporting, and no further frame is
// coming to correct stale content.
_refreshBuffer() {
if (this._bufferLoading) {
this._bufferRefreshPending = true;
@@ -1156,23 +1350,26 @@
// Reflow to the container and tell the PTY, as one step: the xterm and the
// PTY must never disagree about size (#464), and a font change is a size
// change too, so the font setters call this rather than localFit().
// `force` resends an unchanged size.
// `force` resends an unchanged size and asks the server to apply it anyway
// (Redraw, restoreTerminalSize). Returns whether a resize went out.
fit({ force = false } = {}) {
this.localFit();
this._sendResize({ force });
return this._sendResize({ force });
}
// Returns true only once a `{t:'z'}` frame was sent, false at every early
// exit, so Redraw can say when nothing reached the PTY.
_sendResize({ force = false } = {}) {
if (!this._wsReady || !this.fitAddon || !this.terminal) return;
if (!this._wsReady || !this.fitAddon || !this.terminal) return false;
// One PTY cannot hold two sizes (mirrors sendResize's own
// detachedElsewhere yield in terminal-ui.js): the session got detached
// to its own window AFTER this pane was opened, so its own window now
// owns the PTY's size and this pane must stand aside.
if (this.detachedSessions?.has(this.sessionId)) return;
if (this.detachedSessions?.has(this.sessionId)) return false;
// A hidden pane (a web tab over it, a zoomed neighbour) measures NaN, and
// fit() then leaves the xterm alone: there is no size worth reporting.
const dims = this.fitAddon.proposeDimensions();
if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) return;
if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) return false;
// Report what the xterm actually holds, so the PTY gets exactly the size
// the pane renders at. Unclamped, unlike the primary pane's 40x10 floor:
// a floor would misreport the split's Pane B at its divider's reachable
@@ -1182,9 +1379,20 @@
const cols = this.terminal.cols;
const rows = this.terminal.rows;
const last = this._lastSentDims;
if (!force && last && last.cols === cols && last.rows === rows) return;
if (!force && last && last.cols === cols && last.rows === rows) return false;
// `f` is the primary pane's forced resize (sendResize, terminal-ui.js):
// Session.resize (session.ts) otherwise skips a size equal to the one it
// last applied, so without it a forced resend reached the server and did
// nothing there (no tmux resize-window, no PTY resize).
const msg = { t: 'z', c: cols, r: rows, v: 'desktop' };
if (force) msg.f = true;
try {
this.ws.send(JSON.stringify(msg));
} catch {
return false; // nothing went out, so nothing is recorded as sent
}
this._lastSentDims = { cols, rows };
this.ws.send(JSON.stringify({ t: 'z', c: cols, r: rows, v: 'desktop' }));
return true;
}
// The session's PTY is new: a tile can connect before its session has a
@@ -1244,6 +1452,9 @@
this.mountEl?.removeEventListener('click', this._onClick);
this._onClick = null;
}
// A disposed xterm never runs its write callbacks, and a pending
// recovery would refresh a pane nobody can see.
this._resetLiveFlow();
// Page keys still waiting for their flush go nowhere: the pane is gone.
clearTimeout(this._scrollFlushTimer);
this._scrollFlushTimer = null;
@@ -1269,6 +1480,15 @@
}
}
// Code units of live output a pane lets sit unparsed in its xterm (or held
// behind a replay) before it drops a frame and recaptures (_writeLive). Not
// the primary pane's 128 KB: that caps its own rAF-paced queues, about two
// frames of them, while here xterm itself is the pacer, a burst normally
// parses within a frame or two, and a tight cap would trip on ordinary
// bursts and blank-and-reload the tile over and over. A few MB keeps a flood
// far below xterm's 50M code-unit throw and bounds each tile's memory.
TerminalTile.LIVE_BACKLOG_BUDGET = 4 * 1024 * 1024;
// The marker a pane writes when its socket drops: a transient drop says it is
// reconnecting; a permanent stop says why, keyed by close code. All start
// with `[disconnected` so a reader (and a test) can tell any of them apart
+16 -3
View File
@@ -4664,11 +4664,24 @@ Object.assign(CodemanApp.prototype, {
*/
async restoreTerminalSize() {
// A second pane owns its own geometry: refit it and force its PTY to the
// size it renders at (TerminalTile.fit), whatever another device set.
// size it renders at (TerminalTile.fit sends the same forced `f` resize
// sendResize sends below), whatever another device set. fit() says whether
// the resize went out; when it did not, say why rather than report a size
// that was never sent, as the primary branch does below.
const pane = this._focusedPane();
if (!pane.isPrimary) {
pane.tile.fit({ force: true });
this.showToast(`Terminal restored to ${pane.terminal.cols}x${pane.terminal.rows}`, 'success');
const sent = pane.tile.fit({ force: true });
if (sent !== false) {
this.showToast(`Terminal restored to ${pane.terminal.cols}x${pane.terminal.rows}`, 'success');
} else if (this.detachedSessions?.has(pane.sessionId)) {
// Its own window owns the PTY's size (TerminalTile._sendResize yields).
this.showToast('This session is sized by its own window', 'warning');
} else if (!pane.tile._wsReady) {
// The tile announces its size again as soon as its socket reopens.
this.showToast('Terminal not connected: its size is sent when it reconnects', 'warning');
} else {
this.showToast('Could not determine terminal size', 'error');
}
return;
}
if (!this.activeSessionId) {
+30
View File
@@ -127,6 +127,36 @@ describe('terminal shortcuts follow the focused pane', () => {
expect(app.showToast).toHaveBeenCalledWith('Terminal restored to 60x30', 'success');
});
// TerminalTile.fit() returns whether its resize went out. When it did not,
// Redraw used to report a size that was never sent.
it.each([
[
'popped out to its own window',
{ detached: true, wsReady: true },
'This session is sized by its own window',
'warning',
],
[
'whose socket is down',
{ detached: false, wsReady: false },
'Terminal not connected: its size is sent when it reconnects',
'warning',
],
['that could not measure itself', { detached: false, wsReady: true }, 'Could not determine terminal size', 'error'],
])('Ctrl+Shift+R on a second pane %s reports no success', async (_label, state, message, level) => {
const app = loadApp();
app.detachedSessions = new Set(state.detached ? ['session-b'] : []);
const tile = paneB({ fit: vi.fn(() => false), _wsReady: state.wsReady });
app._noteFocusedTile(tile);
await app.restoreTerminalSize();
expect(tile.fit).toHaveBeenCalledWith({ force: true });
expect(app.showToast).toHaveBeenCalledTimes(1);
expect(app.showToast).toHaveBeenCalledWith(message, level);
expect(app.sendResize).not.toHaveBeenCalled();
});
it('Ctrl+Shift+R keeps restoring the primary when it holds the keyboard', async () => {
const app = loadApp();
+24 -4
View File
@@ -64,7 +64,8 @@ export class FakeTerminal {
/**
* Opt-in, set by a test BEFORE the tile connects: the buffer's rows follow
* what is written, as in xterm. Every `\n` adds a line, `baseY` is the lines
* beyond the screen, a clear leaves one line, and a resize recomputes it
* beyond the screen, a clear or an in-stream reset (RIS, `\x1bc`) leaves one
* line, and a resize recomputes it
* (a row-shrinking fit pushes rows above the screen, a growing one pulls them
* back). The viewport follows the bottom. Off, `baseY` stays where a test
* puts it.
@@ -148,17 +149,36 @@ export class FakeTerminal {
}
registerLinkProvider() {}
writes: string[] = [];
/** Set by a test: write callbacks never run, as on a disposed xterm. */
/**
* Set by a test: write callbacks do not run, as while xterm is still parsing
* (or never, on a disposed xterm). They wait in `heldParses` until `parse()`.
*/
holdParse = false;
heldParses: Array<() => void> = [];
/** xterm catches up: runs every write callback held so far, in order. */
parse() {
for (const cb of this.heldParses.splice(0)) cb();
}
/** Set by a test: the next write of exactly this data throws, as xterm's WriteBuffer does past 50M. */
throwOnWrite: string | null = null;
write(data: string, cb?: () => void) {
if (this.throwOnWrite !== null && data === this.throwOnWrite) {
this.throwOnWrite = null;
throw new Error('write data discarded, use flow control to avoid losing data');
}
// An empty write puts nothing on screen; the replay queues one only to hear
// (its callback) that everything before it has been parsed.
if (data) this.writes.push(data);
if (data && this.emulate) {
this.lineCount += data.split('\n').length - 1;
// A replay's reset (RIS) empties the buffer, as clear() does.
const reset = data.lastIndexOf('\x1bc');
if (reset !== -1) this.lineCount = 1;
this.lineCount += data.slice(reset === -1 ? 0 : reset + 2).split('\n').length - 1;
this.settleRows();
}
if (!this.holdParse) cb?.();
if (!cb) return;
if (this.holdParse) this.heldParses.push(cb);
else cb();
}
clear() {
this.writes.push('<CLEAR>');
+394 -11
View File
@@ -118,7 +118,7 @@ type Tile = {
connect(): Promise<void>;
destroy(): void;
reconnectNow(): void;
fit(opts?: { force?: boolean }): void;
fit(opts?: { force?: boolean }): boolean;
detachedSessions?: Set<string>;
ws: FakeSocket | null;
_reconnectAttempts: number;
@@ -333,12 +333,14 @@ describe('TerminalTile reconnects after a transient drop', () => {
ws2.open();
await settle();
// The refresh cleared the pane and replayed the current screen, and nothing
// after that clear is a marker: the pane is healthy again.
const lastClear = term.writes.lastIndexOf('<CLEAR>');
expect(lastClear).toBeGreaterThan(-1);
expect(term.writes.slice(lastClear)).toContain('fresh screen');
expect(term.writes.slice(lastClear).some(isMarker)).toBe(false);
// The refresh reset the pane in-stream (never xterm's clear()) and replayed
// the current screen, and nothing after that reset is a marker: the pane is
// healthy again.
expect(term.writes).not.toContain('<CLEAR>');
const lastReset = term.writes.lastIndexOf('\x1bc');
expect(lastReset).toBeGreaterThan(-1);
expect(term.writes.slice(lastReset)).toEqual(['\x1bc', 'fresh screen']);
expect(term.writes.slice(lastReset).some(isMarker)).toBe(false);
expect(tile._reconnectAttempts).toBe(0);
expect(tile.ws).toBe(ws2);
});
@@ -387,6 +389,55 @@ describe('TerminalTile reconnects after a transient drop', () => {
});
});
describe("the server's {t:'c'} frame refreshes the tile (a Claude pane's first prompt)", () => {
// Session.startInteractive (session.ts) sends it once a fresh Claude pane
// shows its prompt, meaning "refresh after startup"; the primary pane refetches
// and replays (_onSessionClearTerminal). A tile used to run a bare xterm
// clear(), which kept only the cursor's row: banner and transcript gone, and an
// idle Claude never repaints them.
it('refetches the capture and replays it, never a bare clear', async () => {
const { ws, term } = await connectTile(makeApp());
ws.open();
fetchMock.mockClear();
fetchMock.mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({ data: { terminalBuffer: 'Claude Code banner\r\n❯ ' } }),
}));
ws.receive({ t: 'o', d: 'banner painted live' });
ws.receive({ t: 'c' });
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe('/api/sessions/s-tile/terminal?full=1');
expect(term.writes.at(-1)).toBe('Claude Code banner\r\n❯ ');
});
it('two clear frames during one refresh fetch once more, not twice', async () => {
const { ws } = await connectTile(makeApp());
ws.open();
fetchMock.mockClear();
let release!: () => void;
fetchMock.mockImplementationOnce(
() =>
new Promise((resolveFetch) => {
release = () =>
resolveFetch({ ok: true, status: 200, json: async () => ({ data: { terminalBuffer: 'a' } }) });
})
);
ws.receive({ t: 'c' });
ws.receive({ t: 'c' });
ws.receive({ t: 'c' });
expect(fetchMock).toHaveBeenCalledTimes(1);
release();
await settle();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe('TerminalTile stops for good on codes that cannot get better', () => {
it.each([
[4004, 'the session ended'],
@@ -463,15 +514,80 @@ describe('TerminalTile geometry (#464: the pane and its PTY never disagree)', ()
const { tile, ws } = await connectTile(makeApp());
ws.open();
tile.fit();
expect(tile.fit()).toBe(false); // unchanged: nothing sent
expect(resizeFrames(ws)).toHaveLength(1);
FakeFit.proposed = { cols: 100, rows: 30 };
tile.fit();
expect(tile.fit()).toBe(true);
expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 100, r: 30, v: 'desktop' });
tile.fit({ force: true });
expect(tile.fit({ force: true })).toBe(true);
expect(resizeFrames(ws)).toHaveLength(3);
// The primary pane's forced resize flag: without it Session.resize skips a
// size equal to the one it last applied, and the forced resend did nothing.
expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 100, r: 30, v: 'desktop', f: true });
});
it('force on a closed socket sends nothing and says so', async () => {
const { tile, ws } = await connectTile(makeApp());
ws.open();
ws.drop(1006);
expect(tile.fit({ force: true })).toBe(false);
expect(resizeFrames(ws)).toHaveLength(1); // only the open's own announcement
});
describe('Redraw (Ctrl+Shift+R, the header button) on a focused tile', () => {
type RedrawApp = App & {
restoreTerminalSize(): Promise<void>;
_noteFocusedTile(tile: unknown): void;
detachedSessions?: Set<string>;
};
it('forces the resize through to the server and reports the size it sent', async () => {
const app = makeApp() as RedrawApp;
const { tile, ws } = await connectTile(app);
ws.open();
app._noteFocusedTile(tile);
await app.restoreTerminalSize();
expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 80, r: 24, v: 'desktop', f: true });
expect(app.showToast).toHaveBeenCalledWith('Terminal restored to 80x24', 'success');
});
it('with the socket down, sends nothing and does not report a success', async () => {
const app = makeApp() as RedrawApp;
const { tile, ws } = await connectTile(app);
ws.open();
app._noteFocusedTile(tile);
ws.drop(1006);
await app.restoreTerminalSize();
expect(resizeFrames(ws)).toHaveLength(1);
expect(app.showToast).not.toHaveBeenCalledWith(expect.stringContaining('restored'), 'success');
expect(app.showToast).toHaveBeenCalledWith(
'Terminal not connected: its size is sent when it reconnects',
'warning'
);
});
it('for a session popped out to its own window, says that window sizes it', async () => {
const app = makeApp() as RedrawApp;
const detached = new Set<string>();
app.detachedSessions = detached;
const { tile, ws } = await connectTile(app, { detachedSessions: detached });
ws.open();
app._noteFocusedTile(tile);
detached.add('s-tile'); // popped out after the tile opened
await app.restoreTerminalSize();
expect(resizeFrames(ws)).toHaveLength(1);
expect(app.showToast).not.toHaveBeenCalledWith(expect.stringContaining('restored'), 'success');
expect(app.showToast).toHaveBeenCalledWith('This session is sized by its own window', 'warning');
});
});
it('re-announces an unchanged size on a reconnected socket', async () => {
@@ -547,7 +663,7 @@ describe('TerminalTile geometry (#464: the pane and its PTY never disagree)', ()
ws.open();
FakeFit.proposed = { cols: 120, rows: 40 };
tile.fit();
expect(tile.fit()).toBe(false);
expect(resizeFrames(ws)).toEqual([]);
});
@@ -686,6 +802,273 @@ describe('TerminalTile claims the keyboard for the app-level shortcuts', () => {
});
});
describe('TerminalTile live-output flow control: a flood cannot pile up in xterm', () => {
// The server applies no backpressure, and a tile used to write every live
// frame straight into xterm: a flood it could not parse as fast piled up in
// xterm's own queue without bound, until xterm's WriteBuffer threw past 50M
// code units and onmessage's catch silently lost the frames. Now unparsed
// (and held) output is counted per tile; past the budget a frame is dropped,
// the tile stops writing onto the hole, and one debounced, bounded refresh
// recaptures the screen (the primary pane's _scheduleDroppedOutputRecovery).
const TileStatics = TerminalTile as unknown as { LIVE_BACKLOG_BUDGET: number };
const defaultBudget = TileStatics.LIVE_BACKLOG_BUDGET;
afterEach(() => {
TileStatics.LIVE_BACKLOG_BUDGET = defaultBudget;
delete windowStub.AbortController;
});
const out = (ws: FakeSocket, d: string) => ws.receive({ t: 'o', d });
const inFlight = (tile: Tile) => (tile as unknown as { _liveInFlight: number })._liveInFlight;
function serve(terminalBuffer: string) {
fetchMock.mockClear();
fetchMock.mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({ data: { terminalBuffer } }),
}));
}
it("the budget is a few MB, not the primary pane's 128 KB: a 1 MiB burst xterm has not parsed yet is still written", async () => {
expect(defaultBudget).toBeGreaterThanOrEqual(2 * 1024 * 1024);
expect(defaultBudget).toBeLessThanOrEqual(16 * 1024 * 1024);
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
serve('');
term.holdParse = true;
out(ws, 'z'.repeat(1024 * 1024));
expect(term.writes.at(-1)).toHaveLength(1024 * 1024);
await vi.advanceTimersByTimeAsync(10_000);
expect(fetchMock).not.toHaveBeenCalled();
});
it('counts output until xterm has parsed it, so parsed bytes no longer hold the budget', async () => {
TileStatics.LIVE_BACKLOG_BUDGET = 100;
const { tile, ws, term } = await connectTile(makeApp());
ws.open();
term.holdParse = true;
out(ws, 'a'.repeat(40));
out(ws, 'b'.repeat(40));
expect(inFlight(tile)).toBe(80);
term.parse();
expect(inFlight(tile)).toBe(0);
out(ws, 'c'.repeat(90));
expect(term.writes.slice(-3)).toEqual(['a'.repeat(40), 'b'.repeat(40), 'c'.repeat(90)]);
});
it('stops writing past the budget, and ONE debounced refresh recaptures the screen', async () => {
TileStatics.LIVE_BACKLOG_BUDGET = 100;
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
serve('recovered screen');
term.holdParse = true; // xterm falls behind
out(ws, 'a'.repeat(60));
out(ws, 'b'.repeat(60)); // 120 unparsed: past the budget, dropped
out(ws, 'c');
term.parse(); // xterm catches up, but the stream already has a hole:
out(ws, 'd'); // nothing more is written onto it
expect(term.writes.filter((w) => /^[abcd]/.test(w))).toEqual(['a'.repeat(60)]);
expect(fetchMock).not.toHaveBeenCalled();
term.holdParse = false;
await vi.advanceTimersByTimeAsync(1999);
expect(fetchMock).not.toHaveBeenCalled(); // debounced, like the primary pane's
await vi.advanceTimersByTimeAsync(1);
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe('/api/sessions/s-tile/terminal?full=1');
expect(term.writes.slice(-2)).toEqual(['\x1bc', 'recovered screen']);
out(ws, 'live again');
expect(term.writes.at(-1)).toBe('live again');
await vi.advanceTimersByTimeAsync(10_000);
expect(fetchMock).toHaveBeenCalledTimes(1); // one recovery for the whole burst
});
it("a write xterm refuses (its 50M throw) is a drop that schedules the same recovery, never a swallowed 'malformed frame'", async () => {
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
serve('recovered screen');
term.throwOnWrite = 'boom';
out(ws, 'boom');
out(ws, 'after the hole');
expect(term.writes).not.toContain('after the hole');
await vi.advanceTimersByTimeAsync(2000);
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(term.writes.slice(-2)).toEqual(['\x1bc', 'recovered screen']);
});
it('frames held behind a replay count against the budget too, and a hole there is recovered by another refresh', async () => {
TileStatics.LIVE_BACKLOG_BUDGET = 100;
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
fetchMock.mockClear();
let answer!: (body: string) => void;
fetchMock.mockImplementationOnce(async () => ({
ok: true,
status: 200,
json: () =>
new Promise((resolveBody) => {
answer = (terminalBuffer) => resolveBody({ data: { terminalBuffer } });
}),
}));
fetchMock.mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({ data: { terminalBuffer: 'second capture' } }),
}));
ws.receive({ t: 'r' });
await settle(); // the headers landed: from here frames are held
out(ws, 'x'.repeat(60));
out(ws, 'y'.repeat(60)); // the held queue would pass the budget: dropped
answer('first capture');
await settle();
// The capture predates the hole, so nothing after it is written onto it.
expect(term.writes.slice(-2)).toEqual(['\x1bc', 'first capture']);
expect(term.writes).not.toContain('y'.repeat(60));
await vi.advanceTimersByTimeAsync(2000);
await settle();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(term.writes.slice(-2)).toEqual(['\x1bc', 'second capture']);
out(ws, 'live again');
expect(term.writes.at(-1)).toBe('live again');
});
it('a recovery that keeps failing is retried a bounded number of times, then lets live output through again', async () => {
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
fetchMock.mockClear();
fetchMock.mockImplementation(async () => {
throw new Error('offline');
});
term.throwOnWrite = 'boom';
out(ws, 'boom');
out(ws, 'held back');
for (let i = 0; i < 10; i++) {
await vi.advanceTimersByTimeAsync(2000);
await settle();
}
const { DROP_RECOVERY_MAX_ATTEMPTS } = windowStub.CodemanDroppedOutput as { DROP_RECOVERY_MAX_ATTEMPTS: number };
expect(fetchMock).toHaveBeenCalledTimes(DROP_RECOVERY_MAX_ATTEMPTS);
expect(term.writes).not.toContain('held back');
out(ws, 'flowing');
expect(term.writes.at(-1)).toBe('flowing'); // never left frozen
});
it('a recovery cut off at its deadline is not retried (a stalled link), and lets live output through again', async () => {
windowStub.AbortController = AbortController;
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
fetchMock.mockClear();
fetchMock.mockImplementation(
(_url: string, init?: { signal?: AbortSignal }) =>
new Promise((_resolveFetch, rejectFetch) => {
init?.signal?.addEventListener('abort', () => rejectFetch(new Error('aborted')));
})
);
term.throwOnWrite = 'boom';
out(ws, 'boom');
await vi.advanceTimersByTimeAsync(2000);
expect(fetchMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(45_000); // the full-capture budget runs out
await settle();
out(ws, 'flowing');
expect(term.writes.at(-1)).toBe('flowing');
await vi.advanceTimersByTimeAsync(20_000);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('a reconnect starts the count over, and callbacks from before it count nothing', async () => {
TileStatics.LIVE_BACKLOG_BUDGET = 100;
vi.useFakeTimers();
const { tile, ws, term } = await connectTile(makeApp());
ws.open();
term.holdParse = true;
out(ws, 'a'.repeat(60));
expect(inFlight(tile)).toBe(60);
ws.drop(1006);
await vi.advanceTimersByTimeAsync(300);
FakeSocket.instances.at(-1)!.open(); // reconnected: its refresh replaces the screen
expect(inFlight(tile)).toBe(0);
term.parse(); // the old write's callback lands after the reset
expect(inFlight(tile)).toBe(0);
});
it('a reconnect drops a pending recovery: its own refresh replaces the screen', async () => {
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
serve('');
term.throwOnWrite = 'boom';
out(ws, 'boom');
ws.drop(1006);
await vi.advanceTimersByTimeAsync(300);
const ws2 = FakeSocket.instances.at(-1)!;
ws2.open();
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1); // the reconnect's refresh
out(ws2, 'flowing');
expect(term.writes.at(-1)).toBe('flowing');
await vi.advanceTimersByTimeAsync(5000);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('destroy() cancels a pending recovery', async () => {
vi.useFakeTimers();
const { tile, ws, term } = await connectTile(makeApp());
ws.open();
fetchMock.mockClear();
term.throwOnWrite = 'boom';
out(ws, 'boom');
const flow = tile as unknown as { _dropRecoveryTimer: unknown; _liveDropped: boolean };
expect(flow._dropRecoveryTimer).not.toBeNull(); // the drop armed one recovery
tile.destroy();
// Cleared by destroy() itself, read before any timer runs: the timer's own
// callback nulls the field and its destroyed guard skips the fetch, so the
// fetch check below alone cannot tell a cleared timer from a leaked one.
expect(flow._dropRecoveryTimer).toBeNull();
expect(flow._liveDropped).toBe(false);
await vi.advanceTimersByTimeAsync(5000);
expect(fetchMock).not.toHaveBeenCalled();
});
it('destroy() starts the count over: a write callback xterm still owed counts nothing', async () => {
TileStatics.LIVE_BACKLOG_BUDGET = 100;
const { tile, ws, term } = await connectTile(makeApp());
ws.open();
term.holdParse = true;
out(ws, 'a'.repeat(60));
expect(inFlight(tile)).toBe(60);
tile.destroy();
expect(inFlight(tile)).toBe(0);
term.parse(); // a callback from before destroy() carries the old epoch
expect(inFlight(tile)).toBe(0);
});
});
describe('the server coming back kicks Pane B', () => {
it("handleInit's reconnect branch asks the split pane's tile to reconnect without waiting out its backoff", () => {
// handleInit needs a whole app to run, so the wiring is pinned by source;
+8 -1
View File
@@ -436,11 +436,18 @@ describe('rows the tile pushed above the screen itself are not history', () => {
expect(flushed(ws)).toEqual([]);
});
it('forgets the overflow on a server clear, so later real history counts', async () => {
it('forgets the overflow when a server clear refreshes the tile, so later real history counts', async () => {
// A `{t:'c'}` is a refresh (a fresh Claude pane's first prompt), and its
// in-stream reset leaves nothing above the screen; the fresh capture is one
// line, so it adds no overflow of its own.
serveCapture(lines(40), 40);
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
expect(term.buffer.active.baseY).toBe(16);
serveCapture('fresh screen', 24);
ws.receive({ t: 'c' });
await vi.advanceTimersByTimeAsync(0);
expect(term.writes.slice(-2)).toEqual(['\x1bc', 'fresh screen']);
expect(term.buffer.active.baseY).toBe(0);
ws.receive({ t: 'o', d: '\r\n'.repeat(term.rows + 1) }); // two real lines above the screen
expect(term.buffer.active.baseY).toBe(2);
+252 -56
View File
@@ -185,6 +185,10 @@ const isMarker = (data: unknown) => typeof data === 'string' && data.includes('[
*/
const screenWrites = (pane: { terminal: FakeTerminal }) =>
pane.terminal.write.mock.calls.map((call) => call[0]).filter((data) => data !== '');
// Live frames are written with a callback (the tile counts them until xterm
// has parsed them, _writeLive), so they are matched on the data argument
// alone, never with toHaveBeenCalledWith(data): that would also miss, and a
// `.not` on it would then pass for nothing.
/**
* Holds xterm's write callbacks, as a real xterm still parsing a replay does:
@@ -203,6 +207,10 @@ function holdParses(pane: { terminal: FakeTerminal }) {
};
}
/** The in-stream reset (RIS) a refresh queues right before its replay, never xterm's clear(). */
const RIS = '\x1bc';
const resets = (pane: { terminal: FakeTerminal }) => screenWrites(pane).filter((data) => data === RIS).length;
/** Lets every microtask the vm-side promise chain queued run. */
const settle = () => new Promise((r) => setTimeout(r, 0));
@@ -236,17 +244,25 @@ describe('TerminalTile.destroy()', () => {
});
describe('TerminalTile server-refresh single-flight', () => {
it('a refresh with nothing in flight clears and fetches straight away', async () => {
// These used to count xterm clear() calls: the refresh wiped the pane with a
// synchronous clear() BEFORE its fetch. It now fetches first and resets with
// the queued in-stream RIS right before the replay (CLAUDE.md, Terminal
// resilience), so they count that reset instead, and pin clear() at zero.
it('a refresh with nothing in flight fetches straight away, then resets in-stream and replays', async () => {
const pane = makePane();
fetchMock.mockResolvedValueOnce(jsonResponse('one'));
pane._refreshBuffer();
// Fetching: the pane keeps its last frame, nothing written or cleared yet.
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(pane.terminal.write).not.toHaveBeenCalled();
await settle();
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
expect(pane.terminal.clear).not.toHaveBeenCalled();
// The second argument carries the load's deadline (an AbortSignal).
expect(fetchMock).toHaveBeenCalledWith('/api/sessions/s1/terminal?full=1', expect.anything());
expect(pane.terminal.write).toHaveBeenCalledWith('one');
// The reset immediately before the replay, queued in the same stream.
expect(screenWrites(pane)).toEqual([RIS, 'one']);
expect(pane._bufferLoading).toBe(false);
});
@@ -260,35 +276,37 @@ describe('TerminalTile server-refresh single-flight', () => {
expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?tail=${1024 * 1024}`, expect.anything());
});
it('refreshes arriving mid-fetch neither clear nor fetch again, and run ONCE after the replay lands', async () => {
it('refreshes arriving mid-fetch neither reset nor fetch again, and run ONCE after the replay lands', async () => {
const pane = makePane();
const first = deferred<ReturnType<typeof jsonResponse>>();
const second = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
pane._refreshBuffer();
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
expect(resets(pane)).toBe(0); // fetch first
expect(fetchMock).toHaveBeenCalledTimes(1);
// Two more frames while the first replay is still in flight.
pane._refreshBuffer();
pane._refreshBuffer();
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
expect(resets(pane)).toBe(0);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(pane._bufferRefreshPending).toBe(true);
first.resolve(jsonResponse('replay-1'));
await settle();
expect(pane.terminal.write).toHaveBeenCalledWith('replay-1');
// Exactly one trailing re-run for the two coalesced frames, not two.
expect(pane.terminal.clear).toHaveBeenCalledTimes(2);
expect(screenWrites(pane)).toEqual([RIS, 'replay-1']);
// Exactly one trailing re-run for the two coalesced frames, not two; it is
// fetching, so it has not reset anything yet.
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(resets(pane)).toBe(1);
second.resolve(jsonResponse('replay-2'));
await settle();
expect(screenWrites(pane).at(-1)).toBe('replay-2');
expect(screenWrites(pane)).toEqual([RIS, 'replay-1', RIS, 'replay-2']);
expect(pane.terminal.clear).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(pane._bufferLoading).toBe(false);
expect(pane._bufferRefreshPending).toBe(false);
@@ -303,9 +321,11 @@ describe('TerminalTile server-refresh single-flight', () => {
pane._refreshBuffer();
await settle();
// Every slice queued at once, then the empty write whose callback ends the
// replay: nothing waits for an animation frame.
// The reset, then every slice queued at once, then the empty write whose
// callback ends the replay: nothing waits for an animation frame.
expect(pane.terminal.write.mock.calls[0][0]).toBe(RIS);
expect(pane.terminal.write.mock.calls.map((call) => call[0].length)).toEqual([
RIS.length,
TERMINAL_CHUNK_SIZE,
TERMINAL_CHUNK_SIZE,
5,
@@ -314,17 +334,19 @@ describe('TerminalTile server-refresh single-flight', () => {
// xterm is still parsing: the replay, and with it the flag, is not done.
expect(pane._bufferLoading).toBe(true);
// A refresh mid-parse must not clear the terminal under the replay, nor
// A refresh mid-parse must not reset the terminal under the replay, nor
// start a second fetch.
pane._refreshBuffer();
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
expect(resets(pane)).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
fetchMock.mockResolvedValueOnce(jsonResponse('after'));
xterm.parse();
await settle();
// Parsed: the coalesced refresh runs now, once.
expect(pane.terminal.clear).toHaveBeenCalledTimes(2);
// Parsed: the coalesced refresh runs now, once, and resets before its replay.
expect(resets(pane)).toBe(2);
expect(screenWrites(pane).slice(-2)).toEqual([RIS, 'after']);
expect(pane.terminal.clear).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(pane._bufferLoading).toBe(true);
@@ -344,7 +366,10 @@ describe('TerminalTile server-refresh single-flight', () => {
pane._refreshBuffer();
await settle();
const queued = () => pane.terminal.write.mock.calls.reduce((n, call) => n + call[0].length, 0);
// The replay's own bytes; the reset queued ahead of them is checked apart.
expect(pane.terminal.write.mock.calls[0][0]).toBe(RIS);
const queued = () =>
pane.terminal.write.mock.calls.slice(1).reduce((n: number, call: unknown[]) => n + (call[0] as string).length, 0);
expect(queued()).toBe(TERMINAL_TAIL_SIZE);
xterm.parse();
@@ -403,6 +428,137 @@ describe('TerminalTile server-refresh single-flight', () => {
});
});
describe('TerminalTile refresh order: fetch, then the in-stream reset, then the held frames', () => {
// The primary pane's order (_onSessionNeedsRefresh / _resetTerminalForReplay,
// CLAUDE.md "Terminal resilience"). The refresh used to wipe the pane with a
// synchronous xterm clear() before its fetch and wrote live frames straight
// through the fetch and the replay: queued bytes fused into the snapshot, the
// capture started at the old cursor column, and a failed fetch left it blank.
it.each([
['the request fails', () => fetchMock.mockRejectedValueOnce(new Error('offline'))],
['the capture is empty', () => fetchMock.mockResolvedValueOnce(jsonResponse(''))],
])('when %s, the screen is left exactly as it was', async (_label, arrange) => {
const pane = makePane();
arrange();
pane._refreshBuffer();
await settle();
expect(pane.terminal.write).not.toHaveBeenCalled();
expect(pane.terminal.clear).not.toHaveBeenCalled();
expect(pane._liveQueue).toBeNull();
expect(pane._bufferLoading).toBe(false);
});
it('a body read that fails after the headers resets nothing and writes every held frame, in order', async () => {
const pane = makePane();
const held = headersOnly();
fetchMock.mockResolvedValueOnce(held.response);
pane._refreshBuffer();
await settle(); // headers landed: the queue is open
pane._onLiveOutput('frame-A');
pane._onLiveOutput('frame-B');
expect(pane.terminal.write).not.toHaveBeenCalled();
held.fail();
await settle();
expect(screenWrites(pane)).toEqual(['frame-A', 'frame-B']);
expect(pane.terminal.clear).not.toHaveBeenCalled();
expect(pane._liveQueue).toBeNull();
expect(pane._bufferLoading).toBe(false);
});
it('writes frames from before the response straight through, holds later ones, and writes them behind the replay', async () => {
const pane = makePane();
const xterm = holdParses(pane);
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise);
pane._refreshBuffer();
expect(pane._liveQueue).toBeNull(); // the round trip holds nothing
clock = 1;
pane._onLiveOutput('early'); // the pane keeps painting; the capture then replaces it
clock = 2; // the response arrives: this is the cutoff
response.resolve(jsonResponse('snapshot'));
await settle();
// The replay is queued and xterm is still parsing it.
expect(pane._liveQueue).not.toBeNull();
clock = 3;
pane._onLiveOutput('late'); // news the capture cannot hold
expect(screenWrites(pane)).toEqual(['early', RIS, 'snapshot']);
xterm.parse();
await settle();
// 'early' sits before the reset (wiped by it, never repeated); 'late' lands after the snapshot.
expect(screenWrites(pane)).toEqual(['early', RIS, 'snapshot', 'late']);
expect(pane.terminal.clear).not.toHaveBeenCalled();
expect(pane._liveQueue).toBeNull();
expect(pane._bufferLoading).toBe(false);
});
it('drops a held frame that arrived before the response once the replay covers it', async () => {
// The response's arrival is the cutoff (the primary pane's `since`): a frame
// held with an earlier stamp is already in the capture.
const pane = makePane();
const held = headersOnly();
clock = 5;
fetchMock.mockResolvedValueOnce(held.response);
pane._refreshBuffer();
await settle(); // headers landed at clock 5
clock = 4; // stamped before the cutoff (a stand-in for a frame the capture holds)
pane._onLiveOutput('in-the-capture');
clock = 6;
pane._onLiveOutput('after-the-capture');
held.release('snapshot');
await settle();
expect(screenWrites(pane)).toEqual([RIS, 'snapshot', 'after-the-capture']);
});
it('a bounded window gives the body read the short budget; an unbounded capture keeps the long one', async () => {
const bounded = makePane('shell'); // a shell loads the `tail=` window
const unbounded = makePane(); // the split's Pane B: a TUI's whole history
const a = headersOnly();
const b = headersOnly();
fetchMock.mockResolvedValueOnce(a.response).mockResolvedValueOnce(b.response);
bounded._refreshBuffer();
await settle(); // headers landed
expect(deadlines.map((d) => d.ms)).toEqual([45_000, 10_000]);
expect(deadlines[0].cleared).toBe(true);
unbounded._refreshBuffer();
await settle();
// No re-arm: a body of up to 32 MB keeps the request's own budget.
expect(deadlines.map((d) => d.ms)).toEqual([45_000, 10_000, 45_000]);
expect(deadlines[2].cleared).toBe(false);
a.release('one');
b.release('two');
await settle();
expect(deadlines.every((d) => d.cleared)).toBe(true);
});
it('a close mid-refresh whose fetch fails writes exactly one marker: nothing wiped it', async () => {
const pane = makePane();
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise);
pane._refreshBuffer();
pane._onSocketClosed(); // owed: a load is running
expect(pane.terminal.write).not.toHaveBeenCalled();
response.resolve({ json: async () => Promise.reject(new Error('body read failed')) } as never);
await settle();
const writes = screenWrites(pane);
expect(writes.filter(isMarker)).toHaveLength(1);
expect(writes).not.toContain(RIS);
});
});
describe('TerminalTile scroll-to-top history pull', () => {
it('a shell pane at the top pulls a bounded window of full history and replays it', async () => {
const pane = makePane('shell');
@@ -586,7 +742,7 @@ describe('TerminalTile scroll-to-top history pull', () => {
// keeps painting during the round trip), and the replay then replaces it.
clock = 1;
pane._onLiveOutput('early');
expect(term.write).toHaveBeenCalledWith('early');
expect(screenWrites(pane)).toContain('early');
await settle();
// 200 rows (more than the pane holds, so it replays) of 400 columns each:
@@ -596,12 +752,14 @@ describe('TerminalTile scroll-to-top history pull', () => {
clock = 2; // the response arrives: this is the cutoff
response.resolve(jsonResponse(bigReplay));
await settle();
expect(xterm.held).toHaveLength(1);
// Two parses pending: 'early' (a live write, counted until xterm parses it)
// and the replay's end marker.
expect(xterm.held).toHaveLength(2);
// Arrives while the snapshot is still being parsed: must not land under it.
clock = 3;
pane._onLiveOutput('late');
expect(term.write).not.toHaveBeenCalledWith('late');
expect(screenWrites(pane)).not.toContain('late');
// The replay parsed, then the pull's own settle write before it scrolls.
xterm.parse();
@@ -626,12 +784,12 @@ describe('TerminalTile scroll-to-top history pull', () => {
pane._maybeLoadMoreHistory();
await settle();
pane._onLiveOutput('held');
expect(pane.terminal.write).not.toHaveBeenCalledWith('held');
expect(screenWrites(pane)).not.toContain('held');
held.release(rowsOf(30)); // nothing to gain: no replay
await settle();
// Nothing replaced the terminal, so the held frame is news.
expect(pane.terminal.write).toHaveBeenCalledWith('held');
expect(screenWrites(pane)).toContain('held');
});
it('a failed fetch releases the flag and the queue, so live output flows again', async () => {
@@ -647,9 +805,9 @@ describe('TerminalTile scroll-to-top history pull', () => {
expect(pane._bufferLoading).toBe(false);
expect(pane._liveQueue).toBeNull();
expect(pane.terminal.write).toHaveBeenCalledWith('held');
expect(screenWrites(pane)).toContain('held');
pane._onLiveOutput('after');
expect(pane.terminal.write).toHaveBeenLastCalledWith('after');
expect(screenWrites(pane).at(-1)).toBe('after');
});
it('a refresh frame during the pull runs once behind it', async () => {
@@ -659,64 +817,97 @@ describe('TerminalTile scroll-to-top history pull', () => {
pane._maybeLoadMoreHistory();
pane._refreshBuffer();
expect(pane.terminal.clear).not.toHaveBeenCalled();
expect(pane.terminal.write).not.toHaveBeenCalled();
expect(pane._bufferRefreshPending).toBe(true);
response.resolve(jsonResponse(rowsOf(30)));
await settle();
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
// The pull had nothing to replay; the refresh behind it reset once, then replayed.
expect(screenWrites(pane)).toEqual([RIS, 'refreshed']);
expect(pane.terminal.clear).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(pane.terminal.write).toHaveBeenCalledWith('refreshed');
});
it('a clear frame during the pull is queued in order, never applied under the replay', async () => {
// The server's `{t:'c'}` means "refresh after startup" (its one emitter is a
// fresh Claude pane's first prompt, session.ts), and the primary pane answers
// it with a refetch and replay (_onSessionClearTerminal). The three below
// replace two tests that pinned it as a bare xterm clear(), which kept only
// the cursor's row: a Claude session Run into the grid came up a near-empty
// tile.
it('a clear frame during the pull is coalesced into one refresh behind it, never applied under the replay', async () => {
const pane = makePane('shell');
const term = pane.terminal;
const order: string[] = [];
term.write.mockImplementation((data: string, done?: () => void) => {
order.push(`write:${data}`);
if (data) order.push(`write:${data}`);
done?.();
});
term.clear.mockImplementation(() => order.push('clear'));
const held = headersOnly();
fetchMock.mockResolvedValueOnce(held.response);
fetchMock.mockResolvedValueOnce(held.response).mockResolvedValueOnce(jsonResponse('after startup'));
pane._maybeLoadMoreHistory();
await settle();
pane._onLiveOutput('before');
pane._onLiveClear();
pane._onLiveClear(); // a second one joins the same trailing refresh
pane._onLiveOutput('after');
// Held: clearing now would wipe a half-written snapshot.
// Held: nothing touches the screen under the pull, and nothing fetches yet.
expect(order).toEqual([]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(pane._bufferRefreshPending).toBe(true);
held.release(rowsOf(30)); // nothing to gain: no replay
await settle();
expect(order).toEqual(['write:before', 'clear', 'write:after']);
// The held frames land in order, then ONE refresh fetches the pane's
// current screen and replays it last.
expect(order.slice(0, 2)).toEqual(['write:before', 'write:after']);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenLastCalledWith(
`/api/sessions/s1/terminal?tail=${TERMINAL_TAIL_SIZE}`,
expect.anything()
);
expect(order.at(-1)).toBe('write:after startup');
expect(pane._liveQueue).toBeNull();
// With nothing in flight a clear frame applies straight away.
pane._onLiveClear();
expect(order.at(-1)).toBe('clear');
expect(pane._bufferLoading).toBe(false);
expect(pane._bufferRefreshPending).toBe(false);
});
it('a clear that arrived before the capture is not replayed after it', async () => {
it('a clear frame with nothing in flight refetches the capture and replays it, like a refresh frame', async () => {
const pane = makePane();
fetchMock.mockResolvedValueOnce(jsonResponse('banner\r\n❯ '));
pane._onLiveClear();
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith('/api/sessions/s1/terminal?full=1', expect.anything());
expect(screenWrites(pane).at(-1)).toBe('banner\r\n❯ ');
expect(pane._bufferLoading).toBe(false);
});
it('a clear frame while a pull waits for its response does not touch the screen, and refreshes behind the pull', async () => {
const pane = makePane('shell');
const term = pane.terminal;
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise);
fetchMock.mockReturnValueOnce(response.promise).mockResolvedValueOnce(jsonResponse('refreshed'));
pane._maybeLoadMoreHistory();
clock = 1;
pane._onLiveClear(); // before the response: applied now, already in the capture
expect(term.clear).toHaveBeenCalledTimes(1);
pane._onLiveClear(); // before the response
expect(term.clear).not.toHaveBeenCalled();
expect(pane._bufferRefreshPending).toBe(true);
clock = 2;
response.resolve(jsonResponse(rowsOf(100)));
await settle();
expect(term.write).toHaveBeenCalledWith('\x1bc');
expect(term.clear).toHaveBeenCalledTimes(1); // not replayed after the capture
const writes = screenWrites(pane);
expect(writes).toContain(rowsOf(100)); // the pull still replayed
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(writes.at(-1)).toBe('refreshed');
expect(pane._bufferLoading).toBe(false);
});
it('destroy() mid-pull leaves nothing running and nothing written to the dead terminal', async () => {
@@ -736,7 +927,7 @@ describe('TerminalTile scroll-to-top history pull', () => {
expect(pane._liveQueue).toBeNull();
expect(pane.terminal).toBeNull();
expect(term.write).not.toHaveBeenCalledWith('\x1bc');
expect(term.write).not.toHaveBeenCalledWith('held');
expect(term.write.mock.calls.map((call) => call[0])).not.toContain('held');
});
it('a pull whose request is aborted (the deadline) frees the pane', async () => {
@@ -752,7 +943,7 @@ describe('TerminalTile scroll-to-top history pull', () => {
expect(pane._bufferLoading).toBe(false);
expect(pane._liveQueue).toBeNull();
expect(pane.terminal.write).toHaveBeenCalledWith('held');
expect(screenWrites(pane)).toContain('held');
});
it('the wheel listener is capture-phase, and only a wheel UP can trigger a pull', async () => {
@@ -913,10 +1104,9 @@ describe('TerminalTile scroll-to-top history pull', () => {
it('back-to-back refreshes on a closed socket leave exactly one marker, at the end', async () => {
// R1's finally runs the trailing refresh R2, so R1 leaves the owed marker to
// R2 instead of stamping it: in real xterm R1's write would still be queued
// when R2's synchronous clear() runs, and would land above R2's replay. The
// write mock records every stamp whatever clear() does, so counting marker
// writes pins that R1 never stamps (the async-parse test below shows why).
// R2 instead of stamping it: R2 settles it, below its own replay. The write
// mock records every stamp, so counting marker writes pins that R1 never
// stamps (the async-parse test below shows the screen).
const pane = makePane('shell');
pane._wsClosed = true;
const first = deferred<ReturnType<typeof jsonResponse>>();
@@ -927,8 +1117,10 @@ describe('TerminalTile scroll-to-top history pull', () => {
pane._refreshBuffer(); // coalesced into the trailing R2
first.resolve(jsonResponse('first'));
await settle();
// R1 settled its marker, then R2 cleared and is still fetching.
expect(pane.terminal.clear).toHaveBeenCalledTimes(2);
// R1 replayed and left the marker to R2, which is still fetching: it has
// reset nothing yet, so R1's replay is still on screen.
expect(screenWrites(pane)).toEqual([RIS, 'first']);
expect(fetchMock).toHaveBeenCalledTimes(2);
second.resolve(jsonResponse('second'));
await settle();
@@ -1015,8 +1207,8 @@ describe('TerminalTile scroll-to-top history pull', () => {
});
it('a refresh queued behind a pull on a closed socket does not wipe the marker', async () => {
// The refresh's clear() runs after the pull's finally block has written the
// marker, so without a re-stamp the dead pane would look current again.
// The refresh's reset lands after the pull's finally block, so without a
// re-stamp the dead pane would look current again.
const pane = makePane('shell');
const held = headersOnly();
fetchMock.mockResolvedValueOnce(held.response).mockResolvedValueOnce(jsonResponse('refreshed'));
@@ -1030,7 +1222,9 @@ describe('TerminalTile scroll-to-top history pull', () => {
await settle();
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
expect(resets(pane)).toBe(1);
expect(pane.terminal.clear).not.toHaveBeenCalled();
expect(writes.indexOf(RIS)).toBe(writes.indexOf('refreshed') - 1);
expect(writes).toContain('refreshed');
expect(isMarker(writes.at(-1))).toBe(true);
expect(writes.lastIndexOf('refreshed')).toBeLessThan(writes.length - 1);
@@ -1063,9 +1257,10 @@ describe('TerminalTile scroll-to-top history pull', () => {
])('with xterm parsing writes on a later tick, %s leave one marker on screen, last', async (_label, drive) => {
// Real xterm queues write() and parses it on a later tick (WriteBuffer's
// setTimeout), while clear() rewrites the buffer at once. The default fake
// applies writes synchronously and so cannot show a marker overtaken by a
// trailing refresh's clear(): parsed after it, that marker sat above the
// refresh's replay as a second, stale copy.
// applies writes synchronously and so cannot show ordering on the real
// screen: back when a trailing refresh began with a clear(), a marker parsed
// after it sat above the refresh's replay as a second, stale copy. A
// refresh never calls clear() now (pinned below); its reset is in-stream.
const pane = makePane('shell');
const screen: string[] = [];
const pending: Array<{ data: string; done?: () => void }> = [];
@@ -1088,6 +1283,7 @@ describe('TerminalTile scroll-to-top history pull', () => {
await drive(pane);
for (let i = 0; i < 5; i++) await settle();
expect(pane.terminal.clear).not.toHaveBeenCalled();
expect(pane._bufferLoading).toBe(false);
expect(screen.filter(isMarker)).toHaveLength(1);
expect(screen.at(-1)).toSatisfy(isMarker);
+52 -8
View File
@@ -13,8 +13,9 @@
* for N tiles reconnecting together;
* - the focused tile goes first, then reading order, and a history pull (the
* user is waiting on it) jumps ahead of background refreshes;
* - `{t:'r'}` goes through the same queue, and a tile waiting its turn keeps its
* last frame (the clear happens at its turn);
* - `{t:'r'}` and `{t:'c'}` go through the same queue, and a tile keeps its
* last frame while it waits its turn and through its own capture's round
* trip (it is reset in-stream only once the capture is in hand);
* - a destroyed tile's queued load is dropped, and destroying the tile whose
* load is running aborts its fetch so the queue moves on;
* - a load that never answers is cut off by its deadline;
@@ -270,12 +271,54 @@ describe('refreshes', () => {
await settle();
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a']);
// b has not been cleared: it shows its last frame until its load runs.
expect(b.terminal?.writes).not.toContain('<CLEAR>');
// b has not been reset: it shows its last frame until its load runs.
const before = [...(b.terminal?.writes ?? [])];
expect(before).not.toContain('<CLEAR>');
await drain('fresh');
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a', 'b']);
expect(b.terminal?.writes.slice(-2)).toEqual(['<CLEAR>', 'fresh']);
// Its turn reset it in-stream, right before the replay, never with clear().
expect(b.terminal?.writes).toEqual([...before, '\x1bc', 'fresh']);
});
it("a server {t:'c'} (a Claude pane's first prompt) is a refresh through the same queue", async () => {
const { tiles } = makeGrid(['a', 'b']);
await connectAll(tiles);
const [a, b] = tiles;
a.ws?.receive({ t: 'r' });
b.ws?.receive({ t: 'c' });
await settle();
// b waits behind a, like any refresh: one capture in flight.
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a']);
expect(await drain('banner')).toBe(1);
expect(captures.map((c) => c.url)).toEqual([
`/api/sessions/a/terminal?full=1&tail=${TAIL}${LINES}`,
`/api/sessions/b/terminal?full=1&tail=${TAIL}${LINES}`,
]);
expect(b.terminal?.writes.at(-1)).toBe('banner');
});
it("a tile keeps its last frame through its own capture's round trip, and one cut off leaves it as it was", async () => {
vi.useFakeTimers();
const { tiles } = makeGrid(['a']);
await connectAll(tiles);
const [a] = tiles;
a.ws?.receive({ t: 'o', d: 'last frame' });
a.ws?.receive({ t: 'r' });
await settle();
// Its turn came and its capture is in flight: nothing reset yet.
expect(inFlight()).toBe(1);
expect(a.terminal?.writes.at(-1)).toBe('last frame');
// The full-capture budget runs out with no answer.
await vi.advanceTimersByTimeAsync(45_000);
await settle();
expect(captures.at(-1)?.aborted).toBe(true);
expect(a.terminal?.writes.at(-1)).toBe('last frame');
expect(a.terminal?.writes).not.toContain('\x1bc');
expect(a.terminal?.writes).not.toContain('<CLEAR>');
});
it('a history pull jumps ahead of background refreshes', async () => {
@@ -308,10 +351,11 @@ describe('refreshes', () => {
const markers = () => (b.terminal?.writes ?? []).filter((w) => w.includes('[disconnected')).length;
expect(markers()).toBe(1);
await drain('fresh');
// Its turn cleared the screen, so the marker is written again below the replay: one on screen.
// Its replay reset the screen, so the marker is written again below the replay: one on screen.
const writes = b.terminal?.writes ?? [];
expect(writes.slice(writes.lastIndexOf('<CLEAR>'))).toEqual([
'<CLEAR>',
expect(writes).not.toContain('<CLEAR>');
expect(writes.slice(writes.lastIndexOf('\x1bc'))).toEqual([
'\x1bc',
'fresh',
expect.stringContaining('[disconnected'),
]);