diff --git a/.changeset/codex-wheel-dead-zone.md b/.changeset/codex-wheel-dead-zone.md new file mode 100644 index 00000000..90e84050 --- /dev/null +++ b/.changeset/codex-wheel-dead-zone.md @@ -0,0 +1,5 @@ +--- +'aicodeman': patch +--- + +A mouse wheel over a Codex pane (and any CLI paged with PageUp/PageDown) now moves the transcript on its first notch. Because the wheel is turned into whole-page keys when the CLI does not track the mouse, it used to send nothing until half a screen of travel had built up: 5 notches in Chrome, 7 on Firefox's 3-line notches, so a short scroll looked dead. The first notch now pays its page at once, as a trackpad flick already did, and the notches after it repay it, so the page rate is unchanged. diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index c3778ffe..87840dae 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -98,10 +98,16 @@ const PAGE_KEY_GESTURE_GAP_MS = 150; // ...but not for sub-row jitter (0.1 row, ~2px)... const PAGE_KEY_MIN_START_ROWS = 0.1; - // ...and only for a trackpad-sized opening event. A mouse-wheel notch (100px, - // 4 rows) accumulates toward half a screen as before; paging at once on it made - // slow notches, each its own gesture past the gap, page a full screen apiece. + // ...and only for a trackpad-sized opening event. A mouse-wheel notch (100px, 4 rows) also pages at + // once now (see PAGE_KEY_NOTCH_DEBT_MS), but is told apart from a trackpad opener by this size. const PAGE_KEY_IMMEDIATE_MAX_ROWS = 2; + // A mouse-wheel notch used to just accumulate toward half a screen, so a mouse showed nothing for the + // first 5 notches (7 on Firefox's 3-line notches) and a gesture of a few notches seemed dead. The first + // notch of a gesture now pre-pays its page like a trackpad flick does, and the notches that follow repay + // it: the same gesture sends the same pages, the first one sooner. The old objection was slow notches, + // each its own gesture past the gap, paging a full screen apiece, so a notch's debt outlives the pauses + // between notches for this long (a trackpad's lasts PAGE_KEY_DEBT_MS). + const PAGE_KEY_NOTCH_DEBT_MS = 3000; // A pre-paid page's debt outlives the gesture gap for this long when the next // event goes the SAME way, so slow small wheel ticks (40 px every 250 ms, a // Firefox 1-line notch) pay it back instead of each pre-paying a page of its own. @@ -216,8 +222,10 @@ // - a direction change inside a gesture (a reversal within the gap, or // within the same contact) drops a pre-paid debt and accumulates, never // pre-paying, so a +/-3 px trackpad wobble cannot page back and forth; - // - everything else, a wheel notch included, accumulates through - // pageKeysForTravel, carried across notches. + // - a mouse-wheel notch (any opening event under one page, past the trackpad size) pre-pays its page + // the same way, and its debt lasts PAGE_KEY_NOTCH_DEBT_MS, so the notches that follow repay it + // however slowly they come instead of each paging a screen of its own; + // - everything else accumulates through pageKeysForTravel, carried across notches. // Returns { state, keys } with keys '' when nothing is due. function pageKeysForGesture(state, ev, lines, rows, now) { const prev = state || {}; @@ -239,7 +247,8 @@ gestureStart = false; // the same contact, however long it has been } else { const quiet = now - prev.lastAt; - gestureStart = quiet > (prepaid && !reversed ? PAGE_KEY_DEBT_MS : PAGE_KEY_GESTURE_GAP_MS); + const debtMs = prev.notch ? PAGE_KEY_NOTCH_DEBT_MS : PAGE_KEY_DEBT_MS; + gestureStart = quiet > (prepaid && !reversed ? debtMs : PAGE_KEY_GESTURE_GAP_MS); } if (gestureStart || reversed) { // A pre-paid debt belongs to its gesture and its direction. @@ -248,10 +257,17 @@ } if (gestureStart) { const size = Math.abs(lines); - if (size >= PAGE_KEY_MIN_START_ROWS && size < Math.min(perPage, PAGE_KEY_IMMEDIATE_MAX_ROWS)) { + if (size >= PAGE_KEY_MIN_START_ROWS && size < perPage) { // Pre-pay one page; the skipped travel is owed back (pending carries the opposite sign). return { - state: { pending: lines - dir * perPage, lastAt: now, dir, prepaid: true, touchId }, + state: { + pending: lines - dir * perPage, + lastAt: now, + dir, + prepaid: true, + notch: size >= PAGE_KEY_IMMEDIATE_MAX_ROWS, + touchId, + }, keys: dir < 0 ? KEY_PAGE_UP : KEY_PAGE_DOWN, }; } @@ -261,7 +277,10 @@ // or crosses zero it is repaid, and what is left is plain travel (so the // one-second debt lifetime no longer applies). if (prepaid && step.pending * dir >= 0) prepaid = false; - return { state: { pending: step.pending, lastAt: now, dir, prepaid, touchId }, keys: step.keys }; + return { + state: { pending: step.pending, lastAt: now, dir, prepaid, notch: prepaid && !!prev.notch, touchId }, + keys: step.keys, + }; } const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4; @@ -425,6 +444,7 @@ PAGE_KEY_MIN_START_ROWS, PAGE_KEY_IMMEDIATE_MAX_ROWS, PAGE_KEY_DEBT_MS, + PAGE_KEY_NOTCH_DEBT_MS, TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM, MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR, MOBILE_KEYBOARD_DISMISS_TAP_SLOP, @@ -6050,8 +6070,9 @@ Object.assign(CodemanApp.prototype, { * (PAGE_KEY_GESTURE_GAP_MS, or PAGE_KEY_DEBT_MS while a same-direction page is * still owed), a reversal after the gap or a tab switch starts a new gesture; * one contact is never split by time, and a quick reversal never pre-pays. A - * larger opening event is a wheel notch and accumulates as before, so slow - * notches still page once per `perPage` rows. + * mouse-wheel notch pages at once too, and its debt (PAGE_KEY_NOTCH_DEBT_MS) is repaid by the + * notches after it, so a mouse pages once per `perPage` rows of travel with the + * first page on the first notch, not the fifth. * * Consumed WITHOUT paging: a pinch (`ctrlKey`, how Chrome reports a trackpad * pinch), a mostly horizontal swipe (both deltas present, |deltaX| > |deltaY|; diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index a374b395..246567f5 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -706,6 +706,54 @@ describe('pageKeysForGesture, the pure gesture rules both panes share', () => { expect(steps.map((s) => s.keys).join('')).toBe(UP); }); + it('pages a mouse notch at once (4 rows Chrome, 3 rows Firefox) and repays it over the notches that follow', () => { + // Before this, the first 5 Chrome notches (7 Firefox) sent nothing at all: a gesture of a few notches + // looked dead. Each page now comes with the notch that opens it instead of the one that completes it: + // the rate is the same, one page per 19 rows of travel, a page ahead. + const notches = (lines: number, count: number) => + run( + Array.from({ length: count }, (_, i) => [lines, i * 400] as [number, number]), + 38 + ); + // Chrome: a page at notch 1, repaid by notches 2 to 5, and the next gesture opens at notch 6. + expect(notches(-4, 11).map((s) => s.keys)).toEqual([UP, '', '', '', '', UP, '', '', '', '', UP]); + // Firefox's 3-row notches repay it over notches 2 to 7, so the next page opens at notch 8. + expect(notches(-3, 8).map((s) => s.keys)).toEqual([UP, '', '', '', '', '', '', UP]); + }); + + it('keeps a notch debt across slow pauses (up to PAGE_KEY_NOTCH_DEBT_MS), then pages the next notch at once', () => { + const { PAGE_KEY_NOTCH_DEBT_MS } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + expect(PAGE_KEY_NOTCH_DEBT_MS).toBe(3000); + // Notches 1.2 s apart (past the old second of debt) still add up to one page, not one each. + const slow = run( + Array.from({ length: 5 }, (_, i) => [-4, i * 1200] as [number, number]), + 38 + ); + expect(slow.map((s) => s.keys)).toEqual([UP, '', '', '', '']); + // After more than that of quiet it is a new gesture: its first notch pages at once. + const resumed = run( + [ + [-4, 0], + [-4, 400], + [-4, 4_000], + ], + 38 + ); + expect(resumed.map((s) => s.keys)).toEqual([UP, '', UP]); + }); + + it('drops a notch debt when the direction reverses, so a notch back pages too', () => { + const steps = run( + [ + [-4, 0], + [-4, 400], + [4, 800], + ], + 38 + ); + expect(steps.map((s) => s.keys)).toEqual([UP, '', DOWN]); + }); + it('keeps a same-direction pre-paid debt for PAGE_KEY_DEBT_MS, then drops it with its gesture', () => { const { PAGE_KEY_DEBT_MS } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; expect(PAGE_KEY_DEBT_MS).toBe(1000); @@ -1056,11 +1104,12 @@ describe('consistent scroll gesture adapters', () => { expect(app._wheelTickGesture.pending).toBeCloseTo(-0.8); }); - it('preserves accumulated coarse Codex wheel fallback travel', () => { + it('pages a coarse Codex wheel notch at once and keeps the travel after it as repayment', () => { const { app, sent } = hollowApp({ mode: 'codex', rows: 43 }); app._maybePageCliTranscript({ deltaY: -100 }, -4); app._flushWheelSgrQueue(); - expect(sent).toEqual([]); + expect(sent).toHaveLength(1); + // Five more notches (20 rows, a page is 22) repay the page the first one took: no second page. for (let i = 0; i < 5; i++) app._maybePageCliTranscript({ deltaY: -100 }, -4); app._flushWheelSgrQueue(); expect(sent).toHaveLength(1); diff --git a/test/terminal-tile-scroll.test.ts b/test/terminal-tile-scroll.test.ts index 8f17e0dd..2df3547a 100644 --- a/test/terminal-tile-scroll.test.ts +++ b/test/terminal-tile-scroll.test.ts @@ -588,11 +588,11 @@ describe('page-key travel, cap and coalescing', () => { FakeFit.proposed = { cols: 80, rows: 36 }; }); - it('accumulates coarse sub-page travel before sending one page', async () => { + it('pages a coarse opening event at once, and the next one repays it instead of paging again', 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 }]); @@ -946,14 +946,14 @@ describe('rows the tile pushed above the screen itself are not history', () => { } expect(flushed(ws)).toEqual([]); - // Back on the live screen, only travel made there counts: two quarter-screen - // wheels are one PageUp, with nothing carried over from the wheels xterm had - // (the gate sits before the pending travel is touched). + // Back on the live screen, only travel made there counts: the first quarter-screen wheel pages at + // once and the second repays it, with nothing carried over from the wheels xterm had (the gate sits + // before the pending travel is touched). term.buffer.active.viewportY = baseY; 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 }]); });