mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
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:
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user