mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
Merge pull request #506 from timkjr/fix/split-pane-scroll-history
fix(split-pane): let a Shell Pane B's scroll-up reach tmux history
This commit is contained in:
@@ -270,7 +270,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
|
||||
|
||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`SplitTerminalPane._pullHistory`, terminal-split.js); keep the two in step. → invariants: "Split-pane sessions" ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||
|
||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in its own `SplitTerminalPane` (terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Deliberately plainer than the primary pane — no local-echo overlay, CJK IME, or touch handlers — and NOT persisted across reloads. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -14,6 +14,9 @@
|
||||
*/
|
||||
|
||||
(function (global) {
|
||||
// How long a scroll-to-top history pull may hold Pane B's live output.
|
||||
const HISTORY_PULL_TIMEOUT_MS = 10000;
|
||||
|
||||
/**
|
||||
* Minimal chunked write for Pane B's own xterm instance — write() in
|
||||
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
|
||||
@@ -68,10 +71,18 @@
|
||||
this.fitAddon = null;
|
||||
this.ws = null;
|
||||
this._wsReady = false;
|
||||
this._wsClosed = false;
|
||||
this._destroyed = false;
|
||||
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
|
||||
this._bufferLoading = false;
|
||||
this._bufferRefreshPending = false;
|
||||
// Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
|
||||
// `_liveQueue` is non-null exactly while a pull is replaying: live frames
|
||||
// are held there with their arrival time instead of written under it.
|
||||
this._historyPullAt = 0;
|
||||
this._historyPullUseless = false;
|
||||
this._liveQueue = null;
|
||||
this._onWheel = null;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
@@ -95,6 +106,8 @@
|
||||
this.terminal.open(this.mountEl);
|
||||
this.fitAddon.fit();
|
||||
|
||||
this._installWheelListener();
|
||||
|
||||
this.terminal.onData((data) => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ t: 'i', d: data }));
|
||||
@@ -254,9 +267,9 @@
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.t === 'o') {
|
||||
this.terminal.write(msg.d);
|
||||
this._onLiveOutput(msg.d);
|
||||
} else if (msg.t === 'c') {
|
||||
this.terminal.clear();
|
||||
this._onLiveClear();
|
||||
} else if (msg.t === 'r') {
|
||||
// Server-triggered refresh (SSE backpressure cleared, terminal
|
||||
// data was dropped). The primary pane routes this to
|
||||
@@ -282,7 +295,8 @@
|
||||
// user's place in Pane B's scrollback for a transient blip.
|
||||
this.ws.onclose = () => {
|
||||
this._wsReady = false;
|
||||
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
|
||||
this._wsClosed = true;
|
||||
this._writeDisconnectedMarker();
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
@@ -290,6 +304,12 @@
|
||||
};
|
||||
}
|
||||
|
||||
// Extracted so both onclose and a history-pull replay that lands on an
|
||||
// already-closed socket can write it (see _pullHistory()'s finally block).
|
||||
_writeDisconnectedMarker() {
|
||||
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
|
||||
}
|
||||
|
||||
// Fetches and writes the session's current scrollback. Used both by
|
||||
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
|
||||
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
|
||||
@@ -324,14 +344,165 @@
|
||||
} catch {
|
||||
/* Best-effort — live output still arrives once the socket connects. */
|
||||
} finally {
|
||||
this._bufferLoading = false;
|
||||
this._endBufferLoad();
|
||||
}
|
||||
}
|
||||
|
||||
// Ends a single-flight load (initial, refresh or history pull): clears the
|
||||
// flag, then runs the ONE trailing refresh that arrived while it was busy.
|
||||
_endBufferLoad() {
|
||||
this._bufferLoading = false;
|
||||
if (this._bufferRefreshPending && !this._destroyed) {
|
||||
this._bufferRefreshPending = false;
|
||||
this._refreshBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
_onLiveOutput(data) {
|
||||
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data });
|
||||
else this.terminal?.write(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.
|
||||
_onLiveClear() {
|
||||
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true });
|
||||
else this.terminal?.clear();
|
||||
}
|
||||
|
||||
// Capture phase, because xterm's own wheel handler stopPropagation()s every
|
||||
// event it consumes, so a bubbling listener here would never see the wheel
|
||||
// while the pane still has scrollback to scroll. Passive: this only observes,
|
||||
// xterm keeps doing the scrolling.
|
||||
_installWheelListener() {
|
||||
this._onWheel = (ev) => {
|
||||
if (ev.deltaY < 0) this._maybeLoadMoreHistory();
|
||||
};
|
||||
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true });
|
||||
}
|
||||
|
||||
// Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of
|
||||
// output (`cat` of a file longer than the screen) instead of scrolling it,
|
||||
// so this pane's xterm ends up with about one screen of scrollback while
|
||||
// tmux holds every line — and nothing here ever went back to ask, so the
|
||||
// history was unreachable. The primary pane has the same pull
|
||||
// (app.js _maybeRefetchFullHistory); Pane B is a separate xterm and needs its
|
||||
// own. Shell only: a non-shell CLI's history is out of scope for this pull
|
||||
// (its load already takes `full=1`; codex and Claude's inline renderer do
|
||||
// grow tmux history, this just isn't how they recover it). The alternate-
|
||||
// screen skip (nano, vim, less) only matters for a direct-PTY shell — under
|
||||
// tmux the browser xterm never enters the alternate buffer.
|
||||
_maybeLoadMoreHistory() {
|
||||
if (this.sessionMode !== 'shell' || this._destroyed || !this.terminal) return;
|
||||
if (this._bufferLoading) return;
|
||||
// Mirrors app.js _maybeRefetchFullHistory and this pane's own
|
||||
// _sendResize(): a detached session's own window already owns its PTY
|
||||
// size and scrollback, so Pane B has nothing of its own to reconcile.
|
||||
if (this.detachedSessions?.has(this.sessionId)) return;
|
||||
const active = this.terminal.buffer.active;
|
||||
if (active.type !== 'normal' || active.viewportY !== 0) return;
|
||||
// Momentum scrolling fires this dozens of times per flick, so cooldown
|
||||
// rather than latch; a pull that could only have downgraded the pane
|
||||
// waits far longer.
|
||||
const cooldown = this._historyPullUseless ? 60000 : 4000;
|
||||
const now = Date.now();
|
||||
if (now - this._historyPullAt < cooldown) return;
|
||||
this._historyPullAt = now;
|
||||
void this._pullHistory();
|
||||
}
|
||||
|
||||
// Pulls a BOUNDED window of tmux's full history (the same TERMINAL_TAIL_SIZE
|
||||
// a tab switch loads, so a multi-megabyte capture never lands on xterm's
|
||||
// main thread) and replays it under the reader's current place. Holds the
|
||||
// single-flight flag across the fetch AND the replay, like _loadBuffer().
|
||||
async _pullHistory() {
|
||||
this._bufferLoading = true;
|
||||
this._liveQueue = [];
|
||||
let replayed = false;
|
||||
let capturedAt = 0;
|
||||
try {
|
||||
// A deadline, because live output is held for as long as this runs: a
|
||||
// request that hangs would otherwise freeze the whole pane. Aborting
|
||||
// lands in the catch below, which releases the flag and the queue. It
|
||||
// covers the body read too, not just the headers.
|
||||
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
|
||||
signal: global.AbortSignal?.timeout?.(HISTORY_PULL_TIMEOUT_MS),
|
||||
});
|
||||
// The cutoff below is the response's arrival, the same `since` rule the
|
||||
// primary pane uses (_finishBufferLoad). It is a client clock standing in
|
||||
// for the instant tmux took the capture, which lies somewhere in the
|
||||
// round trip, so a frame in that window can be lost or doubled. Bounded
|
||||
// by one round trip and not closable without a server-side capture time.
|
||||
capturedAt = performance.now();
|
||||
const payload = (await res.json())?.data;
|
||||
const buffer = payload?.terminalBuffer;
|
||||
const term = this.terminal;
|
||||
if (!buffer || !term || this._destroyed) return;
|
||||
const rowsBefore = term.buffer.active.length;
|
||||
const rowsIncoming = global.app?._estimateReplayRows?.(buffer, term.cols) ?? buffer.split('\n').length;
|
||||
// xterm keeps at most `scrollback + rows` rows while tmux keeps far more
|
||||
// lines, so a window of short lines can carry more rows than this pane
|
||||
// can ever hold, and `rowsIncoming <= rowsBefore` would never come true.
|
||||
const scrollbackCap = term.options?.scrollback || 0;
|
||||
const paneFull = scrollbackCap > 0 && rowsBefore >= scrollbackCap + term.rows;
|
||||
// Nothing to gain (this also covers a downgrade, which would delete
|
||||
// history mid-scroll), and a reset+rewrite would jump the viewport. An
|
||||
// untruncated window IS all of tmux's history and the next burst can add
|
||||
// more, so keep the 4 s cooldown. A truncated window can never reach past
|
||||
// what the pane shows, and every ask costs the server a capture-pane of
|
||||
// the whole history (`tail` is cut after it): back off to 60 s, as the
|
||||
// primary pane does (app.js _maybeRefetchFullHistory). A full pane backs
|
||||
// off too, since no window can ever fit in it.
|
||||
if (rowsIncoming <= rowsBefore || paneFull) {
|
||||
if (payload.truncated || paneFull) this._historyPullUseless = true;
|
||||
return;
|
||||
}
|
||||
this._historyPullUseless = false;
|
||||
term.write('\x1bc');
|
||||
replayed = true;
|
||||
await writeChunked(term, buffer, () => this._destroyed);
|
||||
if (this._destroyed || !this.terminal) return;
|
||||
// xterm parses asynchronously: an empty write's callback fires only
|
||||
// after everything before it, so the row count below is the settled one.
|
||||
await new Promise((resolve) => this.terminal.write('', resolve));
|
||||
if (this._destroyed || !this.terminal) return;
|
||||
// The replay grew the buffer UPWARD, so what was row 0 is now `delta`
|
||||
// rows down; land there and the recovered history sits above it.
|
||||
const delta = this.terminal.buffer.active.length - rowsBefore;
|
||||
if (delta > 0) this.terminal.scrollToLine(delta);
|
||||
else this.terminal.scrollToTop();
|
||||
} catch {
|
||||
/* Best-effort — live output keeps arriving whatever happens here. */
|
||||
} finally {
|
||||
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.terminal?.clear();
|
||||
else this.terminal?.write(entry.data);
|
||||
}
|
||||
// A replay's own `\x1bc` wipes the disconnected marker onclose wrote,
|
||||
// painting a fresh, current-looking history while onData keeps
|
||||
// silently dropping every keystroke on the dead socket. Re-stamp it
|
||||
// if the socket closed in either order (before the pull started, or
|
||||
// while the fetch was in flight) — checked after the queue flush so
|
||||
// it is the last thing on screen, matching what onclose would have
|
||||
// left had the pull never run.
|
||||
if (replayed && this._wsClosed) this._writeDisconnectedMarker();
|
||||
this._endBufferLoad();
|
||||
}
|
||||
}
|
||||
|
||||
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
|
||||
// frames in a row used to start two concurrent replays, each clearing
|
||||
// the terminal under the other's chunked write. A refresh that arrives
|
||||
@@ -381,6 +552,10 @@
|
||||
|
||||
destroy() {
|
||||
this._destroyed = true;
|
||||
if (this._onWheel) {
|
||||
this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true });
|
||||
this._onWheel = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.onopen = null;
|
||||
this.ws.onmessage = null;
|
||||
|
||||
@@ -11,17 +11,31 @@
|
||||
// refresh arriving mid-replay is now coalesced into ONE trailing re-run rather
|
||||
// than dropped, because the in-flight fetch may predate the drop the new frame
|
||||
// reports and no further frame comes to correct stale content.
|
||||
//
|
||||
// The last block covers the scroll-to-top history pull: a burst of output leaves
|
||||
// a shell pane's xterm with about one screen of scrollback while tmux holds every
|
||||
// line, and Pane B (a separate xterm from the primary pane) never went back to
|
||||
// ask. See _maybeLoadMoreHistory / _pullHistory in terminal-split.js.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const TERMINAL_CHUNK_SIZE = 32 * 1024;
|
||||
const TERMINAL_TAIL_SIZE = 1024 * 1024;
|
||||
/** The pane's `performance.now()`, so frame arrival vs. capture time is set by hand, not raced. */
|
||||
let clock = 0;
|
||||
|
||||
type FakeTerminal = {
|
||||
write: ReturnType<typeof vi.fn>;
|
||||
clear: ReturnType<typeof vi.fn>;
|
||||
dispose: ReturnType<typeof vi.fn>;
|
||||
scrollToLine: ReturnType<typeof vi.fn>;
|
||||
scrollToTop: ReturnType<typeof vi.fn>;
|
||||
cols: number;
|
||||
rows: number;
|
||||
options: { scrollback: number };
|
||||
buffer: { active: { type: string; viewportY: number; length: number } };
|
||||
};
|
||||
type FakeSocket = {
|
||||
onopen: unknown;
|
||||
@@ -36,43 +50,79 @@ type PaneUnderTest = {
|
||||
_destroyed: boolean;
|
||||
_bufferLoading: boolean;
|
||||
_bufferRefreshPending: boolean;
|
||||
_historyPullAt: number;
|
||||
_historyPullUseless: boolean;
|
||||
_liveQueue: unknown[] | null;
|
||||
_onWheel: unknown;
|
||||
_wsClosed: boolean;
|
||||
detachedSessions: Set<string> | undefined;
|
||||
destroy(): void;
|
||||
_loadBuffer(): Promise<void>;
|
||||
_refreshBuffer(): void;
|
||||
_maybeLoadMoreHistory(): void;
|
||||
_pullHistory(): Promise<void>;
|
||||
_onLiveOutput(data: string): void;
|
||||
_onLiveClear(): void;
|
||||
_installWheelListener(): void;
|
||||
_writeDisconnectedMarker(): void;
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
/** requestAnimationFrame stand-in: chunked writes queue here and are drained by hand. */
|
||||
const rafQueue: Array<() => void> = [];
|
||||
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-split.js'), 'utf8');
|
||||
|
||||
function loadSplitTerminalPane() {
|
||||
const dir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const src = readFileSync(resolve(dir, 'terminal-split.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
window: {},
|
||||
// The primary pane's row estimator, reduced to a line count: the pull only
|
||||
// compares it with the pane's own row count.
|
||||
window: {
|
||||
app: { _estimateReplayRows: (text: string) => text.split('\n').length },
|
||||
AbortSignal: { timeout: (ms: number) => ({ timeoutMs: ms }) },
|
||||
},
|
||||
performance: { now: () => clock },
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
requestAnimationFrame: (fn: () => void) => rafQueue.push(fn),
|
||||
// The constants.js globals the module reads at call time.
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
TERMINAL_TAIL_SIZE: 1024 * 1024,
|
||||
TERMINAL_TAIL_SIZE,
|
||||
});
|
||||
// The module's tail patches CodemanApp.prototype; nothing on it runs here.
|
||||
vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${src}`, context);
|
||||
vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${SOURCE}`, context);
|
||||
return (context.window as { SplitTerminalPane: new (id: string, mount: unknown, opts?: object) => PaneUnderTest })
|
||||
.SplitTerminalPane;
|
||||
}
|
||||
|
||||
const SplitTerminalPane = loadSplitTerminalPane();
|
||||
|
||||
function makePane(mode = 'claude'): PaneUnderTest & { terminal: FakeTerminal } {
|
||||
const pane = new SplitTerminalPane('s1', {}, { mode });
|
||||
pane.terminal = { write: vi.fn(), clear: vi.fn(), dispose: vi.fn() };
|
||||
function makePane(
|
||||
mode = 'claude',
|
||||
mount: unknown = {},
|
||||
opts: { detachedSessions?: Set<string> } = {}
|
||||
): PaneUnderTest & { terminal: FakeTerminal } {
|
||||
const pane = new SplitTerminalPane('s1', mount, { mode, ...opts });
|
||||
pane.terminal = {
|
||||
// xterm invokes a write's callback once everything before it is parsed.
|
||||
write: vi.fn((_data: string, done?: () => void) => done?.()),
|
||||
clear: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
scrollToLine: vi.fn(),
|
||||
scrollToTop: vi.fn(),
|
||||
cols: 80,
|
||||
rows: 30,
|
||||
// xterm keeps at most `scrollback + rows` rows; small here so a test can fill it.
|
||||
options: { scrollback: 1000 },
|
||||
// A pane sitting at the top of a 40-row buffer on the normal screen.
|
||||
buffer: { active: { type: 'normal', viewportY: 0, length: 40 } },
|
||||
};
|
||||
return pane as PaneUnderTest & { terminal: FakeTerminal };
|
||||
}
|
||||
|
||||
function jsonResponse(terminalBuffer: string) {
|
||||
return { json: async () => ({ data: { terminalBuffer } }) };
|
||||
const rowsOf = (n: number) => Array.from({ length: n }, (_, i) => `line ${i}`).join('\n');
|
||||
|
||||
function jsonResponse(terminalBuffer: string, extra: Record<string, unknown> = {}) {
|
||||
return { json: async () => ({ data: { terminalBuffer, ...extra } }) };
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
@@ -89,6 +139,7 @@ const settle = () => new Promise((r) => setTimeout(r, 0));
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
rafQueue.length = 0;
|
||||
clock = 0;
|
||||
});
|
||||
|
||||
describe('SplitTerminalPane.destroy()', () => {
|
||||
@@ -232,3 +283,444 @@ describe('SplitTerminalPane server-refresh single-flight', () => {
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('back');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SplitTerminalPane 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');
|
||||
const term = pane.terminal;
|
||||
// The replay grows the buffer once xterm has parsed it (the empty write's callback).
|
||||
term.write.mockImplementation((data: string, done?: () => void) => {
|
||||
if (data === '' && done) term.buffer.active.length = 140;
|
||||
done?.();
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100)));
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
|
||||
// With a deadline: live output is held for as long as the pull runs, so a
|
||||
// request that never answers would freeze the pane.
|
||||
expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
|
||||
signal: { timeoutMs: 10_000 },
|
||||
});
|
||||
expect(term.write).toHaveBeenCalledWith('\x1bc');
|
||||
expect(term.write).toHaveBeenCalledWith(rowsOf(100));
|
||||
// What was row 0 is now 100 rows down (140 - 40): the reader keeps their
|
||||
// place with the recovered history above it, instead of being dropped at the bottom.
|
||||
expect(term.scrollToLine).toHaveBeenCalledWith(100);
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
expect(pane._liveQueue).toBeNull();
|
||||
});
|
||||
|
||||
it('does nothing away from the top, for other modes, or on the alternate screen', async () => {
|
||||
const midScroll = makePane('shell');
|
||||
midScroll.terminal.buffer.active.viewportY = 12;
|
||||
midScroll._maybeLoadMoreHistory();
|
||||
|
||||
// A repaint-mode agent CLI keeps no tmux history to recover.
|
||||
makePane('claude')._maybeLoadMoreHistory();
|
||||
|
||||
// nano/vim/less own the wheel; their screen is not scrollback.
|
||||
const fullScreenApp = makePane('shell');
|
||||
fullScreenApp.terminal.buffer.active.type = 'alternate';
|
||||
fullScreenApp._maybeLoadMoreHistory();
|
||||
|
||||
await settle();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stands aside for a detached session, mirroring _sendResize()', async () => {
|
||||
// A detached session's own window already owns its PTY size and
|
||||
// scrollback (buildSplitPickerSessions() already refuses to open one).
|
||||
const pane = makePane('shell', {}, { detachedSessions: new Set(['s1']) });
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a flick fires once: overlapping triggers are dropped, then the cooldown holds', async () => {
|
||||
const pane = makePane('shell');
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
const startedAt = pane._historyPullAt;
|
||||
// The cooldown is cleared between triggers on purpose, so that only the
|
||||
// in-flight guard can be what drops the overlapping ones.
|
||||
pane._historyPullAt = 0;
|
||||
pane._maybeLoadMoreHistory();
|
||||
pane._historyPullAt = 0;
|
||||
pane._maybeLoadMoreHistory();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
pane._historyPullAt = startedAt;
|
||||
|
||||
response.resolve(jsonResponse(rowsOf(100)));
|
||||
await settle();
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
|
||||
// Nothing in flight any more, so now it is the 4s cooldown alone.
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Once the cooldown lapses a later scroll-to-top may pull again.
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100)));
|
||||
pane._historyPullAt = Date.now() - 5000;
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('a window the pane already holds in full is not rewritten, and is not latched as useless', async () => {
|
||||
const pane = makePane('shell');
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(30)));
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
|
||||
// A reset+rewrite here would jump the viewport for no new rows.
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc');
|
||||
expect(pane.terminal.scrollToLine).not.toHaveBeenCalled();
|
||||
expect(pane.terminal.scrollToTop).not.toHaveBeenCalled();
|
||||
// The next burst can put more history in tmux than the pane has.
|
||||
expect(pane._historyPullUseless).toBe(false);
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a downgrade, keeping the 4s cooldown when the window is all of tmux history', async () => {
|
||||
const pane = makePane('shell');
|
||||
pane.terminal.buffer.active.length = 500;
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(5)));
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc');
|
||||
// Untruncated: tmux has nothing older, but the next burst can add history.
|
||||
expect(pane._historyPullUseless).toBe(false);
|
||||
});
|
||||
|
||||
it('a truncated window that fits in the pane backs off for a minute', async () => {
|
||||
// Every ask costs the server a capture-pane of the WHOLE history (`tail` is
|
||||
// cut after the capture), and a window cut at the tail size can never reach
|
||||
// anything older than what the pane already shows.
|
||||
const pane = makePane('shell');
|
||||
pane.terminal.buffer.active.length = 500;
|
||||
// Within a screen of what the pane holds, so the old downgrade guard never
|
||||
// latched it: only the truncated-skip rule can back this off.
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(480), { truncated: true, truncationReason: 'tail' }));
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc');
|
||||
expect(pane._historyPullUseless).toBe(true);
|
||||
|
||||
// Inside the 60s back-off, well past the normal 4s cooldown.
|
||||
pane._historyPullAt = Date.now() - 10_000;
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a pane already at its scrollback cap skips the window and backs off for a minute', async () => {
|
||||
// A 1 MiB window of short lines can carry more rows than xterm will ever hold
|
||||
// (`scrollback + rows`), so `incoming <= rows held` never comes true and every
|
||||
// scroll-to-top would reset and re-parse it.
|
||||
const pane = makePane('shell');
|
||||
pane.terminal.buffer.active.length = 1030;
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(5000)));
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc');
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(rowsOf(5000));
|
||||
expect(pane._historyPullUseless).toBe(true);
|
||||
});
|
||||
|
||||
it('a successful replay clears the one-minute back-off', async () => {
|
||||
const pane = makePane('shell');
|
||||
pane._historyPullUseless = true;
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100), { truncated: true, truncationReason: 'tail' }));
|
||||
|
||||
void pane._pullHistory();
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(rowsOf(100));
|
||||
expect(pane._historyPullUseless).toBe(false);
|
||||
});
|
||||
|
||||
it('holds live output during the replay and replays only what arrived after the capture', async () => {
|
||||
const pane = makePane('shell');
|
||||
const term = pane.terminal;
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
expect(pane._liveQueue).toEqual([]);
|
||||
|
||||
// Arrives before the response does: it is IN the capture already.
|
||||
clock = 1;
|
||||
pane._onLiveOutput('early');
|
||||
expect(term.write).not.toHaveBeenCalledWith('early');
|
||||
await settle();
|
||||
|
||||
// 200 rows (more than the pane holds, so it replays) of 400 columns each:
|
||||
// three chunks, which leaves the replay mid-write once the fetch lands.
|
||||
const bigReplay = Array.from({ length: 200 }, () => 'y'.repeat(400)).join('\n');
|
||||
expect(bigReplay.length).toBeGreaterThan(TERMINAL_CHUNK_SIZE * 2);
|
||||
clock = 2; // the response arrives: this is the cutoff
|
||||
response.resolve(jsonResponse(bigReplay));
|
||||
await settle();
|
||||
expect(rafQueue).toHaveLength(1);
|
||||
|
||||
// Arrives while the snapshot is still being written: must not land under it.
|
||||
clock = 3;
|
||||
pane._onLiveOutput('late');
|
||||
expect(term.write).not.toHaveBeenCalledWith('late');
|
||||
|
||||
rafQueue.shift()!();
|
||||
rafQueue.shift()!();
|
||||
await settle();
|
||||
|
||||
const written = term.write.mock.calls.map((call) => call[0]);
|
||||
expect(written).not.toContain('early');
|
||||
expect(written.at(-1)).toBe('late');
|
||||
expect(pane._liveQueue).toBeNull();
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('writes every held frame when the pull ends without replaying', async () => {
|
||||
const pane = makePane('shell');
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
pane._onLiveOutput('held');
|
||||
await settle();
|
||||
response.resolve(jsonResponse(rowsOf(30))); // nothing to gain: no replay
|
||||
await settle();
|
||||
|
||||
// Nothing replaced the terminal, so the frame is news even though it
|
||||
// arrived before the response did.
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('held');
|
||||
});
|
||||
|
||||
it('a failed fetch releases the flag and the queue, so live output flows again', async () => {
|
||||
const pane = makePane('shell');
|
||||
fetchMock.mockRejectedValueOnce(new Error('offline'));
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
pane._onLiveOutput('held');
|
||||
await settle();
|
||||
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
expect(pane._liveQueue).toBeNull();
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('held');
|
||||
pane._onLiveOutput('after');
|
||||
expect(pane.terminal.write).toHaveBeenLastCalledWith('after');
|
||||
});
|
||||
|
||||
it('a refresh frame during the pull runs once behind it', async () => {
|
||||
const pane = makePane('shell');
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise).mockResolvedValueOnce(jsonResponse('refreshed'));
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
pane._refreshBuffer();
|
||||
expect(pane.terminal.clear).not.toHaveBeenCalled();
|
||||
expect(pane._bufferRefreshPending).toBe(true);
|
||||
|
||||
response.resolve(jsonResponse(rowsOf(30)));
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
|
||||
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 () => {
|
||||
const pane = makePane('shell');
|
||||
const term = pane.terminal;
|
||||
const order: string[] = [];
|
||||
term.write.mockImplementation((data: string, done?: () => void) => {
|
||||
order.push(`write:${data}`);
|
||||
done?.();
|
||||
});
|
||||
term.clear.mockImplementation(() => order.push('clear'));
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
pane._onLiveOutput('before');
|
||||
pane._onLiveClear();
|
||||
pane._onLiveOutput('after');
|
||||
// Held: clearing now would wipe a half-written snapshot.
|
||||
expect(order).toEqual([]);
|
||||
|
||||
response.resolve(jsonResponse(rowsOf(30))); // nothing to gain: no replay
|
||||
await settle();
|
||||
|
||||
expect(order).toEqual(['write:before', 'clear', 'write:after']);
|
||||
expect(pane._liveQueue).toBeNull();
|
||||
|
||||
// With nothing in flight a clear frame applies straight away.
|
||||
pane._onLiveClear();
|
||||
expect(order.at(-1)).toBe('clear');
|
||||
});
|
||||
|
||||
it('a clear that arrived before the capture is not replayed after it', async () => {
|
||||
const pane = makePane('shell');
|
||||
const term = pane.terminal;
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
clock = 1;
|
||||
pane._onLiveClear(); // already reflected in the capture
|
||||
clock = 2;
|
||||
response.resolve(jsonResponse(rowsOf(100)));
|
||||
await settle();
|
||||
|
||||
expect(term.write).toHaveBeenCalledWith('\x1bc');
|
||||
expect(term.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('destroy() mid-pull leaves nothing running and nothing written to the dead terminal', async () => {
|
||||
const pane = makePane('shell');
|
||||
const term = pane.terminal;
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
pane._onLiveOutput('held');
|
||||
pane.destroy();
|
||||
response.resolve(jsonResponse(rowsOf(100)));
|
||||
await settle();
|
||||
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
expect(pane._liveQueue).toBeNull();
|
||||
expect(pane.terminal).toBeNull();
|
||||
expect(term.write).not.toHaveBeenCalledWith('\x1bc');
|
||||
expect(term.write).not.toHaveBeenCalledWith('held');
|
||||
});
|
||||
|
||||
it('a pull whose request is aborted (the deadline) frees the pane', async () => {
|
||||
const pane = makePane('shell');
|
||||
fetchMock.mockRejectedValueOnce(new Error('The operation timed out'));
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
pane._onLiveOutput('held');
|
||||
await settle();
|
||||
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
expect(pane._liveQueue).toBeNull();
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('held');
|
||||
});
|
||||
|
||||
it('the wheel listener is capture-phase, and only a wheel UP can trigger a pull', async () => {
|
||||
const mount = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
||||
const pane = makePane('shell', mount);
|
||||
fetchMock.mockResolvedValue(jsonResponse(rowsOf(100)));
|
||||
|
||||
pane._installWheelListener();
|
||||
|
||||
// Capture phase: xterm's own wheel handler stopPropagation()s the events it
|
||||
// consumes, so a bubbling listener would never fire while the pane still has
|
||||
// scrollback to scroll, and the pull would work only from the exact top row.
|
||||
const [type, listener, options] = mount.addEventListener.mock.calls[0];
|
||||
expect(type).toBe('wheel');
|
||||
expect(options).toEqual({ capture: true, passive: true });
|
||||
|
||||
listener({ deltaY: 120 }); // wheel down
|
||||
listener({ deltaY: 0 });
|
||||
await settle();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
listener({ deltaY: -120 }); // wheel up, at the top
|
||||
await settle();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('destroy() detaches exactly the wheel listener it registered', () => {
|
||||
const mount = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
||||
const pane = makePane('shell', mount);
|
||||
pane._installWheelListener();
|
||||
const registered = mount.addEventListener.mock.calls[0][1];
|
||||
|
||||
pane.destroy();
|
||||
|
||||
expect(mount.removeEventListener).toHaveBeenCalledWith('wheel', registered, { capture: true });
|
||||
expect(pane._onWheel).toBeNull();
|
||||
});
|
||||
|
||||
it('connect() installs the wheel listener (static guard)', () => {
|
||||
// connect() needs a whole xterm to run, so its wiring is pinned by source
|
||||
// rather than executed; the listener's behaviour is exercised above.
|
||||
const connect = SOURCE.slice(SOURCE.indexOf('async connect()'), SOURCE.indexOf('async _loadBuffer()'));
|
||||
expect(connect).toContain('this._installWheelListener();');
|
||||
expect(connect).toContain('this._onLiveClear();');
|
||||
expect(connect).not.toContain('this.terminal.clear();');
|
||||
});
|
||||
|
||||
it('re-stamps the disconnected marker after a replay if the socket closed before the pull started', async () => {
|
||||
// onclose already wrote the marker once; a replay's own `\x1bc` would wipe
|
||||
// it and paint a fresh, current-looking history while onData keeps
|
||||
// silently dropping every keystroke on the dead socket.
|
||||
const pane = makePane('shell');
|
||||
pane._wsClosed = true;
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100)));
|
||||
|
||||
void pane._pullHistory();
|
||||
await settle();
|
||||
|
||||
const marker = expect.stringContaining('Pane B disconnected');
|
||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
||||
expect(writes.at(-1)).toEqual(expect.stringMatching(/Pane B disconnected/));
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(marker);
|
||||
});
|
||||
|
||||
it('re-stamps the disconnected marker after a replay if the socket closes mid-fetch', async () => {
|
||||
// The other order Ark0N's review called out: the close lands while the
|
||||
// capture is in flight, so the HTTP pull still succeeds (a Codeman
|
||||
// restart drops the WS while the tmux session, and so the pull, survives).
|
||||
const pane = makePane('shell');
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
const pull = pane._pullHistory();
|
||||
pane._wsClosed = true; // the close arrives mid-fetch, before the response
|
||||
response.resolve(jsonResponse(rowsOf(100)));
|
||||
await pull;
|
||||
|
||||
expect(pane.terminal.write.mock.calls.at(-1)?.[0]).toEqual(expect.stringMatching(/Pane B disconnected/));
|
||||
});
|
||||
|
||||
it('does not re-stamp the marker when the socket is still open', async () => {
|
||||
const pane = makePane('shell');
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100)));
|
||||
|
||||
void pane._pullHistory();
|
||||
await settle();
|
||||
|
||||
for (const call of pane.terminal.write.mock.calls) {
|
||||
expect(call[0]).toEqual(expect.not.stringMatching(/Pane B disconnected/));
|
||||
}
|
||||
});
|
||||
|
||||
it('does not re-stamp the marker when the pull never replayed (skip/downgrade path)', async () => {
|
||||
// Nothing erased the marker in this path, so re-stamping it would be a
|
||||
// second, redundant write.
|
||||
const pane = makePane('shell');
|
||||
pane._wsClosed = true;
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(30))); // held in full already: no replay
|
||||
|
||||
void pane._pullHistory();
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user