diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index d1af4739..449178c5 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -415,15 +415,21 @@ Object.assign(CodemanApp.prototype, { // ignores wheel reports); older versions DO capture wheel as option // navigation, so they keep the local wheel. // Shift+wheel always scrolls xterm's local scrollback (Codeman's restored - // history lives there), and once the viewport left the bottom the wheel - // stays local until the user scrolls back down — so both scrollbacks stay - // reachable without a mode switch. + // history lives there); the plain wheel stays on the CLI's transcript for + // those modes regardless of scroll position, so the CLI's input box never + // slides off the screen (see _shouldForwardWheelToApp). container.addEventListener( 'wheel', (ev) => { ev.preventDefault(); const lines = this._wheelScrollLines(ev); if (this._shouldForwardWheelToApp(ev)) { + // SGR coordinates address the LIVE screen (the bottom `rows` of the + // buffer), so a report computed from a scrolled-up viewport would + // hit-test a different row entirely — and forwarding while the user + // stares at stale scrollback looks like the wheel is dead. Snap back + // first: the wheel then always acts on what the CLI is drawing now. + if (!this._terminalViewportAtBottom()) this.terminal.scrollToBottom(); this._sendSyntheticSgrWheel(ev.clientX, ev.clientY, lines); return; } @@ -2868,7 +2874,23 @@ Object.assign(CodemanApp.prototype, { } else if (sessionMode !== 'codex') { return false; } - return this._terminalViewportAtBottom(); + // Deliberately NOT gated on _terminalViewportAtBottom(). It used to be, so + // that leaving the bottom handed the wheel back to local scrollback and both + // histories stayed reachable without a mode switch. In practice that inverted + // the behavior users actually want: a repaint-mode CLI keeps NO terminal + // scrollback of its own (tmux reports history_size=0 for a Claude pane), so + // xterm's buffer holds only Codeman's REPLAYED repaint frames. Scrolling that + // locally drags the CLI's own pinned furniture (the prompt box, the status + // line) up the screen and shows stale frames underneath, which reads as "the + // window scrolled away" rather than "I am reading history". + // + // And it was easy to fall into: scrollToLastNonEmptyLine() parks the viewport + // `rows - 2` above the last non-empty row, so any tab switch onto a session + // with trailing blank rows left the viewport off-bottom and every later wheel + // went local. Forwarding unconditionally keeps the CLI's transcript as the + // plain wheel's target and its input box fixed in place; local scrollback is + // still on Shift+wheel and on the "Wheel scrolls local history" opt-out above. + return true; }, // Encode wheel ticks as SGR reports (button 64 = up, 65 = down) at the pointer diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 7c4ad557..046cbcce 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -311,7 +311,7 @@ describe('terminal touch tap mouse guard', () => { expect(sent).toEqual(['\x1b[<0;7;4M\x1b[<0;7;4m']); }); - it('wheel: forwards to the app only for verified sessions at the buffer bottom without Shift', () => { + it('wheel: forwards to the app for verified sessions without Shift, at ANY scroll position', () => { const { app } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.187' }]]); @@ -323,8 +323,14 @@ describe('terminal touch tap mouse guard', () => { expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true); expect(app._shouldForwardWheelToApp({ shiftKey: true })).toBe(false); // Shift = local scrollback - app.terminal.buffer.active.viewportY = 10; // browsing local scrollback - expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + // Scrolled up into local scrollback still forwards. Gating this on the + // viewport being at the bottom is what let a repaint-mode CLI's own prompt + // box scroll off the screen: scrollToLastNonEmptyLine() parks the viewport + // above the bottom, so a tab switch silently pinned the wheel to local + // scrollback full of stale replayed frames. The wheel handler snaps the + // viewport back to the bottom before encoding the report instead. + app.terminal.buffer.active.viewportY = 10; + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true); app.terminal.buffer.active.viewportY = 50; app.terminal.modes.mouseTrackingMode = 'vt200'; // xterm's own encoder live @@ -335,6 +341,24 @@ describe('terminal touch tap mouse guard', () => { expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); }); + it('wheel: converts deltaMode line/page units instead of assuming pixels', () => { + const { app } = loadTerminalUiHarness(); + 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); + // 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); + // DOM_DELTA_PAGE: one page is one screenful. + expect(app._wheelScrollLines({ 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); + // Shift + macOS trackpad reports the magnitude on deltaX (issue #154). + expect(app._wheelScrollLines({ 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)', () => { const { app } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1';