diff --git a/CLAUDE.md b/CLAUDE.md index 03898844..89490918 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -286,7 +286,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](docs/architecture-invariants.md#terminal-paste-ctrlv) -**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode gets the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) pages the wheel and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) +**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode gets the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) **Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install) **Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 5e269f95..aed0a1c4 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -225,7 +225,7 @@ Further detail: the `: ` form (`w3-myapp: fix the login redirect` **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 hollow pane 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`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: 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 guard (`_localScrollbackIsHollow()` — `claude` or `opencode`, 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). ⚠️ `shell`/`pi` own real terminal scrollback and are never paged, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ 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()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForTravel`, so the mode list stays in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles have no touch path and do not forward SGR wheel (tile-grid-plan follow-up 4). Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`. +**A false gate on a hollow pane 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`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: 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 guard (`_localScrollbackIsHollow()` — `claude` or `opencode`, 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). ⚠️ `shell`/`pi` own real terminal scrollback and are never paged, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ 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()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForTravel`, so the mode list stays in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to Claude's fullscreen renderer as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.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/tile-grid-plan.md b/docs/tile-grid-plan.md index 4a2573ee..943706b4 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -1011,13 +1011,17 @@ exits green. Use the browser runner for those files and read the file count. viewer keeps a stale width and renders garbled output (#464). 3. WebSocket backpressure (`bufferedAmount` threshold, drop and send `{t:'r'}` on drain) for grids over slow links. -4. Tile parity extras: mouse-wheel forwarding for Claude's fullscreen renderer, - a "Load full history" action inside a tile. (Done since: a tile pages a - hollow buffer's CLI transcript with PageUp/PageDown, the primary pane's - #555 route, and hand-reports a plain click while its session has - `cliMouseTracking` on, both through the primary pane's gates aimed at the - tile. The SGR wheel forwarding itself is still open: a fullscreen Claude - tile leaves the wheel to xterm.) +4. Tile parity extras: a "Load full history" action inside a tile. (Done + since: a tile pages a hollow buffer's CLI transcript with PageUp/PageDown, + the primary pane's #555 route, hand-reports a plain click while its session + has `cliMouseTracking` on, and forwards the wheel to Claude's fullscreen + renderer as SGR wheel reports from its own cells + (`TerminalTile._maybeForwardWheelToCli`, encoding shared with the primary + pane via `CodemanTerminalInput.sgrWheelReports`), all through the primary + pane's gates aimed at the tile. Before that, a fullscreen Claude tile left + the wheel to xterm, which scrolled only stale replayed frames. Shift+wheel + scrolls the tile's local scrollback itself (`_maybeScrollLocalOnShift`), + since xterm turns it into a horizontal no-op off macOS.) 5. WebGL in tiles, after measuring the DOM renderer with nine busy tiles. 6. Named grid presets, possibly per owner on the server. 7. The end state: the main terminal becomes a 1x1 grid of `TerminalTile`, diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 222ebc65..5ac18139 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -11,14 +11,20 @@ * * Deliberately plainer than the primary pane (this.terminal/this._ws in * terminal-ui.js): no local-echo overlay, no CJK IME textarea, no touch/mobile - * handlers (a swipe on a touch screen pages nothing), no keyboard accessory - * bar, and no SGR wheel forwarding to Claude's fullscreen renderer - * (docs/tile-grid-plan.md follow-up 4). Built for wide screens; see - * docs/split-pane-sessions-plan.md and docs/tile-grid-plan.md. + * handlers (a swipe on a touch screen pages nothing), and no keyboard + * accessory bar. Built for wide screens; see docs/split-pane-sessions-plan.md + * and docs/tile-grid-plan.md. * * What it does carry over from the primary pane, through the primary pane's * own code aimed at THIS pane (its terminal, its session, never the active * one): + * - SGR wheel forwarding (_maybeForwardWheelToCli): Claude's fullscreen + * renderer scrolls its own transcript on SGR wheel reports, while this + * xterm holds only replayed repaint frames, so the wheel goes to the CLI + * as reports at the pointer's cell in this pane, through the primary + * pane's forwarding gate and its encoding (sgrWheelReports). Shift+wheel + * scrolls the local scrollback itself (_maybeScrollLocalOnShift), as the + * primary pane does, since xterm turns it into a horizontal no-op. * - Hollow-buffer paging (#555): a CLI that draws in place (opencode on the * alternate screen, Claude's repaint mode) leaves the xterm no scrollback, * so the wheel pages the CLI's own transcript with PageUp/PageDown @@ -43,7 +49,7 @@ * * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js * @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE) - * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_handleDesktopTerminalClick) + * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick) * @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands) * @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js */ @@ -201,6 +207,9 @@ // Hollow-buffer paging (_maybePageCliTranscript): wheel travel short of a // whole page, carried to the next wheel event. this._pageKeyPending = 0; + // Shift+wheel travel short of a whole line, carried to the next wheel + // event (_maybeScrollLocalOnShift). + this._shiftScrollPending = 0; // Page keys waiting for the 40 ms flush, and its timer (_queueScrollBytes). this._scrollBytes = ''; this._scrollFlushTimer = null; @@ -1027,16 +1036,18 @@ // Capture phase, because xterm's own wheel handler stopPropagation()s every // event it consumes, so a bubbling listener here would never see the wheel - // while the pane still has scrollback to scroll. Not passive: the one route - // this pane takes over, paging a hollow buffer's CLI transcript - // (_maybePageCliTranscript), is consumed right here (preventDefault plus + // while the pane still has scrollback to scroll. Not passive: the three + // routes this pane takes over, forwarding the wheel to Claude's fullscreen + // renderer (_maybeForwardWheelToCli), paging a hollow buffer's CLI + // transcript (_maybePageCliTranscript) and Shift+wheel's local scrollback + // (_maybeScrollLocalOnShift), are consumed right here (preventDefault plus // stopPropagation in the capture phase, the primary pane's technique), so - // xterm's viewport, a descendant, never sees it. Every other wheel is left + // xterm's viewport, a descendant, never sees them. Every other wheel is left // to xterm, which keeps doing the scrolling, and only observed for the // shell history pull. _installWheelListener() { this._onWheel = (ev) => { - if (this._maybePageCliTranscript(ev)) { + if (this._maybeForwardWheelToCli(ev) || this._maybePageCliTranscript(ev) || this._maybeScrollLocalOnShift(ev)) { ev.preventDefault(); ev.stopPropagation(); return; @@ -1046,6 +1057,71 @@ this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: false }); } + // SGR wheel forwarding, the twin of the primary pane's capture-phase wheel + // handler and _forwardScrollToApp (terminal-ui.js; keep them in step). + // Claude's fullscreen renderer (claude 2.1.187+ while its mouse tracking is + // on, cliMouseTracking) scrolls its own transcript on SGR wheel reports, + // while this xterm holds only Codeman's replayed repaint frames (tmux keeps + // no history for such a pane). Left to xterm, the wheel dragged those stale + // frames, Claude's pinned input box with them, up the tile, or scrolled + // nothing at all. The gate is the primary pane's own, asked for THIS pane + // (its terminal, its session, never the active one), so the CLI rules stay + // in terminal-ui.js and this file names no CLI; the reports go to this + // pane's session through its own coalescer. Returns true when the wheel + // belongs to the CLI: a gesture with no whole line or no measurable cell is + // consumed too, as in the primary pane, so xterm never scrolls the stale + // frames under a forwarding session. Shift fails the gate, so Shift+wheel + // still scrolls the local scrollback (_maybeScrollLocalOnShift). + _maybeForwardWheelToCli(ev) { + if (this._destroyed || !this.terminal || !ev) return false; + const app = global.app; + const input = global.CodemanTerminalInput; + if (!app?._shouldForwardWheelToApp || !input?.sgrWheelReports || !input.wheelDeltaWholeLines) return false; + // xterm's own encoder forwards the wheel while the CLI's tracking reaches + // it, and its alt-scroll owns the alternate buffer, as in the primary pane. + const tracking = this.terminal.modes?.mouseTrackingMode; + if (tracking && tracking !== 'none') return false; + if (this.terminal.buffer?.active?.type === 'alternate') return false; + if (!app._shouldForwardWheelToApp(ev, { terminal: this.terminal, sessionId: this.sessionId })) return false; + // SGR coordinates address the live screen, so a report from a scrolled-up + // viewport would hit-test another row: snap home first (_forwardScrollToApp). + if (!app._terminalViewportAtBottom?.(this.terminal)) this.terminal.scrollToBottom?.(); + const lines = input.wheelDeltaWholeLines(ev, this.terminal.rows); + const pos = app._clientPointToCell?.(ev.clientX, ev.clientY, this.terminal); + const bytes = input.sgrWheelReports(lines, pos); + if (bytes) this._queueScrollBytes(bytes); + return true; + } + + // Shift+wheel scrolls this xterm's local scrollback, the explicit "local + // history" gesture, here as in the primary pane (whose capture-phase wheel + // handler scrolls with terminal.scrollLines() for the same reason). Left to + // xterm it was dead off macOS: Chrome on Windows sends Shift+wheel as a + // HORIZONTAL wheel (deltaX), and xterm's own scroller turns a Shift+vertical + // wheel into a horizontal one, so the viewport never moved. Reads the + // dominant axis under Shift (wheelDeltaLines), keeps the sub-line remainder + // for the next event (a trackpad's small deltas), and on the way up still + // asks a shell pane for more history. Returns true when the wheel was + // consumed here. + _maybeScrollLocalOnShift(ev) { + if (this._destroyed || !this.terminal || !ev?.shiftKey) return false; + const input = global.CodemanTerminalInput; + if (!input?.wheelDeltaLines) return false; + // xterm's own encoder forwards the wheel while the CLI's tracking reaches + // it, and its alt-scroll owns the alternate buffer, as in the primary pane. + const tracking = this.terminal.modes?.mouseTrackingMode; + if (tracking && tracking !== 'none') return false; + if (this.terminal.buffer?.active?.type === 'alternate') return false; + const total = this._shiftScrollPending + input.wheelDeltaLines(ev, this.terminal.rows); + const lines = Math.trunc(total); + this._shiftScrollPending = total - lines; + if (lines) { + this.terminal.scrollLines(lines); + if (lines < 0) this._maybeLoadMoreHistory(); + } + return true; + } + // A plain left-click reported to the CLI, the primary pane's desktop click // (terminal-ui.js _handleDesktopTerminalClick) aimed at this pane. The // server strips the mouse DECSETs of some modes (opencode's since #555, so a @@ -1087,9 +1163,9 @@ const tracking = this.terminal.modes?.mouseTrackingMode; if (tracking && tracking !== 'none') return false; const target = { terminal: this.terminal, sessionId: this.sessionId }; - // The primary pane would forward this wheel to Claude's fullscreen - // renderer as SGR reports. Tiles do not do that yet (docs/tile-grid-plan.md - // follow-up 4), so the wheel stays with xterm, as before. + // A wheel for Claude's fullscreen renderer was forwarded as SGR reports + // before this ran (_maybeForwardWheelToCli); the gate is repeated so a + // forwarding session is never paged. if (app._shouldForwardWheelToApp?.(ev, target)) return false; if (!app._localScrollbackIsHollow?.({ ...target, localRows: this._localRows() })) return false; // Only from the live screen. The one gate the primary pane never needs: a @@ -1111,8 +1187,9 @@ return true; } - // Coalesces the page keys into one send per 40 ms, bounded at 512 bytes so a - // fling cannot build a backlog that keeps paging after it stops. A narrow + // Coalesces the scroll bytes (SGR wheel reports and page keys alike) into + // one send per 40 ms, bounded at 512 bytes so a fling cannot build a backlog + // that keeps scrolling after it stops. A narrow // twin of the primary pane's _queueScrollBytes / _flushWheelSgrQueue // (terminal-ui.js; keep the two in step), which flushes to the active // session only. Sent ephemeral (no seq, never persisted) to THIS pane's diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index d95960b2..dda3d431 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -107,6 +107,32 @@ : delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad) } + // The same travel rounded to whole lines for the SGR wheel reports: a pure + // horizontal swipe is 0 (nothing to send), and anything else moves at least + // one line, so the small pixel deltas of a precision touchpad still scroll. + // The body of the primary pane's _wheelScrollLines. + function wheelDeltaWholeLines(ev, rows) { + const lines = wheelDeltaLines(ev, rows); + if (!lines) return 0; + return Math.round(lines) || (lines > 0 ? 1 : -1); + } + + // Ticks one gesture batch may report: Claude applies its own scroll-speed + // multiplier and acceleration on top, so a bigger batch only overshoots. + const SGR_WHEEL_MAX_TICKS = 5; + + // Whole wheel lines → SGR wheel reports at a 1-based cell `pos` ({ col, row }, + // live-screen relative): button 64 per line up, 65 per line down, capped at + // SGR_WHEEL_MAX_TICKS. '' when there is nothing to send. The encoding of the + // primary pane's _sendSyntheticSgrWheel, pure so a TerminalTile forwards + // byte-identical reports to its own session. + function sgrWheelReports(lines, pos) { + if (!lines || !pos) return ''; + const btn = lines < 0 ? 64 : 65; + const ticks = Math.min(Math.abs(lines), SGR_WHEEL_MAX_TICKS); + return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks); + } + // Gesture travel → PageUp/PageDown keys for a terminal `rows` tall: adds // `lines` to the sub-page travel already `pending`, and returns the travel // left over plus the keys to send ('' below one page). The arithmetic of the @@ -260,6 +286,9 @@ PAGE_KEY_SCREEN_FRACTION, PAGE_KEY_MAX_PER_BATCH, wheelDeltaLines, + wheelDeltaWholeLines, + SGR_WHEEL_MAX_TICKS, + sgrWheelReports, pageKeysForTravel, TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM, MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR, @@ -5563,9 +5592,8 @@ Object.assign(CodemanApp.prototype, { // the ±1 fallback — one line per notch, versus 4-5 for Chrome's ~110px. In // Claude mode the same value also capped the forwarded SGR report at one tick. _wheelScrollLines(ev) { - const lines = this._wheelScrollLinesFloat(ev); - if (!lines) return 0; // pure horizontal swipe: don't fall through to -1 - return Math.round(lines) || (lines > 0 ? 1 : -1); + // Pure horizontal swipe: 0, never the ±1 fallback (wheelDeltaWholeLines). + return window.CodemanTerminalInput.wheelDeltaWholeLines(ev, this.terminal?.rows); }, /** Unrounded variant for the smooth local-scroll path, which accumulates @@ -5639,13 +5667,13 @@ Object.assign(CodemanApp.prototype, { // scroll-speed multiplier and acceleration on top), and the queue is bounded // so a wild scroll can't build a backlog that keeps scrolling after the finger // stops. Flushed via _sendInputEphemeral — loss-tolerant, off the durable queue. + // The encoding is the pure CodemanTerminalInput.sgrWheelReports, which a + // TerminalTile calls with its own cell (TerminalTile._maybeForwardWheelToCli). _sendSyntheticSgrWheel(clientX, clientY, lines) { if (!this.activeSessionId || !lines) return; const pos = this._clientPointToCell(clientX, clientY); if (!pos) return; - const btn = lines < 0 ? 64 : 65; - const ticks = Math.min(Math.abs(lines), 5); - this._queueScrollBytes(`\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks)); + this._queueScrollBytes(window.CodemanTerminalInput.sgrWheelReports(lines, pos)); }, /** diff --git a/test/mocks/terminal-tile-fakes.ts b/test/mocks/terminal-tile-fakes.ts index 2b70614a..73e940f6 100644 --- a/test/mocks/terminal-tile-fakes.ts +++ b/test/mocks/terminal-tile-fakes.ts @@ -199,6 +199,19 @@ export class FakeTerminal { } scrollToLine() {} scrollToTop() {} + /** Every scrollLines() amount; the viewport moves within [0, baseY], as in xterm. */ + scrolledLines: number[] = []; + scrollLines(amount: number) { + this.scrolledLines.push(amount); + const active = this.buffer.active; + active.viewportY = Math.max(0, Math.min(active.baseY, active.viewportY + amount)); + } + /** Times scrollToBottom() was called; the viewport moves to the live screen, as in xterm. */ + scrolledToBottom = 0; + scrollToBottom() { + this.scrolledToBottom++; + this.buffer.active.viewportY = this.buffer.active.baseY; + } dispose() {} type(data: string) { this.dataCb?.(data); diff --git a/test/terminal-tile-scroll.browser.test.ts b/test/terminal-tile-scroll.browser.test.ts index 55eebce4..cfeccbdb 100644 --- a/test/terminal-tile-scroll.browser.test.ts +++ b/test/terminal-tile-scroll.browser.test.ts @@ -14,6 +14,12 @@ * wheel-down scrolls xterm home instead of being paged, and paging resumes * from the live screen. * + * - a fullscreen Claude tile sends the wheel to the CLI as SGR wheel reports + * at the pointer's cell in the TILE (Claude scrolls its own transcript on + * them), and xterm's viewport stays on the live screen instead of + * scrolling the stale replayed frames above it. Shift+wheel scrolls the + * local scrollback, which xterm alone turns into a horizontal no-op. + * * The wheel is a real one (`page.mouse.wheel()`), and the last step proves it * reaches xterm: a wheel the tile does not page scrolls xterm's viewport, so * "xterm did not scroll" on a paged wheel means something. What keeps xterm @@ -200,4 +206,95 @@ describe('TerminalTile wheel paging in a real browser', () => { }, PROBE_ID); } }); + + it("forwards a fullscreen Claude tile's wheel as SGR reports from its own cells, and keeps xterm on the live screen", async () => { + await page.evaluate(async (id) => { + const w = window as any; + const app = w.app; + const CAPTURE_ROWS = 40; + const capture = Array.from({ length: CAPTURE_ROWS }, (_, i) => `frame ${i}`).join('\r\n'); + + // The tile's session: Claude in its fullscreen renderer (mouse tracking on). + app.sessions.set(id, { id, mode: 'claude', cliVersion: '2.1.295', cliMouseTracking: true }); + const sent: Array<[string, string]> = []; + const realEphemeral = app._sendInputEphemeral; + app._sendInputEphemeral = (sessionId: string, data: string) => sent.push([sessionId, data]); + const realFetch = w.fetch; + w.fetch = async (url: string, init?: unknown) => + String(url).includes(`/api/sessions/${id}/terminal`) + ? new Response(JSON.stringify({ data: { terminalBuffer: capture, captureRows: CAPTURE_ROWS } })) + : realFetch(url, init); + const RealWebSocket = w.WebSocket; + w.WebSocket = class { + static OPEN = 1; + readyState = 0; + send() {} + close() {} + }; + + // Offset from the page corner, so a cell computed against the wrong + // element (the primary pane's) would come out different. + const mount = document.createElement('div'); + mount.style.cssText = + 'position:fixed;left:100px;top:120px;width:640px;height:300px;z-index:99999;background:#000'; + document.body.appendChild(mount); + const tile = new w.TerminalTile(id, mount, { mode: 'claude' }); + try { + await tile.connect(); + } finally { + w.fetch = realFetch; + w.WebSocket = RealWebSocket; + } + w.__tileProbe = { tile, mount, sent, realEphemeral }; + }, PROBE_ID); + + try { + // Where the pointer will be, as the tile's own screen maps it (1-based). + const expected = await page.evaluate(() => { + const term = (window as any).__tileProbe.tile.terminal; + const rect = term.element.querySelector('.xterm-screen').getBoundingClientRect(); + const cell = term._core._renderService.dimensions.css.cell; + const x = rect.left + cell.width * 7.5; + const y = rect.top + cell.height * 3.5; + return { x, y, col: 8, row: 4 }; + }); + // The stale frames sit above the live screen: something xterm COULD scroll. + const before = await snap(); + expect(before.baseY).toBeGreaterThan(0); + expect(before.viewportY).toBe(before.baseY); + + await page.mouse.move(expected.x, expected.y); + await page.mouse.wheel(0, -100); // one Chrome-on-Windows notch up + await page.waitForTimeout(150); + await page.mouse.wheel(0, 50); // half a notch down + await page.waitForTimeout(150); + + const after = await snap(); + expect(after.viewportY).toBe(before.baseY); // xterm never scrolled the stale frames + expect(after.sent.every(([sid]) => sid === PROBE_ID)).toBe(true); + const up = `\x1b[<64;${expected.col};${expected.row}M`; + const down = `\x1b[<65;${expected.col};${expected.row}M`; + expect(after.sent.map(([, data]) => data)).toEqual([up.repeat(4), down.repeat(2)]); + + // 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'); + await page.mouse.wheel(0, -100); + await page.keyboard.up('Shift'); + await page.waitForTimeout(150); + const shifted = await snap(); + expect(shifted.sent.length).toBe(after.sent.length); + expect(shifted.viewportY).toBeLessThan(before.baseY); + } finally { + await page.evaluate((id) => { + const w = window as any; + const probe = w.__tileProbe; + probe.tile.destroy(); + probe.mount.remove(); + w.app.sessions.delete(id); + w.app._sendInputEphemeral = probe.realEphemeral; + delete w.__tileProbe; + }, PROBE_ID); + } + }); }); diff --git a/test/terminal-tile-scroll.test.ts b/test/terminal-tile-scroll.test.ts index 1ad5459e..a75640cd 100644 --- a/test/terminal-tile-scroll.test.ts +++ b/test/terminal-tile-scroll.test.ts @@ -18,6 +18,13 @@ * history, so the tile discounts them (`_localRows`); the overflow cases below * fail without that discount. * + * Claude's fullscreen renderer is the other half: it scrolls its own + * transcript on SGR wheel reports, which the primary pane sends it, while a + * tile's xterm holds only replayed repaint frames. A tile left that wheel to + * xterm too, so in a grid of fullscreen Claude sessions the wheel dragged + * stale frames around or did nothing; it now forwards the reports to its own + * session, from its own geometry (`_maybeForwardWheelToCli`). + * * Real code under test: constants.js + app.js + terminal-ui.js + * terminal-tile.js in one `vm` context, as in terminal-tile-input.test.ts. * xterm, the fit addon and WebSocket are fakes (test/mocks/terminal-tile-fakes.ts), @@ -260,7 +267,8 @@ describe('a tile pages a hollow buffer through the primary pane gates', () => { expect(ev.stopPropagation).not.toHaveBeenCalled(); }; - fireAndCheck(wheelLines(-12, { shiftKey: true })); // the explicit "local scrollback" gesture + // The explicit "local scrollback" gesture: scrolled locally, never paged. + mount.fire('wheel', wheelLines(-12, { shiftKey: true })); term.modes.mouseTrackingMode = 'any'; // xterm's own encoder forwards the wheel fireAndCheck(wheelLines(-12)); @@ -280,18 +288,207 @@ describe('a tile pages a hollow buffer through the primary pane gates', () => { }); }); -describe("the forwarding gate behaves as in the primary pane, for the tile's session", () => { - it('leaves a fullscreen Claude tile to xterm (tiles do not forward SGR wheel yet)', async () => { - const app = makeApp({ 's-tile': { mode: 'claude', cliVersion: '2.1.223', cliMouseTracking: true } }); - const { ws, mount } = await connectTile(app, { mode: 'claude' }); +describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports', () => { + const FULLSCREEN: Session = { mode: 'claude', cliVersion: '2.1.295', cliMouseTracking: true }; + // 10 + 8 * 20 + 1, 20 + 16 * 5 + 1 inside the tile's own screen: column 21, row 6. + const AT = { clientX: 171, clientY: 101 }; + const UP = '\x1b[<64;21;6M'; + const DOWN = '\x1b[<65;21;6M'; - const ev = wheelLines(-12); - mount.fire('wheel', ev); + it("sends ephemeral reports on the tile's socket, from the tile's own geometry, and keeps xterm out", async () => { + const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' }); + + const up = wheelLines(-3, AT); + mount.fire('wheel', up); + + expect(up.preventDefault).toHaveBeenCalled(); + expect(up.stopPropagation).toHaveBeenCalled(); + expect(ws.inputFrames()).toEqual([]); // coalesced, not sent per event + 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 () => { + const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' }); + + mount.fire('wheel', wheel(-100, AT)); + expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(4) }]); + + mount.fire('wheel', wheelLines(-1000, AT)); + 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 () => { + const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' }); + + mount.fire('wheel', wheel(-3, AT)); // 0.12 of a line + expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); + + const sideways = wheel(0, { ...AT, deltaX: 120 }); + mount.fire('wheel', sideways); + expect(sideways.preventDefault).toHaveBeenCalled(); // never xterm's stale frames + expect(flushed(ws)).toHaveLength(1); + }); + + it('coalesces the reports of wheels within 40 ms into one frame', 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) }]); + }); + + it('snaps a scrolled-up viewport home before reporting, since SGR rows address the live screen', async () => { + const { ws, term, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' }); + term.buffer.active.baseY = 10; + term.buffer.active.viewportY = 0; + + mount.fire('wheel', wheelLines(-1, AT)); + + expect(term.scrolledToBottom).toBe(1); + expect(term.buffer.active.viewportY).toBe(10); + expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); + }); + + it("follows the tile session's gate, never the active session's", async () => { + // Active session is a shell; the tile shows fullscreen Claude: the tile forwards. + const forwarded = await connectTile(makeApp({ other: { mode: 'shell' }, 's-tile': FULLSCREEN }, 'other'), { + mode: 'claude', + }); + forwarded.mount.fire('wheel', wheelLines(-1, AT)); + expect(flushed(forwarded.ws)).toEqual([{ t: 'i', d: UP }]); + + // Active session is fullscreen Claude; the tile shows inline Claude (no + // tracking): no reports, the hollow buffer is paged instead. + const inline = await connectTile( + makeApp({ other: FULLSCREEN, 's-tile': { ...FULLSCREEN, cliMouseTracking: false } }, 'other'), + { mode: 'claude' } + ); + inline.mount.fire('wheel', wheelLines(-12, AT)); + expect(flushed(inline.ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + }); + + it('scrolls Shift locally, and leaves a tracking xterm and the alternate buffer to xterm', async () => { + const { ws, term, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' }); + const fireAndCheck = (ev: ReturnType<typeof wheel>) => { + mount.fire('wheel', ev); + expect(ev.preventDefault).not.toHaveBeenCalled(); + expect(ev.stopPropagation).not.toHaveBeenCalled(); + }; + + const shifted = wheelLines(-3, { ...AT, shiftKey: true }); // the explicit "local scrollback" gesture + mount.fire('wheel', shifted); + expect(shifted.preventDefault).toHaveBeenCalled(); + expect(term.scrolledLines).toEqual([-3]); + + term.modes.mouseTrackingMode = 'any'; // xterm's own encoder forwards the wheel + fireAndCheck(wheelLines(-3, AT)); + term.modes.mouseTrackingMode = 'none'; + + term.buffer.active.type = 'alternate'; // xterm's alt-scroll owns it + fireAndCheck(wheelLines(-3, AT)); - expect(ev.preventDefault).not.toHaveBeenCalled(); expect(flushed(ws)).toEqual([]); }); + it('pages, never reports, a Claude older than 2.1.187', async () => { + const app = makeApp({ 's-tile': { ...FULLSCREEN, cliVersion: '2.1.100' } }); + const { ws, mount } = await connectTile(app, { mode: 'claude' }); + + mount.fire('wheel', wheelLines(-12, AT)); + + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + }); + + it('sends exactly the bytes the primary pane sends for the same wheel sequence', async () => { + const sequence = [-1, -3, 2, -100, 40, -0.2, 7].map((n) => wheelLines(n, AT)); + const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' }); + const tileBytes: string[] = []; + for (const ev of sequence) { + mount.fire('wheel', ev); + tileBytes.push( + ...flushed(ws) + .splice(tileBytes.length) + .map((f) => f.d as string) + ); + } + + const primary = makeApp({ other: FULLSCREEN }, 'other') as App & { + terminal: unknown; + _sendSyntheticSgrWheel(x: number, y: number, lines: number): void; + _wheelScrollLines(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._flushWheelSgrQueue(); + } + + expect(tileBytes.join('')).not.toBe(''); + expect(tileBytes).toEqual(sent); + }); +}); + +describe("Shift+wheel scrolls the tile's local scrollback itself", () => { + // xterm's own scroller turns a Shift+vertical wheel into a horizontal one off + // macOS, and Chrome on Windows already delivers Shift+wheel as deltaX, so + // left to xterm the gesture moved nothing. + it.each(['claude', 'opencode', 'shell'])('scrolls a %s tile with real history, and sends nothing', async (mode) => { + const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode } }), { mode }); + term.write(lines(40)); + const bottom = term.buffer.active.baseY; + expect(bottom).toBeGreaterThan(5); + + const up = wheelLines(-3, { shiftKey: true }); + mount.fire('wheel', up); + + expect(up.preventDefault).toHaveBeenCalled(); + expect(up.stopPropagation).toHaveBeenCalled(); + expect(term.buffer.active.viewportY).toBe(bottom - 3); + mount.fire('wheel', wheelLines(3, { shiftKey: true })); + expect(term.buffer.active.viewportY).toBe(bottom); + expect(flushed(ws)).toEqual([]); + }); + + it("reads Chrome-on-Windows' horizontal Shift+wheel (deltaX) as vertical travel", async () => { + const { term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'claude' } }), { mode: 'claude' }); + + mount.fire('wheel', wheel(0, { deltaX: -100, shiftKey: true })); + + expect(term.scrolledLines).toEqual([-4]); + }); + + it('carries sub-line travel to the next event, so a trackpad still scrolls', async () => { + const { term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'claude' } }), { mode: 'claude' }); + + mount.fire('wheel', wheel(-10, { shiftKey: true })); // 0.4 of a line + mount.fire('wheel', wheel(-10, { shiftKey: true })); + expect(term.scrolledLines).toEqual([]); + mount.fire('wheel', wheel(-10, { shiftKey: true })); + + expect(term.scrolledLines).toEqual([-1]); + }); + + it('still pulls history on a Shift+wheel-up at the top of a shell tile', async () => { + const { mount } = await connectTile(makeApp({ 's-tile': { mode: 'shell' } }), { mode: 'shell' }); + expect(fetchMock).toHaveBeenCalledTimes(1); // the initial load + + mount.fire('wheel', wheelLines(-3, { shiftKey: true })); + await vi.advanceTimersByTimeAsync(0); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[1][0])).toContain('full=1&tail='); + }); +}); + +describe("the forwarding gate behaves as in the primary pane, for the tile's session", () => { it('pages that same tile under the "Wheel scrolls local history" opt-out (the footgun rescue)', async () => { const app = makeApp({ 's-tile': { mode: 'claude', cliVersion: '2.1.223', cliMouseTracking: true } }); app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });