fix(terminal): address scrolling review and preserve upstream speed policies

Fix transport-timer regression coverage, zero-delta tile handling and capability documentation. Record isolated Codex 0.163.0 transcript and approval evidence; defer event/batch rate and coarse paging policy changes.

Refs: https://github.com/johoja12/Codeman/issues/7
This commit is contained in:
Saqeb Akhter committed 2026-10-18 11:23:05 +04:00
1 parent 39d7c01b7b
commit 8cb3441dd9
11 files changed
+158 -139

No files matched your search

+9
View File
@@ -435,3 +435,12 @@ describe('capability shapes', () => {
expect(paging.sort()).toEqual(['claude', 'codex', 'opencode']);
});
});
describe('observed mouse-tracking wheel policy', () => {
it('advertises and validates tracked Codex forwarding without a fictitious minimum version', () => {
const codex = STOCK_CLIS.find((e) => e.id === 'codex')!;
expect(codex.capabilities.wheelForward).toEqual({ mode: 'mouse-tracking' });
expect(CliEntrySchema.safeParse(codex).success).toBe(true);
expect(codex.capabilities.transcriptPageKeys).toBe(true);
});
});
+70 -33
View File
@@ -36,7 +36,18 @@ function loadTerminalUiHarness(
// 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 clock = { now: () => 1_000 };
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,
@@ -44,8 +55,12 @@ function loadTerminalUiHarness(
_crashDiag: { log: vi.fn() },
performance: clock,
requestAnimationFrame: (_fn: () => void) => 1,
setTimeout: (_fn: () => void) => 1,
clearTimeout: vi.fn(),
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) {
@@ -206,24 +221,30 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
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[6~' }]);
expect(sent).toEqual([
{ id: 'sess-1', data: '\x1b[5~' },
{ id: 'sess-1', data: '\x1b[6~' },
]);
});
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]) {
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~' }]);
@@ -446,7 +467,14 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter
h.app._flushWheelSgrQueue();
return h.sent.map((s) => s.data).join('');
};
return { ...h, keys, advance: (ms: number) => (now += ms) };
return {
...h,
keys,
advance: (ms: number) => {
now += ms;
h.clock.flushTimers();
},
};
}
const count = (keys: string, key: string) => keys.split(key).length - 1;
@@ -533,7 +561,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(''); // obsolete queued direction cancelled before its flush
expect(p.keys()).toBe(''); // the opening report was cancelled before its 40ms flush
});
it('still pages a deliberate reversal at once after a pause', () => {
@@ -541,7 +569,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(DOWN);
expect(p.keys()).toBe(UP + DOWN);
});
it('does not let a reversal inherit the other direction’s debt', () => {
@@ -863,28 +891,43 @@ describe('consistent scroll gesture adapters', () => {
expect(app._wheelSgrQueue || '').toBe('');
});
it('answers the first ordinary Codex wheel event, without paging every event', () => {
it('preserves accumulated coarse Codex wheel fallback travel', () => {
const { app, sent } = hollowApp({ mode: 'codex', rows: 43 });
app._maybePageCliTranscript({ deltaY: -100 }, -4);
app._flushWheelSgrQueue();
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
expect(sent).toEqual([]);
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', () => {
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(5));
expect(queued).toBe(up.repeat(100));
expect(coalesceScrollBytes(queued, down)).toBe(down);
expect(coalesceScrollBytes('\x1b[5~'.repeat(3), '\x1b[5~')).toBe('\x1b[5~'.repeat(3));
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~');
@@ -939,16 +982,6 @@ describe('native Codex scrolling instead of an initial page jump', () => {
}
});
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 });
@@ -959,9 +992,9 @@ describe('native Codex scrolling instead of an initial page jump', () => {
});
});
describe('one native tick per wheel event', () => {
describe('native wheel travel preserves upstream units', () => {
it('normalizes coarse Chrome, Firefox and page-unit wheel events without guessing the device', () => {
const { wheelDeltaNativeLines } = loadTerminalUiHarness().windowRef.CodemanTerminalInput;
const { wheelDeltaLines } = loadTerminalUiHarness().windowRef.CodemanTerminalInput;
for (const [deltaY, deltaMode] of [
[100, 0],
[120, 0],
@@ -969,21 +1002,25 @@ describe('one native tick per wheel event', () => {
[3, 1],
[1, 2],
]) {
expect(wheelDeltaNativeLines({ deltaY, deltaMode }, 43)).toBe(1);
expect(wheelDeltaNativeLines({ deltaY: -deltaY, deltaMode }, 43)).toBe(-1);
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 { wheelDeltaNativeLines, wheelTicksForGesture } = windowRef.CodemanTerminalInput;
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 = wheelDeltaNativeLines(ev, 43);
const lines = wheelDeltaLines(ev, 43);
expect(lines).toBe(-0.12);
expect(app._wheelScrollNativeLines(ev)).toBe(lines);
expect(app._wheelScrollLinesFloat(ev)).toBe(lines);
const step = wheelTicksForGesture(state, lines, i * 10);
state = step.state;
ticks += step.ticks;
+25 -13
View File
@@ -319,11 +319,11 @@ 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([{ t: 'i', d: UP }]); // first report never waits for a timer
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); // no seq: never persisted
expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP.repeat(3) }]); // first report never waits for a timer
expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(3) }]); // no seq: never persisted
mount.fire('wheel', wheelLines(2, AT));
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: DOWN });
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: DOWN.repeat(2) });
});
it('uses fractional native scrolling for a tracked Codex tile, never PageUp', async () => {
@@ -334,14 +334,14 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports'
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
});
it('normalizes a pixel wheel event to one native tick, including accelerated deltas', async () => {
it('preserves coarse pixel wheel travel and caps individual accelerated events', 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 }]);
expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(4) }]);
mount.fire('wheel', wheelLines(-1000, AT));
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP });
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP.repeat(5) });
});
it('accumulates precision touchpad deltas and consumes a pure horizontal swipe without sending', async () => {
@@ -366,7 +366,18 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports'
mount.fire('wheel', wheelLines(-2, AT));
mount.fire('wheel', wheelLines(-2, AT));
expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP }]);
expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP.repeat(2) }]);
expect(flushed(ws)).toEqual([
{ t: 'i', d: UP.repeat(2) },
{ t: 'i', d: UP.repeat(4) },
]);
});
it('does not discard queued reports when a zero-delta wheel arrives', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
mount.fire('wheel', wheelLines(-1, AT));
mount.fire('wheel', wheelLines(-2, AT));
mount.fire('wheel', wheel(0, AT));
expect(flushed(ws)).toEqual([
{ t: 'i', d: UP },
{ t: 'i', d: UP.repeat(2) },
@@ -451,14 +462,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;
_wheelScrollNativeLines(ev: unknown): number;
_wheelScrollLinesFloat(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._wheelScrollNativeLines(ev));
primary._sendSyntheticSgrWheel(ev.clientX, ev.clientY, primary._wheelScrollLinesFloat(ev));
primary._flushWheelSgrQueue();
}
@@ -546,11 +557,11 @@ describe('page-key travel, cap and coalescing', () => {
FakeFit.proposed = { cols: 80, rows: 36 };
});
it('answers sub-page travel immediately and repays it on the next event', async () => {
it('accumulates coarse sub-page travel before sending one page', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
mount.fire('wheel', wheelLines(-10));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
expect(flushed(ws)).toEqual([]);
mount.fire('wheel', wheelLines(-10));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
@@ -614,6 +625,7 @@ describe("gesture handling is the primary pane's, through the shared pure helper
clock.now = () => now;
return (ms: number) => {
now += ms;
vi.advanceTimersByTime(ms);
};
}
@@ -750,7 +762,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(''); // rapid reversals cancel unsent movement
expect(tileBytes).toBe(PAGE_UP); // the first slow tick flushed before the later wobble
const primary = makeApp({ other: { mode: 'codex' } }, 'other') as App & {
terminal: unknown;
@@ -909,7 +921,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([{ t: 'i', d: PAGE_UP }]);
expect(flushed(ws)).toEqual([]);
mount.fire('wheel', wheelLines(-6));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
});
+13 -13
View File
@@ -738,17 +738,17 @@ describe('terminal touch tap mouse guard', () => {
app.terminal = { rows: 40 };
// DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad): ~110px per notch.
expect(app._wheelScrollLines({ deltaY: 110, deltaX: 0, deltaMode: 0, shiftKey: false })).toBe(4);
expect(app._wheelScrollLinesFloat({ deltaY: 110, deltaX: 0, deltaMode: 0, shiftKey: false })).toBe(4.4);
// DOM_DELTA_LINE (Firefox mouse wheel): deltaY is already lines. Read as
// pixels this rounded to 0 and fell through to the ±1 fallback.
expect(app._wheelScrollLines({ deltaY: 3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(3);
expect(app._wheelScrollLines({ deltaY: -3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(-3);
expect(app._wheelScrollLinesFloat({ deltaY: 3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(3);
expect(app._wheelScrollLinesFloat({ deltaY: -3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(-3);
// DOM_DELTA_PAGE: one page is one screenful.
expect(app._wheelScrollLines({ deltaY: 1, deltaX: 0, deltaMode: 2, shiftKey: false })).toBe(40);
expect(app._wheelScrollLinesFloat({ deltaY: 1, deltaX: 0, deltaMode: 2, shiftKey: false })).toBe(40);
// A pure horizontal swipe must not fall through to a phantom -1.
expect(app._wheelScrollLines({ deltaY: 0, deltaX: 90, deltaMode: 0, shiftKey: false })).toBe(0);
expect(app._wheelScrollLinesFloat({ deltaY: 0, deltaX: 90, deltaMode: 0, shiftKey: false })).toBe(0);
// Shift + macOS trackpad reports the magnitude on deltaX (issue #154).
expect(app._wheelScrollLines({ deltaY: 0, deltaX: -100, deltaMode: 0, shiftKey: true })).toBe(-4);
expect(app._wheelScrollLinesFloat({ deltaY: 0, deltaX: -100, deltaMode: 0, shiftKey: true })).toBe(-4);
});
it('wheel: gates claude forwarding on CLI version 2.1.187+ (unknown or older stays local)', () => {
@@ -838,19 +838,19 @@ describe('terminal touch tap mouse guard', () => {
const { app } = loadTerminalUiHarness();
// Plain vertical wheel: unchanged, driven by deltaY.
expect(app._wheelScrollLines({ shiftKey: false, deltaX: 0, deltaY: 100 })).toBe(4);
expect(app._wheelScrollLines({ shiftKey: false, deltaX: 0, deltaY: -50 })).toBe(-2);
expect(app._wheelScrollLinesFloat({ shiftKey: false, deltaX: 0, deltaY: 100 })).toBe(4);
expect(app._wheelScrollLinesFloat({ shiftKey: false, deltaX: 0, deltaY: -50 })).toBe(-2);
// Shift on a macOS trackpad: deltaY≈0, deltaX carries direction+magnitude.
// Old code collapsed this to a fixed -1; now it tracks the horizontal delta.
expect(app._wheelScrollLines({ shiftKey: true, deltaX: -100, deltaY: 0 })).toBe(-4); // scroll up
expect(app._wheelScrollLines({ shiftKey: true, deltaX: 75, deltaY: 0 })).toBe(3); // scroll down
expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: -100, deltaY: 0 })).toBe(-4); // scroll up
expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: 75, deltaY: 0 })).toBe(3); // scroll down
// Shift with a real vertical wheel (mouse): deltaY dominates, deltaX ignored.
expect(app._wheelScrollLines({ shiftKey: true, deltaX: 2, deltaY: 100 })).toBe(4);
expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: 2, deltaY: 100 })).toBe(4);
// Sub-25px delta still nudges one line in the gesture's direction.
expect(app._wheelScrollLines({ shiftKey: true, deltaX: -5, deltaY: 0 })).toBe(-1);
// Sub-25px deltas retain their fractional travel.
expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: -5, deltaY: 0 })).toBe(-0.2);
});
it('wheel: encodes SGR 64/65 ticks, caps per event, and coalesces into one flush', () => {