fix(terminal): prepay isolated small wheel ticks without amplifying streams

Address the remaining PR #645 review: preserve shell Ctrl/sideways local scrolling, restore the paging threshold export, document tile leading-report lifetime, and update internal and user scrolling documentation.

Refs: https://github.com/johoja12/Codeman/issues/7
This commit is contained in:
Saqeb Akhter committed 2026-10-18 13:59:28 +04:00
1 parent 8cb3441dd9
commit 1b6c2664b6
9 files changed
+232 -29

No files matched your search

+47 -5
View File
@@ -559,8 +559,8 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: dy }, dy / 25);
p.advance(16);
}
// The opening event is a fresh gesture and pages; no reversal inside the
// gap pre-pays, so the wobble sends nothing after it.
// 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
});
@@ -857,6 +857,44 @@ describe('scroll routing diagnostic (issue #205 round 2)', () => {
});
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('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;
@@ -882,13 +920,17 @@ describe('consistent scroll gesture adapters', () => {
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();
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(app._wheelSgrQueue || '').toBe('');
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('preserves accumulated coarse Codex wheel fallback travel', () => {
+130 -1
View File
@@ -276,6 +276,16 @@ describe('TerminalTile wheel paging in a real browser', () => {
const down = `\x1b[<65;${expected.col};${expected.row}M`;
expect(after.sent.map(([, data]) => data)).toEqual([up.repeat(4), down.repeat(2)]);
// A slow macOS mouse click is only ~4px. Each isolated click must report,
// not lose its fraction during the 300ms pause between clicks.
await page.waitForTimeout(300);
for (let click = 0; click < 5; click++) {
await page.mouse.wheel(0, -4);
await page.waitForTimeout(300);
}
const afterSlow = await snap();
expect(afterSlow.sent.slice(after.sent.length).map(([, data]) => data)).toEqual(Array(5).fill(up));
// Shift+wheel is the explicit "local scrollback" gesture: scrolled locally
// by the tile (xterm alone turns it into a horizontal no-op), unforwarded.
await page.keyboard.down('Shift');
@@ -283,7 +293,7 @@ describe('TerminalTile wheel paging in a real browser', () => {
await page.keyboard.up('Shift');
await page.waitForTimeout(150);
const shifted = await snap();
expect(shifted.sent.length).toBe(after.sent.length);
expect(shifted.sent.length).toBe(afterSlow.sent.length);
expect(shifted.viewportY).toBeLessThan(before.baseY);
} finally {
await page.evaluate((id) => {
@@ -297,4 +307,123 @@ describe('TerminalTile wheel paging in a real browser', () => {
}, PROBE_ID);
}
});
it('preserves local Ctrl/sideways scrolling and forwards small native clicks without amplifying touchpad streams', async () => {
const id = 'primary-wheel-probe';
await page.evaluate(async (id) => {
const a = (window as any).app;
const t = a.terminal;
(window as any).__primaryWheelProbe = {
sent: [],
activeId: a.activeSessionId,
realEphemeral: a._sendInputEphemeral,
point: a._clientPointToCell,
};
a._sendInputEphemeral = (sid: string, data: string) => (window as any).__primaryWheelProbe.sent.push([sid, data]);
a._clientPointToCell = () => ({ col: 1, row: 1 });
a.sessions.set(id, { id, mode: 'shell' });
a.activeSessionId = id;
t.reset();
await new Promise<void>((resolve) =>
t.write(Array.from({ length: 100 }, (_, i) => `LOCAL_${i}\r\n`).join(''), resolve)
);
t.scrollToBottom();
}, id);
const fire = async (options: { deltaY: number; deltaX?: number; ctrlKey?: boolean }) => {
await page.evaluate((options) => {
const t = (window as any).app.terminal;
t.element.dispatchEvent(new WheelEvent('wheel', { ...options, deltaMode: 0, bubbles: true, cancelable: true }));
}, options);
await page.waitForTimeout(80); // actual 40ms batch and animation frames
};
try {
for (const options of [
{ deltaY: -25, ctrlKey: true },
{ deltaY: -25, deltaX: 100 },
]) {
const before = await page.evaluate(() => {
const t = (window as any).app.terminal;
t.scrollToBottom();
return t.buffer.active.viewportY;
});
expect(before).toBeGreaterThan(0);
await fire(options);
const after = await page.evaluate(() => (window as any).app.terminal.buffer.active.viewportY);
expect(after).toBe(before - 1); // local shell behavior matches master
}
for (const mode of ['claude', 'codex']) {
await page.evaluate(
({ id, mode }) => {
const a = (window as any).app;
a.sessions.set(id, {
id,
mode,
cliVersion: mode === 'claude' ? '2.1.300' : undefined,
cliMouseTracking: true,
});
a._wheelTickGesture = null;
a._wheelTickSession = null;
a._selectGeneration++;
(window as any).__primaryWheelProbe.sent.length = 0;
},
{ id, mode }
);
for (const options of [
{ deltaY: -25, ctrlKey: true },
{ deltaY: -25, deltaX: 100 },
])
await fire(options);
expect(await page.evaluate(() => (window as any).__primaryWheelProbe.sent.length)).toBe(0);
for (let click = 0; click < 5; click++) {
await fire({ deltaY: -4 });
await page.waitForTimeout(220);
}
expect(await page.evaluate(() => (window as any).__primaryWheelProbe.sent)).toEqual(
Array.from({ length: 5 }, () => [id, '\x1b[<64;1;1M'])
);
await page.waitForTimeout(200);
await page.evaluate(() => {
(window as any).__primaryWheelProbe.sent.length = 0;
});
await page.evaluate(() => {
const t = (window as any).app.terminal;
for (let event = 0; event < 10; event++)
t.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -2.5,
deltaMode: 0,
bubbles: true,
cancelable: true,
})
);
});
await page.waitForTimeout(80);
expect(await page.evaluate(() => (window as any).__primaryWheelProbe.sent)).toEqual([[id, '\x1b[<64;1;1M']]);
}
await page.evaluate((id) => {
const a = (window as any).app;
a.sessions.set(id, { id, mode: 'codex', cliMouseTracking: false });
a.terminal.clear();
a.terminal.scrollToBottom();
(window as any).__primaryWheelProbe.sent.length = 0;
}, id);
expect(await page.evaluate(() => (window as any).app.terminal.buffer.active.baseY)).toBe(0);
for (const options of [
{ deltaY: -25, ctrlKey: true },
{ deltaY: -25, deltaX: 100 },
])
await fire(options);
expect(await page.evaluate(() => (window as any).__primaryWheelProbe.sent.length)).toBe(0);
} finally {
await page.evaluate((id) => {
const a = (window as any).app,
p = (window as any).__primaryWheelProbe;
a.activeSessionId = p.activeId;
a._sendInputEphemeral = p.realEphemeral;
a._clientPointToCell = p.point;
a.sessions.delete(id);
delete (window as any).__primaryWheelProbe;
}, id);
}
});
});
+24 -2
View File
@@ -344,11 +344,33 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports'
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP.repeat(5) });
});
it('forwards an isolated small wheel event and five slow clicks for Claude and tracked Codex', async () => {
for (const mode of ['claude', 'codex']) {
let now = 1000;
clock.now = () => now;
const session = mode === 'claude' ? FULLSCREEN : { mode, cliMouseTracking: true };
const { ws, mount } = await connectTile(makeApp({ 's-tile': session }), { mode });
mount.fire('wheel', wheel(-4, AT));
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
for (let i = 1; i < 5; i++) {
now += 300;
vi.advanceTimersByTime(300);
mount.fire('wheel', wheel(-4, AT));
flushed(ws);
}
expect(ws.inputFrames().map((f) => f.d)).toEqual(Array(5).fill(UP));
// Separate pane/visit: ten 2.5px events produce only the pre-paid tick.
const fine = await connectTile(makeApp({ 's-tile': session }), { mode });
for (let i = 0; i < 10; i++) fine.mount.fire('wheel', wheel(-2.5, AT));
expect(flushed(fine.ws)).toEqual([{ t: 'i', d: UP }]);
}
});
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 must not round up
expect(flushed(ws)).toEqual([]);
mount.fire('wheel', wheel(-3, AT)); // pre-pay one tick, then repay it
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
for (let i = 0; i < 8; i++) mount.fire('wheel', wheel(-3, AT));
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);