Merge pull request #577: fix(tiles): the wheel scrolls Claude's fullscreen transcript in a tile, and Shift+wheel scrolls local history

This commit is contained in:
Codeman maintainer
2026-10-10 02:53:26 +02:00
8 changed files with 454 additions and 38 deletions
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -225,7 +225,7 @@ Further detail: the `<prefix>: <title>` 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.
+11 -7
View File
@@ -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`,
+92 -15
View File
@@ -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
+34 -6
View File
@@ -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));
},
/**
+13
View File
@@ -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);
+97
View File
@@ -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);
}
});
});
+205 -8
View File
@@ -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 });