From 8406c497e2bd697792edcaba848e577bfcf5c2fc Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 9 Aug 2026 03:17:38 +0200 Subject: [PATCH] fix(terminal): stop forwarding the wheel to codex, it ignores SGR reports DodgyBadger reported a completely dead wheel in codex tabs (#227 comment) while the scrollbar drag worked, and the [scroll] line confirmed the branch: forward-sgr with 967 rows of healthy local scrollback unused. Measured against codex-cli 0.147.0 in a bare tmux: codex never enables mouse tracking (mouse_any_flag=0), runs an inline viewport (alternate_on=0) and pushes its transcript into the terminal's own scrollback (history_size grows), and SGR wheel reports written to its pane change nothing at all. Hand-encoded SGR taps are no-ops too, so they stay (harmless), which means click-to-position is merely unavailable there rather than damaging. _shouldForwardWheelToApp now returns true for claude >= 2.1.187 and nothing else; codex falls to the local-scrollback path like shell/gemini/opencode, which is the same history the scrollbar drag was already reaching. The claude-only PageUp fallback is untouched. Verified in Chromium against a live codex session on an isolated instance: routing logs local-scrollback, the viewport moves 39 -> 4 and zero bytes go to the PTY. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 4 ++- docs/scrollback-fix-plan.md | 51 +++++++++++++++++++++++++++++++++ src/web/public/index.html | 2 +- src/web/public/terminal-ui.js | 30 +++++++++++-------- test/terminal-touch-tap.test.ts | 12 ++++++-- 6 files changed, 83 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7498ede9..5681d688 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,7 +212,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of EACH session per page load requests `full=1` (`_fullHistoryLoaded` Set); tab switches keep the cheap `?tail=` path, and scrolling up at the TOP of the buffer re-pulls `full=1` on demand (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). ⚠️ That re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) -**Terminal scrollback strip + wheel/touch forwarding** (#205): codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time `smcup`, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on `useMux`: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for codex/claude ≥ 2.1.187 at ANY scroll position (snap-to-bottom first); Shift+wheel and the `terminalWheelLocalScrollback` setting stay local. `_wheelScrollLines()` reads `ev.deltaMode` (Firefox = LINE units). ⚠️ When that gate is FALSE on a claude session whose local buffer is hollow (`baseY === 0`), the gesture becomes coalesced PageUp/PageDown key sends (`_maybePageCliTranscript`) instead of a no-op; ⚠️ and `getClaudeCliVersion()` must never cache a FAILED probe (one timeout used to disable forwarding process-wide until restart). `_logScrollRouting()` prints the routing decision and its inputs once per session — read it 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** (#205): codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time `smcup`, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on `useMux`: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for **claude ≥ 2.1.187 ONLY** at ANY scroll position (snap-to-bottom first); Shift+wheel and the `terminalWheelLocalScrollback` setting stay local. ⚠️ Codex was in that list and must never go back without a fresh measurement: codex-cli 0.147.0 ignores SGR wheel reports entirely (`mouse_any_flag=0`, inline viewport, transcript pushed into terminal scrollback), so forwarding produced a dead wheel (#227 follow-up). `_wheelScrollLines()` reads `ev.deltaMode` (Firefox = LINE units). ⚠️ When that gate is FALSE on a claude session whose local buffer is hollow (`baseY === 0`), the gesture becomes coalesced PageUp/PageDown key sends (`_maybePageCliTranscript`) instead of a no-op; ⚠️ and `getClaudeCliVersion()` must never cache a FAILED probe (one timeout used to disable forwarding process-wide until restart). `_logScrollRouting()` prints the routing decision and its inputs once per session — read it 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** (issue #231): `codeman web -d` relaunches the SAME entry script with `detached:true` (setsid), so there is no controlling terminal and no shell job entry. ⚠️ `nohup` is NOT what makes this work: Node re-arms SIGHUP to its default disposition even when it inherits "ignore", and `cli.ts` handles SIGHUP with a graceful shutdown, so a delivered HUP still stops the server. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile check + `/api/status` probe): a second instance on the shared tmux socket attaches PTYs to the first one's live sessions. ⚠️ Neither may report success it has not observed — the parent polls `/api/status` until the child answers or dies, since `launchctl load` and a clean spawn are both silent about a server that starts and immediately exits. `--stop` verifies the pid still LOOKS like a Codeman server (`ps -o command=`) before signalling, because pids get recycled. Unit/label names live in `config/service-names.ts` so install.sh, `detectSupervisor()` and `service install` cannot drift into supervising two copies; they are instance-scoped, and identical to the historical names for the default instance. `service install` bakes the installing shell's PATH into the unit (launchd gives a job `/usr/bin:/bin:/usr/sbin:/sbin`, which finds neither a Homebrew/nvm `node` nor `tmux`/`claude`) and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 4f00ef94..c53c0530 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -80,7 +80,9 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough **Two strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. Every other mode (shell/opencode/antigravity) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same narrow branch; the frontend `_sessionUsesServerMouseStrip()` mirror stays claude/codex/gemini because only the FULL strip touches mouse DECSETs. The chunk-boundary carry (`_altScreenSeqCarry`) runs for both flavors. Tests: `test/claude-scrollback-strip.test.ts`. -**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 (codex, claude ≥ 2.1.187 — version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`. +**Only claude ≥ 2.1.187 forwards the wheel; every other mode scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS stay enabled for codex (`_sessionUsesServerMouseStrip`); measured, they are no-ops that insert nothing, so click-to-position is simply unavailable there rather than harmful. + +**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 — version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`. **A false gate on a Claude session must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, or the user turned on `terminalWheelLocalScrollback`. The 1.12.0 retest reported exactly that: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the triple guard (claude mode + gate false + `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. Tests: `test/terminal-scroll-routing.test.ts`, `test/claude-cli-version-cache.test.ts`. diff --git a/docs/scrollback-fix-plan.md b/docs/scrollback-fix-plan.md index fa267814..65d8870d 100644 --- a/docs/scrollback-fix-plan.md +++ b/docs/scrollback-fix-plan.md @@ -163,6 +163,57 @@ both self-reporting, so the retest ask is now "open the console and paste the `[ - iPhone: Claude or shell session, and whether a full tab kill changes anything. - Browser console: `app.terminalUi?.terminal?.modes?.mouseTrackingMode` (false-path 4). +## ROUND 3 (2026-08-09): Codex wheel dead — CONFIRMED AND FIXED + +DodgyBadger (Codex latest, Chrome, Windows 11): mouse wheel does nothing in a CODEX session +while working fine in shell and web tabs; DRAGGING THE SCROLLBAR WORKS, so xterm's local +buffer demonstrably has content for their codex pane. Analysis against the shipped code: + +- `_shouldForwardWheelToApp` returns true UNCONDITIONALLY for `codex` (no version gate, unlike + claude's `>= 2.1.187`), so every plain wheel tick is sent as SGR reports to Codex. +- The "verified to scroll its transcript on SGR wheel reports" claim for codex predates + current Codex builds; if Codex latest ignores SGR wheel, forwarding eats the gesture while + the healthy local scrollback (proven by the working scrollbar) sits unused. +- The #227 PageUp fallback cannot rescue this: it is gated to `claude` mode AND `baseY === 0`, + and codex here has real local scrollback. The `[scroll]` diagnostic will still say + `forward-sgr (mode=codex, ...)`, confirming the branch, worth asking the reporter to paste. + +**CONFIRMED by the reporter's `[scroll]` line (2026-08-09, PR #227 comment)**: +`forward-sgr (mode=codex, cliVersion=unknown, localScrollbackOptOut=false, mouseTracking=none, +localScrollbackRows=967)`. Forwarding branch active, 967 rows of healthy local scrollback +unused, Codex ignoring the SGR reports. Environment: Codex latest, Chrome, Windows 11. + +**Measured against codex-cli 0.147.0** (isolated `tmux -L codexwheel`, fake `CODEX_HOME/auth.json`, +history built with 401ing prompts), which settles it without needing a version gate at all: + +| Probe | Result | +| ---------------------------------------------- | ----------------------------------------------- | +| `#{mouse_any_flag}` once the TUI is up | `0`: codex never enables mouse tracking | +| `#{alternate_on}` | `0`: inline viewport, not an alt-screen pager | +| `#{history_size}` while prompting | grows 3 → 32: the transcript goes to scrollback | +| 6 × `\x1b[<64;10;10M` written to the pane | pane capture byte-identical, nothing happens | +| control: literal `zz` | pane changes, so the probe can see changes | +| `\x1b[<0;12;5M` + release (the click-tap path) | no change either: taps are no-ops, not garbage | + +Codex has no in-app pager to drive: its history lives in the terminal's own scrollback, which is +exactly what forwarding was stealing the gesture from. A version gate would be the wrong fix (and +`cliVersion=unknown` means there is no codex probe to gate on anyway). + +**Fix (shipped):** `_shouldForwardWheelToApp` now returns true for `claude >= 2.1.187` and nothing +else. Codex falls to the normal local-scrollback path like shell/gemini/opencode, so wheel and touch +scroll the same history the scrollbar drag was already scrolling. The claude-only PageUp fallback is +untouched: codex never needs it, its local buffer is real. Taps stay hand-encoded for codex +(`_sessionUsesServerMouseStrip`), measured harmless, so click-to-position is merely unavailable +there rather than damaging. Lesson for the next mode added to the forward list: "it is a strip mode" +proves nothing, write a real SGR report into a live pane and diff the capture first. + +Verified end-to-end in Chromium against a live codex session on an isolated instance +(`CODEMAN_INSTANCE=codexwheel`, port 5055, `envOverrides.CODEX_HOME` pointing at the fake auth +dir): trusted `page.mouse.wheel` up now logs +`[scroll] … → local-scrollback (mode=codex, …, localScrollbackRows=43)`, moves the viewport +39 → 4 (back to the Codex banner), and sends ZERO bytes to the PTY. Unit coverage: +`test/terminal-touch-tap.test.ts` ("only claude forwards — codex and gemini keep the local wheel"). + Original plan follows. ## Reports diff --git a/src/web/public/index.html b/src/web/public/index.html index 1124ed99..98142965 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1322,7 +1322,7 @@
Input
-
+
Wheel Scrolls Local History Plain wheel/trackpad pages the terminal scrollback diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index fdbffe6c..1cd5ed49 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -452,8 +452,8 @@ Object.assign(CodemanApp.prototype, { this.registerFilePathLinkProvider(); // Mouse wheel: forward to the TUI only for sessions verified to handle SGR - // wheel reports (codex, and claude 2.1.187+ — see _shouldForwardWheelToApp), - // local scrollback otherwise. Claude Code 2.1.187+ scrolls its own + // wheel reports (claude 2.1.187+ — see _shouldForwardWheelToApp), local + // scrollback otherwise. Claude Code 2.1.187+ scrolls its own // transcript on SGR wheel reports — scrolled-away tool blocks re-render // live and stay clickable — and its select menus no longer capture wheel // as option navigation (verified against 2.1.202: /model menu highlight @@ -3107,11 +3107,20 @@ Object.assign(CodemanApp.prototype, { // Wheel forwarding gate for the container wheel handler: no Shift override, // xterm's own encoder dormant, viewport at the bottom, and a TUI VERIFIED to - // scroll its transcript on SGR wheel reports: codex, or claude 2.1.187+ - // (older Claude Code captures wheel as select-menu option navigation; an - // unknown version is treated as older). Gemini is a strip mode too but its - // wheel behavior is unverified, so it keeps the local wheel — taps/clicks - // are still forwarded for it (harmless no-ops at worst). + // scroll its transcript on SGR wheel reports — which today is claude 2.1.187+ + // and nothing else (older Claude Code captures wheel as select-menu option + // navigation; an unknown version is treated as older). Gemini and codex are + // strip modes too but keep the local wheel — taps/clicks are still forwarded + // for them (harmless no-ops at worst). + // + // Codex USED to forward here and was the #227 regression (DodgyBadger, Codex + // latest / Chrome / Win11: dead wheel in codex, working scrollbar drag). + // Measured on codex-cli 0.147.0 in a bare tmux: it never enables mouse + // tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change + // NOTHING on screen — it runs an inline viewport (`alternate_on=0`) and pushes + // its transcript into the terminal's own scrollback (tmux `history_size` + // grows), so there is no in-app pager to drive and local scrollback IS the + // codex transcript. Forwarding therefore swallowed every tick. // Wheel delta → whole scroll lines. macOS trackpads turn Shift+two-finger // scroll into a HORIZONTAL wheel (deltaY≈0, deltaX carries the magnitude), and // Shift routes the wheel to local scrollback (_shouldForwardWheelToApp returns @@ -3165,11 +3174,8 @@ Object.assign(CodemanApp.prototype, { if (mode && mode !== 'none') return false; const session = this.sessions?.get(this.activeSessionId); const sessionMode = session?.mode || 'claude'; - if (sessionMode === 'claude') { - if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false; - } else if (sessionMode !== 'codex') { - return false; - } + if (sessionMode !== 'claude') return false; + if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false; // Deliberately NOT gated on _terminalViewportAtBottom(). It used to be, so // that leaving the bottom handed the wheel back to local scrollback and both // histories stayed reachable without a mode switch. In practice that inverted diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 773ddc48..378ba1a7 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -379,7 +379,7 @@ describe('terminal touch tap mouse guard', () => { expect(withVersion('garbage')).toBe(false); // unparseable → assume older }); - it('wheel: codex forwards without a version; gemini never forwards', () => { + it('wheel: only claude forwards — codex and gemini keep the local wheel', () => { const { app } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; app.terminal = { @@ -387,8 +387,14 @@ describe('terminal touch tap mouse guard', () => { buffer: { active: { viewportY: 50, baseY: 50 } }, }; - app.sessions = new Map([['sess-1', { mode: 'codex' }]]); // verified TUI, no version gate - expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true); + // Codex used to forward unconditionally, which is PR #227's regression: measured + // on codex-cli 0.147.0, it never enables mouse tracking and ignores SGR wheel + // reports outright, so forwarding ate every tick while its real local scrollback + // (the codex transcript lives there — inline viewport, no in-app pager) sat unused. + app.sessions = new Map([['sess-1', { mode: 'codex' }]]); + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + app.sessions = new Map([['sess-1', { mode: 'codex', cliVersion: '9.9.9' }]]); // no version rescues it + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); app.sessions = new Map([['sess-1', { mode: 'gemini', cliVersion: '9.9.9' }]]); // unverified TUI expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);