mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Merge pull request #645: fix(terminal): isolate scroll batches and support tracked Codex scrolling
The remote-history PATH bootstrap that rode along in this PR was left out at merge (it belongs in its own PR that routes every remote tmux call through one wrapper), so 242bbfcd's title overstates what lands here. Conflicts in CLAUDE.md and docs/architecture-invariants.md resolved by keeping this PR's rewrite of the wheel/touch paragraphs and re-adding #664's touch-forwarding sentence to both. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
3652670a7d
16 files changed
+747
-169
No files matched your search
@@ -486,15 +486,15 @@ describe('capability shapes', () => {
|
||||
});
|
||||
|
||||
it('declares which CLIs get the wheel forwarded, and whether a version gates it', () => {
|
||||
// claude forwards from 2.1.187 (fullscreen renderer only); copilot forwards at any version while
|
||||
// it tracks the mouse. codex ignores SGR wheel reports, so every other entry stays 'never'.
|
||||
// Claude forwards from 2.1.187 (fullscreen only); tracked Codex and Copilot
|
||||
// forward without a version gate. Unmeasured entries stay 'never'.
|
||||
const mode = (id: string) => STOCK_CLIS.find((e) => (e.id as string) === id)?.capabilities.wheelForward;
|
||||
expect(mode('copilot')).toEqual({ mode: 'always' });
|
||||
expect(mode('claude')).toEqual({ mode: 'version-gated', minVersion: '2.1.187' });
|
||||
const forwarding = STOCK_CLIS.filter((e) => e.capabilities.wheelForward.mode !== 'never').map(
|
||||
(e) => e.id as string
|
||||
);
|
||||
expect(forwarding.sort()).toEqual(['claude', 'copilot']);
|
||||
expect(forwarding.sort()).toEqual(['claude', 'codex', 'copilot']);
|
||||
expect(CliEntrySchema.safeParse(baseEntry('copilot')).success).toBe(true);
|
||||
});
|
||||
|
||||
@@ -505,3 +505,12 @@ describe('capability shapes', () => {
|
||||
expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', '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: 'always' });
|
||||
expect(CliEntrySchema.safeParse(codex).success).toBe(true);
|
||||
expect(codex.capabilities.transcriptPageKeys).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
// Adversarial browser layout fixture, not evidence of native Codex history:
|
||||
// authentic CLI behavior is measured separately in an isolated live pane.
|
||||
describe('tracked Codex wheel routing in the primary pane', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
}, 60000);
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.stop();
|
||||
});
|
||||
|
||||
it('sends plain wheel to the CLI rather than scrolling stale repaint frames', async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 1200, height: 800 } });
|
||||
try {
|
||||
await page.goto(`http://127.0.0.1:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal);
|
||||
await page.waitForTimeout(3500);
|
||||
await page.evaluate(async () => {
|
||||
const app = (window as any).app;
|
||||
app.activeSessionId = 'codex-wheel-probe';
|
||||
app.sessions.set(app.activeSessionId, { id: app.activeSessionId, mode: 'codex', cliMouseTracking: true });
|
||||
(window as any).scrollInputs = [];
|
||||
// Cell hit testing is irrelevant to routing; the empty dashboard covers the test terminal.
|
||||
app._clientPointToCell = () => ({ col: 1, row: 1 });
|
||||
app._sendInputEphemeral = (id: string, data: string) => (window as any).scrollInputs.push({ id, data });
|
||||
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false });
|
||||
await new Promise<void>((resolve) => app.terminal.write('\r\nold repaint row'.repeat(100), resolve));
|
||||
app.terminal.scrollToTop();
|
||||
});
|
||||
const fire = (shiftKey = false) =>
|
||||
page.evaluate((shiftKey) => {
|
||||
const t = (window as any).app.terminal;
|
||||
t.element.dispatchEvent(new WheelEvent('wheel', { deltaY: -100, shiftKey, bubbles: true, cancelable: true }));
|
||||
}, shiftKey);
|
||||
await fire();
|
||||
await page.waitForTimeout(150);
|
||||
const forwarded = await page.evaluate(() => {
|
||||
const app = (window as any).app;
|
||||
return {
|
||||
inputs: (window as any).scrollInputs,
|
||||
baseY: app.terminal.buffer.active.baseY,
|
||||
viewportY: app.terminal.buffer.active.viewportY,
|
||||
};
|
||||
});
|
||||
expect(forwarded.inputs.length).toBeGreaterThan(0);
|
||||
expect(forwarded.inputs.every((i: { id: string }) => i.id === 'codex-wheel-probe')).toBe(true);
|
||||
expect(
|
||||
forwarded.inputs
|
||||
.map((i: { data: string }) => i.data)
|
||||
.join('')
|
||||
.match(/\x1b\[<64;/g)
|
||||
).toHaveLength(4);
|
||||
expect(forwarded.viewportY).toBe(forwarded.baseY);
|
||||
for (const shift of [false, true]) {
|
||||
await page.evaluate((tracked) => {
|
||||
const app = (window as any).app;
|
||||
app.sessions.get(app.activeSessionId).cliMouseTracking = tracked;
|
||||
app.terminal.scrollToBottom();
|
||||
(window as any).scrollInputs = [];
|
||||
}, shift);
|
||||
await fire(shift);
|
||||
await page.waitForTimeout(150);
|
||||
const local = await page.evaluate(() => {
|
||||
const b = (window as any).app.terminal.buffer.active;
|
||||
return { inputs: (window as any).scrollInputs, baseY: b.baseY, viewportY: b.viewportY };
|
||||
});
|
||||
expect(local.inputs).toEqual([]);
|
||||
expect(local.viewportY).toBeLessThan(local.baseY);
|
||||
}
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,7 +55,12 @@ function loadTerminalUiHarness(
|
||||
_crashDiag: { log: vi.fn() },
|
||||
performance: clock,
|
||||
requestAnimationFrame: (_fn: () => void) => 1,
|
||||
setTimeout: (_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) {
|
||||
@@ -205,26 +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[5~\x1b[5~\x1b[6~' }]);
|
||||
expect(sent).toEqual([
|
||||
{ id: 'sess-1', data: '\x1b[5~' },
|
||||
{ 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.
|
||||
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~' }]);
|
||||
@@ -375,8 +395,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' }]]);
|
||||
|
||||
@@ -473,7 +492,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;
|
||||
|
||||
@@ -558,9 +584,9 @@ 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.
|
||||
expect(p.keys()).toBe(UP);
|
||||
// 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', () => {
|
||||
@@ -577,10 +603,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', () => {
|
||||
@@ -747,8 +773,8 @@ describe('GitHub Copilot wheel forwarding', () => {
|
||||
expect(idle.app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('does not change what other non-claude modes do', () => {
|
||||
for (const mode of ['codex', 'opencode', 'grok', 'gemini']) {
|
||||
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);
|
||||
}
|
||||
@@ -790,13 +816,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 }]);
|
||||
});
|
||||
|
||||
@@ -933,3 +959,237 @@ describe('touch scrolling when xterm owns native mouse tracking', () => {
|
||||
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('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([]);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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', 'copilot']) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
@@ -328,14 +328,22 @@ 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(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.repeat(2) });
|
||||
});
|
||||
|
||||
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('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));
|
||||
@@ -345,10 +353,34 @@ 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('moves a precision touchpad delta one line, and consumes a pure horizontal swipe without sending', async () => {
|
||||
it('forwards an isolated small wheel event and five slow clicks for every tracked CLI', async () => {
|
||||
for (const mode of ['claude', 'codex', 'copilot']) {
|
||||
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
|
||||
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 }]);
|
||||
|
||||
const sideways = wheel(0, { ...AT, deltaX: 120 });
|
||||
@@ -357,14 +389,30 @@ 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.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) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('snaps a scrolled-up viewport home before reporting, since SGR rows address the live screen', async () => {
|
||||
@@ -445,14 +493,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;
|
||||
_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._wheelScrollLines(ev));
|
||||
primary._sendSyntheticSgrWheel(ev.clientX, ev.clientY, primary._wheelScrollLinesFloat(ev));
|
||||
primary._flushWheelSgrQueue();
|
||||
}
|
||||
|
||||
@@ -540,7 +588,7 @@ 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('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));
|
||||
@@ -608,10 +656,11 @@ describe("gesture handling is the primary pane's, through the shared pure helper
|
||||
clock.now = () => now;
|
||||
return (ms: number) => {
|
||||
now += ms;
|
||||
vi.advanceTimersByTime(ms);
|
||||
};
|
||||
}
|
||||
|
||||
it('pages a Codex tile, which draws on the alternate screen and ignores SGR wheel reports', async () => {
|
||||
it('pages an untracked Codex tile through the hollow-pane fallback', async () => {
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'codex' } }), { mode: 'codex' });
|
||||
|
||||
mount.fire('wheel', wheelLines(-18));
|
||||
@@ -744,8 +793,8 @@ describe("gesture handling is the primary pane's, through the shared pure helper
|
||||
const tileBytes = flushed(ws)
|
||||
.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);
|
||||
// One page for the slow ticks; the wobble cancels its unsent opening page on reversal.
|
||||
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;
|
||||
|
||||
@@ -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',
|
||||
@@ -737,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)', () => {
|
||||
@@ -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);
|
||||
@@ -837,22 +838,22 @@ 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', () => {
|
||||
it('wheel: encodes SGR 64/65 ticks, caps per event, and sends the first report before batching the rest', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: Array<{ id: string; data: string }> = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
@@ -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