mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(terminal): make native scrolling consistent and responsive
Track fractional wheel gestures, bound unsent input, enable tracked Codex scrolling and send the first native report immediately after tab selection. Refs: https://github.com/johoja12/Codeman/issues/7
This commit is contained in:
1 parent
b6218c1490
commit
39d7c01b7b
5 files changed
+342
-96
No files matched your search
@@ -45,6 +45,7 @@ function loadTerminalUiHarness(
|
||||
performance: clock,
|
||||
requestAnimationFrame: (_fn: () => void) => 1,
|
||||
setTimeout: (_fn: () => void) => 1,
|
||||
clearTimeout: vi.fn(),
|
||||
Blob: function Blob() {},
|
||||
URL: { createObjectURL: () => 'blob:yield', revokeObjectURL: () => {} },
|
||||
Worker: function Worker(this: any) {
|
||||
@@ -210,14 +211,12 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
|
||||
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~\x1b[5~\x1b[6~' }]);
|
||||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[6~' }]);
|
||||
});
|
||||
|
||||
it('lets wheel notches accumulate instead of paging a full screen on each one', () => {
|
||||
// A 100 px notch is 4 rows. Only a trackpad-sized opening event (under 2 rows)
|
||||
// pages at once; a notch adds up toward half a screen as it always did, so
|
||||
// slow notches (each one its own gesture by the 150 ms gap) still page once
|
||||
// per 18 rows here, not once per notch.
|
||||
it('answers the first wheel notch and amortizes the page over subsequent notches', () => {
|
||||
// The first 100px notch responds immediately. Later notches repay the
|
||||
// pre-paid page, including when they arrive past the normal gesture gap.
|
||||
for (const gapMs of [250, 40]) {
|
||||
const { app, sent, clock } = hollowApp();
|
||||
let now = 1_000;
|
||||
@@ -350,8 +349,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
|
||||
});
|
||||
|
||||
it('pages a Codex transcript only while its local scrollback is empty', () => {
|
||||
// Codex is never sent SGR wheel reports (it ignores them); this is the
|
||||
// separate PageUp/PageDown fallback, which codex does honour.
|
||||
// Codex without observed mouse tracking retains its PageUp/PageDown fallback.
|
||||
const { app, sent } = hollowApp();
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||||
|
||||
@@ -535,7 +533,7 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter
|
||||
}
|
||||
// The opening event is a fresh gesture and pages; no reversal inside the
|
||||
// gap pre-pays, so the wobble sends nothing after it.
|
||||
expect(p.keys()).toBe(UP);
|
||||
expect(p.keys()).toBe(''); // obsolete queued direction cancelled before its flush
|
||||
});
|
||||
|
||||
it('still pages a deliberate reversal at once after a pause', () => {
|
||||
@@ -543,7 +541,7 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter
|
||||
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);
|
||||
expect(p.keys()).toBe(DOWN);
|
||||
});
|
||||
|
||||
it('does not let a reversal inherit the other direction’s debt', () => {
|
||||
@@ -552,10 +550,10 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter
|
||||
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(UP);
|
||||
expect(p.keys()).toBe(''); // reversal discards the unsent UP
|
||||
p.advance(16);
|
||||
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 }, 1);
|
||||
expect(p.keys()).toBe(UP + DOWN);
|
||||
expect(p.keys()).toBe(DOWN);
|
||||
});
|
||||
|
||||
it('ends the one-second debt lifetime once the debt is repaid, exactly or overpaid', () => {
|
||||
@@ -643,8 +641,8 @@ describe('pageKeysForGesture, the pure gesture rules both panes share', () => {
|
||||
expect(steps.map((s) => s.keys)).toEqual([UP, '', '', UP]);
|
||||
});
|
||||
|
||||
it('accumulates a notch-sized opening event, and carries plain travel across notches', () => {
|
||||
// Five 4-row notches 400 ms apart: each is a new gesture, none pages alone.
|
||||
it('responds immediately to a notch without paging for every later notch', () => {
|
||||
// Five 4-row notches 400ms apart share the pre-paid page debt.
|
||||
const steps = run([
|
||||
[-4, 0],
|
||||
[-4, 400],
|
||||
@@ -742,13 +740,13 @@ describe('the paging gates asked for another pane (a TerminalTile)', () => {
|
||||
const event = { shiftKey: false, ...extra };
|
||||
const step = pageKeysForGesture(state, event, lines, 36, 1_000);
|
||||
state = step.state;
|
||||
expected += step.keys;
|
||||
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).toContain('\x1b[6~');
|
||||
expect(expected).not.toContain('\x1b[6~');
|
||||
expect(sent).toEqual([{ id: 'sess-1', data: expected }]);
|
||||
});
|
||||
|
||||
@@ -829,3 +827,167 @@ describe('scroll routing diagnostic (issue #205 round 2)', () => {
|
||||
expect(logs[0]).toContain('cliVersion=unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('consistent scroll gesture adapters', () => {
|
||||
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(0);
|
||||
const { app } = hollowApp();
|
||||
app._clientPointToCell = () => ({ col: 1, row: 1 });
|
||||
app._sendSyntheticSgrWheel(0, 0, 0.9);
|
||||
app.activeSessionId = 'other';
|
||||
app._sendSyntheticSgrWheel(0, 0, 0.2);
|
||||
expect(app._wheelSgrQueue || '').toBe('');
|
||||
});
|
||||
|
||||
it('answers the first ordinary Codex wheel event, without paging every event', () => {
|
||||
const { app, sent } = hollowApp({ mode: 'codex', rows: 43 });
|
||||
app._maybePageCliTranscript({ deltaY: -100 }, -4);
|
||||
app._flushWheelSgrQueue();
|
||||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
|
||||
for (let i = 0; i < 5; i++) app._maybePageCliTranscript({ deltaY: -100 }, -4);
|
||||
app._flushWheelSgrQueue();
|
||||
expect(sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('bounds a sustained burst 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(5));
|
||||
expect(coalesceScrollBytes(queued, down)).toBe(down);
|
||||
expect(coalesceScrollBytes('\x1b[5~'.repeat(3), '\x1b[5~')).toBe('\x1b[5~'.repeat(3));
|
||||
expect(coalesceScrollBytes('\x1b[5~', '\x1b[6~')).toBe('\x1b[6~');
|
||||
});
|
||||
|
||||
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('preserves the Codex dialog guard for primary and tiled routing', () => {
|
||||
const { app } = hollowApp({ mode: 'codex', cliMouseTracking: true });
|
||||
app.sessions.set('tile', { mode: 'codex', cliMouseTracking: true });
|
||||
app.tabAlerts = new Map([['sess-1', 'action']]);
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile' })).toBe(true);
|
||||
app.tabAlerts.set('tile', 'action');
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile' })).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('one native tick per wheel event', () => {
|
||||
it('normalizes coarse Chrome, Firefox and page-unit wheel events without guessing the device', () => {
|
||||
const { wheelDeltaNativeLines } = loadTerminalUiHarness().windowRef.CodemanTerminalInput;
|
||||
for (const [deltaY, deltaMode] of [
|
||||
[100, 0],
|
||||
[120, 0],
|
||||
[1000, 0],
|
||||
[3, 1],
|
||||
[1, 2],
|
||||
]) {
|
||||
expect(wheelDeltaNativeLines({ deltaY, deltaMode }, 43)).toBe(1);
|
||||
expect(wheelDeltaNativeLines({ deltaY: -deltaY, deltaMode }, 43)).toBe(-1);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps fractional touchpad distance and primary/tile normalization identical', () => {
|
||||
const { app, windowRef } = hollowApp({ mode: 'codex', cliMouseTracking: true });
|
||||
const { wheelDeltaNativeLines, wheelTicksForGesture } = windowRef.CodemanTerminalInput;
|
||||
let state;
|
||||
let ticks = 0;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const ev = { deltaY: -3, deltaMode: 0 };
|
||||
const lines = wheelDeltaNativeLines(ev, 43);
|
||||
expect(lines).toBe(-0.12);
|
||||
expect(app._wheelScrollNativeLines(ev)).toBe(lines);
|
||||
const step = wheelTicksForGesture(state, lines, i * 10);
|
||||
state = step.state;
|
||||
ticks += step.ticks;
|
||||
}
|
||||
expect(ticks).toBe(-1);
|
||||
});
|
||||
});
|
||||
@@ -232,7 +232,7 @@ describe('a tile pages a hollow buffer through the primary pane gates', () => {
|
||||
|
||||
expect(up.preventDefault).toHaveBeenCalled();
|
||||
expect(up.stopPropagation).toHaveBeenCalled();
|
||||
expect(ws.inputFrames()).toEqual([]); // coalesced, not sent per event
|
||||
expect(ws.inputFrames()).toEqual([]); // fallback page reports remain batched
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); // no seq: never persisted
|
||||
|
||||
mount.fire('wheel', wheelLines(term.rows / 2));
|
||||
@@ -319,27 +319,37 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports'
|
||||
|
||||
expect(up.preventDefault).toHaveBeenCalled();
|
||||
expect(up.stopPropagation).toHaveBeenCalled();
|
||||
expect(ws.inputFrames()).toEqual([]); // coalesced, not sent per event
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(3) }]); // no seq: never persisted
|
||||
expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP }]); // first report never waits for a timer
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); // no seq: never persisted
|
||||
|
||||
mount.fire('wheel', wheelLines(2, AT));
|
||||
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: DOWN.repeat(2) });
|
||||
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: DOWN });
|
||||
});
|
||||
|
||||
it('forwards a Chrome-on-Windows notch (100 px) as four ticks and caps a fling at five', async () => {
|
||||
it('uses fractional native scrolling for a tracked Codex tile, never PageUp', async () => {
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'codex', cliMouseTracking: true } }), {
|
||||
mode: 'codex',
|
||||
});
|
||||
for (let i = 0; i < 10; i++) mount.fire('wheel', wheel(-3, AT));
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
|
||||
});
|
||||
|
||||
it('normalizes a pixel wheel event to one native tick, including accelerated deltas', async () => {
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
||||
|
||||
mount.fire('wheel', wheel(-100, AT));
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(4) }]);
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
|
||||
|
||||
mount.fire('wheel', wheelLines(-1000, AT));
|
||||
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP.repeat(5) });
|
||||
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP });
|
||||
});
|
||||
|
||||
it('moves a precision touchpad delta one line, and consumes a pure horizontal swipe without sending', async () => {
|
||||
it('accumulates precision touchpad deltas and consumes a pure horizontal swipe without sending', async () => {
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
||||
|
||||
mount.fire('wheel', wheel(-3, AT)); // 0.12 of a line
|
||||
mount.fire('wheel', wheel(-3, AT)); // 0.12 of a line must not round up
|
||||
expect(flushed(ws)).toEqual([]);
|
||||
for (let i = 0; i < 8; i++) mount.fire('wheel', wheel(-3, AT));
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
|
||||
|
||||
const sideways = wheel(0, { ...AT, deltaX: 120 });
|
||||
@@ -348,14 +358,19 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports'
|
||||
expect(flushed(ws)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('coalesces the reports of wheels within 40 ms into one frame', async () => {
|
||||
it('sends the first report immediately and coalesces subsequent wheels within 40 ms', async () => {
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
||||
|
||||
mount.fire('wheel', wheelLines(-2, AT));
|
||||
vi.advanceTimersByTime(20);
|
||||
mount.fire('wheel', wheelLines(-2, AT));
|
||||
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(4) }]);
|
||||
mount.fire('wheel', wheelLines(-2, AT));
|
||||
expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP }]);
|
||||
expect(flushed(ws)).toEqual([
|
||||
{ t: 'i', d: UP },
|
||||
{ t: 'i', d: UP.repeat(2) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('snaps a scrolled-up viewport home before reporting, since SGR rows address the live screen', async () => {
|
||||
@@ -436,14 +451,14 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports'
|
||||
const primary = makeApp({ other: FULLSCREEN }, 'other') as App & {
|
||||
terminal: unknown;
|
||||
_sendSyntheticSgrWheel(x: number, y: number, lines: number): void;
|
||||
_wheelScrollLines(ev: unknown): number;
|
||||
_wheelScrollNativeLines(ev: unknown): number;
|
||||
_flushWheelSgrQueue(): void;
|
||||
};
|
||||
const sent: string[] = [];
|
||||
primary._sendInputEphemeral = (_id: string, data: string) => sent.push(data);
|
||||
primary.terminal = new FakeTerminal({});
|
||||
for (const ev of sequence) {
|
||||
primary._sendSyntheticSgrWheel(ev.clientX, ev.clientY, primary._wheelScrollLines(ev));
|
||||
primary._sendSyntheticSgrWheel(ev.clientX, ev.clientY, primary._wheelScrollNativeLines(ev));
|
||||
primary._flushWheelSgrQueue();
|
||||
}
|
||||
|
||||
@@ -531,11 +546,11 @@ describe('page-key travel, cap and coalescing', () => {
|
||||
FakeFit.proposed = { cols: 80, rows: 36 };
|
||||
});
|
||||
|
||||
it('accumulates sub-page travel: two wheels of 10 lines are one PageUp', async () => {
|
||||
it('answers sub-page travel immediately and repays it on the next event', async () => {
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
||||
|
||||
mount.fire('wheel', wheelLines(-10));
|
||||
expect(flushed(ws)).toEqual([]);
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
||||
mount.fire('wheel', wheelLines(-10));
|
||||
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
||||
@@ -707,7 +722,6 @@ describe("gesture handling is the primary pane's, through the shared pure helper
|
||||
primary._flushWheelSgrQueue();
|
||||
|
||||
expect(tileBytes).toContain(PAGE_UP);
|
||||
expect(tileBytes).toContain(PAGE_DOWN);
|
||||
expect(tileBytes).toBe(sent.join(''));
|
||||
});
|
||||
|
||||
@@ -736,7 +750,7 @@ describe("gesture handling is the primary pane's, through the shared pure helper
|
||||
.map((f) => f.d)
|
||||
.join('');
|
||||
// One page for the slow ticks, one for the wobble's opening event, nothing more.
|
||||
expect(tileBytes).toBe(PAGE_UP + PAGE_UP);
|
||||
expect(tileBytes).toBe(''); // rapid reversals cancel unsent movement
|
||||
|
||||
const primary = makeApp({ other: { mode: 'codex' } }, 'other') as App & {
|
||||
terminal: unknown;
|
||||
@@ -895,7 +909,7 @@ describe('rows the tile pushed above the screen itself are not history', () => {
|
||||
const first = wheelLines(-6);
|
||||
mount.fire('wheel', first);
|
||||
expect(first.preventDefault).toHaveBeenCalled();
|
||||
expect(flushed(ws)).toEqual([]);
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
||||
mount.fire('wheel', wheelLines(-6));
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ function loadTerminalUiHarness() {
|
||||
performance: { now: () => now },
|
||||
requestAnimationFrame: (_fn: () => void) => 1,
|
||||
setTimeout: (_fn: () => void) => 1,
|
||||
clearTimeout: vi.fn(),
|
||||
Blob: function Blob() {},
|
||||
URL: {
|
||||
createObjectURL: () => 'blob:yield',
|
||||
@@ -789,7 +790,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
|
||||
});
|
||||
|
||||
it('wheel: only claude forwards — codex and gemini keep the local wheel', () => {
|
||||
it('wheel: tracked fullscreen Codex forwards; inline Codex and Gemini do not', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.terminal = {
|
||||
@@ -803,10 +804,10 @@ describe('terminal touch tap mouse guard', () => {
|
||||
// (the codex transcript lives there — inline viewport, no in-app pager) sat unused.
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||
// Tracking on and a high version, so only the mode check can say no: without
|
||||
// them the gate is false for claude too and this would pin nothing.
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex', cliVersion: '9.9.9', cliMouseTracking: true }]]); // no version rescues it
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||
// Current fullscreen Codex requests tracking and supports native row scrolling.
|
||||
// Its version is not published as Claude-specific cliInfo; observed tracking gates it.
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex', cliMouseTracking: true }]]);
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
|
||||
|
||||
app.sessions = new Map([['sess-1', { mode: 'gemini', cliVersion: '9.9.9', cliMouseTracking: true }]]); // unverified TUI
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||
@@ -871,13 +872,16 @@ describe('terminal touch tap mouse guard', () => {
|
||||
|
||||
app._sendSyntheticSgrWheel(50, 50, -2); // 2 ticks up
|
||||
app._sendSyntheticSgrWheel(50, 50, 9); // capped at 5 ticks down
|
||||
expect(sent).toEqual([]); // nothing until the flush timer fires
|
||||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<64;7;4M'.repeat(2) }]); // leading report is immediate
|
||||
|
||||
app._flushWheelSgrQueue();
|
||||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<64;7;4M'.repeat(2) + '\x1b[<65;7;4M'.repeat(5) }]);
|
||||
expect(sent).toEqual([
|
||||
{ id: 'sess-1', data: '\x1b[<64;7;4M'.repeat(2) },
|
||||
{ id: 'sess-1', data: '\x1b[<65;7;4M'.repeat(5) },
|
||||
]);
|
||||
|
||||
app._flushWheelSgrQueue(); // queue drained — no duplicate send
|
||||
expect(sent).toHaveLength(1);
|
||||
expect(sent).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('forwarded scrolls (wheel AND touch) snap the viewport home first, then encode SGR ticks', () => {
|
||||
|
||||
Reference in new issue
Block a user