mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #494 from timkjr/fix/shell-scroll-history
fix(terminal): let a Shell pane's scroll-up reach tmux history # Conflicts: # docs/wiki/The-Dashboard.md
This commit is contained in:
@@ -267,7 +267,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 Shell loads the rest only via **Load full history**, never on ordinary scroll. ⚠️ 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**. ⚠️ 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
@@ -151,8 +151,9 @@ Worth knowing:
|
||||
|
||||
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
|
||||
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
|
||||
switching; press **Load full history** to pull the rest explicitly. Ordinary Shell scrolling
|
||||
and automatic output recovery stay within the bounded browser buffer.
|
||||
switching. Scrolling to the top of a Shell pane pulls the most recent 1 MiB of its tmux
|
||||
history; press **Load full history** to pull the rest explicitly. Automatic output
|
||||
recovery stays within the bounded browser buffer.
|
||||
- **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent
|
||||
Claude runs fullscreen, so the wheel scrolls the conversation rather than the terminal.
|
||||
Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is
|
||||
|
||||
+42
-6
@@ -6320,10 +6320,15 @@ class CodemanApp {
|
||||
if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return;
|
||||
if (this.detachedSessions?.has(sessionId)) return;
|
||||
const session = this.sessions.get(sessionId);
|
||||
// A shell's full capture can be many megabytes. Replaying it from an
|
||||
// ordinary scroll gesture blocks xterm's main thread, so keep that cost
|
||||
// behind the explicit "Load full history" button.
|
||||
if (!force && session?.mode === 'shell') return;
|
||||
// A shell's full capture can be many megabytes, and replaying all of it from
|
||||
// an ordinary scroll gesture blocks xterm's main thread. So a shell scroll
|
||||
// pulls a BOUNDED window of tmux's full history (the same 1 MiB a tab switch
|
||||
// loads, but of the scrollback rather than the visible frame) and the
|
||||
// unbounded pull stays behind the "Load full history" button. Declining
|
||||
// outright left a shell pane about one screen of browser scrollback after any
|
||||
// burst, and the button only renders once a replay was truncated, so a young
|
||||
// shell tab had no way back to output tmux was still holding.
|
||||
const boundedShellPull = !force && session?.mode === 'shell';
|
||||
const now = Date.now();
|
||||
// Momentum scrolling fires this dozens of times per flick, and a burst of new
|
||||
// output is the normal reason to want a re-pull, so cooldown rather than latch.
|
||||
@@ -6336,7 +6341,12 @@ class CodemanApp {
|
||||
this._fullHistoryRepullInFlight = true;
|
||||
try {
|
||||
const requestStartedAt = performance.now();
|
||||
const capture = await this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?full=1`, { full: true });
|
||||
const capture = await this._fetchTerminalCapture(
|
||||
boundedShellPull
|
||||
? `/api/sessions/${sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`
|
||||
: `/api/sessions/${sessionId}/terminal?full=1`,
|
||||
{ full: true }
|
||||
);
|
||||
const headersReceivedAt = capture.headersAt;
|
||||
const payload = capture.json?.data ?? {};
|
||||
const bodyParsedAt = performance.now();
|
||||
@@ -6357,7 +6367,33 @@ class CodemanApp {
|
||||
// Bail on a tab switch mid-fetch: writing here would paint another session's
|
||||
// history into the terminal the user is now looking at.
|
||||
if (!buffer || this.activeSessionId !== sessionId) return;
|
||||
if (this._replayWouldShrinkBuffer(buffer)) {
|
||||
const windowRows = this._estimateReplayRows(buffer, this.terminal.cols);
|
||||
// A bounded window no longer than the browser's buffer buys nothing, and
|
||||
// resetting to rewrite it would jump the viewport on every scroll that
|
||||
// outlasts the cooldown at the top. This runs BEFORE the downgrade guard
|
||||
// on purpose: that guard reads "smaller than the browser" as "tmux has
|
||||
// nothing more to give", which is true of an unbounded capture but not of a
|
||||
// window cut at the tail size, so a bounded window must never reach the
|
||||
// exhausted path, which would take Load full history off the banner while
|
||||
// tmux still holds the rest. Nothing was written here, so the banner state
|
||||
// is left as the load that produced it set it: re-labelling it from this
|
||||
// payload would call a terminal that holds ALL of a Load full history pull
|
||||
// "the most recent 1 MiB".
|
||||
if (boundedShellPull && windowRows <= this.terminal.buffer.active.length) {
|
||||
// An untruncated window IS all of tmux's history, so nothing is missing,
|
||||
// and the next burst of output can put more in tmux than the browser has:
|
||||
// keep the normal 4 s cooldown. A truncated one is the opposite case, since
|
||||
// the gesture can never reach anything older than what the browser already
|
||||
// shows, and every ask costs the server a synchronous capture-pane of the
|
||||
// whole history (`tail` is applied after the capture): back off to 60 s.
|
||||
// Trade-off: only a successful replay clears that latch, so a tab switch or
|
||||
// burst that shrinks the browser's buffer below the window can leave a
|
||||
// scroll-to-top inert for up to a minute. Load full history (`force`)
|
||||
// bypasses the cooldown, and the latch is bounded, never permanent.
|
||||
if (payload.truncated) (this._fullHistoryRepullUseless ||= new Set()).add(sessionId);
|
||||
return;
|
||||
}
|
||||
if (this._replayWouldShrinkBuffer(buffer, windowRows)) {
|
||||
timing.refused = true;
|
||||
timing.totalMs = performance.now() - requestStartedAt;
|
||||
this._recordTerminalLoadTiming(timing);
|
||||
|
||||
@@ -3304,9 +3304,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* Post-scroll companion to _noteTerminalUserScroll: hitting the TOP of the
|
||||
* buffer while scrolling up gives the app a chance to pull the rest of tmux's
|
||||
* scrollback (issue #205, see _maybeRefetchFullHistory). Shell sessions decline
|
||||
* automatic pulls because their captures can be large; their banner button is
|
||||
* the explicit path. Must be called AFTER scrollLines(), since the check is on
|
||||
* scrollback (issue #205, see _maybeRefetchFullHistory). Shell sessions pull a
|
||||
* bounded window because their captures can be large; their banner button is
|
||||
* the unbounded path. Must be called AFTER scrollLines(), since the check is on
|
||||
* the resulting position, and it is deliberately not folded into
|
||||
* _noteTerminalUserScroll for exactly that reason.
|
||||
*/
|
||||
@@ -3354,13 +3354,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
* below the last line, and _estimateReplayRows can only approximate wrapping.
|
||||
* Only a capture that is worse by more than a full screen counts as a
|
||||
* downgrade, which leaves every genuine recovery case untouched.
|
||||
*
|
||||
* A caller that already estimated the capture's rows passes them as
|
||||
* `estimatedRows`, so a megabyte capture is not scanned twice.
|
||||
*/
|
||||
_replayWouldShrinkBuffer(capture) {
|
||||
_replayWouldShrinkBuffer(capture, estimatedRows) {
|
||||
const term = this.terminal;
|
||||
const rowsNow = term?.buffer?.active?.length || 0;
|
||||
if (!rowsNow) return false;
|
||||
const screen = term?.rows || 24;
|
||||
return this._estimateReplayRows(capture, term?.cols) + screen < rowsNow;
|
||||
const rows = estimatedRows ?? this._estimateReplayRows(capture, term?.cols);
|
||||
return rows + screen < rowsNow;
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
|
||||
expect(app).not.toContain('earlier output truncated for performance');
|
||||
});
|
||||
|
||||
it('loads a bounded shell tail first and keeps full history user-triggered', () => {
|
||||
it('loads a bounded shell tail first and keeps unbounded full history user-triggered', () => {
|
||||
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
expect(app).toContain("session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId)");
|
||||
expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'");
|
||||
@@ -131,10 +131,13 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
|
||||
// an abort deadline (a `?full=1` body can be megabytes and used to hang
|
||||
// indefinitely on a stalled mobile link). The URL and the full-vs-tail
|
||||
// decision this guard exists to pin are unchanged.
|
||||
expect(app).toContain('this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?full=1`, { full: true })');
|
||||
expect(app).toContain(': `/api/sessions/${sessionId}/terminal?full=1`,\n { full: true }');
|
||||
expect(app).toContain("if (this.sessions.get(sessionId)?.mode !== 'shell')");
|
||||
expect(app).toContain("if (session?.mode === 'shell')");
|
||||
expect(app).toContain("if (!force && session?.mode === 'shell') return;");
|
||||
// A shell scroll gesture pulls a BOUNDED window of full history; only the
|
||||
// button pulls all of it (behaviour pinned in shell-scroll-history-pull.test.ts).
|
||||
expect(app).toContain("const boundedShellPull = !force && session?.mode === 'shell';");
|
||||
expect(app).toContain('`/api/sessions/${sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`');
|
||||
expect(app).toContain("trigger: force ? 'full-history-button' : 'full-history-scroll'");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* @fileoverview A shell pane's scroll-up must reach the history tmux still holds.
|
||||
*
|
||||
* tmux repaints a burst of output instead of scrolling it, so after `cat` of a
|
||||
* file longer than the screen the browser holds about one screen of scrollback
|
||||
* while tmux holds all of it. Other modes recover it by re-pulling `?full=1`
|
||||
* when the wheel reaches the top (`_maybeRefetchFullHistory`, issue #205). Shell
|
||||
* declined that gesture outright to keep a multi-megabyte capture off xterm's
|
||||
* main thread, leaving only the "Load full history" button, and that button
|
||||
* renders only once a replay was truncated. A young shell tab therefore had no
|
||||
* way to scroll back at all.
|
||||
*
|
||||
* The gesture now pulls a BOUNDED window (`?full=1&tail=TERMINAL_TAIL_SIZE`),
|
||||
* the button stays the unbounded path, and a window the browser already holds
|
||||
* in full is not rewritten.
|
||||
*
|
||||
* ORDER MATTERS: that skip must run BEFORE the downgrade guard. The guard reads
|
||||
* "smaller than the browser" as "tmux has nothing more to give", which is true of
|
||||
* an unbounded capture and false of a window cut at the tail size, so a bounded
|
||||
* window that reached it marked the session exhausted and took Load full history
|
||||
* off the banner while tmux still held the rest. The second block below drives
|
||||
* the real `_setHistoryTruncation` and the real `computeHistoryTruncationNotice`
|
||||
* to pin what the user is actually told.
|
||||
*
|
||||
* The method is extracted from app.js and run in a `vm` against stubs (no jsdom
|
||||
* on this box; see connection-indicator.test.ts), with the REAL row estimators
|
||||
* from terminal-ui.js, which decide both the downgrade and the no-gain skip.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const TERMINAL_TAIL_SIZE = 1024 * 1024;
|
||||
|
||||
function methodSource(source: string, method: string): string {
|
||||
const start = source.search(new RegExp(`^ {2}(?:async )?${method}\\(`, 'm'));
|
||||
expect(start, `${method} not found`).toBeGreaterThan(-1);
|
||||
const next = /^ {2}(?:async )?[A-Za-z_$][\w$]*\(/m.exec(source.slice(start + 1));
|
||||
return next ? source.slice(start, start + 1 + next.index) : source.slice(start);
|
||||
}
|
||||
|
||||
/** Real terminal-ui.js mixin, for `_estimateReplayRows` / `_replayWouldShrinkBuffer`. */
|
||||
function loadTerminalMixin(): Record<string, unknown> {
|
||||
const source = readFileSync(resolve(PUBLIC, 'terminal-ui.js'), 'utf8');
|
||||
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
requestAnimationFrame: vi.fn(),
|
||||
CodemanApp: FakeCodemanApp,
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
document: { addEventListener: vi.fn() },
|
||||
});
|
||||
vm.runInContext(source, context);
|
||||
return FakeCodemanApp.prototype;
|
||||
}
|
||||
|
||||
function loadRefetch(): (this: unknown, opts?: { force?: boolean }) => Promise<void> {
|
||||
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
const body = methodSource(app, '_maybeRefetchFullHistory');
|
||||
const context = vm.createContext({ performance, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE: 32 * 1024 });
|
||||
return vm.runInContext(`({ ${body} })._maybeRefetchFullHistory`, context);
|
||||
}
|
||||
|
||||
/** The REAL `_setHistoryTruncation`, so the banner state a pull leaves behind is what production would hold. */
|
||||
function loadSetHistoryTruncation(): (this: unknown, sessionId: string, payload?: Record<string, unknown>) => void {
|
||||
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
const body = methodSource(app, '_setHistoryTruncation');
|
||||
return vm.runInContext(`({ ${body} })._setHistoryTruncation`, vm.createContext({}));
|
||||
}
|
||||
|
||||
/** The REAL banner decision from constants.js: what the user is told, and whether Load full history is offered. */
|
||||
function loadNotice() {
|
||||
const context = vm.createContext({ console, window: {}, document: {}, navigator: { userAgent: 'test' } });
|
||||
vm.runInContext(
|
||||
`${readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8')}\n;globalThis.__notice = computeHistoryTruncationNotice;`,
|
||||
context,
|
||||
{ filename: 'constants.js' }
|
||||
);
|
||||
return (context as { __notice: (s: Record<string, unknown>) => { visible: boolean; canLoadMore: boolean } }).__notice;
|
||||
}
|
||||
|
||||
const mixin = loadTerminalMixin();
|
||||
const refetch = loadRefetch();
|
||||
const setHistoryTruncation = loadSetHistoryTruncation();
|
||||
const computeNotice = loadNotice();
|
||||
const lines = (n: number) => Array.from({ length: n }, (_, i) => `line ${i}`).join('\r\n');
|
||||
|
||||
/** A `?full=1&tail=` answer whose window was CUT at the tail size: tmux holds ~3 MiB, the window carries 1 MiB. */
|
||||
const TAIL_CUT = {
|
||||
truncated: true,
|
||||
truncationReason: 'tail',
|
||||
fullSize: 3 * 1024 * 1024,
|
||||
retainedBytes: TERMINAL_TAIL_SIZE,
|
||||
source: 'mux-full-history',
|
||||
};
|
||||
|
||||
function makeApp(
|
||||
mode: string,
|
||||
{ bufferRows, capture, payload = {} }: { bufferRows: number; capture: string; payload?: Record<string, unknown> }
|
||||
) {
|
||||
const urls: string[] = [];
|
||||
const app = {
|
||||
activeSessionId: 's1',
|
||||
sessions: new Map([['s1', { mode }]]),
|
||||
detachedSessions: new Set<string>(),
|
||||
_fullHistoryRepullInFlight: false,
|
||||
_isLoadingBuffer: false,
|
||||
_fullHistoryRepullAt: new Map<string, number>(),
|
||||
_fullHistoryRepullUseless: new Set<string>(),
|
||||
terminalBufferCache: new Map<string, string>(),
|
||||
terminal: {
|
||||
cols: 80,
|
||||
rows: 30,
|
||||
buffer: { active: { length: bufferRows } },
|
||||
scrollToLine: vi.fn(),
|
||||
scrollToTop: vi.fn(),
|
||||
},
|
||||
_estimateReplayRows: mixin._estimateReplayRows,
|
||||
_replayWouldShrinkBuffer: mixin._replayWouldShrinkBuffer,
|
||||
_fetchTerminalCapture: vi.fn(async (url: string) => {
|
||||
urls.push(url);
|
||||
return {
|
||||
headersAt: performance.now(),
|
||||
headers: { get: () => '' },
|
||||
json: { data: { terminalBuffer: capture, source: 'mux-full-history', ...payload } },
|
||||
};
|
||||
}),
|
||||
_recordTerminalLoadTiming: vi.fn(),
|
||||
_logScrollRouting: vi.fn(),
|
||||
// The real method behind a spy, so a test sees both what it was called with
|
||||
// and the banner state (`_historyTruncation`) it leaves behind.
|
||||
_historyTruncation: new Map<string, unknown>(),
|
||||
_renderHistoryTruncationBanner: vi.fn(),
|
||||
_setHistoryTruncation: vi.fn((sessionId: string, p?: Record<string, unknown>): void => {
|
||||
setHistoryTruncation.call(app, sessionId, p);
|
||||
}),
|
||||
_resetTerminalForReplay: vi.fn(),
|
||||
_bufferLoadFinishOpts: vi.fn(() => ({})),
|
||||
chunkedTerminalWrite: vi.fn(async () => ({ parsedAt: performance.now(), bufferLength: 400, completed: true })),
|
||||
_syncStickyScrollBaseline: vi.fn(),
|
||||
};
|
||||
return { app, urls };
|
||||
}
|
||||
|
||||
describe('shell scroll-up pulls a bounded window of tmux history', () => {
|
||||
it('a shell scroll gesture requests full history bounded by the tail size', async () => {
|
||||
const { app, urls } = makeApp('shell', { bufferRows: 40, capture: lines(300) });
|
||||
await refetch.call(app);
|
||||
expect(urls).toEqual([`/api/sessions/s1/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`]);
|
||||
// It then actually replays the recovered history.
|
||||
expect(app._resetTerminalForReplay).toHaveBeenCalledTimes(1);
|
||||
expect(app.chunkedTerminalWrite).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('the Load full history button stays unbounded for a shell', async () => {
|
||||
const { app, urls } = makeApp('shell', { bufferRows: 40, capture: lines(300) });
|
||||
await refetch.call(app, { force: true });
|
||||
expect(urls).toEqual(['/api/sessions/s1/terminal?full=1']);
|
||||
});
|
||||
|
||||
it('other modes keep the unbounded scroll pull', async () => {
|
||||
const { app, urls } = makeApp('claude', { bufferRows: 40, capture: lines(300) });
|
||||
await refetch.call(app);
|
||||
expect(urls).toEqual(['/api/sessions/s1/terminal?full=1']);
|
||||
});
|
||||
|
||||
it('a bounded window the browser already holds is not rewritten', async () => {
|
||||
// Browser already has every row the window carries: resetting to rewrite
|
||||
// it would jump the viewport on every scroll that outlasts the cooldown.
|
||||
const { app } = makeApp('shell', { bufferRows: 320, capture: lines(300) });
|
||||
await refetch.call(app);
|
||||
expect(app._resetTerminalForReplay).not.toHaveBeenCalled();
|
||||
expect(app.chunkedTerminalWrite).not.toHaveBeenCalled();
|
||||
// Not latched as useless: more output can put more history in tmux.
|
||||
expect(app._fullHistoryRepullUseless.has('s1')).toBe(false);
|
||||
// Nothing was written, so the banner state is left exactly as it was.
|
||||
expect(app._setHistoryTruncation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a skipped bounded window never damages the Load full history banner', () => {
|
||||
it('a tail-cut window smaller than the browser is not replayed and never marks the session exhausted', async () => {
|
||||
// The browser holds far more rows than a 1 MiB window carries, and tmux holds
|
||||
// ~3 MiB. The downgrade guard reads that as "tmux has nothing more to give",
|
||||
// which is true of an unbounded capture and false of a window cut at the tail.
|
||||
const { app } = makeApp('shell', { bufferRows: 5000, capture: lines(300), payload: TAIL_CUT });
|
||||
// The tab load that put this session on screen left it truncated and recoverable.
|
||||
app._setHistoryTruncation('s1', TAIL_CUT);
|
||||
app._setHistoryTruncation.mockClear();
|
||||
|
||||
await refetch.call(app);
|
||||
|
||||
expect(app._resetTerminalForReplay).not.toHaveBeenCalled();
|
||||
expect(app.chunkedTerminalWrite).not.toHaveBeenCalled();
|
||||
// Not even a relabel: a skipped window writes nothing, banner state included.
|
||||
expect(app._setHistoryTruncation).not.toHaveBeenCalled();
|
||||
const notice = computeNotice(app._historyTruncation.get('s1') as Record<string, unknown>);
|
||||
expect(notice.visible).toBe(true);
|
||||
// "Earlier output is no longer kept" would be a lie: tmux still holds ~2 MiB more.
|
||||
expect(notice.canLoadMore).toBe(true);
|
||||
});
|
||||
|
||||
it('a skip right after Load full history leaves the banner as that load set it', async () => {
|
||||
// Load full history replayed everything, so nothing is truncated any more.
|
||||
const afterLoadFullHistory = {
|
||||
truncated: false,
|
||||
fullSize: 3 * 1024 * 1024,
|
||||
retainedBytes: 3 * 1024 * 1024,
|
||||
source: 'mux-full-history',
|
||||
};
|
||||
const { app } = makeApp('shell', { bufferRows: 5000, capture: lines(300), payload: TAIL_CUT });
|
||||
app._setHistoryTruncation('s1', afterLoadFullHistory);
|
||||
const before = structuredClone(app._historyTruncation.get('s1'));
|
||||
app._setHistoryTruncation.mockClear();
|
||||
|
||||
await refetch.call(app);
|
||||
|
||||
// Relabelling it from the bounded payload would call a terminal that holds ALL
|
||||
// of the history "the most recent 1.0 MB".
|
||||
expect(app._setHistoryTruncation).not.toHaveBeenCalled();
|
||||
expect(app._historyTruncation.get('s1')).toEqual(before);
|
||||
expect(computeNotice(app._historyTruncation.get('s1') as Record<string, unknown>).visible).toBe(false);
|
||||
});
|
||||
|
||||
it('backs off for a minute after a truncated skip, and keeps the 4 s cooldown after an untruncated one', async () => {
|
||||
// Truncated: the gesture cannot reach anything older than the browser shows, and
|
||||
// every ask costs the server a synchronous capture of the whole history.
|
||||
// Only just larger than the window, so the downgrade guard does not fire here:
|
||||
// the back-off has to come from the skip itself.
|
||||
const cut = makeApp('shell', { bufferRows: 320, capture: lines(300), payload: TAIL_CUT });
|
||||
await refetch.call(cut.app);
|
||||
expect(cut.app._fullHistoryRepullUseless.has('s1')).toBe(true);
|
||||
expect(cut.app._fetchTerminalCapture).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Well past 4 s, still inside the minute: no second capture.
|
||||
cut.app._fullHistoryRepullAt.set('s1', Date.now() - 10_000);
|
||||
await refetch.call(cut.app);
|
||||
expect(cut.app._fetchTerminalCapture).toHaveBeenCalledTimes(1);
|
||||
|
||||
cut.app._fullHistoryRepullAt.set('s1', Date.now() - 61_000);
|
||||
await refetch.call(cut.app);
|
||||
expect(cut.app._fetchTerminalCapture).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Untruncated: it IS all of tmux's history, and the next burst can add to it.
|
||||
const whole = makeApp('shell', { bufferRows: 320, capture: lines(300) });
|
||||
await refetch.call(whole.app);
|
||||
expect(whole.app._fullHistoryRepullUseless.has('s1')).toBe(false);
|
||||
whole.app._fullHistoryRepullAt.set('s1', Date.now() - 5000);
|
||||
await refetch.call(whole.app);
|
||||
expect(whole.app._fetchTerminalCapture).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('the downgrade guard still refuses an unbounded capture smaller than the browser, and still marks it exhausted', async () => {
|
||||
// Reordering must not weaken the guard it moved above: a repaint-mode pane's
|
||||
// capture really is one frame, and rewriting with it would destroy history.
|
||||
const oneFrame = makeApp('claude', { bufferRows: 300, capture: lines(36) });
|
||||
await refetch.call(oneFrame.app);
|
||||
expect(oneFrame.app._resetTerminalForReplay).not.toHaveBeenCalled();
|
||||
expect(oneFrame.app._setHistoryTruncation).toHaveBeenCalledWith('s1', expect.objectContaining({ exhausted: true }));
|
||||
expect(oneFrame.app._fullHistoryRepullUseless.has('s1')).toBe(true);
|
||||
|
||||
// The button is unbounded too, so the same guard governs it for a shell.
|
||||
const button = makeApp('shell', { bufferRows: 5000, capture: lines(300) });
|
||||
await refetch.call(button.app, { force: true });
|
||||
expect(button.app._resetTerminalForReplay).not.toHaveBeenCalled();
|
||||
expect(button.app._setHistoryTruncation).toHaveBeenCalledWith('s1', expect.objectContaining({ exhausted: true }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('_replayWouldShrinkBuffer takes rows the caller already estimated', () => {
|
||||
const shrink = mixin._replayWouldShrinkBuffer as (this: unknown, capture: string, rows?: number) => boolean;
|
||||
const make = () => ({
|
||||
terminal: { cols: 80, rows: 30, buffer: { active: { length: 200 } } },
|
||||
_estimateReplayRows: vi.fn(mixin._estimateReplayRows as (t: string, c: number) => number),
|
||||
});
|
||||
|
||||
it('does not scan the capture again when handed the estimate', () => {
|
||||
const ctx = make();
|
||||
expect(shrink.call(ctx, lines(300), 300)).toBe(false);
|
||||
expect(shrink.call(ctx, lines(300), 5)).toBe(true);
|
||||
expect(ctx._estimateReplayRows).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still estimates for itself when called the old way', () => {
|
||||
const ctx = make();
|
||||
expect(shrink.call(ctx, lines(300))).toBe(false);
|
||||
expect(ctx._estimateReplayRows).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -111,12 +111,20 @@ describe('full-history re-pull downgrade guard (issue #205 round 2)', () => {
|
||||
// Anchor on the open paren, not the full empty signature: the method takes
|
||||
// options since #258 ({ force }) and this guard is about ORDER, not arity.
|
||||
const start = source.indexOf('async _maybeRefetchFullHistory(');
|
||||
const guard = source.indexOf('this._replayWouldShrinkBuffer(buffer)', start);
|
||||
// Also anchored on the open paren: the guard is handed the rows the caller
|
||||
// already estimated, and this test is about ORDER, not the argument list.
|
||||
const guard = source.indexOf('this._replayWouldShrinkBuffer(buffer', start);
|
||||
const boundedSkip = source.indexOf('boundedShellPull && windowRows <=', start);
|
||||
const reset = source.indexOf('this._resetTerminalForReplay()', start);
|
||||
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
expect(guard).toBeGreaterThan(start);
|
||||
expect(guard).toBeLessThan(reset); // refuse first, only then reset+rewrite
|
||||
// A bounded shell window is skipped BEFORE the guard sees it: the guard reads
|
||||
// "smaller than the browser" as "tmux has nothing more", which a window cut at
|
||||
// the tail size does not mean (see shell-scroll-history-pull.test.ts).
|
||||
expect(boundedSkip).toBeGreaterThan(start);
|
||||
expect(boundedSkip).toBeLessThan(guard);
|
||||
// A hollow pane must also stop re-fetching megabytes on every scroll-up.
|
||||
expect(source).toContain('this._fullHistoryRepullUseless');
|
||||
expect(source).toContain('this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000');
|
||||
|
||||
Reference in New Issue
Block a user