mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
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:
1 parent
8cb3441dd9
commit
1b6c2664b6
9 files changed
+232
-29
No files matched your search
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 }]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user