From 58dae1e18257bfaa95187113e6f0f344f91198ea Mon Sep 17 00:00:00 2001 From: Randalix Date: Sun, 1 Nov 2026 22:09:46 +0100 Subject: [PATCH] fix(pi): forward the wheel from cliMouseTracking so scrolling survives replay pi 1.4.0 is an alt-screen TUI that enables mouse tracking once at start. Codeman's replay (tab switch, reload) drops that mode in xterm (mouseTrackingMode none, baseY 0), so the wheel hit an empty local buffer and stayed dead until a resize made pi redraw. Same shape as copilot: altScreen strip-mux-and-mouse (the server records the stripped DECSETs as cliMouseTracking, which survives the replay), wheelForward always, transcriptPageKeys (PageUp/PageDown fallback for a stale flag, measured on pi 1.4.0). _shouldForwardWheelToApp treats pi like copilot. Registry pins and docs updated. Tests: 2 new in terminal-scroll-routing (red before). Full run: 12989 passed, 5 red from the de_DE locale / XDG env (custom-model-run-menu-ui, git-worktrees, edit-history-sync), all green under LANG=C.UTF-8. Co-Authored-By: Claude Sonnet 5.5 (cherry picked from commit 53e4136d9f4401e9250ac6a122599ffcb2a57e8a) --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 6 ++++-- src/config/cli-registry/stock.ts | 10 +++++++++- src/config/cli-registry/types.ts | 4 ++-- src/session.ts | 22 ++++++++++------------ src/web/public/terminal-ui.js | 4 ++-- test/cli-registry-schema.test.ts | 8 ++++---- test/terminal-scroll-routing.test.ts | 20 ++++++++++++++++++++ 8 files changed, 52 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4f350fdb..6253c1fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -217,7 +217,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 whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection, drop recovery, clear and a tile's load use `SHELL_LOAD_QUERY` (constants.js), a bounded window of tmux's RENDERED history (`?full=1&tail=1MiB&alt=recording&lines=10000`), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client `lines=` bound below the history ceiling that left lines in tmux as `truncationReason: 'tail'`, and `alt=recording` falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). A Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) -**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, or tracked Copilot**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. Copilot has no version gate; a stale/false tracking flag falls back to PageUp/PageDown. Touch alone has one more branch (`_shouldForwardTouchToApp`): in any mode, a drag also forwards as SGR wheel reports while xterm ITSELF tracks the mouse with SGR encoding (an app in the pane asked for it; a tracking mode other than `x10` and `coreMouseService.activeEncoding === 'SGR'`, so DEFAULT, X10 and SGR-pixel apps fail closed), because xterm encodes a wheel there but the touch listeners consume the drag. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. ⚠️ The hollow-buffer PageUp/PageDown fallback (`_maybePageCliTranscript`) stands down when the last capture reported tmux scrollback (`paneHistoryLines` > 0: a tail replayed after a server restart carries only the attach repaint), so the gesture scrolls locally and the scroll-to-top pull loads `full=1`; 0 and unknown keep paging. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) +**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode, copilot and pi get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, tracked Copilot, or tracked Pi**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. Copilot and Pi have no version gate; a stale/false tracking flag falls back to PageUp/PageDown. Pi (1.4.0) enables tracking once at start, so the flag is what survives a tab switch or reload (xterm's own mode does not). Touch alone has one more branch (`_shouldForwardTouchToApp`): in any mode, a drag also forwards as SGR wheel reports while xterm ITSELF tracks the mouse with SGR encoding (an app in the pane asked for it; a tracking mode other than `x10` and `coreMouseService.activeEncoding === 'SGR'`, so DEFAULT, X10 and SGR-pixel apps fail closed), because xterm encodes a wheel there but the touch listeners consume the drag. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. ⚠️ The hollow-buffer PageUp/PageDown fallback (`_maybePageCliTranscript`) stands down when the last capture reported tmux scrollback (`paneHistoryLines` > 0: a tail replayed after a server restart carries only the attach repaint), so the gesture scrolls locally and the scroll-to-top pull loads `full=1`; 0 and unknown keep paging. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) **Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install) **Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 479d279b..3356d314 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -231,14 +231,16 @@ Further detail: the `: ` form (`w3-myapp: fix the login redirect` ### Terminal scrollback: strip flavors and wheel/touch forwarding -**Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode and copilot) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. Copilot is the same shape (measured on 1.0.95: alt screen, mouse reporting on): left on the narrow strip, Auto Copy and copy-on-select did nothing and only Shift+drag selected. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/pi/grok/deepseek/omp) 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 — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ 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 three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in a DECSET-stripping branch (full or mouse), so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`. +**Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode, copilot and pi) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. Copilot is the same shape (measured on 1.0.95: alt screen, mouse reporting on): left on the narrow strip, Auto Copy and copy-on-select did nothing and only Shift+drag selected. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/pi/grok/deepseek/omp) 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 — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ 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 three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in a DECSET-stripping branch (full or mouse), so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`. ⚠️ **What a mouse-DECSET strip (full or mouse) removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach. -**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187, tracked Codex or tracked Copilot** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including a 0.162.0 measurement (2026-10) with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'always'` (observed mouse tracking is still required); Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact. +**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187, tracked Codex, tracked Copilot or tracked Pi** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including a 0.162.0 measurement (2026-10) with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'always'` (observed mouse tracking is still required); Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact. **Copilot also receives forwarded reports** (1.0.95 measured in tmux: alt screen, `mouse_any_flag=1`): its mouse DECSETs are stripped so a drag selects, which also stops xterm encoding the wheel for it, so `_shouldForwardWheelToApp` sends the same SGR reports by hand while `cliMouseTracking` is true; six `\x1b[<64;…M` reports moved its transcript 18 lines, a phone swipe takes the same gate, and there is NO version gate. +**Pi is the same shape, and the reason it moved to the mouse strip** (1.4.0 measured in tmux, default settings: `alternate_on=1`, `history_size=0`, `mouse_any_flag=1`, SGR): pi enables mouse tracking ONCE at start. Left on the narrow strip, the wheel worked only until Codeman replayed the buffer (tab switch, reload): the replay carries no DECSETs, xterm's `mouseTrackingMode` fell to `'none'` with `baseY` 0, and the wheel hit an empty local buffer until a resize made pi redraw. Measured on a fresh session: `drag` right after the first select, `none` after a tab switch and after a reload. With the mouse strip the server records the tracking (`cliMouseTracking`), which survives the replay; `_shouldForwardWheelToApp` forwards by hand while it is true. One SGR wheel report moves pi's transcript one row; PageUp/PageDown page it (the `transcriptPageKeys` fallback for a stale flag). Not measured: pi versions before 1.4.0. + Codex 0.163.0 approval check in that isolated read-only/on-request session: the "Would you like to run the following command?" prompt remained on `1. Yes, proceed` after SGR wheel-up and wheel-down over both the transcript and approval option rows 36/37; the requested marker file was not created. The background transcript can scroll while the approval prompt remains fixed. Codeman does alert on this prompt: `src/web/screen-blocked.ts` reads Codex dialogs off the pane (`capabilities.workDetect.blockedLine`) and broadcasts `hook:permission_prompt`, which `_onHookPermissionPrompt` (settings-ui.js) records and `updateTabAlertFromHooks` (app.js) turns into the `'action'` tab alert, and both page-key fallbacks check it. The SGR wheel path deliberately does not consult that alert, because this measurement showed wheel reports leave the approval selection unchanged. Native Codex owns its approval selection; do not mistake a hook-only unit fixture for approval-dialog evidence. Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Firefox 3-line events = 3 lines); no one-tick-per-event policy is introduced here. A fresh native gesture at or above 0.1 row (2.5px) rounds its opening travel to at least one tick (subject to the per-event cap), so an isolated 4px mouse click responds, 40px sends two ticks and 120px sends five. The signed remainder is carried forward; travel discarded by the per-event cap does not become a backlog. The signed remainder is debt repaid by subsequent same-direction events: ten 2.5px events still total one tick. Debt/fractions expire after a 150ms gap; a reversal inside that gap discards the old debt without another pre-payment. Sub-threshold jitter does not pre-pay. The first native report of each tab visit bypasses the 40 ms batching timer; later reports coalesce and only unsent bytes can be cancelled on reversal. Every queued batch belongs to its originating session, so switching tabs cannot send its bytes into the newly selected pane. Zero-delta events leave pending movement untouched. Tiles use the same helpers and their own queue/session. diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 031c3a9f..10b5c41c 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -1180,7 +1180,15 @@ const PI: CliEntry = { // (hasTrustRequiringProjectResources, pi 1.1.0), which would stall a spawned worker on // a dialog. pi reads the user-level `~/.agents/skills` without that prompt, so a pi // user installs it there (`npx skills add Ark0N/Codeman --skill codeman -g`). - altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback + // pi 1.4.0 measured (tmux pane: alternate_on=1, history_size=0, mouse on, SGR): a fullscreen + // TUI that enables tracking once at start. Codeman's replay (tab switch, reload) drops that + // mode in xterm, so the wheel hit an empty local buffer and went dead until a redraw. Same + // shape as copilot: strip the DECSETs (a drag selects locally), publish `cliMouseTracking`, + // and forward the wheel by hand from that flag. pi scrolls on SGR wheel (1 line per report) + // and on PageUp/PageDown, which is the fallback for a stale flag. + altScreen: 'strip-mux-and-mouse', + wheelForward: { mode: 'always' }, + transcriptPageKeys: true, echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, // Measured on a live pi 1.1.0 pane (capture-pane every 250 ms through a turn, // 2026-10-09): pi has no composer glyph. Its composer sits between two `─` rules, and diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index ebad4977..f3d1314e 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -560,8 +560,8 @@ export interface CliCapabilities { * own mouse support must work in the pane (htop/vim in a shell). * * Stock CLIs: `strip-full` = claude, codex, gemini (Ink TUIs); `strip-mux-and-mouse` = - * opencode and copilot (full-screen TUIs that enable tracking themselves); `strip-mux-only` = - * antigravity, grok, deepseek, omp; `preserve` = shell, pi. + * opencode, copilot and pi (full-screen TUIs that enable tracking themselves); `strip-mux-only` = + * antigravity, grok, deepseek, omp; `preserve` = shell. * * A fourth combination is the point to split this into flags; three is still cheaper * as an enum. diff --git a/src/session.ts b/src/session.ts index c91a70d5..dc01f2ea 100644 --- a/src/session.ts +++ b/src/session.ts @@ -304,23 +304,21 @@ function cliExportsTruecolor(mode: SessionMode): boolean { * Codex, Claude Code, and Gemini are known, controlled (Ink/React) TUIs that * repaint via cursor positioning, so dropping the alt-screen switch is safe — * content stays in the normal buffer. Excluded: `shell` (arbitrary programs like - * vim/less/htop legitimately need the alt screen), `opencode` (its own MIDDLE strip, - * isMuxMouseStripMode), `pi` (below) and `grok` (a fullscreen alt-screen TUI with + * vim/less/htop legitimately need the alt screen), `opencode` and `pi` (their own MIDDLE strip, + * isMuxMouseStripMode; pi since 1.4.0) and `grok` (a fullscreen alt-screen TUI with * mouse support). Keep parity with the replay-side strip (`stripReplayBuffer` in * session-routes.ts); the table in `CliCapabilities.altScreen` is pinned for every * stock CLI in test/claude-scrollback-strip.test.ts. * * ⚠️ Being excluded here does NOT preserve the alt screen. Every excluded mode - * falls through to isMuxAltScreenOnlyStripMode(), which strips the alt-screen - * toggles too whenever the session is tmux-backed, and pi/opencode ALWAYS are - * (both refuse the direct-PTY fallback). What exclusion actually buys is the rest - * of the full strip: `\x1b[3J` and the mouse-tracking DECSETs survive. That is the - * real reason pi is out: its default TUI renders into the MAIN screen with - * terminal-owned scrollback and is mouse-aware, so it is a `3J`/mouse consumer in - * a way an Ink TUI repainting in place is not. Consequence to know before - * debugging it: pi's runtime-switchable fullscreen TUI (`/settings`, 0.84.0+) - * still gets its `?1049h` stripped and paints into the main buffer, exactly like - * vim inside a tmux `shell` session. + * falls through to isMuxMouseStripMode() or isMuxAltScreenOnlyStripMode(), which + * strip the alt-screen toggles too whenever the session is tmux-backed, and + * pi/opencode ALWAYS are (both refuse the direct-PTY fallback). What exclusion + * actually buys is the rest of the full strip: `\x1b[3J` survives, and for the + * narrow strip the mouse-tracking DECSETs too. pi (1.4.0, measured) is a + * mouse-aware fullscreen TUI like opencode and copilot, so it takes the mouse + * strip (isMuxMouseStripMode): a drag selects locally and `cliMouseTracking` + * keeps the wheel working across a buffer replay. */ export function isAltScreenStripMode(mode: SessionMode): boolean { return getCli(mode)?.capabilities.altScreen === 'strip-full'; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 53985d79..1c10c158 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -6065,12 +6065,12 @@ Object.assign(CodemanApp.prototype, { if (mode && mode !== 'none') return false; const session = this.sessions?.get(target.sessionId || this.activeSessionId); const sessionMode = session?.mode || 'claude'; - // GitHub Copilot CLI scrolls its own transcript on SGR wheel reports (checked with xterm + // GitHub Copilot CLI (and pi, same shape since 1.4.0) scrolls its own transcript on SGR wheel reports (checked with xterm // encoding them natively). Its mouse DECSETs are stripped server-side so a drag selects // locally (strip-mux-and-mouse), which also means xterm no longer encodes the wheel: send the // same reports by hand, but only while the CLI is actually tracking the mouse. Not gated on a // version; a stale-false flag falls through to the PageUp/PageDown fallback like claude's. - if (sessionMode === 'copilot') return session?.cliMouseTracking === true; + if (sessionMode === 'copilot' || sessionMode === 'pi') return session?.cliMouseTracking === true; if (sessionMode !== 'claude' && sessionMode !== 'codex') return false; if (sessionMode === 'claude' && !this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false; // Only while Claude or Codex is actually listening for the mouse. Claude's diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts index 0de54fdf..fc5a2811 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -633,22 +633,22 @@ describe('capability shapes', () => { it('declares which CLIs get the wheel forwarded, and whether a version gates it', () => { // Claude forwards from 2.1.187 (fullscreen only); tracked Codex and Copilot - // forward without a version gate. Unmeasured entries stay 'never'. + // forward without a version gate, and so does pi (1.4.0 measured). Unmeasured entries stay 'never'. const mode = (id: string) => STOCK_CLIS.find((e) => (e.id as string) === id)?.capabilities.wheelForward; expect(mode('copilot')).toEqual({ mode: 'always' }); expect(mode('claude')).toEqual({ mode: 'version-gated', minVersion: '2.1.187' }); const forwarding = STOCK_CLIS.filter((e) => e.capabilities.wheelForward.mode !== 'never').map( (e) => e.id as string ); - expect(forwarding.sort()).toEqual(['claude', 'codex', 'copilot']); + expect(forwarding.sort()).toEqual(['claude', 'codex', 'copilot', 'pi']); expect(CliEntrySchema.safeParse(baseEntry('copilot')).success).toBe(true); }); it('declares transcriptPageKeys for exactly the CLIs measured to page on PageUp/PageDown', () => { - // claude (repaint mode), codex, opencode and copilot (all on the alternate screen) were + // claude (repaint mode), codex, opencode, copilot and pi (all on the alternate screen) were // measured paging their own transcript on PageUp/PageDown; no other stock entry has been. const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string); - expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'opencode']); + expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'opencode', 'pi']); }); }); diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index fa82c75d..e29ef27c 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -898,6 +898,26 @@ describe('GitHub Copilot wheel forwarding', () => { }); }); +describe('pi wheel forwarding (1.4.0: alt-screen TUI with mouse tracking)', () => { + // pi enables mouse tracking once at start. Codeman's replay (tab switch, reload) drops that + // mode in xterm (`mouseTrackingMode` none, baseY 0), so the wheel hit an empty local buffer + // and went dead until a redraw. Like Copilot, the server strips the DECSETs and publishes + // `cliMouseTracking`; the gate forwards from that flag, which survives the replay. + it('forwards the wheel while pi tracks the mouse', () => { + const { app } = hollowApp({ mode: 'pi', cliMouseTracking: true }); + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true); + }); + + it('keeps Shift+wheel local and falls back to paging when the flag is stale', () => { + expect(hollowApp({ mode: 'pi', cliMouseTracking: true }).app._shouldForwardWheelToApp({ shiftKey: true })).toBe( + false + ); + const { app } = hollowApp({ mode: 'pi', cliMouseTracking: false }); + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + expect(app._localScrollbackIsHollow()).toBe(true); + }); +}); + describe('the paging gates asked for another pane (a TerminalTile)', () => { it('exports the paging math, and the primary pane runs on it', () => { const { app, sent, windowRef } = hollowApp();