diff --git a/CLAUDE.md b/CLAUDE.md index cb81f7fa..1237e70e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -206,7 +206,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit) -**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. Only the FIRST buffer load after a page load requests `full=1`; tab switches keep the cheap `?tail=` path. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) +**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). → [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). → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) **Self-update** (App Settings → Updates): in-app updater for git-clone installs supervised by systemd/launchd (`systemd`, `launchd`, `launchd-daemon`, else `none` → "restart manually"). The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` that outlives the restart and writes progress to `update-status.json`, which the browser polls across the connection drop. `src/web/self-update.ts` splits pure helpers (unit-tested) from IO wrappers. npm installs report as non-updatable. → [architecture-invariants#self-update](docs/architecture-invariants.md#self-update) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index ae1c143e..2bb8c74d 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -58,7 +58,15 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough ### Full-scrollback replay -**Full-scrollback replay** (COD-164/#148): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). Only the FIRST buffer load after a page load requests `full=1` (one-shot `_initialFullBufferLoad` flag in app.js); tab switches keep the cheap `?tail=` visible-frame path. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`. +**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load OF EACH SESSION per page load requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other tab one frame of history); later switches keep the cheap `?tail=` visible-frame path. On top of that, scrolling up while already at the TOP of the buffer re-pulls `full=1` on demand (`_maybeRefetchFullHistory`, 4s per-session cooldown, in-flight + tab-switch guards, viewport position held across the replay). The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`. + +### Terminal scrollback: strip flavors and wheel/touch forwarding + +**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`. + +**The wheel listener is CAPTURE-phase and Codeman owns the scroll** (#205 follow-up, measured on the live instance): xterm's viewport is a vscode-style ScrollableElement that consumes wheel events itself (preventDefault + stopPropagation) whenever it believes a scrollbar exists, ignores `attachCustomWheelEventHandler`, and goes DEAF after `terminal.reset()` — a tab switch or full-history replay leaves its scroll dimensions stale, after which wheel events neither scroll nor propagate reliably. A bubble-phase container listener therefore never fired once local scrollback existed (forwarding, deltaMode and the top-of-buffer re-pull all silently dead exactly on sessions WITH history), and after a tab switch nothing scrolled at all ("works at first, breaks after a tab switch"). The container wheel listener is `{capture: true}`, stops propagation, and scrolls locally via buffer-level `terminal.scrollLines()` (immune to the stale scroller). ⚠️ Two cases are deliberately passed through untouched, in this order BEFORE preventDefault: `mouseTrackingMode !== 'none'` (xterm's encoder forwards the wheel to the PTY — htop/vim with mouse on) and `buffer.active.type === 'alternate'` (direct-PTY vim/less: xterm's alt-scroll converts the wheel to cursor keys). Do not "simplify" this back to a bubble listener or re-delegate local scrolling to xterm's viewport. E2E guard: the reload → tab-switch → wheel matrix in the #205 verification scripts. ### Run launch synchronization diff --git a/docs/scrollback-fix-plan.md b/docs/scrollback-fix-plan.md new file mode 100644 index 00000000..acc21f3d --- /dev/null +++ b/docs/scrollback-fix-plan.md @@ -0,0 +1,112 @@ +# Scrollback fix plan (issue #205) + +Status: IMPLEMENTED on `fix/scrollback-shell-alt-screen` (2026-08-07), with one deliberate +divergence from the recommendation below. Kept for the diagnosis record; the measured evidence +behind it is `docs/scrollback-issues-analysis.md`, and the mechanisms as shipped are documented +in `docs/architecture-invariants.md` (§ Full-scrollback replay, § Terminal scrollback: strip +flavors and wheel/touch forwarding). + +What shipped vs. what this doc proposed: + +- **Bug A (deltaMode)**: implemented as specified (`_wheelScrollLines()` normalizes + line/page/pixel units, Shift-axis trap kept). +- **Bug B (shell scrollback)**: implemented via the NARROW alt-screen strip for tmux-backed + shell/opencode/antigravity plus the scroll-to-top `full=1` re-pull, NOT the recommended + approach (a) `tmux mouse on`. The measurements in the analysis doc showed the alt buffer + comes from tmux's own client-side `smcup` at attach (tmux never forwards a pane program's + alt-screen toggles), so stripping that one sequence fixes both symptoms with no selection + tradeoff, keeps vim/less/htop untouched, and the re-pull also covers the repaint-burst + history loss that `mouse on` would not have addressed. +- **Invariant change**: the "viewport-at-bottom gate stays" invariant below was deliberately + DROPPED for forwarding modes: a repaint-mode CLI keeps no real terminal scrollback, so the + gate pinned users to a buffer of stale frames whenever the viewport parked off-bottom. + Forwarding now snaps to bottom first; Shift+wheel and the opt-out setting keep local + scrollback reachable. Touch forwards through the same gate (the mobile half of the fix). +- **Finding 5 (remote probe)**: implemented (`probeRemoteCliVersion` over ssh, deferred at + session start, same login-shell wrapper as the launch). + +Original plan follows. + +## Reports + +- **Issue #205** (https://github.com/Ark0N/Codeman/issues/205), OPEN: + - **jonocodes** (author, 2026-08-03): SHELL session. Host Mac M4, brew tmux. On Android, touch-scrolling the terminal does nothing. On desktop, the mouse wheel cycles shell command history (acts like Up/Down arrows) instead of scrolling the screen. + - **mtiller** (comment, 2026-08-06): "similar issue just with scrolling backward to see agent output. This is with Firefox on MacOS." (Claude session implied.) +- **Reddit r/selfhosted** comment `p21x6ts` by mmtiller (= mtiller on GitHub): scrolling broken enough across phone/iPad/laptop that they fall back to Claude's own remote-control feature. Churn-risk user who otherwise loves the product; fixing this has promo value beyond the bug itself. + +## How scrolling works today (read this before touching anything) + +Three independent paths, all in `src/web/public/terminal-ui.js` unless noted: + +1. **Desktop wheel** (container `wheel` listener, ~line 421): ALWAYS `preventDefault()`s, then either + - forwards synthetic SGR wheel reports to the app (`_sendSyntheticSgrWheel`, coalesced every 40ms, fire-and-forget) when `_shouldForwardWheelToApp(ev)` (~line 2823) passes: no Shift held, opt-out setting `terminalWheelLocalScrollback` off, xterm `mouseTrackingMode === 'none'`, session mode is `claude` with `cliVersion >= 2.1.187` or `codex`, and viewport is at bottom; + - otherwise scrolls xterm's LOCAL scrollback via `terminal.scrollLines(lines)`. + - `lines` comes from `_wheelScrollLines(ev)` (~line 2818): `delta / 25`, i.e. it assumes PIXEL deltas. + - NOTE: xterm.js's own internal wheel handler sits on an element INSIDE the container, so it runs FIRST (bubble order) and is not suppressed by the container's `preventDefault`. +2. **Touch** (touchstart/move/end, ~lines 441-585): converts touch deltas to `terminal.scrollLines()` with momentum. Touch is ALWAYS local-scrollback, never forwarded to the app. Tap-to-position (touchend, ~line 533) is separate and already handles both mouse-tracking-on and server-strip cases. +3. **Server-side strip** (`_handleTerminalOutput`, `src/session.ts:1384`): for modes in `isAltScreenStripMode()` (`src/session.ts:179` = `codex | claude | gemini`), strips alt-screen switches (`?47/?1047/?1049`), scrollback erase (`3J`), and mouse-tracking DECSETs (`?1000-?1007` except `?1004` focus) so content stays in xterm's normal buffer with scrollback intact. Includes a chunk-boundary carry so split sequences can't leak. `shell` and `opencode` (and `antigravity`) are deliberately EXCLUDED: arbitrary shell programs (vim/less/htop) legitimately need the alt screen. There is a parity copy of this strip on the replay path (`src/web/routes/session-routes.ts`, ~line 1697) and a frontend parity check `_sessionUsesServerMouseStrip()` (terminal-ui.js ~line 2751). All three must stay in sync. +4. Related: full-scrollback replay (`GET .../terminal?full=1` on first buffer load) fills xterm local scrollback; client scrollback is hardcoded 50k (`DEFAULT_SCROLLBACK`, constants.js) vs tmux 100k. + +## Diagnosis + +### Bug A: Firefox wheel deltas (mtiller's desktop case) + +`_wheelScrollLines()` divides by 25 assuming `WheelEvent.deltaY` is pixels (`deltaMode === 0`, Chrome/Safari behavior). Firefox commonly fires `deltaMode === 1` (LINE units, deltaY around 1-3 per notch), so `Math.round(3/25) = 0` and the `|| ±1` fallback yields 1 line per event. With a discrete mouse wheel that is 1 line per notch: scrolling feels dead/broken. This hits BOTH the local-scroll path and the forwarded path, since both use the same function. + +**Fix**: normalize by `ev.deltaMode` in `_wheelScrollLines()`: +- `deltaMode 0` (pixels): current behavior, `delta / 25`. +- `deltaMode 1` (lines): use the delta directly (round, keep sign fallback). +- `deltaMode 2` (pages): `delta * terminal.rows` (or a sane page size). +Keep the existing Shift-axis trap intact: on macOS trackpads Shift+two-finger scroll arrives as a HORIZONTAL wheel (deltaX carries the magnitude, deltaY ~0); that's why the function reads deltaX when Shift is held (issue #154). Don't lose it. + +**Verify**: don't trust this diagnosis blindly. First reproduce in real Firefox on macOS and log `deltaMode`/`deltaY` (Firefox trackpad input can arrive as pixels; external mouse as lines). Also confirm the session's `cliVersion` probe succeeded (a failed probe disables forwarding entirely, which would point elsewhere). Unit-test by dispatching synthetic `WheelEvent`s with explicit `deltaMode` values; a Playwright `firefox` project pass is the end-to-end check. + +### Bug B: shell mode has NO working scrollback at all (jonocodes) + +Chain: shell mode is excluded from the alt-screen strip (correctly) → tmux attaches on the alternate screen → xterm's alt buffer has zero scrollback. Consequences: +- **Wheel**: xterm's own internal wheel handler runs first and, in the alt buffer, converts wheel ticks into Up/Down arrow keys (alternateScroll behavior). The shell receives arrows → command history cycles. That is jonocodes' exact desktop symptom. The container handler's `scrollLines()` afterwards is a no-op (no scrollback in alt buffer). +- **Touch**: the touch handler's `scrollLines()` is equally a no-op → "scrolling does nothing" on Android. Exact symptom two. +- The real history exists the whole time in tmux's 100k-line buffer; nothing exposes it. + +**Fix, recommended approach (a): enable tmux `mouse on` for shell sessions.** +- Server-side, set `mouse on` scoped to shell sessions' tmux sessions (`tmux set-option -t mouse on` at create + on attach of recovered sessions). Do NOT set it globally on the socket: claude/codex/gemini sessions rely on the DECSET strip and must not change. +- What this buys, all natively: tmux enables mouse tracking on the outer terminal → xterm `mouseTrackingMode` goes non-none → the container handler stands down (line ~2830 check) and xterm's own encoder forwards wheel as SGR reports → tmux scrolls its OWN copy-mode history on wheel-up, auto-exits at bottom. The alt-scroll arrow conversion disappears too (tracking mode takes precedence). Desktop is fully fixed with no new endpoints. +- **Touch**: still needs one small client change: in the touchmove path, when the active session is `shell` AND `mouseTrackingMode !== 'none'`, convert accumulated lines to `_sendSyntheticSgrWheel(x, y, lines)` instead of `scrollLines()`. The 40ms coalescing already prevents the tmux process storm (each send is a tmux send-keys server-side; unbatched flicks would spawn dozens of processes: this constraint is documented at `_sendSyntheticSgrWheel`, do not bypass it). +- **Selection tradeoff to verify**: with tracking on, xterm hands drag events to tmux instead of doing local browser selection. Shift+drag still does local selection (xterm shift-override). Verify this UX on desktop before shipping; if it's unacceptable, fall back to approach (b). +- **Also verify**: vim/less/htop inside the shell still behave (they'll now receive real mouse events via tmux, generally an improvement); remote shell sessions run tmux on the REMOTE host (`tmux -L codeman-remote`) and need the same option set there if remote shells are in scope (fine to defer, note it in the changeset if skipped). + +**Fallback approach (b), only if (a)'s selection tradeoff fails testing**: keep mouse off; when a shell session is in the alt buffer, have the client send scroll intents to a small server endpoint that drives `tmux copy-mode -e -t ` + `send-keys -X -N scroll-up/down`. Preserves selection semantics exactly, but needs a new endpoint, server-side batching, AND suppression of xterm's native alt-scroll arrow conversion (capture-phase wheel listener with `stopPropagation`, or `attachCustomWheelEventHandler` if the vendored xterm version has it). More moving parts; (a) should be tried first. + +**Not acceptable**: adding `shell` to `isAltScreenStripMode()`. vim/less/htop need the alt screen; that exclusion is deliberate and documented. + +### Bug C: mtiller's phone/iPad case — UNREPRODUCED, do not guess + +Touch is always-local by design, and Claude sessions keep content in the normal buffer (strip), so touch scrollback "should" work there. Before coding anything: build a repro matrix (iPhone Safari / iPad Safari / Android Chrome × claude / shell) on the current release. Plausible candidates if it does reproduce: auto-scroll-to-bottom fighting user scrolls (`_noteTerminalUserScroll`, ~line 2004), or they were in shell sessions on mobile too (then Bug B covers it). Ask mtiller on #205 for session mode + Codeman version if the matrix comes up clean. + +## Invariants the implementation MUST respect + +- Shift+wheel always scrolls local scrollback; the trackpad Shift-axis handling from #154 stays. +- The `terminalWheelLocalScrollback` opt-out setting keeps working (pins plain wheel to local). +- The viewport-at-bottom gate stays: once the user scrolled up locally, wheel stays local until they return to bottom. +- 40ms SGR coalescing: never send per-event writes to the server. +- Strip parity triangle: `session.ts` live strip ↔ `session-routes.ts` replay strip ↔ `_sessionUsesServerMouseStrip()` in the frontend. If you touch mode lists, update all three. +- Don't add `opencode`/`antigravity` to any strip/forward list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`). +- The chunk-boundary sequence carry in `_handleTerminalOutput` must not be weakened. + +## Testing (per repo rules) + +- `npm test -- test/.test.ts` only; never bare `npm test`. New test ports 3150+, never 3000. +- Browser-test traps (documented in CLAUDE.md Testing): drive input/scroll through real events (`page.mouse.wheel`, real touch), not app internals; headless Chromium reports `isTouchDevice()` false even with `hasTouch: true`; assert on real state (xterm viewport position, `tmux -L codeman capture-pane`), not HTTP 200. +- Shell-mode E2E: create a throwaway shell session, `seq 1 500`, then (1) wheel up on desktop shows earlier lines, not history cycling; (2) touch-scroll on a phone shows earlier lines; (3) `vim` + `less` still enter/leave the alt screen cleanly; (4) Shift+drag still selects text. +- Firefox E2E: Playwright `firefox` project, wheel over a Claude session's finished output, assert viewport moved more than 1 line per notch. +- End-to-end against the REAL environment before claiming done (standing user rule). w1/w2/w3 tmux sessions are the user's live sessions: never send input to them; create your own throwaway session and DELETE it by exact id when done. + +## Related observation (not a reported bug, worth a look while in there) + +The `claude --version` probe that feeds the forwarding gate runs only for local and docker sessions (`src/session.ts:1490` gates `!this._remote`; docker handled at :1507). Remote Claude sessions therefore never get `cliVersion` and silently keep local-only wheel. Harmless (local scrollback works) but inconsistent; cheap to fix by probing over ssh, or document as intended. + +## Rollout + +1. Bug A (deltaMode) is small and independent: can ship alone as a patch. +2. Bug B (shell scrollback) is the headline fix for #205: patch or minor per COM flow. +3. After deploy + verification: comment on #205 (what was fixed, what needs their retest), then reply to the Reddit comment `p21x6ts` with the release version. Both reporters gave environment details; address them specifically. diff --git a/docs/scrollback-issues-analysis.md b/docs/scrollback-issues-analysis.md new file mode 100644 index 00000000..c8d7cd27 --- /dev/null +++ b/docs/scrollback-issues-analysis.md @@ -0,0 +1,255 @@ +# Scrollback issues: analysis and test evidence + +Covers GitHub issue **#205** ("Scrollback in terminal not working", jonocodes, shell mode, +Android + macOS desktop) and the follow-up comment on it from **mtiller** (Firefox on macOS, +"scrolling backward to see agent output"). Related closed issue: **#154** (fixed in 1.3.3). + +Status: **analysis only, nothing implemented.** Measured against the live 1.11.2 instance on +2026-08-06 with throwaway `zz-*` shell sessions (all deleted afterwards; the user's `w*` +sessions were never touched). + +--- + +## TL;DR + +Five distinct problems, not one. #205 is fully explained by finding 1; findings 2 and 3 are +independent and hit **every** mode including Claude, and are the likely substance of the +"similar issue" follow-up. + +| # | Problem | Modes affected | Severity | Confirmed | +| - | ------- | -------------- | -------- | --------- | +| 1 | xterm parked in the **alternate buffer** for the whole session, so there is no scrollback at all and the wheel is translated into Up/Down arrow keys | `shell`, `opencode`, `antigravity` | High | Reproduced end to end | +| 2 | **Bursty output silently destroys a screenful** of the browser's scrollback and adds ~1 row | all | High | Measured | +| 3 | **Tab switch collapses scrollback** to roughly one screen (`full=1` fires once per page load) | all | Medium | Measured | +| 4 | `deltaMode` is never read, so Firefox scrolls ~4x slower per notch | all, Firefox | Low | Static, needs reporter data | +| 5 | **Remote SSH Claude cases get no `claude --version` probe**, so wheel forwarding silently stays off (residual #154) | `claude` + remote | Medium | Static | + +--- + +## Finding 1: shell / opencode / antigravity are stuck in xterm's alternate buffer + +### Root cause + +The local tmux **client** (the `tmux attach` that node-pty spawns) emits `smcup` as its very +first bytes on attach. Captured from a real PTY: + +``` +b'\x1b[?1049h\x1b[22;0;0t\x1b[?1h\x1b=\x1b[H\x1b[2J\x1b[?12l\x1b[?25h\x1b[?1000l...' + ^^^^^^^^^^ enter alternate screen ^^^^^ application cursor keys ON +``` + +`Session._handleTerminalOutput()` strips `\x1b[?1049h` from the live stream, but only when +`isAltScreenStripMode(mode)` is true, and that is `claude | codex | gemini` only +(`src/session.ts:179`). For `shell`, `opencode` and `antigravity` the sequence reaches the +browser verbatim and xterm switches to the alternate buffer, where: + +1. `buffer.active.type === 'alternate'` and `baseY` is pinned at 0, so there is **no + scrollback to reach**. `terminal.scrollLines()` is a no-op, which is why touch scrolling + on Android "does nothing". +2. xterm's own wheel listener takes over. From the vendored bundle + (`src/web/public/vendor/xterm.min.js`): + + ```js + if (!this.buffer.hasScrollback) { + if (ev.deltaY === 0) return false; + if (coreMouseService.consumeWheelEvent(...) === 0) return this.cancel(ev, true); + const seq = ESC + (decPrivateModes.applicationCursorKeys ? 'O' : '[') + (ev.deltaY < 0 ? 'A' : 'B'); + coreService.triggerDataEvent(seq, true); + return this.cancel(ev, true); + } + ``` + + tmux also set `\x1b[?1h`, so the emitted sequence is `\x1bOA`, i.e. **Up arrow**, straight + into the shell's readline. That is exactly the reported "the mouse wheel scrolls back + through previous commands, like pressing up". + +3. `cancel(ev, true)` calls `preventDefault()` **and `stopPropagation()`**, and xterm's + listener sits on `terminal.element` (a child of Codeman's container). So Codeman's own + container wheel handler, `_shouldForwardWheelToApp` and `_wheelScrollLines` included, is + **never reached** for these modes. That whole path is dead code for shell. + +### Reproduction (live instance, real browser) + +Create a shell session with the page already open, print 150 lines, then dispatch 8 wheel-up +events over `.xterm-screen`: + +``` +t+1500 after shell start {"type":"alternate","length":35,"baseY":0} +t+3000 after shell start {"type":"alternate","length":35,"baseY":0} +after 150 live lines {"type":"alternate","length":35,"baseY":0} +WHEEL on live shell: {"ptyBytes":["OA","OA","OA","OA", + "OA","OA","OA","OA"], + "before":0,"after":0,"type":"alternate"} +``` + +Both reported symptoms, one root cause. + +### Why it looks intermittent + +The alternate-screen sequence only ever reaches the browser through the **live stream at +attach**. Neither replay path carries it: + +- `?full=1` returns `capture-pane` output (`source: mux-full-history`), verified 0 hits for + `\x1b[?1049h`. +- `?tail=` returns the visible pane frame (`source: mux-visible`), also 0 hits; the shell byte + buffer was empty in every probe. +- `_resetTerminalForReplay()` calls `terminal.reset()`, which returns xterm to the normal + buffer. + +So: watching a shell from creation leaves you in the alternate buffer until you reload or +switch tabs, at which point it silently starts working again. Then the next PTY attach (a +restart, or the auto-reattach in `selectSession()`) puts you back. + +### Is stripping safe for shell? Probably yes when tmux-backed, and the current code comment is wrong about why + +`src/session.ts:1404` says *"shell must keep the alt screen for vim/less/htop"*. For a +**tmux-backed** shell that reasoning does not hold: tmux is a full terminal emulator and never +forwards a pane's alternate-screen toggles to its client, it repaints instead. Measured per +phase on a real attach: + +| phase | bytes | `?1049h` | `?1049l` | `?47/1047` | +| ----- | ----: | -------: | -------: | ---------: | +| attach | 772 | **1** | 0 | 0 | +| `seq 1 60` echo | 1402 | 0 | 0 | 0 | +| `less` open / end / quit | 284 / 230 / 321 | 0 | 0 | 0 | +| `vim` open / quit | 2200 / 646 | 0 | 0 | 0 | + +`vim` and `less` inside tmux emit **zero** alternate-screen sequences to the client. + +The caveat that does matter: `startShell()` falls back to a **direct PTY with no tmux** when +mux creation fails (`src/session.ts:1961`, `this._useMux = false`). In that path the inner +app's own `?1049h` does reach xterm, and a blanket strip would break vim/less/htop for real. +Any fix has to be conditional on `_useMux`, which is known server-side. + +Second caveat: stripping alone buys less than it looks like, because of finding 2. It fixes +the wheel (no more phantom Up arrows) and it makes the `full=1` replay reachable, but live +output still will not accumulate. + +--- + +## Finding 2: bursty output silently overwrites a screenful of browser scrollback + +Independent of the alternate buffer, and it hits Claude sessions too. + +tmux decides per flush whether to emit real linefeeds (which push rows into the outer +terminal's scrollback) or to repaint the pane rectangle with cursor addressing (which +overwrites the visible rows in place). When output outpaces its flush interval it coalesces +into a repaint, and one screenful of the browser's history is **destroyed**. + +Measured on one session, same page, `rows = 36`: + +| step | `baseY` | rows containing SEED | BURST | SLOW | +| ---- | ------: | -------------------: | ----: | ---: | +| after `?full=1` replay (120 seeded lines) | 86 | 120 | 0 | 0 | +| after 60 lines emitted as fast as possible | **87** (+1) | **86** (-34) | 35 | 0 | +| after 60 lines at ~16/s (`sleep 0.06`) | **148** (+61) | 86 | 35 | 60 | + +The burst added **one** row of scrollback and ate **34** rows of existing history. The slow +run behaved correctly. So "I printed a bunch of lines and now I cannot scroll back" reproduces +without the alternate buffer being involved at all, and it is rate dependent, which is exactly +the kind of thing that reads as random flakiness. + +Consequence: the browser's scrollback is effectively frozen at whatever the last `?full=1` +replay produced, minus a screen per burst. tmux's own history is fine throughout +(`history_size` kept growing, `history-limit` 2000), so the data is never actually lost +server-side, it just never reaches the browser again until a reload. + +--- + +## Finding 3: switching tabs collapses a session's scrollback + +`_initialFullBufferLoad` is true for the **first buffer load after a page load only** +(`app.js:4374`). Everything after that uses `?tail=`, which returns byte history plus the +visible pane frame. Worse, the snapshot restore path deliberately throws away the restored +xterm snapshot (which does carry scrollback) and replaces it with that frame +(`app.js:4316-4328` plus `needsRewrite`). + +Measured, switching away from session A and back: + +``` +A: initial full=1 load {"len":152,"baseY":116,"AAA":150} +A: after switch away and back {"len": 87,"baseY": 51,"AAA": 59} +``` + +150 lines of history down to 59. Note also that the page's single `full=1` is consumed by +whichever session auto-selects at load, so **every other tab starts life with one frame of +history**. + +--- + +## Finding 4: `deltaMode` is never read (Firefox) + +`grep -rn "deltaMode" src/web/public packages` returns nothing. `_wheelScrollLines()` +(`terminal-ui.js:2818`) treats `deltaY` as pixels unconditionally: + +```js +return Math.round(delta / 25) || (delta > 0 ? 1 : -1); +``` + +Chrome/WebKit report `deltaMode: 0` with `deltaY` around 100 to 120 px per notch, so about 4 +to 5 lines. Firefox reports `deltaMode: 1` (`DOM_DELTA_LINE`) with `deltaY` around 3, so +`Math.round(3/25) === 0` and the `|| ±1` fallback yields **1 line per notch**, roughly 4x +slower. In Claude mode the same value caps the forwarded SGR report at 1 tick per event +instead of 4, so the transcript crawls too. + +This is sluggishness, not breakage, so it is a plausible but unproven contributor to the +mtiller report. No Firefox build is installed under `~/.cache/ms-playwright` (chromium and +webkit only), so this was not measured. Worth asking the reporter for `deltaMode` / `deltaY` +from a live wheel event before acting on it. + +--- + +## Finding 5: remote SSH Claude cases still have no version probe + +`src/session.ts:1490` deliberately skips the deterministic `claude --version` probe for +remote sessions and defers to the startup-banner scrape, which the same comment block +describes as unreliable ("newer Claude Code builds don't print the banner and resumed sessions +never show it"). That is precisely the condition #154 was filed for: `cliVersion` empty means +`_shouldForwardWheelToApp()` returns false, wheel forwarding is off, and the user is left with +local scrollback that (per finding 2) does not accumulate. + +Local and Docker Claude sessions are fine; verified all 7 live sessions report +`cliVersion=2.1.223`, so the 1.3.3 fix is still working there. + +--- + +## Candidate directions (not decided) + +Roughly in order of value per unit of risk. + +1. **Extend the alternate-screen strip to tmux-backed `shell` / `opencode` / `antigravity`.** + Gate on `_useMux` so the direct-PTY fallback keeps vim/less/htop working. Kills the phantom + Up arrows and makes replayed history reachable. `isAltScreenStripMode()` currently takes + only `mode`, so it would need the mux flag threaded in, and + `test/claude-scrollback-strip.test.ts:16-17` plus `test/antigravity-mode.test.ts:116` pin + the current answers and would need updating. + +2. **Re-pull `?full=1` when the user scrolls to the top of the buffer.** Directly addresses + findings 2 and 3 with machinery that already exists and is already proven to return + complete history (200/200 lines in the probe). Needs a guard against refetch storms. + +3. **Stop discarding the xterm snapshot on tab switch**, or request `full=1` on the first load + per session rather than per page. Cheaper partial fix for finding 3 alone. + +4. **Read `ev.deltaMode`** in `_wheelScrollLines()` and normalise line/page deltas to lines. + Small, self-contained, worth doing regardless of whether it is mtiller's actual bug. + +5. **Probe the CLI version over SSH for remote Claude cases**, mirroring the deferred + in-container probe that Docker cases already use. + +Option 1 alone does not fix #205's "print a bunch of lines then scroll" complaint; that needs +2 as well. + +## Reproduction assets + +Scripts used, in the session scratchpad +(`/tmp/claude-1000/-home-arkon-default-claudeman/597ffc9f-.../scratchpad/`): + +- `ptycap.py` / `ptycap2.py`: PTY-level capture of the tmux client stream, per phase counts of + alternate-screen and mouse-tracking sequences. +- `sim.mjs`: replays a captured stream through `@xterm/headless` with and without the strip. +- `browser-test*.mjs`: Playwright against the live instance, reports `buffer.active.type`, + `baseY`, row content and the exact bytes xterm sends to the PTY on a wheel event. + +`@xterm/headless` was installed with `npm i --no-save`, so `package.json` and the lockfile are +untouched. diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 753d45ee..9d43213c 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -256,6 +256,69 @@ export async function checkRemoteTmuxAvailable( } } +/** + * The CLI binary each session mode runs on the remote host. Antigravity's + * binary is `agy` (the mode name is not the command); shell has no CLI to + * probe, so it is absent. + */ +const REMOTE_CLI_BIN: Partial> = { + claude: 'claude', + opencode: 'opencode', + codex: 'codex', + gemini: 'gemini', + antigravity: 'agy', +}; + +/** + * Build the SSH command that reads the remote CLI's version (`claude --version` + * on the remote host). The version query is routed through + * `remoteLoginShellCommand` (the SAME `$SHELL -i -l -c` wrapper the real + * launch uses), because agent CLIs live on PATH only after the remote user's + * interactive-login startup files run (see defaultRemoteCommandForMode); a bare + * `claude --version` over ssh exits 127. Connection options come from the + * shared `buildSshConnectionArgs`, so the probe reaches exactly the hosts the + * launch can reach. Returns null for modes with no CLI (shell). + */ +export function buildRemoteCliVersionProbeCommand( + host: Pick & RemoteSshOptions, + mode: SessionMode +): string | null { + const bin = REMOTE_CLI_BIN[mode]; + if (!bin) return null; + return [ + ...buildSshConnectionArgs(host), + remoteSshTarget(host), + shellescape(remoteLoginShellCommand(`${bin} --version`)), + ].join(' '); +} + +/** + * Read the CLI version installed ON THE REMOTE HOST. Feeds Session.cliVersion + * for remote sessions: the deterministic local probe deliberately skips them + * (it would report the LOCAL host's claude), and the startup-banner scrape is + * unreliable (newer Claude Code builds print no banner; resumed sessions never + * do), which left cliVersion undefined and silently disabled wheel-forwarding + * to the CLI transcript (residual #154, noted in the #205 analysis). The + * version is parsed as the first semver in stdout, never raw output: an + * interactive-login shell may echo rc-file noise around it. Returns undefined + * on any failure. No-op under VITEST (mirrors checkRemoteTmuxAvailable). + */ +export async function probeRemoteCliVersion( + host: Pick & RemoteSshOptions, + mode: SessionMode +): Promise { + if (process.env.VITEST) return undefined; + const command = buildRemoteCliVersionProbeCommand(host, mode); + if (!command) return undefined; + try { + const { stdout } = await execAsync(command, { timeout: 15_000 }); + const match = stdout.match(/\d+\.\d+\.\d+/); + return match ? match[0] : undefined; + } catch { + return undefined; + } +} + /** * COD-105 — build the SSH command that lists `codeman-*` tmux sessions on a * remote host's canonical `-L codeman` socket. diff --git a/src/session.ts b/src/session.ts index f86139f5..23e29271 100644 --- a/src/session.ts +++ b/src/session.ts @@ -54,6 +54,7 @@ import { type SessionDocker, } from './types.js'; import { probeDockerCliVersion } from './docker-hosts.js'; +import { probeRemoteCliVersion } from './remote-hosts.js'; import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js'; import { RalphTracker } from './ralph-tracker.js'; @@ -180,6 +181,37 @@ export function isAltScreenStripMode(mode: SessionMode): boolean { return mode === 'codex' || mode === 'claude' || mode === 'gemini'; } +/** + * Modes that need the NARROW strip: alt-screen toggles only, leaving `\x1b[3J` + * and the mouse-tracking DECSETs alone. Applies to every mode `isAltScreenStripMode` + * excludes, but ONLY when the session is tmux-backed (`useMux`). + * + * The bug (issue #205): the tmux CLIENT emits `smcup` (`\x1b[?1049h`) as its first + * bytes on attach, before any program has run. Unstripped, xterm.js parks in the + * alternate buffer for the whole session, where `baseY` is pinned at 0 (no + * scrollback to reach, so touch scrolling is a no-op) and xterm's own wheel handler + * translates the wheel into `\x1bOA`/`\x1bOB` cursor keys — which readline receives + * as shell history navigation. Both reported symptoms, one sequence. + * + * Why this is safe under tmux, despite the old "shell must keep the alt screen for + * vim/less/htop" reasoning: tmux is a full terminal emulator and NEVER forwards a + * pane's alt-screen toggles to its client, it repaints instead. Captured from a real + * attach, `\x1b[?1049h` appears exactly once (at attach) and vim/less/htop sessions + * inside the pane emit zero. So the only thing stripped here is tmux's own smcup. + * + * Why it is gated on `useMux`: `startShell()`/`startInteractive()` fall back to a + * DIRECT PTY when mux creation fails. There the inner program's `\x1b[?1049h` really + * does reach xterm, and stripping it would break vim/less/htop for real. + * + * Why it is narrower than the full strip: with tmux `mouse off`, a mouse-aware + * program in the pane (htop, vim with `set mouse=a`) still gets its DECSETs passed + * through to the client, so stripping those would break its mouse support. And + * `\x1b[3J` from a user's own `clear` is a deliberate "wipe my scrollback". + */ +export function isMuxAltScreenOnlyStripMode(mode: SessionMode, useMux: boolean): boolean { + return useMux && !isAltScreenStripMode(mode); +} + // Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv) /** PTY fallback geometry when tmux can't be queried (matches pre-#80 hardcoded values). */ @@ -195,6 +227,8 @@ const IS_TEST_MODE = !!process.env.VITEST; const TEST_PTY_SCRIPT = 'if (process.stdin.isTTY) process.stdin.setRawMode(true); process.stdin.pipe(process.stdout);'; /** Delay before the in-container Claude CLI version probe (lets the container start). */ const DOCKER_CLI_VERSION_PROBE_DELAY_MS = 3000; +/** Delay before the over-ssh Claude CLI version probe (keeps session start off the ssh round-trip). */ +const REMOTE_CLI_VERSION_PROBE_DELAY_MS = 3000; /** * Ask tmux for the current window geometry of `muxName` so a re-attaching PTY @@ -733,6 +767,15 @@ export class Session extends EventEmitter { return this._muxSession?.muxName ?? null; } + /** + * True when this session's PTY is a tmux client rather than the program itself. + * Read by the replay-side alt-screen strip, which must apply the same + * `useMux` gate as the live strip (isMuxAltScreenOnlyStripMode). + */ + get usesMux(): boolean { + return this._useMux; + } + get totalCost(): number { return this._totalCost; } @@ -1401,9 +1444,16 @@ export class Session extends EventEmitter { // SSE/WS stream carries them, keeping everything in the main buffer with // scrollback intact. These are controlled TUIs whose cursor-positioned // redraws overwrite only the cells they target, so non-erased rows keep - // their content. Gated to Codex/Claude (isAltScreenStripMode) — shell must - // keep the alt screen for vim/less/htop. - if (isAltScreenStripMode(this.mode)) { + // their content. Gated to Codex/Claude/Gemini (isAltScreenStripMode). + // + // Every OTHER mode (shell/opencode/antigravity) gets the NARROW strip when it + // is tmux-backed: alt-screen toggles only, because the sequence that breaks + // scrollback there is tmux's own client-side smcup at attach, not anything the + // program in the pane emitted (issue #205, see isMuxAltScreenOnlyStripMode). + // 3J and the mouse DECSETs stay, so `clear` and mouse-aware TUIs keep working. + const fullStrip = isAltScreenStripMode(this.mode); + const altOnlyStrip = !fullStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux); + if (fullStrip || altOnlyStrip) { // Reassemble sequences split across PTY chunk boundaries first: a chunk // ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the // strip below and leave xterm stuck in the scrollback-less alt buffer @@ -1419,13 +1469,15 @@ export class Session extends EventEmitter { data = data.slice(0, -splitTail[0].length); if (!data) return; } - data = data - // eslint-disable-next-line no-control-regex - .replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '') - // eslint-disable-next-line no-control-regex - .replace(/\x1b\[3J/g, '') - // eslint-disable-next-line no-control-regex - .replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, ''); + // eslint-disable-next-line no-control-regex + data = data.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, ''); + if (fullStrip) { + data = data + // eslint-disable-next-line no-control-regex + .replace(/\x1b\[3J/g, '') + // eslint-disable-next-line no-control-regex + .replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, ''); + } } // Scan terminal output for attachment requests. `codeman://attach?...` is an @@ -1485,8 +1537,8 @@ export class Session extends EventEmitter { // never show it — which left cliVersion undefined and silently disabled // wheel-forwarding to Claude's own transcript (the only route to history in // repaint/alt-screen mode; issue #154). Remote sessions run claude on - // another host, so a local probe wouldn't reflect their version — skip them - // and let the banner scrape handle those. Cached process-wide, best-effort. + // another host, so a local probe wouldn't reflect their version; they get + // their own over-ssh probe below. Cached process-wide, best-effort. if (this.mode === 'claude' && !this._remote && !this._docker && !this._cliVersion) { const probedVersion = getClaudeCliVersion(); if (probedVersion) { @@ -1525,6 +1577,32 @@ export class Session extends EventEmitter { }, DOCKER_CLI_VERSION_PROBE_DELAY_MS); } + // Remote sessions run claude on ANOTHER HOST, so neither the local nor the + // docker probe applies, and the banner-scrape fallback they were left with + // is the unreliable path #154 was filed for, so remote Claude cases silently + // never got wheel-forwarding (noted in the #205 analysis). Probe over ssh, + // deferred so session start never waits on the ssh round-trip. + if (this.mode === 'claude' && this._remote && !this._cliVersion) { + const remoteMeta = this._remote; + setTimeout(() => { + if (this._isStopped || this._cliVersion) return; + void probeRemoteCliVersion(remoteMeta, this.mode) + .then((version) => { + if (!version || this._isStopped || this._cliVersion) return; + this._cliVersion = version; + this.emit('cliInfoUpdated', { + version: this._cliVersion, + model: this._cliModel, + accountType: this._cliAccountType, + latestVersion: this._cliLatestVersion, + }); + }) + .catch(() => { + /* best-effort */ + }); + }, REMOTE_CLI_VERSION_PROBE_DELAY_MS); + } + // If mux wrapping is enabled, create or attach to a mux session if (this._useMux && this._mux) { try { diff --git a/src/web/public/app.js b/src/web/public/app.js index 723d584c..6566d2ab 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -511,7 +511,14 @@ class CodemanApp { this._initGeneration = 0; // dedup concurrent handleInit calls this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive this._selectGeneration = 0; // cancel stale selectSession loads - this._initialFullBufferLoad = true; // first buffer load after a page load fetches full tmux scrollback (COD-47) + // Sessions whose full tmux scrollback has already been replayed this page load + // (COD-47). Tracked PER SESSION rather than as a single "first load" flag: the + // flag was consumed by whichever session auto-selected at page load, so every + // OTHER tab started life with one visible frame of history (issue #205). + this._fullHistoryLoaded = new Set(); + // Cooldown per session for the scroll-to-top "load more history" re-pull. + this._fullHistoryRepullAt = new Map(); // Map + this._fullHistoryRepullInFlight = false; this.terminalLoadStates = new Map(); // Map this.respawnStatus = {}; this.respawnTimers = {}; // Track timed respawn timers @@ -4098,6 +4105,58 @@ class CodemanApp { this.terminal.write('\x1b[3J\x1b[H\x1b[2J'); } + /** + * "Load more history": re-pull the whole tmux scrollback when the user scrolls up + * while already at the top of what the browser has. + * + * xterm's buffer is only ever a WINDOW onto tmux's real history, and two things + * shrink it. tmux repaints the pane rectangle instead of emitting linefeeds + * whenever output outpaces its flush interval, which OVERWRITES already-rendered + * scrollback rather than pushing rows into it (measured: a 60-line burst added 1 + * row and destroyed 34, while the same 60 lines emitted slowly added all 60). And + * a tab switch replays only the visible frame. Either way tmux still holds + * everything (history-limit 100k by default), so the fix is to go ask for it with + * the same `?full=1` capture a page reload uses (issue #205). + * + * On demand rather than automatic because that capture is unbounded-ish work: at + * the default history limit it can be megabytes, which is fine to pay when the + * user is explicitly reaching for history and not fine on every tab switch. + */ + async _maybeRefetchFullHistory() { + const sessionId = this.activeSessionId; + if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return; + if (this.detachedSessions?.has(sessionId)) return; + const now = Date.now(); + // Momentum scrolling fires this dozens of times per flick, and a burst of new + // output is the normal reason to want a re-pull, so cooldown rather than latch. + if (now - (this._fullHistoryRepullAt.get(sessionId) || 0) < 4000) return; + this._fullHistoryRepullAt.set(sessionId, now); + this._fullHistoryRepullInFlight = true; + try { + const res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`); + const buffer = (await res.json())?.data?.terminalBuffer; + // Bail on a tab switch mid-fetch: writing here would paint another session's + // history into the terminal the user is now looking at. + if (!buffer || this.activeSessionId !== sessionId) return; + const rowsBefore = this.terminal.buffer.active.length; + this._resetTerminalForReplay(); + await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId); + if (this.activeSessionId !== sessionId) return; + this.terminalBufferCache.set(sessionId, buffer); + // Hold the user's place. The replay is a superset that grew the buffer + // UPWARD, so what used to be row 0 (what they were looking at) is now `delta` + // rows down; scrolling there reveals the recovered history above it instead + // of teleporting them to the bottom the way a normal buffer load does. + const delta = this.terminal.buffer.active.length - rowsBefore; + if (delta > 0) this.terminal.scrollToLine(delta); + else this.terminal.scrollToTop(); + } catch { + // Transient (offline, 5xx) — the next scroll-up past the cooldown retries. + } finally { + this._fullHistoryRepullInFlight = false; + } + } + _shouldFocusTerminalForTabSwitch() { if (typeof MobileDetection === 'undefined' || !MobileDetection.isTouchDevice()) { return true; @@ -4368,11 +4427,14 @@ class CodemanApp { this._setTerminalLoadState(sessionId, selectGen, 'fetching'); _crashDiag.log('FETCH_START'); - // The FIRST buffer load after a page load requests the full tmux scrollback - // (?full=1, COD-47) so history that scrolled off the server's byte buffer - // comes back after a reload. Tab switches keep the fast ?tail= frame path. - const useFullHistory = this._initialFullBufferLoad === true; - this._initialFullBufferLoad = false; + // The first load OF EACH SESSION this page load requests the full tmux + // scrollback (?full=1, COD-47) so history that scrolled off the server's byte + // buffer comes back. Later switches to an already-replayed session keep the + // fast ?tail= frame path, which is why this is a Set and not a flag: the flag + // version gave the full replay to the auto-selected tab and one frame of + // history to every other one (issue #205). + const useFullHistory = !this._fullHistoryLoaded.has(sessionId); + if (useFullHistory) this._fullHistoryLoaded.add(sessionId); const res = await fetch( useFullHistory ? `/api/sessions/${sessionId}/terminal?full=1` diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index ea65fa57..99065d03 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -415,31 +415,71 @@ Object.assign(CodemanApp.prototype, { // ignores wheel reports); older versions DO capture wheel as option // navigation, so they keep the local wheel. // Shift+wheel always scrolls xterm's local scrollback (Codeman's restored - // history lives there), and once the viewport left the bottom the wheel - // stays local until the user scrolls back down — so both scrollbacks stay - // reachable without a mode switch. + // history lives there); the plain wheel stays on the CLI's transcript for + // those modes regardless of scroll position, so the CLI's input box never + // slides off the screen (see _shouldForwardWheelToApp). + // + // CAPTURE phase, deliberately, and Codeman owns the scroll. xterm's + // viewport is a vscode-style ScrollableElement that consumes wheel events + // itself (preventDefault + stopPropagation) whenever it believes a + // scrollbar exists, does NOT consult attachCustomWheelEventHandler, and — + // measured on the live instance — goes DEAF after terminal.reset(): a tab + // switch or full-history replay leaves its scroll dimensions stale, after + // which wheel events neither scroll nor propagate reliably. A bubble-phase + // listener here therefore never fired once local scrollback existed + // (measured: _shouldForwardWheelToApp call count stayed 0 while xterm + // scrolled), and after a tab switch NOTHING scrolled at all — the "input + // box scrolls up then it fights", "works at first, breaks after a tab + // switch" reports on #205. + // + // So: capture runs ancestors-first; this handler sees every wheel first + // and stops propagation, keeping xterm's scroller out of it entirely. + // Local scrolling goes through terminal.scrollLines() — buffer-level, so + // it keeps working after resets — with our own deltaMode normalization + // (_wheelScrollLines) covering Firefox's line-unit wheels. Two cases still + // belong to xterm and are passed through untouched: + // - mouseTrackingMode active: xterm's own encoder forwards the wheel to + // the PTY (htop/vim with mouse on in a shell pane); + // - alternate buffer (direct-PTY fallback running vim/less): xterm's + // alt-scroll handling converts the wheel to cursor keys, which is what + // those apps expect. container.addEventListener( 'wheel', (ev) => { + const trackingMode = this.terminal?.modes?.mouseTrackingMode; + if (trackingMode && trackingMode !== 'none') return; + if (this.terminal?.buffer?.active?.type === 'alternate') return; ev.preventDefault(); - const lines = this._wheelScrollLines(ev); + ev.stopPropagation(); if (this._shouldForwardWheelToApp(ev)) { - this._sendSyntheticSgrWheel(ev.clientX, ev.clientY, lines); + this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLines(ev)); return; } + // Local scrolling accumulates FRACTIONAL lines: a macOS trackpad emits + // a stream of tiny pixel deltas, and rounding each one to a whole line + // (the ±1 fallback) made slow drags scroll faster than the finger. + const lines = this._wheelScrollLinesFloat(ev); this._noteTerminalUserScroll(lines); - this.terminal.scrollLines(lines); + this._smoothScrollBy(lines); }, - { passive: false } + { passive: false, capture: true } ); // Touch scrolling — use terminal.scrollLines() for all devices. // xterm.js DOM renderer doesn't populate xterm-viewport's scroll area, // so native CSS scrolling (overflow-y: scroll + touch-action: pan-y) // has nothing to scroll. Instead, convert touch deltas into scrollLines() - // calls, matching the wheel handler above. + // calls, matching the wheel handler above, including the forwarding + // branch: for the sessions whose wheel goes to the CLI's own transcript + // (_shouldForwardWheelToApp), a touch drag must go there too, or every + // phone/tablet swipe scrolls the local buffer of stale repaint frames and + // drags the CLI's pinned input box off the screen (issue #205's mobile + // half). Same gate, so Shift has no touch analog but the local-scrollback + // opt-out setting and the CLI-version gate apply to touch exactly as they + // do to the wheel. { const cellHeight = () => this.terminal._core?._renderService?.dimensions?.css?.cell?.height || 13; + let touchLastX = 0; let touchLastY = 0; let velocity = 0; let lastTime = 0; @@ -453,7 +493,16 @@ Object.assign(CodemanApp.prototype, { if (!isTouching && Math.abs(velocity) > 0.3) { // Momentum phase — convert pixel velocity to lines const lines = Math.round(velocity / cellHeight()); - if (lines !== 0) this.terminal.scrollLines(lines); + if (lines !== 0) { + if (this._shouldForwardWheelToApp({ shiftKey: false })) { + // Flick momentum keeps feeding the CLI's transcript from the last + // touch point; the 40ms coalescer batches the per-frame reports. + this._forwardScrollToApp(touchLastX, touchLastY, lines); + } else { + this.terminal.scrollLines(lines); + this._maybeLoadMoreHistoryOnScroll(lines); + } + } velocity *= 0.92; scrollFrame = requestAnimationFrame(scrollLoop); } else if (!isTouching) { @@ -474,6 +523,7 @@ Object.assign(CodemanApp.prototype, { 'touchstart', (ev) => { if (ev.touches.length === 1) { + touchLastX = ev.touches[0].clientX; touchLastY = ev.touches[0].clientY; touchStartY = touchLastY; velocity = 0; @@ -509,13 +559,19 @@ Object.assign(CodemanApp.prototype, { const delta = touchLastY - touchY; // positive = scroll down pixelAccum += delta; velocity = delta * 1.2; + touchLastX = ev.touches[0].clientX; touchLastY = touchY; // Convert accumulated pixels to whole lines const ch = cellHeight(); const lines = Math.trunc(pixelAccum / ch); if (lines !== 0) { - this._noteTerminalUserScroll(lines); - this.terminal.scrollLines(lines); + if (this._shouldForwardWheelToApp({ shiftKey: false })) { + this._forwardScrollToApp(touchLastX, touchLastY, lines); + } else { + this._noteTerminalUserScroll(lines); + this.terminal.scrollLines(lines); + this._maybeLoadMoreHistoryOnScroll(lines); + } pixelAccum -= lines * ch; } } @@ -2009,6 +2065,73 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * Post-scroll companion to _noteTerminalUserScroll: hitting the TOP of the + * buffer while scrolling up is the user reaching for history the browser does + * not have, so pull the rest of tmux's scrollback (issue #205, see + * _maybeRefetchFullHistory). Must be called AFTER scrollLines(), since the + * check is on the resulting position, and it is deliberately not folded into + * _noteTerminalUserScroll for exactly that reason. Cheap: one integer compare + * per scroll event, and the pull itself is cooldown-guarded. + */ + _maybeLoadMoreHistoryOnScroll(lines) { + if (lines >= 0) return; + if (this.terminal?.buffer?.active?.viewportY === 0) this._maybeRefetchFullHistory?.(); + }, + + /** + * Ease-out smooth scrolling for the local wheel path. The capture-phase + * wheel handler owns local scrolling (xterm's own smooth scroller is + * bypassed, see the listener comment), so without this every notch was an + * instant multi-line jump. Wheel deltas accumulate into a pending line + * count (fractional — see _wheelScrollLinesFloat) and drain ~22% per + * animation frame with a one-line floor, so a single notch starts with a + * gentle step and glides to an exact landing; more notches mid-glide deepen + * the pending count, which reads as natural acceleration. A sub-line + * residual stays pending until further input pushes it past a whole line + * (that is what makes slow trackpad drags track the finger). Direction + * reversals cancel arithmetically. The pending amount is dropped when the + * active session changes mid-glide — leftover momentum must never scroll + * the tab the user just switched to. + */ + _smoothScrollBy(lines) { + if (!lines) return; + this._smoothScrollPending = (this._smoothScrollPending || 0) + lines; + this._smoothScrollSession = this.activeSessionId; + if (this._smoothScrollFrame) return; + const step = () => { + this._smoothScrollFrame = null; + const pending = this._smoothScrollPending || 0; + if (!pending) return; + if (this.activeSessionId !== this._smoothScrollSession) { + this._smoothScrollPending = 0; + return; + } + if (Math.abs(pending) < 1) return; // sub-line residual: wait for more input + const eased = pending * 0.22; + const move = pending > 0 ? Math.max(1, Math.floor(eased)) : Math.min(-1, Math.ceil(eased)); + this._smoothScrollPending = pending - move; + this.terminal.scrollLines(move); + this._maybeLoadMoreHistoryOnScroll(move); + if (Math.abs(this._smoothScrollPending) >= 1) this._smoothScrollFrame = requestAnimationFrame(step); + }; + this._smoothScrollFrame = requestAnimationFrame(step); + }, + + /** + * Hand a scroll gesture (wheel tick or touch drag, already converted to + * lines) to the CLI as synthetic SGR wheel reports. SGR coordinates address + * the LIVE screen (the bottom `rows` of the buffer), so a report computed + * from a scrolled-up viewport would hit-test a different row entirely, and + * forwarding while the user stares at stale scrollback looks like the + * gesture is dead. Snap back first: the gesture then always acts on what the + * CLI is drawing now. + */ + _forwardScrollToApp(clientX, clientY, lines) { + if (!this._terminalViewportAtBottom()) this.terminal.scrollToBottom(); + this._sendSyntheticSgrWheel(clientX, clientY, lines); + }, + _hasRecentUserScrollUp() { if (typeof this._lastUserScrollUpAt !== 'number') return false; return performance.now() - this._lastUserScrollUpAt < window.CodemanTerminalInput.USER_SCROLL_STICKY_SUPPRESS_MS; @@ -2815,9 +2938,29 @@ Object.assign(CodemanApp.prototype, { // deltaY≈0 collapses to a fixed ±1 line/tick and the gesture can't page through // history on a trackpad (issue #154). Non-Shift and mouse-wheel paths are // unchanged (they carry deltaY). The `|| ±1` keeps sub-25px deltas moving. + // + // `deltaMode` says what UNIT the delta is in, and ignoring it made every + // non-pixel browser scroll ~4x too slowly: Firefox reports DOM_DELTA_LINE (1) + // with deltaY≈3 per notch, so the pixel math rounded to 0 and fell through to + // 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); + }, + + /** Unrounded variant for the smooth local-scroll path, which accumulates + * sub-line fractions across events instead of forcing every tiny trackpad + * delta to a whole ±1 line. Same unit handling and Shift-axis trap. */ + _wheelScrollLinesFloat(ev) { const delta = ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY; - return Math.round(delta / 25) || (delta > 0 ? 1 : -1); + if (!delta) return 0; + return ev.deltaMode === 1 // DOM_DELTA_LINE (Firefox mouse wheel) + ? delta + : ev.deltaMode === 2 // DOM_DELTA_PAGE + ? delta * (this.terminal?.rows || 24) + : delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad) }, _shouldForwardWheelToApp(ev) { @@ -2836,7 +2979,23 @@ Object.assign(CodemanApp.prototype, { } else if (sessionMode !== 'codex') { return false; } - return this._terminalViewportAtBottom(); + // Deliberately NOT gated on _terminalViewportAtBottom(). It used to be, so + // that leaving the bottom handed the wheel back to local scrollback and both + // histories stayed reachable without a mode switch. In practice that inverted + // the behavior users actually want: a repaint-mode CLI keeps NO terminal + // scrollback of its own (tmux reports history_size=0 for a Claude pane), so + // xterm's buffer holds only Codeman's REPLAYED repaint frames. Scrolling that + // locally drags the CLI's own pinned furniture (the prompt box, the status + // line) up the screen and shows stale frames underneath, which reads as "the + // window scrolled away" rather than "I am reading history". + // + // And it was easy to fall into: scrollToLastNonEmptyLine() parks the viewport + // `rows - 2` above the last non-empty row, so any tab switch onto a session + // with trailing blank rows left the viewport off-bottom and every later wheel + // went local. Forwarding unconditionally keeps the CLI's transcript as the + // plain wheel's target and its input box fixed in place; local scrollback is + // still on Shift+wheel and on the "Wheel scrolls local history" opt-out above. + return true; }, // Encode wheel ticks as SGR reports (button 64 = up, 65 = down) at the pointer diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 5bf58a7a..8d8c8397 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -21,7 +21,7 @@ import { type GeminiConfig, type AntigravityConfig, } from '../../types.js'; -import { Session, isAltScreenStripMode } from '../../session.js'; +import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js'; import { SseEvent } from '../sse-events.js'; import { CreateSessionSchema, @@ -1703,6 +1703,11 @@ export function registerSessionRoutes( .replace(ALT_SCREEN_TOGGLE_PATTERN, '') .replace(ERASE_SCROLLBACK_PATTERN, '') .replace(MOUSE_TRACKING_PATTERN, ''); + } else if (isMuxAltScreenOnlyStripMode(session.mode, session.usesMux)) { + // tmux-backed shell/opencode/antigravity: drop tmux's own client smcup only. + // A byte buffer recorded before the live-side strip existed can still carry + // it, and one replayed `\x1b[?1049h` re-parks xterm in the alt buffer (#205). + strippedBuffer = strippedBuffer.replace(ALT_SCREEN_TOGGLE_PATTERN, ''); } if (tailBytes > 0 && strippedBuffer.length > tailBytes) { diff --git a/test/claude-scrollback-strip.test.ts b/test/claude-scrollback-strip.test.ts index 155cc089..513b036e 100644 --- a/test/claude-scrollback-strip.test.ts +++ b/test/claude-scrollback-strip.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { Session, isAltScreenStripMode } from '../src/session.js'; +import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../src/session.js'; type SessionInternals = { _handleTerminalOutput(data: string): void; @@ -81,7 +81,7 @@ describe('Claude terminal scrollback strip', () => { }); }); -describe('Shell terminal output is NOT stripped (vim/less/htop need the alt screen)', () => { +describe('Shell terminal output on a DIRECT PTY is NOT stripped (vim/less/htop need the alt screen)', () => { it('leaves alt-screen toggles, scrollback-erase, and mouse-tracking intact for shell', () => { const session = new Session({ workingDir: '/tmp', mode: 'shell' }); @@ -91,3 +91,59 @@ describe('Shell terminal output is NOT stripped (vim/less/htop need the alt scre expect(session.terminalBuffer).toBe(vimLike); }); }); + +describe('isMuxAltScreenOnlyStripMode', () => { + it('covers exactly the modes the full strip does not, and only under tmux', () => { + for (const mode of ['shell', 'opencode', 'antigravity'] as const) { + expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(true); + // Direct-PTY fallback: the program's own alt screen really does reach xterm. + expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false); + } + // The full strip already owns these; never double-gate them here. + for (const mode of ['claude', 'codex', 'gemini'] as const) { + expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false); + } + }); +}); + +describe('tmux-backed shell: strip tmux’s own client smcup, keep everything else (#205)', () => { + it('drops alt-screen toggles so xterm keeps a scrollback buffer', () => { + const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true }); + + // What a real `tmux attach` emits as its first bytes. + handleOutput(session, '\x1b[?1049h\x1b[22;0;0t\x1b[?1h\x1b=\x1b[H\x1b[2Jprompt$ '); + + expect(session.terminalBuffer).toBe('\x1b[22;0;0t\x1b[?1h\x1b=\x1b[H\x1b[2Jprompt$ '); + expect(session.terminalBuffer).not.toContain('\x1b[?1049h'); + }); + + it('KEEPS 3J and mouse-tracking, unlike the full strip', () => { + const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true }); + + // `clear` legitimately wipes scrollback; htop/vim mouse modes are passed + // through by tmux even with `mouse off` and must keep working. + handleOutput(session, '\x1b[3J\x1b[?1002h\x1b[?1006hhtop\x1b[?1006l\x1b[?1002l'); + + expect(session.terminalBuffer).toBe('\x1b[3J\x1b[?1002h\x1b[?1006hhtop\x1b[?1006l\x1b[?1002l'); + }); + + it('reassembles alt-screen sequences split across PTY chunk boundaries', () => { + const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true }); + const emitted: string[] = []; + session.on('terminal', (data) => emitted.push(data)); + + handleOutput(session, 'before\x1b[?104'); + handleOutput(session, '9h after'); + + expect(session.terminalBuffer).toBe('before after'); + expect(emitted).toEqual(['before', ' after']); + }); + + it('applies to opencode and antigravity too', () => { + for (const mode of ['opencode', 'antigravity'] as const) { + const session = new Session({ workingDir: '/tmp', mode, useMux: true }); + handleOutput(session, '\x1b[?1049hTUI\x1b[3J'); + expect(session.terminalBuffer).toBe('TUI\x1b[3J'); + } + }); +}); diff --git a/test/remote-ssh-options.test.ts b/test/remote-ssh-options.test.ts index f13fc8b4..cb565424 100644 --- a/test/remote-ssh-options.test.ts +++ b/test/remote-ssh-options.test.ts @@ -20,7 +20,12 @@ import { homedir } from 'node:os'; import { describe, it, expect } from 'vitest'; -import { buildSshConnectionArgs, buildRemoteTmuxCheckCommand, remoteSshTarget } from '../src/remote-hosts.js'; +import { + buildSshConnectionArgs, + buildRemoteTmuxCheckCommand, + buildRemoteCliVersionProbeCommand, + remoteSshTarget, +} from '../src/remote-hosts.js'; import { buildRemoteLaunchCommand } from '../src/tmux-manager.js'; import type { SessionRemote } from '../src/types.js'; @@ -198,3 +203,29 @@ describe('COD-107 buildRemoteTmuxCheckCommand — same connection options as the expect(buildRemoteTmuxCheckCommand({ username: 'ubuntu', host: '10.0.0.42', port: 2222 })).toContain('-p 2222'); }); }); + +describe('buildRemoteCliVersionProbeCommand: remote CLI version over the same connection (#205)', () => { + it('routes the version query through the interactive-login shell wrapper, like the launch', () => { + const cmd = buildRemoteCliVersionProbeCommand(baseRemote, 'claude'); + // Same PATH-resolution wrapper as defaultRemoteCommandForMode: a bare + // `claude --version` over ssh sees only sshd's minimal PATH (exit 127). + expect(cmd).toBe( + 'ssh -o BatchMode=yes -o ConnectTimeout=10 ubuntu@10.0.0.42 ' + + `'exec "\${SHELL:-/bin/sh}" -i -l -c '\\''claude --version'\\'''` + ); + }); + + it('uses the shared connection args (proxy/identity/port), so it reaches what the launch reaches', () => { + const cmd = buildRemoteCliVersionProbeCommand(aaDesktop, 'claude'); + expect(cmd).toContain('-o BatchMode=yes'); + expect(cmd).toContain('-p 2222'); + expect(cmd).toContain(`-i '${HOME}/.ssh/remote_ed25519'`); + expect(cmd).toContain("-o 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'"); + expect(cmd).toContain('aakht@192.168.55.170'); + }); + + it('maps antigravity to its real binary name and shell to no probe at all', () => { + expect(buildRemoteCliVersionProbeCommand(baseRemote, 'antigravity')).toContain('agy --version'); + expect(buildRemoteCliVersionProbeCommand(baseRemote, 'shell')).toBeNull(); + }); +}); diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 7c4ad557..773ddc48 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -311,7 +311,7 @@ describe('terminal touch tap mouse guard', () => { expect(sent).toEqual(['\x1b[<0;7;4M\x1b[<0;7;4m']); }); - it('wheel: forwards to the app only for verified sessions at the buffer bottom without Shift', () => { + it('wheel: forwards to the app for verified sessions without Shift, at ANY scroll position', () => { const { app } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.187' }]]); @@ -323,8 +323,14 @@ describe('terminal touch tap mouse guard', () => { expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true); expect(app._shouldForwardWheelToApp({ shiftKey: true })).toBe(false); // Shift = local scrollback - app.terminal.buffer.active.viewportY = 10; // browsing local scrollback - expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + // Scrolled up into local scrollback still forwards. Gating this on the + // viewport being at the bottom is what let a repaint-mode CLI's own prompt + // box scroll off the screen: scrollToLastNonEmptyLine() parks the viewport + // above the bottom, so a tab switch silently pinned the wheel to local + // scrollback full of stale replayed frames. The wheel handler snaps the + // viewport back to the bottom before encoding the report instead. + app.terminal.buffer.active.viewportY = 10; + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true); app.terminal.buffer.active.viewportY = 50; app.terminal.modes.mouseTrackingMode = 'vt200'; // xterm's own encoder live @@ -335,6 +341,24 @@ describe('terminal touch tap mouse guard', () => { expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); }); + it('wheel: converts deltaMode line/page units instead of assuming pixels', () => { + const { app } = loadTerminalUiHarness(); + app.terminal = { rows: 40 }; + + // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad): ~110px per notch. + expect(app._wheelScrollLines({ deltaY: 110, deltaX: 0, deltaMode: 0, shiftKey: false })).toBe(4); + // DOM_DELTA_LINE (Firefox mouse wheel): deltaY is already lines. Read as + // pixels this rounded to 0 and fell through to the ±1 fallback. + expect(app._wheelScrollLines({ deltaY: 3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(3); + expect(app._wheelScrollLines({ deltaY: -3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(-3); + // DOM_DELTA_PAGE: one page is one screenful. + expect(app._wheelScrollLines({ deltaY: 1, deltaX: 0, deltaMode: 2, shiftKey: false })).toBe(40); + // A pure horizontal swipe must not fall through to a phantom -1. + expect(app._wheelScrollLines({ deltaY: 0, deltaX: 90, deltaMode: 0, shiftKey: false })).toBe(0); + // Shift + macOS trackpad reports the magnitude on deltaX (issue #154). + expect(app._wheelScrollLines({ deltaY: 0, deltaX: -100, deltaMode: 0, shiftKey: true })).toBe(-4); + }); + it('wheel: gates claude forwarding on CLI version 2.1.187+ (unknown or older stays local)', () => { const { app } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; @@ -438,6 +462,39 @@ describe('terminal touch tap mouse guard', () => { expect(sent).toHaveLength(1); }); + it('forwarded scrolls (wheel AND touch) snap the viewport home first, then encode SGR ticks', () => { + const { app } = loadTerminalUiHarness(); + const sent: Array<{ id: string; data: string }> = []; + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data }); + const scrolledToBottom: boolean[] = []; + app.terminal = { + cols: 80, + rows: 24, + // Scrolled up into local scrollback: SGR coordinates address the LIVE + // screen, so the report would hit-test the wrong row without the snap. + buffer: { active: { viewportY: 10, baseY: 50 } }, + scrollToBottom: () => scrolledToBottom.push(true), + element: { + querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }), + }, + _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }, + }; + + app._forwardScrollToApp(50, 50, -3); + expect(scrolledToBottom).toEqual([true]); + app._flushWheelSgrQueue(); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<64;7;4M'.repeat(3) }]); + + // Already at the bottom: no snap, just the report. + app.terminal.buffer.active.viewportY = 50; + app._forwardScrollToApp(50, 50, 2); + expect(scrolledToBottom).toHaveLength(1); + app._flushWheelSgrQueue(); + expect(sent).toHaveLength(2); + }); + it('allows trusted mouse events after the tap window expires', () => { const { app, setNow } = loadTerminalUiHarness(); const { element, dispatch } = createElementHarness();