mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
transcriptPageKeys applied to every pi, and a pi in `tuiMode: regular` binds PageUp/PageDown to its editor. It emits no mouse enable, so the flag stays false, and on a fresh pane the buffer is hollow: the wheel sent PageUp, the caret of a multi-line draft moved, and the next keystroke landed on the wrong line. Measured on real pi 1.1.0, 1.4.0 and 1.5.0. Regular mode is not exotic, it only takes one line in pi's settings. Telling the two modes apart would need a new capability, since the paging gate takes no id, so pi has no fallback: a fullscreen pi with "Wheel scrolls local history" on stays where master left it. altScreen, the half of the fix that does the work, was pinned by nothing: reverting it left 1328 tests green. The strip decision table reads its expected value from the same registry entry. pi now has its own block using the bytes it really emits, which also pins that focus reporting survives. The fullscreen default dates from pi 1.0.0, not 1.4.0, so the comments say that, and the sites still calling pi narrow-strip or main-screen by default are corrected. Where a comment listed CLIs it now names the capability. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1343 lines
60 KiB
TypeScript
1343 lines
60 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';
|
||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||
|
||
/**
|
||
* The run modes whose CLI can page its own transcript with PageUp/PageDown, read
|
||
* off the shipped registry exactly as the server builds `__codemanTranscriptPageKeys`,
|
||
* so these tests break if a stock entry loses (or quietly gains) the capability.
|
||
*/
|
||
const PAGE_KEY_MODES = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id);
|
||
|
||
function loadTerminalUiHarness(
|
||
windowGlobals: Record<string, unknown> = { __codemanTranscriptPageKeys: PAGE_KEY_MODES }
|
||
) {
|
||
const CodemanApp = function CodemanApp(this: any) {};
|
||
const logs: string[] = [];
|
||
// terminal-ui.js hangs CodemanTerminalInput off window; tests read it there.
|
||
const windowRef: Record<string, any> = { ...windowGlobals };
|
||
// Mutable so a test can move time (gesture gaps); defaults to a frozen clock.
|
||
const timers = new Map<number, { due: number; fn: () => void }>();
|
||
let timerId = 0;
|
||
const clock = {
|
||
now: () => 1_000,
|
||
flushTimers: () => {
|
||
for (const [id, timer] of timers)
|
||
if (timer.due <= clock.now()) {
|
||
timers.delete(id);
|
||
timer.fn();
|
||
}
|
||
},
|
||
};
|
||
const context = vm.createContext({
|
||
window: windowRef,
|
||
CodemanApp,
|
||
console: { warn: vi.fn(), log: (msg: string) => logs.push(msg) },
|
||
_crashDiag: { log: vi.fn() },
|
||
performance: clock,
|
||
requestAnimationFrame: (_fn: () => void) => 1,
|
||
setTimeout: (fn: () => void, ms = 0) => {
|
||
const id = ++timerId;
|
||
timers.set(id, { due: clock.now() + ms, fn });
|
||
return id;
|
||
},
|
||
clearTimeout: (id: number) => timers.delete(id),
|
||
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,
|
||
OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/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, windowRef, clock };
|
||
}
|
||
|
||
/** A session whose local buffer holds exactly one screen (baseY 0) — a hollow pane. */
|
||
function hollowApp(overrides: { mode?: string; cliVersion?: string; rows?: number; cliMouseTracking?: boolean } = {}) {
|
||
const { app, logs, windowRef, clock } = loadTerminalUiHarness();
|
||
const sent: Array<{ id: string; data: string }> = [];
|
||
app.activeSessionId = 'sess-1';
|
||
app.sessions = new Map([
|
||
[
|
||
'sess-1',
|
||
{
|
||
mode: overrides.mode ?? 'claude',
|
||
cliVersion: overrides.cliVersion,
|
||
cliMouseTracking: overrides.cliMouseTracking,
|
||
},
|
||
],
|
||
]);
|
||
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, windowRef, clock };
|
||
}
|
||
|
||
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(');
|
||
// 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 <= rowsNow || browserFull)', 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');
|
||
});
|
||
});
|
||
|
||
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 } = hollowApp(); // 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('answers the first event of a gesture at once and owes the travel back', () => {
|
||
const { app, sent } = hollowApp();
|
||
|
||
// A trackpad flick opens with a small delta and stays far short of half a
|
||
// screen (18 rows here). It used to send nothing at all, so a session that
|
||
// always lands here looked dead.
|
||
expect(app._maybePageCliTranscript({ shiftKey: false }, -1)).toBe(true);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
|
||
|
||
// The pre-paid page is owed back: the rest of this page's travel sends nothing…
|
||
app._maybePageCliTranscript({ shiftKey: false }, -17);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toHaveLength(1);
|
||
|
||
// …and the next page arrives after a further half screen, so the rate is unchanged.
|
||
app._maybePageCliTranscript({ shiftKey: false }, -17);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toHaveLength(1);
|
||
app._maybePageCliTranscript({ shiftKey: false }, -1);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([
|
||
{ id: 'sess-1', data: '\x1b[5~' },
|
||
{ id: 'sess-1', data: '\x1b[5~' },
|
||
]);
|
||
});
|
||
|
||
it('starts a new gesture after a pause or a direction change', () => {
|
||
const { app, sent, clock } = hollowApp();
|
||
let now = 1_000;
|
||
clock.now = () => now;
|
||
|
||
app._maybePageCliTranscript({ shiftKey: false }, -1); // first event: one PageUp
|
||
app._maybePageCliTranscript({ shiftKey: false }, 1); // reversal inside the gap: accumulates, no page
|
||
now += 200;
|
||
clock.flushTimers();
|
||
app._maybePageCliTranscript({ shiftKey: false }, -1); // a reversal after the gap: a PageUp at once
|
||
now += 1_100;
|
||
clock.flushTimers();
|
||
app._maybePageCliTranscript({ shiftKey: false }, 1); // after a pause: a PageDown at once
|
||
app._maybePageCliTranscript({ shiftKey: false }, 0.05); // sub-row jitter in the same gesture: nothing
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([
|
||
{ id: 'sess-1', data: '\x1b[5~' },
|
||
{ id: 'sess-1', data: '\x1b[6~' },
|
||
]);
|
||
});
|
||
|
||
it('accumulates coarse wheel notches across pauses instead of prepaying each opening', () => {
|
||
// Coarse events accumulate as on master, even across pauses; they do not
|
||
// pre-pay a full page each time a new gesture starts.
|
||
for (const gapMs of [250, 40, 1200]) {
|
||
const { app, sent, clock } = hollowApp();
|
||
let now = 1_000;
|
||
clock.now = () => now;
|
||
for (let i = 0; i < 5; i++) {
|
||
expect(app._maybePageCliTranscript({ shiftKey: false, deltaY: -100 }, -4)).toBe(true);
|
||
now += gapMs;
|
||
clock.flushTimers();
|
||
}
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
|
||
}
|
||
});
|
||
|
||
it('consumes a mostly horizontal swipe without paging', () => {
|
||
const { app, sent } = hollowApp();
|
||
|
||
// A sideways trackpad swipe carries a little vertical drift (3 px is 0.12 rows,
|
||
// above the jitter floor). It must not page the transcript.
|
||
for (let i = 0; i < 6; i++) {
|
||
expect(app._maybePageCliTranscript({ shiftKey: false, deltaX: 60, deltaY: 3 }, 0.12)).toBe(true);
|
||
}
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([]);
|
||
|
||
// A mostly vertical swipe with some sideways drift still pages.
|
||
app._maybePageCliTranscript({ shiftKey: false, deltaX: 3, deltaY: -25 }, -1);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
|
||
});
|
||
|
||
it('consumes a trackpad pinch without paging', () => {
|
||
const { app, sent } = hollowApp();
|
||
|
||
// Chrome reports a pinch as wheel events with ctrlKey set.
|
||
for (let i = 0; i < 4; i++) {
|
||
expect(app._maybePageCliTranscript({ shiftKey: false, ctrlKey: true, deltaY: 4 }, 0.16)).toBe(true);
|
||
}
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([]);
|
||
});
|
||
|
||
it('does not page while the session is showing a dialog', () => {
|
||
const { app, sent } = hollowApp();
|
||
// 'action' is set while a permission_prompt or elicitation_dialog is pending
|
||
// (updateTabAlertFromHooks in app.js). Page keys must not reach that selector.
|
||
app.tabAlerts = new Map([['sess-1', 'action']]);
|
||
|
||
expect(app._maybePageCliTranscript({ shiftKey: false }, -1)).toBe(true);
|
||
expect(app._maybePageCliTranscript({ shiftKey: false }, -40)).toBe(true);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([]);
|
||
|
||
// Once the dialog is answered (an idle alert, or none) paging resumes.
|
||
app.tabAlerts.set('sess-1', 'idle');
|
||
app._maybePageCliTranscript({ shiftKey: false }, 1);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[6~' }]);
|
||
});
|
||
|
||
it("reads the ACTIVE session's dialog, not some other tab's", () => {
|
||
const { app, sent } = hollowApp();
|
||
// A tile or a background tab with a pending prompt does not block this pane.
|
||
app.tabAlerts = new Map([['tile-1', 'action']]);
|
||
|
||
app._maybePageCliTranscript({ shiftKey: false }, -1);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
|
||
});
|
||
|
||
it('does not page on sub-row jitter that opens a gesture', () => {
|
||
const { app, sent } = hollowApp();
|
||
|
||
expect(app._maybePageCliTranscript({ shiftKey: false }, -0.05)).toBe(true);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([]);
|
||
});
|
||
|
||
it('caps the keys one gesture batch can emit', () => {
|
||
const { app, sent } = hollowApp();
|
||
|
||
app._maybePageCliTranscript({ shiftKey: false }, -1000); // 55 pages of travel
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~'.repeat(3) }]);
|
||
});
|
||
|
||
it('pages a Copilot pane when the tracking flag is stale, so the wheel never goes dead', () => {
|
||
// Copilot pages its own transcript on PageUp/PageDown (three PageUp moved lines 109-140 to
|
||
// 010-042 against a 140-line answer in tmux, and PageDown returned). While it tracks the mouse
|
||
// the wheel is forwarded as SGR reports; with the flag stale (a server restart) this is the
|
||
// gesture's fallback, on an alternate-screen TUI whose local buffer is one screen.
|
||
const { app, sent } = hollowApp({ mode: 'copilot', cliMouseTracking: false });
|
||
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
|
||
|
||
app._maybePageCliTranscript({ shiftKey: false }, 18);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent[1]).toEqual({ id: 'sess-1', data: '\x1b[6~' });
|
||
});
|
||
|
||
it('does not page a Copilot pane that has real local scrollback', () => {
|
||
const { app, sent } = hollowApp({ mode: 'copilot' });
|
||
app.terminal.buffer.active.baseY = 200;
|
||
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([]);
|
||
});
|
||
|
||
it('pages an OpenCode pane too, whose TUI never fills the local buffer', () => {
|
||
// OpenCode's TUI runs on the ALTERNATE SCREEN (measured on 1.18.31: tmux
|
||
// `alternate_on=1`, `history_size=0`), so the browser's normal buffer stays at
|
||
// one screen exactly like a repaint-mode Claude pane. The difference is that
|
||
// OpenCode IGNORES SGR wheel reports (verified against an idle pane: six
|
||
// `\x1b[<64;…M` reports left the capture byte-identical), so PageUp/PageDown
|
||
// — its `messages_page_up/down` binds — is the ONLY gesture that reaches its
|
||
// transcript. Without this the wheel was silently dead in every OpenCode tab.
|
||
const { app, sent } = hollowApp({ mode: 'opencode' });
|
||
|
||
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
|
||
|
||
app._maybePageCliTranscript({ shiftKey: false }, 18);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent[1]).toEqual({ id: 'sess-1', data: '\x1b[6~' });
|
||
});
|
||
|
||
it('leaves every session that has real local scrollback alone', () => {
|
||
const { app } = hollowApp();
|
||
|
||
// 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;
|
||
|
||
// Modes whose CLI does not declare transcriptPageKeys keep their existing
|
||
// behavior (shell/pi own tmux history through the alt-screen strip;
|
||
// gemini/antigravity/… page-key behaviour is unverified — docs/scrollback-fix-plan.md).
|
||
app.sessions = new Map([['sess-1', { mode: 'shell' }]]);
|
||
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
|
||
app.sessions = new Map([['sess-1', { mode: 'gemini' }]]);
|
||
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
|
||
app.sessions = new Map([['sess-1', { mode: 'antigravity' }]]);
|
||
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('falls through to local scrolling when tmux still holds the scrollback the replay left out', () => {
|
||
// After a server restart a tab switch replays a byte tail that is only the
|
||
// tmux client's attach repaint: baseY 0, while the same response reported
|
||
// tmux scrollback (`paneHistoryLines`). Paging there sent PageUp into an
|
||
// inline Claude's composer; the local path's scroll-to-top pull
|
||
// (_maybeRefetchFullHistory) is what loads that scrollback.
|
||
const { app, sent, logs } = hollowApp();
|
||
app._pageKeyGesture = { stale: true };
|
||
// A page key queued before the count was known must not reach the CLI either.
|
||
app._wheelSgrQueue = '\x1b[5~';
|
||
app._paneHistoryLines = new Map([['sess-1', 64]]);
|
||
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
|
||
expect(app._maybePageCliTranscript({ shiftKey: false }, 18)).toBe(false);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([]);
|
||
expect(app._pageKeyGesture).toBeNull();
|
||
// No routing line of its own: the caller's `local-scrollback` line (with
|
||
// `localScrollbackRows=0`) is the record, and a second line per frame
|
||
// would alternate with it until the pull lands.
|
||
expect(logs).toEqual([]);
|
||
|
||
// 0 is a pane that keeps no scrollback (fullscreen claude, codex, opencode):
|
||
// paged as before, and so is unknown (no capture yet, an older server).
|
||
app._paneHistoryLines.set('sess-1', 0);
|
||
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
|
||
app._paneHistoryLines.delete('sess-1');
|
||
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
|
||
delete app._paneHistoryLines;
|
||
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
|
||
|
||
// An open dialog still wins: nothing scrolls under it and no pull starts.
|
||
app._paneHistoryLines = new Map([['sess-1', 64]]);
|
||
app.tabAlerts = new Map([['sess-1', 'action']]);
|
||
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
|
||
});
|
||
|
||
it('pages a Codex transcript only while its local scrollback is empty', () => {
|
||
// Codex without observed mouse tracking retains its PageUp/PageDown fallback.
|
||
const { app, sent } = hollowApp();
|
||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
|
||
|
||
app.terminal.buffer.active.baseY = 40;
|
||
expect(app._maybePageCliTranscript({ shiftKey: false }, 18)).toBe(false);
|
||
expect(app._maybePageCliTranscript({ shiftKey: true }, 18)).toBe(false);
|
||
});
|
||
|
||
it('reads the paging modes from the injected capability map, never an id list of its own', () => {
|
||
// No map (a page rendered without it) means no mode pages, the same fail-safe
|
||
// direction the transcript-gutter map takes.
|
||
const bare = loadTerminalUiHarness({}).app;
|
||
bare.activeSessionId = 'sess-1';
|
||
bare.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||
bare.terminal = { rows: 36, buffer: { active: { type: 'normal', baseY: 0 } } };
|
||
expect(bare._localScrollbackIsHollow()).toBe(false);
|
||
|
||
// A mode the map names pages even if no stock entry has that id.
|
||
const custom = loadTerminalUiHarness({ __codemanTranscriptPageKeys: ['my-cli'] }).app;
|
||
custom.activeSessionId = 'sess-1';
|
||
custom.sessions = new Map([['sess-1', { mode: 'my-cli' }]]);
|
||
custom.terminal = { rows: 36, buffer: { active: { type: 'normal', baseY: 0 } } };
|
||
expect(custom._localScrollbackIsHollow()).toBe(true);
|
||
|
||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||
const helper = source.slice(
|
||
source.indexOf(' _localScrollbackIsHollow(target = {}) {'),
|
||
source.indexOf(' _maybePageCliTranscript(ev, lines) {')
|
||
);
|
||
expect(helper).toContain('window.__codemanTranscriptPageKeys');
|
||
expect(helper).not.toMatch(/=== '(?:claude|codex)'|!== '(?:claude|codex)'/);
|
||
});
|
||
|
||
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.
|
||
// Version and tracking both qualify, so the opt-out is the only thing saying no.
|
||
const { app, sent } = hollowApp({ cliVersion: '2.1.223', cliMouseTracking: true }); // 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 } = hollowApp();
|
||
|
||
app._maybePageCliTranscript({ shiftKey: false }, -1); // first event pages sess-1 at once
|
||
app._maybePageCliTranscript({ shiftKey: false }, -34); // just short of sess-1's second page
|
||
app._flushWheelSgrQueue();
|
||
app.activeSessionId = 'sess-2';
|
||
app.sessions.set('sess-2', { mode: 'claude' });
|
||
app._maybePageCliTranscript({ shiftKey: false }, -1); // a NEW gesture on sess-2, never sess-1's page
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([
|
||
{ id: 'sess-1', data: '\x1b[5~' },
|
||
{ id: 'sess-2', data: '\x1b[5~' },
|
||
]);
|
||
});
|
||
|
||
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, touchId: touchContactId \}, lines\)\)/g
|
||
)
|
||
).toHaveLength(2);
|
||
// ...and every contact gets its own id at touchstart, so a contact is one gesture.
|
||
expect(source).toContain('touchContactId++;');
|
||
});
|
||
});
|
||
|
||
describe('page-key gestures across slow touch drags, slow wheel ticks and jitter (#527 review)', () => {
|
||
const UP = '\x1b[5~';
|
||
const DOWN = '\x1b[6~';
|
||
|
||
/** A hollow 38-row pane (19 rows a page) on a hand-moved clock, with every key sent. */
|
||
function pane(mode = 'codex') {
|
||
const h = hollowApp({ mode, rows: 38 });
|
||
let now = 10_000;
|
||
h.clock.now = () => now;
|
||
const keys = () => {
|
||
h.app._flushWheelSgrQueue();
|
||
return h.sent.map((s) => s.data).join('');
|
||
};
|
||
return {
|
||
...h,
|
||
keys,
|
||
advance: (ms: number) => {
|
||
now += ms;
|
||
h.clock.flushTimers();
|
||
},
|
||
};
|
||
}
|
||
const count = (keys: string, key: string) => keys.split(key).length - 1;
|
||
|
||
it('keeps one touch contact one gesture, however slowly the finger moves', () => {
|
||
const p = pane();
|
||
// touchmove calls once per whole cell, so a slow drag arrives as single lines
|
||
// 170 ms apart, each past the 150 ms gesture gap. It sent one PageUp per line.
|
||
for (let i = 0; i < 12; i++) {
|
||
expect(p.app._maybePageCliTranscript({ shiftKey: false, touchId: 1 }, -1)).toBe(true);
|
||
p.advance(170);
|
||
}
|
||
expect(p.keys()).toBe(UP);
|
||
|
||
// A new contact is a new gesture: its first line pages at once.
|
||
p.app._maybePageCliTranscript({ shiftKey: false, touchId: 2 }, -1);
|
||
expect(p.keys()).toBe(UP + UP);
|
||
});
|
||
|
||
it('pages a slow 40-line drag twice, and keeps momentum in the same gesture', () => {
|
||
const p = pane();
|
||
for (let i = 0; i < 40; i++) {
|
||
p.app._maybePageCliTranscript({ shiftKey: false, touchId: 7 }, -1);
|
||
p.advance(170);
|
||
}
|
||
// 1 at once, then 18 owed back, then 19 more: two pages by line 38.
|
||
expect(count(p.keys(), UP)).toBe(2);
|
||
|
||
// Momentum frames after the finger lifts carry the same contact: they
|
||
// continue the drag's travel instead of opening a gesture of their own.
|
||
for (let i = 0; i < 10; i++) {
|
||
p.app._maybePageCliTranscript({ shiftKey: false, touchId: 7 }, -1);
|
||
p.advance(16);
|
||
}
|
||
expect(count(p.keys(), UP)).toBe(2);
|
||
});
|
||
|
||
it('never splits a contact on a pause, but does not carry its debt into the next contact', () => {
|
||
const p = pane();
|
||
p.app._maybePageCliTranscript({ shiftKey: false, touchId: 3 }, -1); // pages, owes 18
|
||
p.advance(5_000); // the finger rests, still down
|
||
p.app._maybePageCliTranscript({ shiftKey: false, touchId: 3 }, -1);
|
||
expect(p.keys()).toBe(UP);
|
||
p.advance(10);
|
||
p.app._maybePageCliTranscript({ shiftKey: false, touchId: 4 }, -1); // a new contact pages at once
|
||
expect(p.keys()).toBe(UP + UP);
|
||
});
|
||
|
||
it('pages slow small wheel ticks once per half screen, not once per tick', () => {
|
||
// Five 40 px ticks 250 ms apart (1.6 rows each, under the 2-row trackpad
|
||
// size) used to page once per tick. The first one still pages at once.
|
||
const p = pane();
|
||
for (let i = 0; i < 5; i++) {
|
||
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -40, deltaMode: 0 }, -1.6);
|
||
p.advance(250);
|
||
}
|
||
expect(p.keys()).toBe(UP);
|
||
|
||
// Firefox reports a notch as 1 line (deltaMode 1): the same.
|
||
const f = pane();
|
||
for (let i = 0; i < 5; i++) {
|
||
f.app._maybePageCliTranscript({ shiftKey: false, deltaY: -1, deltaMode: 1 }, -1);
|
||
f.advance(250);
|
||
}
|
||
expect(f.keys()).toBe(UP);
|
||
});
|
||
|
||
it('lets a pre-paid debt lapse after a second of quiet, so the next tick pages at once again', () => {
|
||
const p = pane();
|
||
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -40 }, -1.6);
|
||
p.advance(900);
|
||
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -40 }, -1.6); // inside the second: owed
|
||
expect(p.keys()).toBe(UP);
|
||
p.advance(1_100);
|
||
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -40 }, -1.6); // debt lapsed: pages
|
||
expect(p.keys()).toBe(UP + UP);
|
||
});
|
||
|
||
it('does not page back and forth on a +/-3 px trackpad wobble', () => {
|
||
const p = pane();
|
||
for (let i = 0; i < 20; i++) {
|
||
const dy = i % 2 ? 3 : -3;
|
||
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: dy }, dy / 25);
|
||
p.advance(16);
|
||
}
|
||
// A reversal inside the 40ms batch window cancels the unsent opening
|
||
// PageUp; subsequent in-gesture reversals do not pre-pay another page.
|
||
expect(p.keys()).toBe(''); // the opening report was cancelled before its 40ms flush
|
||
});
|
||
|
||
it('still pages a deliberate reversal at once after a pause', () => {
|
||
const p = pane();
|
||
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -25 }, -1);
|
||
p.advance(200);
|
||
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 }, 1);
|
||
expect(p.keys()).toBe(UP + DOWN);
|
||
});
|
||
|
||
it('does not let a reversal inherit the other direction’s debt', () => {
|
||
const p = pane();
|
||
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -25 }, -1); // UP, owes 18 upward
|
||
p.advance(16);
|
||
// Downward travel just short of a page: the upward debt must not count toward it.
|
||
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 * 18 }, 18);
|
||
expect(p.keys()).toBe(''); // reversal discards the unsent UP
|
||
p.advance(16);
|
||
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 }, 1);
|
||
expect(p.keys()).toBe(DOWN);
|
||
});
|
||
|
||
it('ends the one-second debt lifetime once the debt is repaid, exactly or overpaid', () => {
|
||
// 38 rows: 19 a page. The opening -1 pre-pays and owes 18; 18 more lines at
|
||
// 32 ms repay it EXACTLY. Nothing is owed any more, so the 150 ms gap applies
|
||
// again and a -1 300 ms later is a new gesture that pages at once.
|
||
const exact = pane();
|
||
for (let i = 0; i < 19; i++) {
|
||
exact.app._maybePageCliTranscript({ shiftKey: false }, -1);
|
||
exact.advance(32);
|
||
}
|
||
expect(exact.keys()).toBe(UP);
|
||
expect(exact.app._pageKeyGesture).toMatchObject({ pending: 0, prepaid: false });
|
||
exact.advance(300 - 32);
|
||
exact.app._maybePageCliTranscript({ shiftKey: false }, -1);
|
||
expect(exact.keys()).toBe(UP + UP);
|
||
|
||
// Overpaid: 19 lines after the opening one leave 1 row of plain travel.
|
||
const over = pane();
|
||
for (let i = 0; i < 20; i++) {
|
||
over.app._maybePageCliTranscript({ shiftKey: false }, -1);
|
||
over.advance(32);
|
||
}
|
||
expect(over.keys()).toBe(UP);
|
||
expect(over.app._pageKeyGesture).toMatchObject({ pending: -1, prepaid: false });
|
||
over.advance(300 - 32);
|
||
over.app._maybePageCliTranscript({ shiftKey: false }, -1);
|
||
expect(over.keys()).toBe(UP + UP);
|
||
|
||
// One line short of repaid: still owed, so 300 ms later is the same gesture.
|
||
const owed = pane();
|
||
for (let i = 0; i < 18; i++) {
|
||
owed.app._maybePageCliTranscript({ shiftKey: false }, -1);
|
||
owed.advance(32);
|
||
}
|
||
expect(owed.app._pageKeyGesture).toMatchObject({ pending: 1, prepaid: true });
|
||
owed.advance(300 - 32);
|
||
owed.app._maybePageCliTranscript({ shiftKey: false }, -1);
|
||
expect(owed.keys()).toBe(UP);
|
||
});
|
||
|
||
it('keeps a touch contact one gesture after its debt is repaid', () => {
|
||
const p = pane();
|
||
for (let i = 0; i < 19; i++) {
|
||
p.app._maybePageCliTranscript({ shiftKey: false, touchId: 5 }, -1);
|
||
p.advance(32);
|
||
}
|
||
p.advance(2_000); // the finger rests, still down
|
||
p.app._maybePageCliTranscript({ shiftKey: false, touchId: 5 }, -1);
|
||
expect(p.keys()).toBe(UP); // accumulates; no fresh pre-pay inside the contact
|
||
});
|
||
|
||
it('logs a wheel the dialog check consumed', () => {
|
||
const p = pane();
|
||
p.app.tabAlerts = new Map([['sess-1', 'action']]);
|
||
expect(p.app._maybePageCliTranscript({ shiftKey: false }, -1)).toBe(true);
|
||
expect(p.keys()).toBe('');
|
||
expect(p.logs.some((line) => line.includes('[scroll] sess-1 → page-keys-dialog-open'))).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('pageKeysForGesture, the pure gesture rules both panes share', () => {
|
||
const UP = '\x1b[5~';
|
||
const DOWN = '\x1b[6~';
|
||
const helper = () => loadTerminalUiHarness().windowRef.CodemanTerminalInput.pageKeysForGesture;
|
||
type Step = { state: unknown; keys: string };
|
||
/** Runs [lines, msSinceStart, extra] events through the helper; returns every step. */
|
||
function run(events: Array<[number, number, Record<string, unknown>?]>, rows = 36) {
|
||
const pageKeysForGesture = helper();
|
||
let state: unknown = null;
|
||
return events.map(([lines, at, extra]) => {
|
||
const step: Step = pageKeysForGesture(state, { shiftKey: false, ...extra }, lines, rows, 10_000 + at);
|
||
state = step.state;
|
||
return step;
|
||
});
|
||
}
|
||
|
||
it('pages a trackpad-sized opening event at once and owes the half screen back', () => {
|
||
const steps = run([
|
||
[-1, 0],
|
||
[-16, 10],
|
||
[-18, 20],
|
||
[-1, 30],
|
||
]);
|
||
expect(steps.map((s) => s.keys)).toEqual([UP, '', '', UP]);
|
||
});
|
||
|
||
it('pages a notch-sized opening event at once, and the notches after it repay it', () => {
|
||
// Five 4-row notches 400 ms apart: the first pays the page, the next four (16 rows of travel
|
||
// against the 14 owed on 36 rows, half a screen less the opener's 4) repay it, and none of them
|
||
// pages again.
|
||
const steps = run([
|
||
[-4, 0],
|
||
[-4, 400],
|
||
[-4, 800],
|
||
[-4, 1200],
|
||
[-4, 1600],
|
||
]);
|
||
expect(steps.map((s) => s.keys)).toEqual([UP, '', '', '', '']);
|
||
});
|
||
|
||
it('pages a mouse notch at once (4 rows Chrome, 3 rows Firefox) and repays it over the notches that follow', () => {
|
||
// Before this, the first 5 Chrome notches (7 Firefox) sent nothing at all: a gesture of a few notches
|
||
// looked dead. Each page now comes with the notch that opens it instead of the one that completes it:
|
||
// the rate is the same, one page per 19 rows of travel, a page ahead.
|
||
const notches = (lines: number, count: number) =>
|
||
run(
|
||
Array.from({ length: count }, (_, i) => [lines, i * 400] as [number, number]),
|
||
38
|
||
);
|
||
// Chrome: a page at notch 1, repaid by notches 2 to 5, and the next gesture opens at notch 6.
|
||
expect(notches(-4, 11).map((s) => s.keys)).toEqual([UP, '', '', '', '', UP, '', '', '', '', UP]);
|
||
// Firefox's 3-row notches repay it over notches 2 to 7, so the next page opens at notch 8.
|
||
expect(notches(-3, 8).map((s) => s.keys)).toEqual([UP, '', '', '', '', '', '', UP]);
|
||
});
|
||
|
||
it('keeps the leftover travel when a notch opens the next gesture, so the page rate matches plain travel', () => {
|
||
// 6 notches of 4 rows on a 10-row tile (5 rows a page): the page pre-paid by notch 1 is repaid
|
||
// by notch 2, the leftover opens notch 3's page, and so on. Dropping the leftover (the first cut
|
||
// of this rule) sent 3 pages for the same travel that earlier sent 4.
|
||
const steps = run(
|
||
Array.from({ length: 6 }, (_, i) => [-4, i * 400] as [number, number]),
|
||
10
|
||
);
|
||
expect(steps.map((s) => s.keys)).toEqual([UP, '', UP, UP, UP, UP]);
|
||
// Over a long run the count follows the travel (a page per perPage rows, one ahead).
|
||
const long = run(
|
||
Array.from({ length: 200 }, (_, i) => [-4, i * 400] as [number, number]),
|
||
38
|
||
);
|
||
const pages = long.filter((s) => s.keys).length;
|
||
expect(pages).toBeGreaterThanOrEqual(Math.floor((200 * 4) / 19));
|
||
expect(pages).toBeLessThanOrEqual(Math.ceil((200 * 4) / 19) + 1);
|
||
});
|
||
|
||
it('treats a touch contact whose first move is 2 rows or more the same way (opener size, not device)', () => {
|
||
// [lines, ms, extra]: one contact (touchId 1), a 3-row opening touchmove and the next 16 ms later.
|
||
const steps = run(
|
||
[
|
||
[-3, 0, { touchId: 1 }],
|
||
[-3, 16, { touchId: 1 }],
|
||
],
|
||
38
|
||
);
|
||
expect(steps.map((s) => s.keys)).toEqual([UP, '']);
|
||
});
|
||
|
||
it('keeps a notch debt across slow pauses (up to PAGE_KEY_NOTCH_DEBT_MS), then pages the next notch at once', () => {
|
||
const { PAGE_KEY_NOTCH_DEBT_MS } = loadTerminalUiHarness().windowRef.CodemanTerminalInput;
|
||
expect(PAGE_KEY_NOTCH_DEBT_MS).toBe(3000);
|
||
// Notches 1.2 s apart (past the old second of debt) still add up to one page, not one each.
|
||
const slow = run(
|
||
Array.from({ length: 5 }, (_, i) => [-4, i * 1200] as [number, number]),
|
||
38
|
||
);
|
||
expect(slow.map((s) => s.keys)).toEqual([UP, '', '', '', '']);
|
||
// After more than that of quiet it is a new gesture: its first notch pages at once.
|
||
const resumed = run(
|
||
[
|
||
[-4, 0],
|
||
[-4, 400],
|
||
[-4, 4_000],
|
||
],
|
||
38
|
||
);
|
||
expect(resumed.map((s) => s.keys)).toEqual([UP, '', UP]);
|
||
});
|
||
|
||
it('drops a notch debt when the direction reverses, so a notch back pages too', () => {
|
||
const steps = run(
|
||
[
|
||
[-4, 0],
|
||
[-4, 400],
|
||
[4, 800],
|
||
],
|
||
38
|
||
);
|
||
expect(steps.map((s) => s.keys)).toEqual([UP, '', DOWN]);
|
||
});
|
||
|
||
it('keeps a same-direction pre-paid debt for PAGE_KEY_DEBT_MS, then drops it with its gesture', () => {
|
||
const { PAGE_KEY_DEBT_MS } = loadTerminalUiHarness().windowRef.CodemanTerminalInput;
|
||
expect(PAGE_KEY_DEBT_MS).toBe(1000);
|
||
// A tick 500 ms later, past the 150 ms gap, pays the debt back instead of
|
||
// pre-paying a second page; a flick after a second of quiet pages at once.
|
||
const steps = run([
|
||
[-1, 0],
|
||
[-1, 500],
|
||
[-1, 1600],
|
||
]);
|
||
expect(steps.map((s) => s.keys)).toEqual([UP, '', UP]);
|
||
});
|
||
|
||
it('pre-pays a reversal only after the gap, and ignores sub-row jitter', () => {
|
||
const steps = run([
|
||
[-1, 0],
|
||
[1, 10], // reversal inside the gap: accumulates, no page
|
||
[-1, 20], // and back: still nothing
|
||
[1, 300], // a reversal after the gap is a new gesture: pages at once
|
||
[0.05, 310],
|
||
[-0.05, 1500],
|
||
]);
|
||
expect(steps.map((s) => s.keys)).toEqual([UP, '', '', DOWN, '', '']);
|
||
});
|
||
|
||
it('treats one touch contact as one gesture, and a new contact as a new one', () => {
|
||
const steps = run([
|
||
[-1, 0, { touchId: 1 }],
|
||
[-1, 5_000, { touchId: 1 }], // same contact, any pause: owed
|
||
[1, 5_010, { touchId: 1 }], // a reversal within the contact never pre-pays
|
||
[-1, 5_020, { touchId: 2 }], // a new contact pages at once
|
||
[-1, 5_030], // a wheel event is not that contact: a new gesture
|
||
]);
|
||
expect(steps.map((s) => s.keys)).toEqual([UP, '', '', UP, UP]);
|
||
});
|
||
|
||
it('sends nothing for a pinch or a mostly horizontal swipe, and leaves the state as it was', () => {
|
||
const pageKeysForGesture = helper();
|
||
const state = { pending: -7, lastAt: 9_990, dir: -1, prepaid: false };
|
||
const pinch = pageKeysForGesture(state, { ctrlKey: true, deltaY: -900 }, -36, 36, 10_000);
|
||
const swipe = pageKeysForGesture(state, { deltaX: -1000, deltaY: -900 }, -36, 36, 10_000);
|
||
expect(pinch).toEqual({ state, keys: '' });
|
||
expect(swipe).toEqual({ state, keys: '' });
|
||
// A mostly VERTICAL swipe with some sideways drift still pages.
|
||
expect(pageKeysForGesture(state, { deltaX: -100, deltaY: -900 }, -36, 36, 10_000).keys).toBe(UP.repeat(2));
|
||
});
|
||
|
||
it('caps a fling at PAGE_KEY_MAX_PER_BATCH keys', () => {
|
||
expect(run([[-1000, 0]])[0].keys).toBe(UP.repeat(3));
|
||
});
|
||
});
|
||
|
||
describe('GitHub Copilot wheel forwarding', () => {
|
||
// Copilot's mouse DECSETs are stripped so a drag selects locally, which stops xterm from
|
||
// encoding the wheel for it; the gate must therefore forward by hand, but only while tracking.
|
||
it('forwards the wheel while the CLI tracks the mouse, with no version gate', () => {
|
||
const { app } = hollowApp({ mode: 'copilot', cliMouseTracking: true });
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
|
||
});
|
||
|
||
it('keeps Shift+wheel on local scrollback and falls back to paging when not tracking', () => {
|
||
const tracking = hollowApp({ mode: 'copilot', cliMouseTracking: true });
|
||
expect(tracking.app._shouldForwardWheelToApp({ shiftKey: true })).toBe(false);
|
||
const idle = hollowApp({ mode: 'copilot', cliMouseTracking: false });
|
||
expect(idle.app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||
});
|
||
|
||
it('keeps unmeasured CLI modes out of native forwarding', () => {
|
||
for (const mode of ['opencode', 'grok', 'gemini']) {
|
||
const { app } = hollowApp({ mode, cliMouseTracking: true });
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: false }), mode).toBe(false);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('pi wheel forwarding (fullscreen by default since 1.0.0)', () => {
|
||
// pi enables mouse tracking once at start. Codeman's replay (tab switch, reload) drops that
|
||
// mode in xterm (`mouseTrackingMode` none, baseY 0), so the wheel hit an empty local buffer
|
||
// and went dead until a redraw. Like Copilot, the server strips the DECSETs and publishes
|
||
// `cliMouseTracking`; the gate forwards from that flag, which survives the replay.
|
||
it('forwards the wheel while pi tracks the mouse', () => {
|
||
const { app } = hollowApp({ mode: 'pi', cliMouseTracking: true });
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
|
||
});
|
||
|
||
it('keeps Shift+wheel local', () => {
|
||
expect(hollowApp({ mode: 'pi', cliMouseTracking: true }).app._shouldForwardWheelToApp({ shiftKey: true })).toBe(
|
||
false
|
||
);
|
||
});
|
||
|
||
it('sends nothing to an inline pi: page keys there move the caret of a multi-line draft', () => {
|
||
// `tuiMode: regular`, and every pi below 1.0.0, emits no mouse enable, so the flag stays false
|
||
// over a buffer that is hollow on a fresh pane. pi binds PageUp to its editor in that mode:
|
||
// measured on real pi, the wheel moved the caret and the next X landed on the first line.
|
||
const { app, sent } = hollowApp({ mode: 'pi', cliMouseTracking: false });
|
||
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('the paging gates asked for another pane (a TerminalTile)', () => {
|
||
it('exports the paging math, and the primary pane runs on it', () => {
|
||
const { app, sent, windowRef } = hollowApp();
|
||
const { wheelDeltaLines, pageKeysForTravel, pageKeysForGesture } = windowRef.CodemanTerminalInput;
|
||
|
||
expect(wheelDeltaLines({ deltaY: -50, deltaMode: 0 }, 36)).toBe(-2); // pixels, 25 a line
|
||
expect(wheelDeltaLines({ deltaY: 3, deltaMode: 1 }, 36)).toBe(3); // lines (Firefox)
|
||
expect(wheelDeltaLines({ deltaY: 1, deltaMode: 2 }, 36)).toBe(36); // pages: the given rows
|
||
expect(wheelDeltaLines({ deltaY: 0, deltaX: -75, shiftKey: true, deltaMode: 0 }, 36)).toBe(-3); // Shift axis
|
||
expect(pageKeysForTravel(0, -10, 36)).toEqual({ pending: -10, keys: '' });
|
||
expect(pageKeysForTravel(-10, -8, 36)).toEqual({ pending: 0, keys: '\x1b[5~' });
|
||
expect(pageKeysForTravel(0, -1000, 36).keys).toBe('\x1b[5~'.repeat(3));
|
||
expect(pageKeysForTravel(0, 40, 36)).toEqual({ pending: 4, keys: '\x1b[6~'.repeat(2) });
|
||
|
||
// The primary pane's own methods agree with them.
|
||
const ev = { deltaY: -250, deltaMode: 2 };
|
||
expect(app._wheelScrollLinesFloat(ev)).toBe(wheelDeltaLines(ev, 36));
|
||
let state: unknown = null;
|
||
let expected = '';
|
||
// Mixed: a trackpad opening, a notch-sized opening, a reversal, a pinch and
|
||
// a sideways swipe, all under a frozen clock (one gesture per direction).
|
||
const steps: Array<[number, Record<string, unknown>]> = [
|
||
[-1, {}],
|
||
[-10, {}],
|
||
[-30, {}],
|
||
[25, {}], // a reversal inside the gap pages by travel (net of the -5 plain travel left), not at once
|
||
[-5, { ctrlKey: true }],
|
||
[-5, { deltaX: 400, deltaY: -125 }],
|
||
[-1000, {}],
|
||
[-7, {}],
|
||
];
|
||
for (const [lines, extra] of steps) {
|
||
const event = { shiftKey: false, ...extra };
|
||
const step = pageKeysForGesture(state, event, lines, 36, 1_000);
|
||
state = step.state;
|
||
expected = windowRef.CodemanTerminalInput.coalesceScrollBytes(expected, step.keys) || expected;
|
||
app._maybePageCliTranscript(event, lines);
|
||
}
|
||
app._flushWheelSgrQueue();
|
||
expect(app._pageKeyGesture).toEqual(state);
|
||
expect(expected).toContain('\x1b[5~');
|
||
expect(expected).not.toContain('\x1b[6~');
|
||
expect(sent).toEqual([{ id: 'sess-1', data: expected }]);
|
||
});
|
||
|
||
it("_localScrollbackIsHollow reads the target's session, buffer and rows, never the active ones", () => {
|
||
const { app } = hollowApp({ mode: 'shell' }); // the ACTIVE session is a shell
|
||
app.sessions.set('tile-1', { mode: 'opencode' });
|
||
const tileBuffer = { type: 'normal', viewportY: 16, baseY: 16 };
|
||
const tileTerminal = { rows: 24, buffer: { active: tileBuffer } };
|
||
|
||
expect(app._localScrollbackIsHollow()).toBe(false); // the primary's own answer
|
||
// 16 rows above the tile's screen, all of them its own overflow: hollow.
|
||
expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 0 })).toBe(true);
|
||
// Real history in the tile: not hollow, whatever the primary holds.
|
||
expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 3 })).toBe(false);
|
||
// No localRows: the tile's own baseY decides.
|
||
expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal })).toBe(false);
|
||
tileBuffer.type = 'alternate';
|
||
expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 0 })).toBe(false);
|
||
tileBuffer.type = 'normal';
|
||
app.sessions.set('tile-1', { mode: 'gemini' });
|
||
expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 0 })).toBe(false);
|
||
});
|
||
|
||
it("_shouldForwardWheelToApp reads the target's session and the target terminal's tracking mode", () => {
|
||
const { app } = hollowApp({ mode: 'opencode' }); // the ACTIVE session would never forward
|
||
app.sessions.set('tile-1', { mode: 'claude', cliVersion: '2.1.223', cliMouseTracking: true });
|
||
const tileTerminal = { rows: 24, modes: { mouseTrackingMode: 'none' } };
|
||
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile-1', terminal: tileTerminal })).toBe(
|
||
true
|
||
);
|
||
// The tile's own xterm encoder owns the wheel while its tracking is on.
|
||
tileTerminal.modes.mouseTrackingMode = 'any';
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile-1', terminal: tileTerminal })).toBe(
|
||
false
|
||
);
|
||
// And the primary's tracking mode does not leak into the tile's answer.
|
||
tileTerminal.modes.mouseTrackingMode = 'none';
|
||
app.terminal.modes.mouseTrackingMode = 'any';
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile-1', terminal: tileTerminal })).toBe(
|
||
true
|
||
);
|
||
});
|
||
});
|
||
|
||
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 } = hollowApp({ 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');
|
||
// The gate's real tracking input: xterm's own mode above is always 'none'
|
||
// for Claude, since the server strips the DECSETs.
|
||
expect(logs[0]).toContain('cliMouseTracking=false');
|
||
|
||
app._logScrollRouting('page-keys'); // a changed route still prints
|
||
expect(logs).toHaveLength(2);
|
||
expect(logs[1]).toContain('page-keys');
|
||
|
||
// The CLI turning tracking on changes the gate, so it prints again.
|
||
app.sessions.get('sess-1').cliMouseTracking = true;
|
||
app._logScrollRouting('page-keys');
|
||
expect(logs).toHaveLength(3);
|
||
expect(logs[2]).toContain('cliMouseTracking=true');
|
||
});
|
||
|
||
it('reports an unknown CLI version, the false-path that disables forwarding', () => {
|
||
const { app, logs } = hollowApp(); // no cliVersion — the probe failed
|
||
app._logScrollRouting('page-keys');
|
||
expect(logs[0]).toContain('cliVersion=unknown');
|
||
});
|
||
|
||
it("reports xterm's mouse encoding, the input touch forwarding to native tracking reads", () => {
|
||
const { app, logs } = hollowApp({ mode: 'shell' });
|
||
app._logScrollRouting('local-scrollback'); // no readable xterm private
|
||
expect(logs[0]).toContain('mouseEncoding=unknown');
|
||
|
||
// An app switching to SGR changes what touch does, so it prints again.
|
||
app.terminal.modes.mouseTrackingMode = 'any';
|
||
app.terminal._core = { coreMouseService: { activeEncoding: 'SGR' } };
|
||
app._logScrollRouting('local-scrollback');
|
||
expect(logs).toHaveLength(2);
|
||
expect(logs[1]).toContain('mouseTracking=any');
|
||
expect(logs[1]).toContain('mouseEncoding=SGR');
|
||
});
|
||
});
|
||
|
||
describe('touch scrolling when xterm owns native mouse tracking', () => {
|
||
it.each(['vt200', 'drag', 'any'])('forwards touch for %s tracking even for a shell session', (mode) => {
|
||
const { app } = hollowApp({ mode: 'shell' });
|
||
app.terminal.modes.mouseTrackingMode = mode;
|
||
app.terminal._core = { coreMouseService: { activeEncoding: 'SGR' } };
|
||
app.terminal.buffer.active.type = 'alternate';
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||
expect(app._shouldForwardTouchToApp()).toBe(true);
|
||
});
|
||
|
||
it('does not send SGR to a legacy-protocol or X10 application', () => {
|
||
const { app } = hollowApp({ mode: 'shell' });
|
||
app.terminal.modes.mouseTrackingMode = 'vt200';
|
||
app.terminal._core = { coreMouseService: { activeEncoding: 'DEFAULT' } };
|
||
expect(app._shouldForwardTouchToApp()).toBe(false);
|
||
app.terminal._core.coreMouseService.activeEncoding = 'SGR';
|
||
app.terminal.modes.mouseTrackingMode = 'x10';
|
||
expect(app._shouldForwardTouchToApp()).toBe(false);
|
||
});
|
||
|
||
it('does not send cell-based SGR reports to an app that asked for SGR pixel coordinates', () => {
|
||
const { app } = hollowApp({ mode: 'shell' });
|
||
app.terminal.modes.mouseTrackingMode = 'any';
|
||
app.terminal._core = { coreMouseService: { activeEncoding: 'SGR_PIXELS' } };
|
||
expect(app._shouldForwardTouchToApp()).toBe(false);
|
||
});
|
||
|
||
it('keeps the explicit local-history preference for touch', () => {
|
||
const { app } = hollowApp({ mode: 'shell' });
|
||
app.terminal.modes.mouseTrackingMode = 'any';
|
||
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
|
||
expect(app._shouldForwardTouchToApp()).toBe(false);
|
||
});
|
||
|
||
it('uses normal CLI routing when xterm mouse tracking is off', () => {
|
||
const { app } = hollowApp({ cliVersion: '2.1.187', cliMouseTracking: true });
|
||
expect(app._shouldForwardTouchToApp()).toBe(true);
|
||
app.sessions.get('sess-1').cliMouseTracking = false;
|
||
expect(app._shouldForwardTouchToApp()).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('consistent scroll gesture adapters', () => {
|
||
it('keeps the shared paging opening threshold exported', () => {
|
||
expect(loadTerminalUiHarness().windowRef.CodemanTerminalInput.PAGE_KEY_IMMEDIATE_MAX_ROWS).toBe(2);
|
||
});
|
||
|
||
it('preserves rounded travel for isolated coarse clicks without accumulating a capped backlog', () => {
|
||
const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput;
|
||
for (const direction of [-1, 1]) {
|
||
expect(wheelTicksForGesture(null, direction * 4.8, 0).ticks).toBe(direction * 5); // 120px
|
||
expect(wheelTicksForGesture(null, direction * 1.6, 0).ticks).toBe(direction * 2); // 40px
|
||
let state = null;
|
||
let ticks = 0;
|
||
for (let i = 0; i < 5; i++) {
|
||
const step = wheelTicksForGesture(state, direction * 4.8, i * 300);
|
||
state = step.state;
|
||
ticks += step.ticks;
|
||
}
|
||
expect(ticks).toBe(direction * 25);
|
||
const capped = wheelTicksForGesture(null, direction * 40, 0);
|
||
expect(capped.ticks).toBe(direction * 5);
|
||
expect(capped.state.pending).toBe(0); // don't retain travel discarded by the event cap
|
||
}
|
||
});
|
||
|
||
it('pre-pays an isolated deliberate opening tick and repays its signed debt', () => {
|
||
const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput;
|
||
const opening = wheelTicksForGesture(null, -0.16, 0); // a 4px mouse click
|
||
expect(opening.ticks).toBe(-1);
|
||
expect(opening.state.pending).toBeCloseTo(0.84);
|
||
const continued = wheelTicksForGesture(opening.state, -0.16, 20);
|
||
expect(continued.ticks).toBe(0);
|
||
expect(continued.state.pending).toBeCloseTo(0.68);
|
||
expect(wheelTicksForGesture(null, 0.04, 0).ticks).toBe(0); // sub-threshold jitter
|
||
});
|
||
|
||
it('sends each slow 4px click, in either direction, while ten 2.5px events total one tick', () => {
|
||
const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput;
|
||
for (const direction of [-1, 1]) {
|
||
let slowState;
|
||
const slowTicks = [];
|
||
for (let i = 0; i < 5; i++) {
|
||
const step = wheelTicksForGesture(slowState, direction * 0.16, i * 300);
|
||
slowState = step.state;
|
||
slowTicks.push(step.ticks);
|
||
}
|
||
expect(slowTicks).toEqual(Array(5).fill(direction));
|
||
let fineState;
|
||
const fineTicks = [];
|
||
for (let i = 0; i < 10; i++) {
|
||
const step = wheelTicksForGesture(fineState, direction * 0.1, i * 8);
|
||
fineState = step.state;
|
||
fineTicks.push(step.ticks);
|
||
}
|
||
expect(fineTicks).toEqual([direction, ...Array(9).fill(0)]);
|
||
expect(fineState.pending).toBe(0);
|
||
}
|
||
});
|
||
|
||
it('makes ten small Claude deltas equal one combined delta', () => {
|
||
const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput;
|
||
let state;
|
||
let ticks = 0;
|
||
for (let i = 0; i < 10; i++) {
|
||
const step = wheelTicksForGesture(state, -0.12, i * 8);
|
||
state = step.state;
|
||
ticks += step.ticks;
|
||
}
|
||
expect(ticks).toBe(-1);
|
||
let decimalState;
|
||
let decimalTicks = 0;
|
||
for (let i = 0; i < 10; i++) {
|
||
const step = wheelTicksForGesture(decimalState, 0.1, i * 8);
|
||
decimalState = step.state;
|
||
decimalTicks += step.ticks;
|
||
}
|
||
expect(decimalTicks).toBe(1);
|
||
expect(wheelTicksForGesture(null, -1.2, 0).ticks).toBe(ticks);
|
||
});
|
||
|
||
it('drops fractional movement on reversal, inactivity and session changes', () => {
|
||
const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput;
|
||
const state = wheelTicksForGesture(null, 0.9, 0).state;
|
||
expect(wheelTicksForGesture(state, -0.2, 10).ticks).toBe(0);
|
||
expect(wheelTicksForGesture(state, 0.2, 200).ticks).toBe(1);
|
||
const { app, sent } = hollowApp();
|
||
app._clientPointToCell = () => ({ col: 1, row: 1 });
|
||
app._sendSyntheticSgrWheel(0, 0, 0.9);
|
||
app.activeSessionId = 'other';
|
||
app._sendSyntheticSgrWheel(0, 0, 0.2);
|
||
expect(sent).toEqual([
|
||
{ id: 'sess-1', data: '\x1b[<65;1;1M' },
|
||
{ id: 'other', data: '\x1b[<65;1;1M' },
|
||
]);
|
||
expect(app._wheelTickGesture.pending).toBeCloseTo(-0.8);
|
||
});
|
||
|
||
it('pages a coarse Codex wheel notch at once and keeps the travel after it as repayment', () => {
|
||
const { app, sent } = hollowApp({ mode: 'codex', rows: 43 });
|
||
app._maybePageCliTranscript({ deltaY: -100 }, -4);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toHaveLength(1);
|
||
// Five more notches (20 rows, a page is 22) repay the page the first one took: no second page.
|
||
for (let i = 0; i < 5; i++) app._maybePageCliTranscript({ deltaY: -100 }, -4);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toHaveLength(1);
|
||
});
|
||
|
||
it('preserves sustained report density and cancels obsolete direction before transport', () => {
|
||
const { coalesceScrollBytes } = loadTerminalUiHarness().windowRef.CodemanTerminalInput;
|
||
const up = '\x1b[<64;1;1M';
|
||
const down = '\x1b[<65;1;1M';
|
||
let queued = '';
|
||
for (let i = 0; i < 100; i++) queued = coalesceScrollBytes(queued, up);
|
||
expect(queued).toBe(up.repeat(100));
|
||
expect(coalesceScrollBytes(queued, down)).toBe(down);
|
||
expect(coalesceScrollBytes('\x1b[5~'.repeat(3), '\x1b[5~')).toBe('\x1b[5~'.repeat(4));
|
||
expect(coalesceScrollBytes('\x1b[5~', '\x1b[6~')).toBe('\x1b[6~');
|
||
});
|
||
|
||
it('preserves all 120 reports of a 30-frame touch fling with real batch deadlines', () => {
|
||
const { app, sent, clock } = hollowApp();
|
||
let now = 1000;
|
||
clock.now = () => now;
|
||
app._clientPointToCell = () => ({ col: 1, row: 1 });
|
||
for (let frame = 0; frame < 30; frame++) {
|
||
app._sendSyntheticSgrWheel(0, 0, -4);
|
||
now += 16;
|
||
clock.flushTimers();
|
||
}
|
||
now += 40;
|
||
clock.flushTimers();
|
||
expect(sent.map((s) => s.data).join('')).toBe('\x1b[<64;1;1M'.repeat(120));
|
||
});
|
||
|
||
it('does not deliver a queued wheel batch to a newly selected session', () => {
|
||
const { app, sent } = hollowApp();
|
||
app._queueScrollBytes('\x1b[5~');
|
||
app.activeSessionId = 'other';
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([]);
|
||
});
|
||
|
||
it('sends the first native report synchronously on every tab visit, batching subsequent reports', () => {
|
||
const { app, sent } = hollowApp();
|
||
const up = '\x1b[<64;1;1M';
|
||
app._selectGeneration = 1;
|
||
app._queueScrollBytes(up);
|
||
expect(sent).toEqual([{ id: 'sess-1', data: up }]);
|
||
app._queueScrollBytes(up);
|
||
app._queueScrollBytes(up);
|
||
expect(sent).toHaveLength(1);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent.at(-1)).toEqual({ id: 'sess-1', data: up.repeat(2) });
|
||
// Returning A -> B -> A must reset onset even if B was never scrolled.
|
||
app._queueScrollBytes(up); // previous visit has an unsent report
|
||
app._selectGeneration = 3;
|
||
app._queueScrollBytes(up);
|
||
expect(sent).toHaveLength(3);
|
||
expect(sent.at(-1)).toEqual({ id: 'sess-1', data: up });
|
||
app._queueScrollBytes(up); // unsent A report must never leak to B
|
||
app.activeSessionId = 'other';
|
||
app._selectGeneration = 4;
|
||
app._queueScrollBytes(up);
|
||
expect(sent.at(-1)).toEqual({ id: 'other', data: up });
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toHaveLength(4);
|
||
});
|
||
|
||
it('cancels unsent Claude ticks even when reversed travel is sub-line', () => {
|
||
const { app, sent } = hollowApp();
|
||
app._clientPointToCell = () => ({ col: 1, row: 1 });
|
||
app._sendSyntheticSgrWheel(0, 0, -1); // leading report is already delivered
|
||
app._sendSyntheticSgrWheel(0, 0, -2);
|
||
app._sendSyntheticSgrWheel(0, 0, 0.2);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<64;1;1M' }]);
|
||
});
|
||
});
|
||
|
||
describe('native Codex scrolling instead of an initial page jump', () => {
|
||
it('forwards only when Codex explicitly requests mouse tracking', () => {
|
||
for (const cliMouseTracking of [undefined, false, true]) {
|
||
const { app } = hollowApp({ mode: 'codex', cliMouseTracking });
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(cliMouseTracking === true);
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: true })).toBe(false);
|
||
}
|
||
});
|
||
|
||
it('keeps local-history opt-out and native xterm mouse routing', () => {
|
||
const { app } = hollowApp({ mode: 'codex', cliMouseTracking: true });
|
||
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||
app.loadAppSettingsFromStorage = () => ({});
|
||
app.terminal.modes.mouseTrackingMode = 'drag';
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('native wheel travel preserves upstream units', () => {
|
||
it('normalizes coarse Chrome, Firefox and page-unit wheel events without guessing the device', () => {
|
||
const { wheelDeltaLines } = loadTerminalUiHarness().windowRef.CodemanTerminalInput;
|
||
for (const [deltaY, deltaMode] of [
|
||
[100, 0],
|
||
[120, 0],
|
||
[1000, 0],
|
||
[3, 1],
|
||
[1, 2],
|
||
]) {
|
||
expect(wheelDeltaLines({ deltaY, deltaMode }, 43)).toBe(
|
||
deltaMode === 0 ? deltaY / 25 : deltaMode === 1 ? deltaY : deltaY * 43
|
||
);
|
||
expect(wheelDeltaLines({ deltaY: -deltaY, deltaMode }, 43)).toBe(
|
||
-(deltaMode === 0 ? deltaY / 25 : deltaMode === 1 ? deltaY : deltaY * 43)
|
||
);
|
||
}
|
||
});
|
||
|
||
it('keeps fractional touchpad distance and primary/tile normalization identical', () => {
|
||
const { app, windowRef } = hollowApp({ mode: 'codex', cliMouseTracking: true });
|
||
const { wheelDeltaLines, wheelTicksForGesture } = windowRef.CodemanTerminalInput;
|
||
let state;
|
||
let ticks = 0;
|
||
for (let i = 0; i < 10; i++) {
|
||
const ev = { deltaY: -3, deltaMode: 0 };
|
||
const lines = wheelDeltaLines(ev, 43);
|
||
expect(lines).toBe(-0.12);
|
||
expect(app._wheelScrollLinesFloat(ev)).toBe(lines);
|
||
const step = wheelTicksForGesture(state, lines, i * 10);
|
||
state = step.state;
|
||
ticks += step.ticks;
|
||
}
|
||
expect(ticks).toBe(-1);
|
||
});
|
||
});
|