diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 810708a7..848ae8f1 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -215,13 +215,13 @@ Further detail: the `: ` form (`w3-myapp: fix the login redirect` **Two strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. Every other mode (shell/opencode/antigravity/pi) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same narrow branch; the frontend mirror (`_shouldReportMouseToCli()`) stays claude/codex/gemini because only the FULL strip touches mouse DECSETs. The chunk-boundary carry (`_altScreenSeqCarry`) runs for both flavors. Tests: `test/claude-scrollback-strip.test.ts`. -⚠️ **What the full strip removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously (so clicks keep being reported exactly as before), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach. +⚠️ **What the full strip removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach. -**Only claude ≥ 2.1.187 with mouse tracking on forwards the wheel; everything else scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. Claude repeats this exactly in its default INLINE renderer (measured on 2.1.280: `alternate_on=0`, `mouse_any_flag=0`, `history_size` grows), and swipes on iOS Safari were dead there while codex scrolled; only fullscreen claude (`CLAUDE_CODE_NO_FLICKER=1`: alt screen plus modes 1003/1006) pages its transcript on wheel reports. So claude forwards only while the server-observed `cliMouseTracking` flag is true; a stale-false flag after a server restart falls through to the PageUp/PageDown fallback, never a dead wheel. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (strip mode AND the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk. +**Only claude ≥ 2.1.187 with mouse tracking on forwards the wheel; everything else scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. Claude repeats this exactly in its default INLINE renderer (measured on 2.1.280: `alternate_on=0`, `mouse_any_flag=0`, `history_size` grows), and swipes on iOS Safari were dead there while codex scrolled; only fullscreen claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in `~/.claude/settings.json`: alt screen plus modes 1003/1006) pages its transcript on wheel reports. So claude forwards only while the server-observed `cliMouseTracking` flag is true; a stale-false flag after a server restart falls through to the PageUp/PageDown fallback, never a dead wheel. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (strip mode AND the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk. -**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 — version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`. +**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`. -**A false gate on a Claude session must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, or the user turned on `terminalWheelLocalScrollback`. The 1.12.0 retest reported exactly that: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the triple guard (claude mode + gate false + `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. Tests: `test/terminal-scroll-routing.test.ts`, `test/claude-cli-version-cache.test.ts`. +**A false gate on a Claude session must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. The 1.12.0 retest reported exactly that: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the triple guard (claude mode + gate false + `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. Tests: `test/terminal-scroll-routing.test.ts`, `test/claude-cli-version-cache.test.ts`. **Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing. diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index 501d85b5..408294ae 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -155,7 +155,8 @@ Worth knowing: history; press **Load full history** to pull the rest explicitly. Automatic output recovery stays within the bounded browser buffer. - **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent - Claude runs fullscreen, so the wheel scrolls the conversation rather than the terminal. + Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in + `~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal. Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is always local scrollback. Other CLIs scroll locally. - **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not. diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index 39b6754c..41c64ef4 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -166,7 +166,9 @@ Things to try: - `Shift+Wheel` always scrolls the local buffer, whatever else is going on. - On Claude sessions running fullscreen (recent CLI with mouse tracking on), the wheel is forwarded into Claude's own transcript, so it scrolls the conversation rather than the - terminal buffer. That is intended. Claude's default inline view scrolls locally. + terminal buffer. That is intended. Claude's default inline view scrolls locally; turn + fullscreen on with `CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in + `~/.claude/settings.json`. - Scrolling to the very top pulls the full tmux scrollback again on demand. ### The wheel does nothing in a Codex session diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index e7aab353..657e1624 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -650,8 +650,9 @@ Object.assign(CodemanApp.prototype, { } // Mouse wheel: forward to the TUI only for sessions verified to handle SGR - // wheel reports (claude 2.1.187+ — see _shouldForwardWheelToApp), local - // scrollback otherwise. Claude Code 2.1.187+ scrolls its own + // wheel reports (claude 2.1.187+ while it tracks the mouse, which only its + // fullscreen renderer does; see _shouldForwardWheelToApp), local scrollback + // otherwise. Claude Code 2.1.187+ scrolls its own // transcript on SGR wheel reports — scrolled-away tool blocks re-render // live and stay clickable — and its select menus no longer capture wheel // as option navigation (verified against 2.1.202: /model menu highlight @@ -723,7 +724,7 @@ Object.assign(CodemanApp.prototype, { // phone/tablet swipe scrolls the local buffer of stale repaint frames and // drags the CLI's pinned input box off the screen (issue #205's mobile // half). Same gate, so Shift has no touch analog but the local-scrollback - // opt-out setting and the CLI-version gate apply to touch exactly as they + // opt-out setting and the version/tracking gate apply to touch exactly as they // do to the wheel — including the PageUp/PageDown fallback the wheel uses // when that gate is false and there is no local scrollback to scroll // (_maybePageCliTranscript), which is what keeps a swipe from being a @@ -5086,9 +5087,10 @@ Object.assign(CodemanApp.prototype, { // Wheel forwarding gate for the container wheel handler: no Shift override, // xterm's own encoder dormant, viewport at the bottom, and a TUI VERIFIED to - // scroll its transcript on SGR wheel reports — which today is claude 2.1.187+ - // and nothing else (older Claude Code captures wheel as select-menu option - // navigation; an unknown version is treated as older). Gemini and codex are + // scroll its transcript on SGR wheel reports, which today is claude 2.1.187+ + // with mouse tracking on (fullscreen) and nothing else (older Claude Code + // captures wheel as select-menu option navigation, an unknown version is + // treated as older, and inline Claude ignores it). Gemini and codex are // strip modes too but keep the local wheel — taps/clicks are still forwarded // for them (harmless no-ops at worst). // @@ -5159,9 +5161,10 @@ Object.assign(CodemanApp.prototype, { // inline renderer (2.1.280 measured: alternate_on=0, mouse_any_flag=0) the // transcript lives in real scrollback, like codex, and SGR wheel reports are // ignored, so forwarding made every swipe and wheel tick dead. Fullscreen - // (CLAUDE_CODE_NO_FLICKER=1) turns on alt-screen + mode 1003/1006, which the - // server records as cliMouseTracking. A stale-false flag after a server - // restart falls through to _maybePageCliTranscript, so it never goes dead. + // (CLAUDE_CODE_NO_FLICKER=1, or "tui": "fullscreen" in ~/.claude/settings.json) + // turns on alt-screen + mode 1003/1006, which the server records as + // cliMouseTracking. A stale-false flag after a server restart falls through + // to _maybePageCliTranscript, so it never goes dead. if (session?.cliMouseTracking !== true) return false; // Deliberately NOT gated on _terminalViewportAtBottom(). It used to be, so // that leaving the bottom handed the wheel back to local scrollback and both @@ -5236,11 +5239,13 @@ Object.assign(CodemanApp.prototype, { * * The rescue path for every way `_shouldForwardWheelToApp` can come back false * on a Claude session that has no local history to fall back on: the CLI - * version probe failed or is genuinely older than 2.1.187, or the user turned - * on "Wheel scrolls local history" (which pins the wheel to a buffer that, - * for a repaint-mode CLI, is empty — the setting's footgun). Before this, all - * of those produced a completely dead gesture; the #205 reporter proved the - * keyboard route works by paging back through intact text with Fn+Up. + * version probe failed or is genuinely older than 2.1.187, the CLI's mouse + * tracking flag is unset (the inline renderer, or fullscreen right after a + * server restart), or the user turned on "Wheel scrolls local history" (which + * pins the wheel to a buffer that, for a repaint-mode CLI, is empty: the + * setting's footgun). Before this, all of those produced a completely dead + * gesture; the #205 reporter proved the keyboard route works by paging back + * through intact text with Fn+Up. * * Triple-guarded (claude mode + gate false + `baseY === 0`), so a session with * real local scrollback is never touched. Shift is excluded on purpose: it is @@ -5284,14 +5289,19 @@ Object.assign(CodemanApp.prototype, { const session = this.sessions?.get(sessionId); const optOut = !!this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback; const tracking = this.terminal?.modes?.mouseTrackingMode || 'none'; + // xterm's own mode above stays 'none' for a strip mode (the server removes + // the DECSETs), so the CLI's real tracking state is reported separately. + const cliTracking = session?.cliMouseTracking === true; const baseY = this.terminal?.buffer?.active?.baseY ?? -1; - const signature = `${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|${tracking}|${baseY > 0}`; + const signature = + `${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|${tracking}|${cliTracking}|${baseY > 0}`; if (!this._scrollRoutingLogged) this._scrollRoutingLogged = new Map(); if (this._scrollRoutingLogged.get(sessionId) === signature) return; this._scrollRoutingLogged.set(sessionId, signature); console.log( `[scroll] ${sessionId} → ${decision} (mode=${session?.mode || '?'}, cliVersion=${session?.cliVersion || 'unknown'}, ` + - `localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, localScrollbackRows=${baseY})` + `localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, cliMouseTracking=${cliTracking}, ` + + `localScrollbackRows=${baseY})` ); }, diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index 511736a8..2321d8fc 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -47,11 +47,13 @@ function loadTerminalUiHarness() { } /** A Claude session whose local buffer holds exactly one screen (baseY 0). */ -function hollowClaudeApp(overrides: { cliVersion?: string; rows?: number } = {}) { +function hollowClaudeApp(overrides: { cliVersion?: string; rows?: number; cliMouseTracking?: boolean } = {}) { const { app, logs } = loadTerminalUiHarness(); const sent: Array<{ id: string; data: string }> = []; app.activeSessionId = 'sess-1'; - app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: overrides.cliVersion }]]); + app.sessions = new Map([ + ['sess-1', { mode: 'claude', cliVersion: overrides.cliVersion, cliMouseTracking: overrides.cliMouseTracking }], + ]); app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data }); app.terminal = { cols: 80, @@ -194,7 +196,8 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 // "Wheel scrolls local history" ON pins the wheel to a buffer that, for a // repaint-mode CLI, is empty — a user who flipped it while hunting for a fix // on 1.11.x would have ended up with a completely dead wheel on 1.12.0. - const { app, sent } = hollowClaudeApp({ cliVersion: '2.1.223' }); // gate would forward… + // Version and tracking both qualify, so the opt-out is the only thing saying no. + const { app, sent } = hollowClaudeApp({ cliVersion: '2.1.223', cliMouseTracking: true }); // gate would forward… app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true }); expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); // …but the opt-out wins @@ -236,10 +239,19 @@ describe('scroll routing diagnostic (issue #205 round 2)', () => { expect(logs[0]).toContain('cliVersion=2.1.100'); expect(logs[0]).toContain('localScrollbackOptOut=false'); expect(logs[0]).toContain('mouseTracking=none'); + // The gate's real tracking input: xterm's own mode above is always 'none' + // for Claude, since the server strips the DECSETs. + expect(logs[0]).toContain('cliMouseTracking=false'); app._logScrollRouting('page-keys'); // a changed route still prints expect(logs).toHaveLength(2); expect(logs[1]).toContain('page-keys'); + + // The CLI turning tracking on changes the gate, so it prints again. + app.sessions.get('sess-1').cliMouseTracking = true; + app._logScrollRouting('page-keys'); + expect(logs).toHaveLength(3); + expect(logs[2]).toContain('cliMouseTracking=true'); }); it('reports an unknown CLI version, the false-path that disables forwarding', () => { diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index eacd815d..f35106b4 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -683,10 +683,12 @@ 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); - app.sessions = new Map([['sess-1', { mode: 'codex', cliVersion: '9.9.9' }]]); // no version rescues it + // 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); - app.sessions = new Map([['sess-1', { mode: 'gemini', cliVersion: '9.9.9' }]]); // unverified TUI + app.sessions = new Map([['sess-1', { mode: 'gemini', cliVersion: '9.9.9', cliMouseTracking: true }]]); // unverified TUI expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); });