mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Closes #259, closes #258. Both bottom out in the same gap: nothing tracked whether the user was following live output or reading history. #259 — the keyboard path forced the terminal to the bottom unconditionally (onKeyboardShow/onKeyboardHide passed scrollToBottom:true, applied with no check), so opening the keyboard while scrolled up yanked the user down. The settle cycle now captures intent on its FIRST event, before any fit() has reflowed the buffer, and returns to that anchor when the user was reading. A later capture would read an already-moved viewportY, which is why the capture point matters. The param is renamed restoreScroll to match. Separately, flushPendingWrites gated viewport preservation on _hasRecentUserScrollUp(), a 1500ms decay window, so a user who scrolled up and then actually READ for longer lost protection mid-read. Being scrolled up IS the intent however long ago it was expressed, so it now keys off position. The recency window stays as a race guard on the sticky scroll-to-bottom. The full-history repull already held the user's place and is unchanged. #258 — truncation was reported by a grey line written INTO the terminal ("earlier output truncated"), which scrolls away with the output it describes, cannot be acted on, and said the same thing whether the rest was one click away or gone forever. The server set one `truncated` boolean at two sites meaning opposite things, and the client discarded fullSize and source entirely. The route now reports truncationReason ('tail' = intentional partial replay, the rest is retained; 'capped' = the byte ceiling dropped it) plus retainedBytes, and 'capped' is not downgraded by a later tail cut. The client renders a dismissible banner outside terminal output with three honest states: recoverable (offers Load full history), at-ceiling, and exhausted. The Load button forces past the scroll cooldown but NOT past _replayWouldShrinkBuffer, which still refuses a downgrade for repaint-mode panes. The banner is an overlay, not a flex child: FitAddon derives rows/cols from the terminal parent's computed height, so occupying real layout space would SIGWINCH the CLI on every truncation-state change. Verified in a real browser on the 7 skins: banner text and button clear 4.5:1 contrast on all of them, and terminal height is byte-identical with the banner shown. The first cut used --bg-elevated and --accent-muted, which do not exist, so light skins rendered a hardcoded dark bar under dark text; it now uses only tokens every skin redefines. test/terminal-scroll-intent.test.ts lives outside test/mobile/ deliberately — that suite is excluded from test:ci, so a guard placed there is invisible to CI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
243 lines
12 KiB
TypeScript
243 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');
|
|
// 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);
|
|
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');
|
|
});
|
|
});
|