Files
Codeman/test/terminal-scroll-routing.test.ts
T
Codeman maintainer 9dc4620f03 fix(terminal): stop the scroll-to-top re-pull from deleting history, page the CLI when local scrollback is hollow (#205)
The 1.12.0 retest on #205 reported it still broken in two shapes: a wheel that
did nothing at all on Firefox/macOS (while Fn+Up paged back through intact
text), and iPhone history that went back a little, repeated blocks and got
worse the further up it went. Both come from a Claude pane's LOCAL buffer being
hollow: tmux keeps no history for a repaint-mode pane (history_size 0), so
xterm holds only replayed repaint frames.

1. The scroll-to-top full=1 re-pull now refuses a DOWNGRADE. It resets the
   terminal and rewrites it from the capture, which is a win when tmux holds
   more than the browser, but for a repaint-mode pane that capture is roughly
   ONE frame and the rewrite deleted history mid-scroll. Measured A/B on a live
   pane, same gesture: guard off collapses 341 rows to 42, guard on preserves
   all 341. _replayWouldShrinkBuffer() estimates the capture's rendered rows
   (escapes stripped, capture-pane -J re-wrapping accounted for) and skips the
   rewrite when it is more than one screen short; a refused session's cooldown
   goes from 4s to 60s so a hollow pane stops re-fetching megabytes.

2. A false forwarding gate on a Claude session no longer means a dead gesture.
   Under a triple guard (claude mode, gate false, baseY 0), wheel and touch
   travel becomes coalesced PageUp/PageDown through the same 40ms queue as the
   SGR reports, at half a screen of travel per page key. Shift is excluded: it
   keeps meaning "local scrollback".

3. getClaudeCliVersion() no longer caches FAILURE. It stored null on any
   exception and guarded on !== undefined, so one timed-out or PATH-starved
   probe at the first Claude session start disabled wheel-forwarding for every
   Claude session until the server restarted, which fits a report of breakage on
   phone, tablet and laptop at once. Success is still cached for the process
   lifetime; failures retry with a 1/2/4 up to 15min backoff, and the policy is
   a pure function so the semantics are testable without spawning claude.

4. The terminalWheelLocalScrollback footgun is handled by pairing rather than
   scoping: the setting keeps meaning exactly what it says, and fix 2 catches
   the case where "local" is empty. The App Settings tooltip now says to leave
   it off for Claude/Codex sessions.

5. _logScrollRouting() prints one line per session per distinct decision:
   forward-sgr / page-keys / local-scrollback / repull-refused-downgrade, with
   mode, cliVersion, the opt-out state, mouse tracking and local scrollback
   depth. #205 ran two rounds of remote guesswork over questions that line
   answers directly.

Verified end to end against a real isolated instance (own data dir and tmux
socket) with real wheel events: forwarding still sends SGR reports, the opt-out
now sends real PageUp/PageDown where the wheel was dead, a tab-switch collapse
(401 rows to 44) is still fully recovered by the re-pull (back to 401), and a
seeded 341-row Claude buffer survives the same gesture that destroys it with the
guard disabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 16:41:39 +02:00

241 lines
12 KiB
TypeScript

/**
* Issue #205, round 2: the 1.12.0 retest still reported unusable scrollback —
* a completely dead wheel on Firefox/macOS (while Fn+Up paged back through
* intact text), and history on iPhone that went back a little, repeated blocks
* and got worse the further up it went.
*
* Both signatures come from a Claude pane's LOCAL buffer being hollow. tmux
* keeps no history for a repaint-mode pane (`history_size≈0`), so:
* - any gesture routed to local scrollback scrolls nothing, and
* - the scroll-to-top `?full=1` re-pull replaces a multi-frame buffer with a
* single captured frame, deleting history mid-scroll.
*
* These cover the two guards that fix it: `_replayWouldShrinkBuffer` (refuse a
* downgrading re-pull) and `_maybePageCliTranscript` (page the CLI's own
* transcript when there is nothing local to scroll), plus the diagnostic that
* makes the routing decision visible instead of guessable.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
function loadTerminalUiHarness() {
const CodemanApp = function CodemanApp(this: any) {};
const logs: string[] = [];
const context = vm.createContext({
window: {},
CodemanApp,
console: { warn: vi.fn(), log: (msg: string) => logs.push(msg) },
_crashDiag: { log: vi.fn() },
performance: { now: () => 1_000 },
requestAnimationFrame: (_fn: () => void) => 1,
setTimeout: (_fn: () => void) => 1,
Blob: function Blob() {},
URL: { createObjectURL: () => 'blob:yield', revokeObjectURL: () => {} },
Worker: function Worker(this: any) {
this.postMessage = () => {};
},
MobileDetection: { isTouchDevice: () => true },
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
vm.runInContext(code, context, { filename: 'terminal-ui.js' });
return { app: new (CodemanApp as any)(), logs };
}
/** A Claude session whose local buffer holds exactly one screen (baseY 0). */
function hollowClaudeApp(overrides: { cliVersion?: string; rows?: number } = {}) {
const { app, logs } = loadTerminalUiHarness();
const sent: Array<{ id: string; data: string }> = [];
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: overrides.cliVersion }]]);
app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data });
app.terminal = {
cols: 80,
rows: overrides.rows ?? 36,
modes: { mouseTrackingMode: 'none' },
buffer: { active: { type: 'normal', viewportY: 0, baseY: 0, length: 36 } },
};
return { app, sent, logs };
}
describe('full-history re-pull downgrade guard (issue #205 round 2)', () => {
it('estimates replayed rows from wrapped, escape-laden capture text', () => {
const { app } = loadTerminalUiHarness();
expect(app._estimateReplayRows('a\r\nb\r\nc', 80)).toBe(3);
// SGR colour runs occupy no cells, so they must not inflate the estimate.
expect(app._estimateReplayRows('\x1b[38;5;196mred\x1b[0m\r\nplain', 80)).toBe(2);
// capture-pane -J joins wrapped rows, so a long logical line re-wraps on
// write — counting newlines alone would undershoot by 2 rows here.
expect(app._estimateReplayRows('x'.repeat(25), 10)).toBe(3);
expect(app._estimateReplayRows('', 80)).toBe(0);
expect(app._estimateReplayRows(undefined, 80)).toBe(0);
});
it('refuses a capture that would leave LESS history than the terminal holds', () => {
const { app } = loadTerminalUiHarness();
app.terminal = { cols: 80, rows: 36, buffer: { active: { length: 300 } } };
// Claude pane: tmux has no history, so the capture is one frame while xterm
// holds hundreds of replayed rows. Rewriting would delete them mid-scroll.
const oneFrame = Array.from({ length: 36 }, (_, i) => `frame line ${i}`).join('\r\n');
expect(app._replayWouldShrinkBuffer(oneFrame)).toBe(true);
// Shell pane after a burst/tab-switch collapse: tmux really does hold more.
const realHistory = Array.from({ length: 800 }, (_, i) => `history ${i}`).join('\r\n');
expect(app._replayWouldShrinkBuffer(realHistory)).toBe(false);
});
it('tolerates a one-screen shortfall so ordinary recoveries still replay', () => {
const { app } = loadTerminalUiHarness();
// buffer.active.length counts the blank rows under the last line and the row
// estimate can only approximate wrapping, so a near-tie must NOT read as a
// downgrade — only a capture worse by more than a full screen does.
app.terminal = { cols: 80, rows: 36, buffer: { active: { length: 120 } } };
expect(app._replayWouldShrinkBuffer(Array.from({ length: 100 }, () => 'x').join('\r\n'))).toBe(false);
expect(app._replayWouldShrinkBuffer(Array.from({ length: 40 }, () => 'x').join('\r\n'))).toBe(true);
});
it('never refuses when the terminal has no buffer to protect', () => {
const { app } = loadTerminalUiHarness();
app.terminal = { cols: 80, rows: 36, buffer: { active: { length: 0 } } };
expect(app._replayWouldShrinkBuffer('anything')).toBe(false);
});
it('is wired into _maybeRefetchFullHistory BEFORE the destructive reset', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const start = source.indexOf('async _maybeRefetchFullHistory()');
const guard = source.indexOf('this._replayWouldShrinkBuffer(buffer)', 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 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');
});
});
describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2)', () => {
it('pages the CLI transcript when the wheel gate is false and there is no scrollback', () => {
const { app, sent } = hollowClaudeApp(); // cliVersion unknown → gate false
// Half a screen of travel (rows 36 → 18 lines) buys exactly one PageUp.
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
app._flushWheelSgrQueue();
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
// Downward travel pages back toward the live screen.
app._maybePageCliTranscript({ shiftKey: false }, 18);
app._flushWheelSgrQueue();
expect(sent[1]).toEqual({ id: 'sess-1', data: '\x1b[6~' });
});
it('accumulates sub-page travel instead of dropping or over-sending it', () => {
const { app, sent } = hollowClaudeApp();
expect(app._maybePageCliTranscript({ shiftKey: false }, -10)).toBe(true); // consumed…
app._flushWheelSgrQueue();
expect(sent).toEqual([]); // …but below the threshold, so nothing sent yet
app._maybePageCliTranscript({ shiftKey: false }, -8); // -18 total → one page
app._flushWheelSgrQueue();
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
});
it('caps the keys one gesture batch can emit', () => {
const { app, sent } = hollowClaudeApp();
app._maybePageCliTranscript({ shiftKey: false }, -1000); // 55 pages of travel
app._flushWheelSgrQueue();
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~'.repeat(3) }]);
});
it('leaves every session that has real local scrollback alone', () => {
const { app } = hollowClaudeApp();
// Shift is the explicit "give me local scrollback" gesture — never paged.
expect(app._maybePageCliTranscript({ shiftKey: true }, -18)).toBe(false);
// A buffer with history scrolls locally, as before.
app.terminal.buffer.active.baseY = 120;
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
app.terminal.buffer.active.baseY = 0;
// Non-Claude modes keep their existing behavior (shell scrolls tmux history
// through the alt-screen strip; codex/gemini page keys are unverified).
app.sessions = new Map([['sess-1', { mode: 'shell' }]]);
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
// An alternate-screen pane belongs to xterm's own alt-scroll handling.
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app.terminal.buffer.active.type = 'alternate';
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
});
it('rescues the local-scrollback opt-out footgun instead of silently dying', () => {
// "Wheel scrolls local history" ON pins the wheel to a buffer that, for a
// repaint-mode CLI, is empty — a user who flipped it while hunting for a fix
// on 1.11.x would have ended up with a completely dead wheel on 1.12.0.
const { app, sent } = hollowClaudeApp({ cliVersion: '2.1.223' }); // gate would forward…
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); // …but the opt-out wins
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
app._flushWheelSgrQueue();
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
});
it('drops travel accumulated on another tab', () => {
const { app, sent } = hollowClaudeApp();
app._maybePageCliTranscript({ shiftKey: false }, -17); // just short of a page
app.activeSessionId = 'sess-2';
app.sessions.set('sess-2', { mode: 'claude' });
app._maybePageCliTranscript({ shiftKey: false }, -1); // must not complete sess-1's page
app._flushWheelSgrQueue();
expect(sent).toEqual([]);
});
it('is reachable from both the wheel and the touch paths', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
// Wheel: after the forwarding gate, before the local smooth scroll.
expect(source).toContain('if (this._maybePageCliTranscript(ev, lines)) return;');
// Touch: touchmove and the momentum loop both fall through to it.
expect(source.match(/else if \(!this\._maybePageCliTranscript\(\{ shiftKey: false \}, lines\)\)/g)).toHaveLength(2);
});
});
describe('scroll routing diagnostic (issue #205 round 2)', () => {
it('prints the decision and its inputs once per session, and again when it changes', () => {
const { app, logs } = hollowClaudeApp({ cliVersion: '2.1.100' });
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false });
app._logScrollRouting('local-scrollback');
app._logScrollRouting('local-scrollback'); // same decision → stays quiet
expect(logs).toHaveLength(1);
expect(logs[0]).toContain('sess-1 → local-scrollback');
expect(logs[0]).toContain('mode=claude');
expect(logs[0]).toContain('cliVersion=2.1.100');
expect(logs[0]).toContain('localScrollbackOptOut=false');
expect(logs[0]).toContain('mouseTracking=none');
app._logScrollRouting('page-keys'); // a changed route still prints
expect(logs).toHaveLength(2);
expect(logs[1]).toContain('page-keys');
});
it('reports an unknown CLI version, the false-path that disables forwarding', () => {
const { app, logs } = hollowClaudeApp(); // no cliVersion — the probe failed
app._logScrollRouting('page-keys');
expect(logs[0]).toContain('cliVersion=unknown');
});
});