From 58dae1e18257bfaa95187113e6f0f344f91198ea Mon Sep 17 00:00:00 2001 From: Randalix Date: Sun, 1 Nov 2026 22:09:46 +0100 Subject: [PATCH 1/3] 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(); From b5aa3fbdf7e315d03bceefb2aa7743f02fb3d589 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 6 Nov 2026 01:04:01 +0100 Subject: [PATCH 2/3] fix(pi): leave page keys out, and pin the strip that makes the wheel work transcriptPageKeys applied to every pi, and a pi in `tuiMode: regular` binds PageUp/PageDown to its editor. It emits no mouse enable, so the flag stays false, and on a fresh pane the buffer is hollow: the wheel sent PageUp, the caret of a multi-line draft moved, and the next keystroke landed on the wrong line. Measured on real pi 1.1.0, 1.4.0 and 1.5.0. Regular mode is not exotic, it only takes one line in pi's settings. Telling the two modes apart would need a new capability, since the paging gate takes no id, so pi has no fallback: a fullscreen pi with "Wheel scrolls local history" on stays where master left it. altScreen, the half of the fix that does the work, was pinned by nothing: reverting it left 1328 tests green. The strip decision table reads its expected value from the same registry entry. pi now has its own block using the bytes it really emits, which also pins that focus reporting survives. The fullscreen default dates from pi 1.0.0, not 1.4.0, so the comments say that, and the sites still calling pi narrow-strip or main-screen by default are corrected. Where a comment listed CLIs it now names the capability. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .changeset/pi-wheel-replay.md | 5 ++++ CLAUDE.md | 2 +- docs/architecture-invariants.md | 6 ++--- docs/pi-integration.md | 8 +++--- src/config/cli-registry/stock.ts | 20 +++++++++----- src/config/cli-registry/types.ts | 2 +- src/session.ts | 10 +++---- src/types/session.ts | 4 +-- src/web/public/terminal-tile.js | 4 +-- src/web/public/terminal-ui.js | 14 +++++----- src/web/routes/session-routes.ts | 2 +- test/claude-scrollback-strip.test.ts | 40 +++++++++++++++++++++++++++- test/cli-registry-schema.test.ts | 8 +++--- test/terminal-scroll-routing.test.ts | 17 +++++++++--- 14 files changed, 102 insertions(+), 40 deletions(-) create mode 100644 .changeset/pi-wheel-replay.md diff --git a/.changeset/pi-wheel-replay.md b/.changeset/pi-wheel-replay.md new file mode 100644 index 00000000..b0485811 --- /dev/null +++ b/.changeset/pi-wheel-replay.md @@ -0,0 +1,5 @@ +--- +"aicodeman": patch +--- + +pi: the mouse wheel no longer goes dead after a tab switch or reload. pi's fullscreen TUI, the default since 1.0.0, enables mouse tracking once at start, and Codeman's replay dropped that mode in the browser, so the wheel landed in an empty local buffer. pi now takes the same path as Copilot: the server strips the mouse enables and remembers them, and the wheel is forwarded from that record, on the primary pane, tiles, split Pane B and phones. A drag in fullscreen pi now selects text in the browser instead of using pi's own selection. A pi in `tuiMode: regular` is unaffected. Thanks to @Randalix for the report and the fix. diff --git a/CLAUDE.md b/CLAUDE.md index 6253c1fc..f2e7011a 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, 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) +**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 has no version gate; a stale/false tracking flag falls back to PageUp/PageDown. Pi has no version gate either, and is fullscreen by default since 1.0.0: it enables tracking once at start, so the flag is what survives a tab switch or reload (xterm's own mode does not). ⚠️ Pi deliberately has no `transcriptPageKeys`: its `tuiMode: regular` binds those keys to the editor, so paging there moves a draft's caret. 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 3356d314..d834bc81 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -61,7 +61,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test. Model is NOT a session field: it is a composition entry in the profile's config tree (`agent-default-model`), configured in `~/.dsh/settings.yaml` + `cordis.patch.yml`, so both create paths deliberately resolve no model for this mode. Env allowlist: `DSH_*` + `DEEPSEEK_*`; provider keys named by a settings-file `apiKeyEnv` stay OUT, which is pi's 34-provider-key problem in a new shape and gets the same answer. Docker seeds `~/.dsh` per-file (`.env`, `settings.yaml`, `cordis.patch.yml`) and the image installs its OWN profile, because `profiles/` is a per-profile `node_modules` tree — host-arch-specific and far too large to copy per container start. Stays OUT of `isAltScreenStripMode()` (third-party fullscreen TUI — the opencode case, i.e. `isMuxMouseStripMode` is the shape to measure here too). ⚠️ `classifyProfile()` reads the profile's BUNDLES, and "unknown means launchable" is deliberate (anyone can publish an app bundle), but it has one knowably-wrong case: `readProfile()` returns an empty bundle list for a `package.json` with no `dsh.profile.bundles`, which made the SHIPPED `web`/`headless` profiles look third-party and launchable. The directory name is therefore consulted as a LAST resort (`STOCK_NON_INTERACTIVE_PROFILES`), after the bundle patterns, so real bundle evidence always wins over a name the user chose. The loose `tui` arm carries word boundaries for the same reason: it decides which profile boots by default, and matching the middle of `intuition` is not a rule anyone could predict. ⚠️ The generated shim is written **temp + rename**, not in place: the TUI can be exec'ing that exact path while an upgraded Codeman refreshes it, and a half-written file is a syntax error the caller then retries four times per state change forever. Bump `SHIM_VERSION` whenever `SHIM_SOURCE` changes, or an existing shim keeps matching the embedded marker and is never refreshed. Availability via `GET /api/deepseek/status`, the widest per-CLI status shape (`available`/`runnable`/`path`/`version`/`dshHome`/`defaultProfile`/`profiles`); `POST /api/deepseek/install-profile` bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode, and the held-open request is bounded by a HAND-ROLLED timeout over a `detached: true` process group (negative-pid SIGTERM→SIGKILL, as `runGit()` does in git-clone.ts). ⚠️ Node's own `spawn` `timeout` is NOT enough: a plugin install fans out into package-manager children, the built-in timeout signals only the direct child, and the survivors hold the inherited stdio pipes open so `close` never fires and the request leaks forever. User guide: `docs/deepseek-integration.md`. Tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`. -**Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session <id>` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: its default TUI renders into the main screen with terminal-owned scrollback (nothing to strip), and since 0.84.0 the user can flip to a fullscreen TUI at runtime via `/settings`, where the alt screen is load-bearing — being out of the list is exactly what makes that switch safe. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp). +**Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session <id>` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: it takes the mouse strip instead, because its TUI is fullscreen by default since 1.0.0 (`tuiMode`) and inline with terminal-owned scrollback under `tuiMode: regular` and below 1.0.0, so the full strip's `3J` removal would have no owner in the second shape. See "Pi is the same shape" in the scrollback section. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp). **OMP (`omp`, Oh My Pi) specifics** (`docs/omp-integration.md`): architecturally the simplest of the family — omp owns its own auth, provider routing, and trust decisions entirely in `~/.omp` config files (default `tools.approvalMode: yolo`), so `buildOmpCommand()` only ever emits `--model`/`--resume <id>`/`--continue`, and there is no bypass-permissions flag for Codeman to wire or clamp. ⚠️ **That does NOT make the multi-user clamp a no-op**: `OMP_*` is an allowlisted `envOverrides` prefix and admits `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` (where omp resolves credentials from), both dropped for a non-granted owner in `clampEnvOverridesForOwner()` — the same shape as `DEEPSEEK_BASE_URL` — even though, unlike DeepSeek, Codeman forwards no operator-held key into an omp pane today (found in Ark0N/Codeman#353 review). `--continue` alone is ambiguous the moment any other omp conversation has touched the same working directory more recently, since it just picks the newest session file on disk — `resolveAndClaimOmpSessionId()` (`src/utils/omp-session-resolver.ts`) resolves and PINS the real id instead, verifying each candidate's own file header (`{"type":"session","id",cwd"}`, not just the mangled-directory match) and tracking already-claimed ids in a process-wide registry so two omp tabs in the same case dir can't alias onto each other's conversation. ⚠️ Resolution/pinning happens ONLY at the point a respawn is actually confirmed (`_pinOmpRespawnId()`, called from `_setupOrAttachMuxSession()`'s dead-pane branch and `reattachRemote()`) — earlier code resolved eagerly while merely building respawn options, which could mis-pin a still-ALIVE session's id purely from boot-recovery timing. `src/omp-transcript.ts` independently scans `~/.omp/agent/sessions/**/*.jsonl` for Past Sessions history, the omp analog of Claude's own transcript scan, so a conversation survives even a full "Kill Tmux". ⚠️ omp's own env knobs are mostly `PI_*`, not `OMP_*` (`PI_CONFIG_DIR`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `PI_SUBPROCESS_CMD`, `PI_SHELL_PREFIX`), and `PI_*` is already allowlisted globally for pi — so a redirected `PI_CONFIG_DIR` silently moves the `~/.omp` tree the resolver and transcript scanner hardcode, degrading pinning/history with no error; a known gap shared with pi, not fixed here. ⚠️ Docker: `appendResumeFlag()`'s `case 'omp'` keys off the top-level `resumeSessionId`, which Docker panes never receive for omp (built from `defaultDockerCommandForMode`, with no `ompConfig` threaded through) — host-side history recovery and pinning work through the shared `sessions/` mount, but `--resume` does not currently reach an in-container omp process on respawn (flagged in review, not yet fixed). Stays out of `isAltScreenStripMode()` (narrow scrollback strip, alt-screen toggles only) and lands on the `'buffer'` local-echo policy via the `_updateLocalEchoState` fallthrough, same as grok and pi. Resolver: `omp-cli-resolver.ts` version-probes like pi/grok (`omp` is a short, generic name) and requires `omp/<semver>`-shaped output; `OMP_SEARCH_DIRS` leads with `~/.local/bin` (omp.sh's installer targets `$HOME/.local/bin` with no `--dir` override — verified against a real `--no-cache` Docker build, `~/.omp/bin` was the wrong first guess). Tests: `test/omp-mode.test.ts`, `test/omp-cli-resolver.test.ts`, `test/omp-session-resolver.test.ts`, `test/omp-fresh-run-no-resume.test.ts`. @@ -231,7 +231,7 @@ Further detail: the `<prefix>: <title>` 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, 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`. +**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/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. @@ -239,7 +239,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect` **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. +**Pi is the same shape, and the reason it moved to the mouse strip** (fullscreen is the default `tuiMode` since 1.0.0; 1.0.0, 1.1.0, 1.4.0 and 1.5.0 measured in tmux with 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 at a low rate, and pi's `fullscreenWheelScrollLines` default accelerates a fast spin. Under `tuiMode: regular` and below 1.0.0 pi is inline and emits no mouse DECSET, so the strip finds nothing and the flag stays false. ⚠️ Pi deliberately has no `transcriptPageKeys`: fullscreen pi does page on PageUp/PageDown, but pi binds those keys to its editor in regular mode too, where they page nothing and move the caret of a multi-line draft, so the hollow-buffer fallback sent the next keystroke to the wrong line (measured on 1.1.0, 1.4.0 and 1.5.0). The fallback would need a capability to tell the two modes apart, since `_localScrollbackIsHollow` takes no id. The cost: a fullscreen pi with "Wheel scrolls local history" on has no paging fallback. 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. diff --git a/docs/pi-integration.md b/docs/pi-integration.md index 08182ffe..ecd61fe1 100644 --- a/docs/pi-integration.md +++ b/docs/pi-integration.md @@ -88,9 +88,11 @@ bypass, and project trust is a decision the person at the terminal makes. - **`--api-key`.** Never. It would put a provider secret on the spawn command line, visible in `ps`, tmux server state and logs. `PI_*` overrides go through socket-scoped `tmux setenv` for exactly this reason. -- **`--tui-mode`.** Pi's default main-screen TUI is the friendly case for a - browser terminal. The fullscreen mode (0.84.0) stays your own runtime choice via - `/settings`. +- **`--tui-mode`.** Which TUI you get stays your choice, in pi's `tuiMode` setting. + Fullscreen is the default since pi 1.0.0: the wheel and clicks reach pi, and because + Codeman strips pi's mouse enables, a drag selects text in the browser rather than + using pi's own selection or scrollbar drag. `tuiMode: regular`, and every pi below + 1.0.0, is inline with terminal-owned scrollback and emits no mouse enables at all. - **`--name`, `--no-session`, `-p`/`--print`, `--mode json`, `--mode rpc`, `--tools`/`--exclude-tools`, `-e`/`--extension`, `--skill`, `--system-prompt`.** Tracked as follow-ups in the plan doc. diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 10b5c41c..cac33048 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -1180,15 +1180,21 @@ 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`). - // 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. + // Fullscreen is pi's default `tuiMode` since 1.0.0 (measured 1.0.0, 1.1.0, 1.4.0, 1.5.0 in + // tmux: alternate_on=1, history_size=0, mouse on, SGR): a 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 reports, one row each at a low rate, faster on a spin. + // `tuiMode: regular` (and every pi below 1.0.0) is inline and emits no mouse DECSET, so the + // strip finds nothing and the flag stays false. + // ⚠️ No `transcriptPageKeys`, though fullscreen pi does page on PageUp/PageDown. pi binds + // those keys to its editor in regular mode too, where they page nothing and move the caret + // of a multi-line draft, so a wheel over a fresh regular pane sent the next keystroke to the + // wrong line (measured). The cost: a fullscreen pi with "Wheel scrolls local history" on + // has no paging fallback. 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 f3d1314e..79edada4 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -546,7 +546,7 @@ export interface CliCapabilities { * `strip-full` is `isAltScreenStripMode`; `strip-mux-and-mouse` is `isMuxMouseStripMode`; * every other value takes `isMuxAltScreenOnlyStripMode`, so at runtime `preserve` and * `strip-mux-only` are the same row — `preserve` only says what such a CLI's pane - * holds (terminal-owned scrollback: a shell, pi), not a different strip. + * holds (terminal-owned scrollback: a shell), not a different strip. * * - alt-screen: the tmux CLIENT emits `smcup` as its first bytes at attach, parking * xterm in the scrollback-less alternate buffer; a pane program's own toggles never diff --git a/src/session.ts b/src/session.ts index dc01f2ea..f222c216 100644 --- a/src/session.ts +++ b/src/session.ts @@ -304,8 +304,8 @@ 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` and `pi` (their own MIDDLE strip, - * isMuxMouseStripMode; pi since 1.4.0) and `grok` (a fullscreen alt-screen TUI with + * vim/less/htop legitimately need the alt screen), `opencode`, `copilot` and `pi` (their own + * MIDDLE strip, isMuxMouseStripMode) 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. @@ -315,8 +315,8 @@ function cliExportsTruecolor(mode: SessionMode): boolean { * 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 + * narrow strip the mouse-tracking DECSETs too. pi (fullscreen by default since + * 1.0.0) is a mouse-aware 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. */ @@ -3001,7 +3001,7 @@ export class Session extends EventEmitter { // redraws overwrite only the cells they target, so non-erased rows keep // their content. Gated to Codex/Claude/Gemini (isAltScreenStripMode). // - // Every OTHER mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip + // Every OTHER mode (shell/antigravity/grok/deepseek/omp) 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). diff --git a/src/types/session.ts b/src/types/session.ts index e4a0e93a..73fa1724 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -956,8 +956,8 @@ export interface SessionState { /** * True while the CLI in the pane has a mouse-tracking DECSET on, as observed * by the server on its way out of the stream (those sequences are stripped for - * the strip-full and strip-mux-and-mouse modes, claude/codex/gemini and opencode - * under tmux, so the browser can never see them itself). The browser + * the strip-full and strip-mux-and-mouse modes under tmux, so the browser can + * never see them itself). The browser * hand-encodes a click report ONLY when this is true; without it, every click * sent mouse reports to a CLI that never asked for them. */ diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index a069eeee..de99bb85 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -1149,8 +1149,8 @@ // A plain left-click reported to the CLI, the primary pane's desktop click // (terminal-ui.js _handleDesktopTerminalClick) aimed at this pane. The - // server strips the mouse DECSETs of some modes (opencode's since #555, so a - // drag selects text), which leaves this xterm's own mouse encoder idle for + // server strips the mouse DECSETs of some modes (so a drag selects text), + // which leaves this xterm's own mouse encoder idle for // them; without this a click in such a pane never reached the CLI. Only // while this pane's session has tracking on (cliMouseTracking), through the // same skips as the primary pane. Bubble phase, as there. The target is diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 1c10c158..a62b2706 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1572,8 +1572,8 @@ Object.assign(CodemanApp.prototype, { // A real mouse click normally reaches the PTY through xterm's own mouse // encoder, but that encoder only runs while mouseTrackingMode is ON — and // the server strips the enabling DECSETs from claude/codex/gemini output - // (isAltScreenStripMode, session.ts) and from opencode's (isMuxMouseStripMode, - // so a drag selects text) so the wheel keeps scrolling + // (isAltScreenStripMode, session.ts) and from the mouse-strip modes' + // (isMuxMouseStripMode, so a drag selects text) so the wheel keeps scrolling // scrollback. Desktop clicks therefore stopped reporting entirely (the // same breakage the mobile touchend tap branch above works around). // Hand-encode the SGR report for plain left-clicks on those sessions. @@ -5942,8 +5942,8 @@ Object.assign(CodemanApp.prototype, { * literal text (`[<0;88;20M`) and they garble the next line typed. * * Fails toward silence: an unknown or stale flag reports nothing rather than - * injecting bytes. After a server restart the flag is false until the CLI - * re-emits its DECSET, which closing and reopening a dialog does. + * injecting bytes. After a server restart the flag is false until the DECSET + * passes again, which tmux does itself when the server re-attaches to the pane. * * `sessionId` defaults to the primary pane's session; a TerminalTile passes * its own (through _handleDesktopTerminalClick's target), never the active one. @@ -6065,7 +6065,7 @@ 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 (and pi, same shape since 1.4.0) scrolls its own transcript on SGR wheel reports (checked with xterm + // GitHub Copilot CLI (and pi, fullscreen by default) 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 @@ -6408,8 +6408,8 @@ Object.assign(CodemanApp.prototype, { * The reason is that the habit and xterm's Shift mean different things once * the DECSETs are stripped. xterm reads Shift as "force selection" ONLY while * the app actually has mouse tracking on; the server strips those DECSETs for - * claude/codex/gemini (isAltScreenStripMode) and opencode (isMuxMouseStripMode), - * so xterm's mouseTrackingMode is + * claude/codex/gemini (isAltScreenStripMode) and the mouse-strip modes + * (isMuxMouseStripMode), so xterm's mouseTrackingMode is * permanently `none`, that branch is unreachable, and Shift instead falls into * `_onIncrementalClick` — EXTEND an existing selection. Extending is a no-op * when `selectionStart` is null, so the drag never anchors and no selection is diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 2bcd85a6..78cd5bed 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -332,7 +332,7 @@ const MOUSE_TRACKING_PATTERN = /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[h * - `strip-full` (claude/codex/gemini): alt-screen toggles, `3J` and mouse DECSETs. * xterm obeys the toggles by switching to its scrollback-less alt buffer and wiping * saved lines, so history disappeared on tab switch. - * - `strip-mux-and-mouse` (opencode, tmux-backed): alt-screen toggles and mouse DECSETs. + * - `strip-mux-and-mouse` (tmux-backed): alt-screen toggles and mouse DECSETs. * A replayed tracking enable parks xterm in report mode, where a drag goes to the CLI * instead of selecting text (and Ctrl+C without a selection is opencode's app_exit). * `3J` stays: a TUI is not a `clear` consumer. diff --git a/test/claude-scrollback-strip.test.ts b/test/claude-scrollback-strip.test.ts index ee3bf703..d81cc950 100644 --- a/test/claude-scrollback-strip.test.ts +++ b/test/claude-scrollback-strip.test.ts @@ -102,7 +102,7 @@ describe('isMuxAltScreenOnlyStripMode', () => { expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false); } // The full strip and the mouse strip already own their modes; never double-gate. - for (const mode of ['claude', 'codex', 'gemini', 'opencode', 'copilot'] as const) { + for (const mode of ['claude', 'codex', 'gemini', 'opencode', 'copilot', 'pi'] as const) { expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false); } }); @@ -372,6 +372,44 @@ describe('strip decision table: live stream = replay, for every stock CLI', () = * on itself. Left in, xterm reports a plain drag to the TUI instead of selecting, so Auto Copy and * copy-on-select did nothing (only Shift+drag selected). Same strip, same reason. */ +describe('pi: alt-screen + mouse DECSETs stripped, 3J kept (#758)', () => { + // What fullscreen pi really emits on entering its TUI, read off a pane on 1.1.0, 1.4.0 and 1.5.0. + // Tracking comes on once, here, so a replay that drops it leaves the wheel dead unless the + // server remembers it. ?1004h (focus reporting) is not a mouse mode and stays. + const ENTER = '\x1b[?1049h\x1b[?7l\x1b[?1000h\x1b[?1002h\x1b[?1004h\x1b[?1006h'; + + it('is a mouse-strip mode under tmux, and only there', () => { + expect(isMuxMouseStripMode('pi', true)).toBe(true); + expect(isMuxMouseStripMode('pi', false)).toBe(false); + }); + + it('drops the alt-screen and mouse enables, and keeps focus reporting and 3J', () => { + const session = new Session({ workingDir: '/tmp', mode: 'pi' as never, useMux: true }); + handleOutput(session, `${ENTER}\x1b[3JTUI`); + expect(session.terminalBuffer).toBe('\x1b[?7l\x1b[?1004h\x1b[3JTUI'); + }); + + it('publishes cliMouseTracking so the wheel survives a replay', () => { + const session = new Session({ workingDir: '/tmp', mode: 'pi' as never, useMux: true }); + handleOutput(session, `${ENTER}TUI`); + expect(session.toState().cliMouseTracking).toBe(true); + handleOutput(session, '\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l'); + expect(session.toState().cliMouseTracking).toBeFalsy(); + }); + + it('never sets the flag for an inline pi, which emits no mouse enable', () => { + const session = new Session({ workingDir: '/tmp', mode: 'pi' as never, useMux: true }); + handleOutput(session, '\x1b[?2004hpi'); + expect(session.toState().cliMouseTracking).toBeFalsy(); + }); + + it('leaves a direct-PTY pi pane untouched', () => { + const session = new Session({ workingDir: '/tmp', mode: 'pi' as never, useMux: false }); + handleOutput(session, '\x1b[?1049h\x1b[?1002hTUI'); + expect(session.terminalBuffer).toBe('\x1b[?1049h\x1b[?1002hTUI'); + }); +}); + describe('copilot: alt-screen + mouse DECSETs stripped, 3J kept', () => { it('is a mouse-strip mode under tmux, and only there', () => { expect(isMuxMouseStripMode('copilot', true)).toBe(true); diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts index fc5a2811..5fd0a25d 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -633,7 +633,7 @@ 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, and so does pi (1.4.0 measured). Unmeasured entries stay 'never'. + // forward without a version gate, and so does pi. 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' }); @@ -645,10 +645,12 @@ describe('capability shapes', () => { }); it('declares transcriptPageKeys for exactly the CLIs measured to page on PageUp/PageDown', () => { - // claude (repaint mode), codex, opencode, copilot and pi (all on the alternate screen) were + // claude (repaint mode), codex, opencode and copilot (all on the alternate screen) were // measured paging their own transcript on PageUp/PageDown; no other stock entry has been. + // pi pages too when fullscreen, and stays out on purpose: in `tuiMode: regular` the same keys + // move the caret of a multi-line draft. const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string); - expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'opencode', 'pi']); + expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'opencode']); }); }); diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index e29ef27c..990f3454 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -898,7 +898,7 @@ describe('GitHub Copilot wheel forwarding', () => { }); }); -describe('pi wheel forwarding (1.4.0: alt-screen TUI with mouse tracking)', () => { +describe('pi wheel forwarding (fullscreen by default since 1.0.0)', () => { // 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 @@ -908,13 +908,22 @@ describe('pi wheel forwarding (1.4.0: alt-screen TUI with mouse tracking)', () = expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true); }); - it('keeps Shift+wheel local and falls back to paging when the flag is stale', () => { + it('keeps Shift+wheel local', () => { expect(hollowApp({ mode: 'pi', cliMouseTracking: true }).app._shouldForwardWheelToApp({ shiftKey: true })).toBe( false ); - const { app } = hollowApp({ mode: 'pi', cliMouseTracking: false }); + }); + + it('sends nothing to an inline pi: page keys there move the caret of a multi-line draft', () => { + // `tuiMode: regular`, and every pi below 1.0.0, emits no mouse enable, so the flag stays false + // over a buffer that is hollow on a fresh pane. pi binds PageUp to its editor in that mode: + // measured on real pi, the wheel moved the caret and the next X landed on the first line. + const { app, sent } = hollowApp({ mode: 'pi', cliMouseTracking: false }); + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); - expect(app._localScrollbackIsHollow()).toBe(true); + expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); + app._flushWheelSgrQueue(); + expect(sent).toEqual([]); }); }); From b6eea702a8e4e164ec2909a8be549b92f1cd4f2d Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 6 Nov 2026 02:35:41 +0100 Subject: [PATCH 3/3] fix: correct the pi wheel docs and comments (release review) The gate comment in terminal-ui.js said a stale-false flag falls through to the PageUp/PageDown fallback, which is false for pi since the second commit of this PR removed that fallback. The FOOTGUN comment above it made the same claim. Say what happens instead. "Every pi below 1.0.0 emits no mouse enables" was too absolute: regular was the default there, but fullscreen exists from 0.84.0. Measured with --tui-mode fullscreen under tmux on 0.84.1, 0.85.0, 0.86.0 and 0.99.2: alternate_on=1, mouse_any_flag=1, mouse_sgr_flag=1. Default settings on 0.99.2 report all 0. Document that Wheel Scrolls Local History leaves the wheel dead in fullscreen pi (wiki, integration guide, changeset), add a pi block to Troubleshooting, cross-reference the accepted exception from the dead gesture rule in architecture-invariants, and mark the pi-integration-plan passages this PR reversed as superseded. Comments and docs only, no runtime change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .changeset/pi-wheel-replay.md | 2 +- docs/architecture-invariants.md | 6 +++--- docs/pi-integration-plan.md | 17 +++++++++++++++-- docs/pi-integration.md | 8 ++++++-- docs/wiki/Settings-Reference.md | 2 +- docs/wiki/Troubleshooting.md | 11 +++++++++++ src/config/cli-registry/stock.ts | 6 ++++-- src/web/public/terminal-ui.js | 15 +++++++++------ 8 files changed, 50 insertions(+), 17 deletions(-) diff --git a/.changeset/pi-wheel-replay.md b/.changeset/pi-wheel-replay.md index b0485811..75ceac17 100644 --- a/.changeset/pi-wheel-replay.md +++ b/.changeset/pi-wheel-replay.md @@ -2,4 +2,4 @@ "aicodeman": patch --- -pi: the mouse wheel no longer goes dead after a tab switch or reload. pi's fullscreen TUI, the default since 1.0.0, enables mouse tracking once at start, and Codeman's replay dropped that mode in the browser, so the wheel landed in an empty local buffer. pi now takes the same path as Copilot: the server strips the mouse enables and remembers them, and the wheel is forwarded from that record, on the primary pane, tiles, split Pane B and phones. A drag in fullscreen pi now selects text in the browser instead of using pi's own selection. A pi in `tuiMode: regular` is unaffected. Thanks to @Randalix for the report and the fix. +pi: the mouse wheel no longer goes dead after a tab switch or reload. pi's fullscreen TUI, the default since 1.0.0, enables mouse tracking once at start, and Codeman's replay dropped that mode in the browser, so the wheel landed in an empty local buffer. pi now takes the same path as Copilot: the server strips the mouse enables and remembers them, and the wheel is forwarded from that record, on the primary pane, tiles, split Pane B and phones. A drag in fullscreen pi now selects text in the browser instead of using pi's own selection. With Wheel Scrolls Local History on, the wheel does nothing in fullscreen pi: the setting pins it to a local buffer pi leaves empty, and pi has no paging fallback. A pi in `tuiMode: regular` is unaffected. Thanks to @Randalix for the report and the fix. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index d834bc81..62ba9643 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -61,7 +61,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test. Model is NOT a session field: it is a composition entry in the profile's config tree (`agent-default-model`), configured in `~/.dsh/settings.yaml` + `cordis.patch.yml`, so both create paths deliberately resolve no model for this mode. Env allowlist: `DSH_*` + `DEEPSEEK_*`; provider keys named by a settings-file `apiKeyEnv` stay OUT, which is pi's 34-provider-key problem in a new shape and gets the same answer. Docker seeds `~/.dsh` per-file (`.env`, `settings.yaml`, `cordis.patch.yml`) and the image installs its OWN profile, because `profiles/` is a per-profile `node_modules` tree — host-arch-specific and far too large to copy per container start. Stays OUT of `isAltScreenStripMode()` (third-party fullscreen TUI — the opencode case, i.e. `isMuxMouseStripMode` is the shape to measure here too). ⚠️ `classifyProfile()` reads the profile's BUNDLES, and "unknown means launchable" is deliberate (anyone can publish an app bundle), but it has one knowably-wrong case: `readProfile()` returns an empty bundle list for a `package.json` with no `dsh.profile.bundles`, which made the SHIPPED `web`/`headless` profiles look third-party and launchable. The directory name is therefore consulted as a LAST resort (`STOCK_NON_INTERACTIVE_PROFILES`), after the bundle patterns, so real bundle evidence always wins over a name the user chose. The loose `tui` arm carries word boundaries for the same reason: it decides which profile boots by default, and matching the middle of `intuition` is not a rule anyone could predict. ⚠️ The generated shim is written **temp + rename**, not in place: the TUI can be exec'ing that exact path while an upgraded Codeman refreshes it, and a half-written file is a syntax error the caller then retries four times per state change forever. Bump `SHIM_VERSION` whenever `SHIM_SOURCE` changes, or an existing shim keeps matching the embedded marker and is never refreshed. Availability via `GET /api/deepseek/status`, the widest per-CLI status shape (`available`/`runnable`/`path`/`version`/`dshHome`/`defaultProfile`/`profiles`); `POST /api/deepseek/install-profile` bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode, and the held-open request is bounded by a HAND-ROLLED timeout over a `detached: true` process group (negative-pid SIGTERM→SIGKILL, as `runGit()` does in git-clone.ts). ⚠️ Node's own `spawn` `timeout` is NOT enough: a plugin install fans out into package-manager children, the built-in timeout signals only the direct child, and the survivors hold the inherited stdio pipes open so `close` never fires and the request leaks forever. User guide: `docs/deepseek-integration.md`. Tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`. -**Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session <id>` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: it takes the mouse strip instead, because its TUI is fullscreen by default since 1.0.0 (`tuiMode`) and inline with terminal-owned scrollback under `tuiMode: regular` and below 1.0.0, so the full strip's `3J` removal would have no owner in the second shape. See "Pi is the same shape" in the scrollback section. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp). +**Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session <id>` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: it takes the mouse strip instead, because its TUI is fullscreen by default since 1.0.0 (`tuiMode`) and inline with terminal-owned scrollback under `tuiMode: regular`, the default below 1.0.0, so the full strip's `3J` removal would have no owner in the second shape. See "Pi is the same shape" in the scrollback section. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp). **OMP (`omp`, Oh My Pi) specifics** (`docs/omp-integration.md`): architecturally the simplest of the family — omp owns its own auth, provider routing, and trust decisions entirely in `~/.omp` config files (default `tools.approvalMode: yolo`), so `buildOmpCommand()` only ever emits `--model`/`--resume <id>`/`--continue`, and there is no bypass-permissions flag for Codeman to wire or clamp. ⚠️ **That does NOT make the multi-user clamp a no-op**: `OMP_*` is an allowlisted `envOverrides` prefix and admits `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` (where omp resolves credentials from), both dropped for a non-granted owner in `clampEnvOverridesForOwner()` — the same shape as `DEEPSEEK_BASE_URL` — even though, unlike DeepSeek, Codeman forwards no operator-held key into an omp pane today (found in Ark0N/Codeman#353 review). `--continue` alone is ambiguous the moment any other omp conversation has touched the same working directory more recently, since it just picks the newest session file on disk — `resolveAndClaimOmpSessionId()` (`src/utils/omp-session-resolver.ts`) resolves and PINS the real id instead, verifying each candidate's own file header (`{"type":"session","id",cwd"}`, not just the mangled-directory match) and tracking already-claimed ids in a process-wide registry so two omp tabs in the same case dir can't alias onto each other's conversation. ⚠️ Resolution/pinning happens ONLY at the point a respawn is actually confirmed (`_pinOmpRespawnId()`, called from `_setupOrAttachMuxSession()`'s dead-pane branch and `reattachRemote()`) — earlier code resolved eagerly while merely building respawn options, which could mis-pin a still-ALIVE session's id purely from boot-recovery timing. `src/omp-transcript.ts` independently scans `~/.omp/agent/sessions/**/*.jsonl` for Past Sessions history, the omp analog of Claude's own transcript scan, so a conversation survives even a full "Kill Tmux". ⚠️ omp's own env knobs are mostly `PI_*`, not `OMP_*` (`PI_CONFIG_DIR`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `PI_SUBPROCESS_CMD`, `PI_SHELL_PREFIX`), and `PI_*` is already allowlisted globally for pi — so a redirected `PI_CONFIG_DIR` silently moves the `~/.omp` tree the resolver and transcript scanner hardcode, degrading pinning/history with no error; a known gap shared with pi, not fixed here. ⚠️ Docker: `appendResumeFlag()`'s `case 'omp'` keys off the top-level `resumeSessionId`, which Docker panes never receive for omp (built from `defaultDockerCommandForMode`, with no `ompConfig` threaded through) — host-side history recovery and pinning work through the shared `sessions/` mount, but `--resume` does not currently reach an in-container omp process on respawn (flagged in review, not yet fixed). Stays out of `isAltScreenStripMode()` (narrow scrollback strip, alt-screen toggles only) and lands on the `'buffer'` local-echo policy via the `_updateLocalEchoState` fallthrough, same as grok and pi. Resolver: `omp-cli-resolver.ts` version-probes like pi/grok (`omp` is a short, generic name) and requires `omp/<semver>`-shaped output; `OMP_SEARCH_DIRS` leads with `~/.local/bin` (omp.sh's installer targets `$HOME/.local/bin` with no `--dir` override — verified against a real `--no-cache` Docker build, `~/.omp/bin` was the wrong first guess). Tests: `test/omp-mode.test.ts`, `test/omp-cli-resolver.test.ts`, `test/omp-session-resolver.test.ts`, `test/omp-fresh-run-no-resume.test.ts`. @@ -239,7 +239,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect` **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** (fullscreen is the default `tuiMode` since 1.0.0; 1.0.0, 1.1.0, 1.4.0 and 1.5.0 measured in tmux with 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 at a low rate, and pi's `fullscreenWheelScrollLines` default accelerates a fast spin. Under `tuiMode: regular` and below 1.0.0 pi is inline and emits no mouse DECSET, so the strip finds nothing and the flag stays false. ⚠️ Pi deliberately has no `transcriptPageKeys`: fullscreen pi does page on PageUp/PageDown, but pi binds those keys to its editor in regular mode too, where they page nothing and move the caret of a multi-line draft, so the hollow-buffer fallback sent the next keystroke to the wrong line (measured on 1.1.0, 1.4.0 and 1.5.0). The fallback would need a capability to tell the two modes apart, since `_localScrollbackIsHollow` takes no id. The cost: a fullscreen pi with "Wheel scrolls local history" on has no paging fallback. +**Pi is the same shape, and the reason it moved to the mouse strip** (fullscreen is the default `tuiMode` since 1.0.0; 1.0.0, 1.1.0, 1.4.0 and 1.5.0 measured in tmux with 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 at a low rate, and pi's `fullscreenWheelScrollLines` default accelerates a fast spin. Under `tuiMode: regular`, the default below 1.0.0, pi is inline and emits no mouse DECSET, so the strip finds nothing and the flag stays false; a pi from 0.84.0 set to fullscreen does emit them (0.84.1 and 0.99.2 with `--tui-mode fullscreen` measured: `alternate_on=1`, `mouse_any_flag=1`, `mouse_sgr_flag=1`; default settings all 0), and the flag covers that case. ⚠️ Pi deliberately has no `transcriptPageKeys`: fullscreen pi does page on PageUp/PageDown, but pi binds those keys to its editor in regular mode too, where they page nothing and move the caret of a multi-line draft, so the hollow-buffer fallback sent the next keystroke to the wrong line (measured on 1.1.0, 1.4.0 and 1.5.0). The fallback would need a capability to tell the two modes apart, since `_localScrollbackIsHollow` takes no id. The cost: a fullscreen pi with "Wheel scrolls local history" on has no paging fallback, so the wheel does nothing there. That is an accepted exception to "A false gate on a hollow pane must not mean a DEAD gesture" below. 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. @@ -247,7 +247,7 @@ Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Fi **Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (Claude ≥ 2.1.187 or tracked Codex, with observed `cliMouseTracking`; see the native measurement above), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches after the first native report, 5-tick per-event cap; no aggregate byte/tick limit). 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). Touch asks `_shouldForwardTouchToApp()`, which is that gate plus one branch: in any session mode, a drag and its fling momentum also forward as SGR wheel reports while xterm itself tracks the mouse (`modes.mouseTrackingMode` other than `none`/`x10`) with SGR encoding (`_core.coreMouseService.activeEncoding === 'SGR'`, an xterm private pinned by `test/xterm-private-api.test.ts`; DEFAULT, X10 and SGR-pixel apps fail closed). xterm encodes a native wheel for such an app itself, but the touch listeners consume the drag, and a local scroll shows nothing useful: a direct-PTY app is on xterm's alternate screen, and in a tmux-backed session xterm stays in the normal buffer holding stale tmux repaints. `_wheelScrollLinesFloat()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`. -**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ **`baseY === 0` with a pane that still REPORTS scrollback is not hollow, it is unloaded** (`paneHistoryLines` > 0, the last capture's `#{history_size}`): after a server restart the byte recording a tab switch replays (`?tail=`) holds only the tmux client's attach repaint, which scrolls nothing, so an inline Claude session landed here and the page keys moved its composer cursor instead of the transcript (measured 2026-10-21 on the second select after a restart, until a page reload). That case falls through to local scrolling, whose top-of-buffer pull (`_maybeLoadMoreHistoryOnScroll` to `_maybeRefetchFullHistory`) fetches `full=1`, and the gesture scrolls on from there once the pull lands (a slow response can outlive the gesture; the next one scrolls); 0 and unknown counts keep paging (fullscreen Claude, codex, opencode; a remote pane reports no count and a Docker pane 0). Page keys already queued and the gesture debt are dropped on the way out, since they belong to the abandoned route. The `[scroll]` line prints `paneHistoryLines` next to `localScrollbackRows`, and a count turning positive re-logs, so the two states read apart. The trade-off is accepted: while that pull is in flight or has failed, the gesture scrolls an empty buffer and the next upward gesture after the 4 s cooldown retries it, where paging would have moved the composer. An error envelope (`success: false`) records no count, in the pull, the refresh path and the tab switch alike, so one failed capture does not hand the gesture back to paging. ⚠️ The first event of a gesture pages AT ONCE, for any opening event under one page, and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all, and a mouse sent nothing for its first 5 notches (100 px is 4 rows; 7 on Firefox's 3-line notches, issue #697). An opener of `PAGE_KEY_IMMEDIATE_MAX_ROWS` (2) rows or more is treated as a mouse-wheel notch and tagged `notch`: its debt lasts `PAGE_KEY_NOTCH_DEBT_MS` (3 s) instead of `PAGE_KEY_DEBT_MS`, so slow notches repay one page instead of each paging a full screen; past 3 s of quiet each notch pages. The signal is the opener's size, not the device (a touch contact whose first touchmove is 2 rows or more, or a brisk flick that opens large, is treated the same). A new opener keeps same-direction travel left over from the gesture before, so a run of notches still pages once per half screen of travel. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`), or `PAGE_KEY_NOTCH_DEBT_MS` (3 s) for a notch-sized opener, while a pre-paid page is still owed in the SAME direction, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) and slow notches pay the debt back instead of each pre-paying a page; after that much quiet the debt lapses and the next tick pages at once again. The debt also ends the moment travel repays it (reaches or crosses zero): the rest is plain travel and the 150 ms gap applies again. (3) A direction change inside a gesture (within the gap, or within one contact) drops a pre-paid debt (it belongs to the other direction) and accumulates WITHOUT pre-paying, so a +/-3 px trackpad wobble pages once for its opening event and then nothing; a reversal after the gap is a new gesture and pages at once. A tab switch also starts a new gesture. ⚠️ These gesture rules (first-event page, contact and debt lifetimes, reversal handling, notch debt, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Four gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), and anything while a Codex selection menu is on the pane's screen (the "Implement this plan?" prompt, the /model, /permissions and /review pickers, a question, an approval, the trust prompt). All of them exist so a page key never lands in an open selector: Codex binds PageUp/PageDown to the SELECTION in its menus (codex-cli 0.164.0 measured: PageDown from option 1 lands on option 3), so a wheel turned into page keys walked the cursor through the options, one notch onto "Yes, implement this plan". The menu is read off the screen by `CodemanTerminalInput.isCodexSelectionMenu` (a selected numbered option above a key-hint footer that is the last thing drawn; an answer that quotes the hints above the composer is not a menu), not from an alert: the plan prompt is deliberately NOT a blocked dialog (`test/codex-blocked-detection.test.ts`), so nothing about blocked state or notifications changes. The primary pane logs the two dialog-like cases as `page-keys-dialog-open` and `page-keys-menu-open` so a wheel dead under a stale alert or an open menu leaves a `[scroll]` line. The alert check and the menu check are the two rules outside the helper, because they are per pane: the primary pane reads `tabAlerts` and its own terminal for `activeSessionId`, a tile its own `sessionId` and terminal (usually not the active ones; `_codexMenuOpen(target)`). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex retains this fallback when mouse tracking has not been observed: alternate-screen drawing is stripped from xterm and can leave a hollow pane, while PageUp still pages the CLI transcript. Tracked fullscreen Codex 0.163.0 forwards SGR reports first, as measured above. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForGesture` on the tile's own state, so the mode rule and the gesture rules stay in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to tracked Claude/Codex fullscreen renderers as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`. +**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. ⚠️ Pi is an accepted exception: it declares no `transcriptPageKeys`, so a false gate on a fullscreen pi stays dead, for the reason in "Pi is the same shape" above. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ **`baseY === 0` with a pane that still REPORTS scrollback is not hollow, it is unloaded** (`paneHistoryLines` > 0, the last capture's `#{history_size}`): after a server restart the byte recording a tab switch replays (`?tail=`) holds only the tmux client's attach repaint, which scrolls nothing, so an inline Claude session landed here and the page keys moved its composer cursor instead of the transcript (measured 2026-10-21 on the second select after a restart, until a page reload). That case falls through to local scrolling, whose top-of-buffer pull (`_maybeLoadMoreHistoryOnScroll` to `_maybeRefetchFullHistory`) fetches `full=1`, and the gesture scrolls on from there once the pull lands (a slow response can outlive the gesture; the next one scrolls); 0 and unknown counts keep paging (fullscreen Claude, codex, opencode; a remote pane reports no count and a Docker pane 0). Page keys already queued and the gesture debt are dropped on the way out, since they belong to the abandoned route. The `[scroll]` line prints `paneHistoryLines` next to `localScrollbackRows`, and a count turning positive re-logs, so the two states read apart. The trade-off is accepted: while that pull is in flight or has failed, the gesture scrolls an empty buffer and the next upward gesture after the 4 s cooldown retries it, where paging would have moved the composer. An error envelope (`success: false`) records no count, in the pull, the refresh path and the tab switch alike, so one failed capture does not hand the gesture back to paging. ⚠️ The first event of a gesture pages AT ONCE, for any opening event under one page, and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all, and a mouse sent nothing for its first 5 notches (100 px is 4 rows; 7 on Firefox's 3-line notches, issue #697). An opener of `PAGE_KEY_IMMEDIATE_MAX_ROWS` (2) rows or more is treated as a mouse-wheel notch and tagged `notch`: its debt lasts `PAGE_KEY_NOTCH_DEBT_MS` (3 s) instead of `PAGE_KEY_DEBT_MS`, so slow notches repay one page instead of each paging a full screen; past 3 s of quiet each notch pages. The signal is the opener's size, not the device (a touch contact whose first touchmove is 2 rows or more, or a brisk flick that opens large, is treated the same). A new opener keeps same-direction travel left over from the gesture before, so a run of notches still pages once per half screen of travel. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`), or `PAGE_KEY_NOTCH_DEBT_MS` (3 s) for a notch-sized opener, while a pre-paid page is still owed in the SAME direction, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) and slow notches pay the debt back instead of each pre-paying a page; after that much quiet the debt lapses and the next tick pages at once again. The debt also ends the moment travel repays it (reaches or crosses zero): the rest is plain travel and the 150 ms gap applies again. (3) A direction change inside a gesture (within the gap, or within one contact) drops a pre-paid debt (it belongs to the other direction) and accumulates WITHOUT pre-paying, so a +/-3 px trackpad wobble pages once for its opening event and then nothing; a reversal after the gap is a new gesture and pages at once. A tab switch also starts a new gesture. ⚠️ These gesture rules (first-event page, contact and debt lifetimes, reversal handling, notch debt, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Four gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), and anything while a Codex selection menu is on the pane's screen (the "Implement this plan?" prompt, the /model, /permissions and /review pickers, a question, an approval, the trust prompt). All of them exist so a page key never lands in an open selector: Codex binds PageUp/PageDown to the SELECTION in its menus (codex-cli 0.164.0 measured: PageDown from option 1 lands on option 3), so a wheel turned into page keys walked the cursor through the options, one notch onto "Yes, implement this plan". The menu is read off the screen by `CodemanTerminalInput.isCodexSelectionMenu` (a selected numbered option above a key-hint footer that is the last thing drawn; an answer that quotes the hints above the composer is not a menu), not from an alert: the plan prompt is deliberately NOT a blocked dialog (`test/codex-blocked-detection.test.ts`), so nothing about blocked state or notifications changes. The primary pane logs the two dialog-like cases as `page-keys-dialog-open` and `page-keys-menu-open` so a wheel dead under a stale alert or an open menu leaves a `[scroll]` line. The alert check and the menu check are the two rules outside the helper, because they are per pane: the primary pane reads `tabAlerts` and its own terminal for `activeSessionId`, a tile its own `sessionId` and terminal (usually not the active ones; `_codexMenuOpen(target)`). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex retains this fallback when mouse tracking has not been observed: alternate-screen drawing is stripped from xterm and can leave a hollow pane, while PageUp still pages the CLI transcript. Tracked fullscreen Codex 0.163.0 forwards SGR reports first, as measured above. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForGesture` on the tile's own state, so the mode rule and the gesture rules stay in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to tracked Claude/Codex fullscreen renderers as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`. **Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|page-keys-dialog-open|page-keys-menu-open|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing. diff --git a/docs/pi-integration-plan.md b/docs/pi-integration-plan.md index 6c4d165e..c5ae19b1 100644 --- a/docs/pi-integration-plan.md +++ b/docs/pi-integration-plan.md @@ -2,6 +2,12 @@ Tracking issue: [#206 "Plans to support pi.dev?"](https://github.com/Ark0N/Codeman/issues/206) +**SUPERSEDED in part (PR #771, 2026-11):** what this record says about pi's default screen and +the strips no longer holds. Fullscreen became pi's default in 1.0.0, and pi now takes the mouse +strip (`altScreen: 'strip-mux-and-mouse'`). Each affected passage carries a SUPERSEDED note; +current behaviour is in `docs/pi-integration.md` and the "Pi is the same shape" paragraph of +`docs/architecture-invariants.md`. + Status: **IMPLEMENTED 2026-08-13** (see `docs/pi-integration.md` for the user-facing guide). Everything below is the design record; the open questions were resolved empirically against pi 0.84.1 and the answers are recorded inline as **RESULT** @@ -29,7 +35,7 @@ against the upstream docs in `packages/coding-agent/docs/`. | Config dir | `~/.pi/agent` (override: `PI_CODING_AGENT_DIR`). Holds `auth.json`, `trust.json`, `settings.json`, `models.json` (user-defined providers), `models-store.json` (cached catalogs), `keybindings.json`, `extensions/`, `skills/`, `prompts/`, `themes/`, `AGENTS.md`, `SYSTEM.md`, and the package trees `npm/` + `git/` | | Sessions | `~/.pi/agent/sessions/--<cwd with / replaced by ->--/<timestamp>_<uuid>.jsonl`, tree-structured (`id`/`parentId`), format v3. Overrides: `PI_CODING_AGENT_SESSION_DIR`, `--session-dir` | | Credentials | `~/.pi/agent/auth.json` (OAuth subscriptions + API keys, auto-refresh), plus ~34 provider env vars with **no common prefix**. 0.84.1 adds `pi auth check` (auth preflight with optional credential output) | -| TUI | Default: **main screen with terminal-owned scrollback**. Since **0.84.0** an experimental fullscreen mode exists, selectable via `--tui-mode fullscreen` **or at runtime through `/settings`**; the default remains the main-screen mode | +| TUI | Default: **main screen with terminal-owned scrollback**. Since **0.84.0** an experimental fullscreen mode exists, selectable via `--tui-mode fullscreen` **or at runtime through `/settings`**; the default remains the main-screen mode. **SUPERSEDED (PR #771, 2026-11):** fullscreen is the default since pi 1.0.0, and `tuiMode: regular` keeps the main screen | | Providers | 15+ (Anthropic, OpenAI, Google, Azure, Bedrock, Mistral, Groq, xAI, OpenRouter, Copilot, Baseten since 0.84.0, ...). OAuth subscription login via `/login` for six: ChatGPT Plus/Pro, Claude Pro/Max, GitHub Copilot, xAI, OpenRouter, Radius | | Permission model | **No permission prompts at all.** No built-in sandbox, no MCP (none planned), no sub-agents, no plan mode, no to-dos, no background bash. Tools run with the user's own permissions | | Trust model | "Project trust" gates **loading** of project-local `.pi/` config/extensions/skills and **installing missing project packages**, not tool execution. Triggered only when the cwd (or an ancestor) contains `.pi/settings.json`, `.pi/extensions\|skills\|prompts\|themes`, `.pi/SYSTEM.md`/`.pi/APPEND_SYSTEM.md`, or `.agents/skills`; a bare `.pi/` directory does NOT prompt. Global `defaultProjectTrust`: `ask` (default) / `always` / `never` | @@ -88,6 +94,12 @@ for pi, i.e. pi appears in none of them): the replay-side strip in `session-rout live-stream twin in `session.ts`, and the frontend `_sessionUsesServerMouseStrip()` in `terminal-ui.js` (usages `:3432`, `:3697`). +**SUPERSEDED (PR #771, 2026-11):** pi still stays out of `isAltScreenStripMode()`, but it is no +longer outside every strip: it takes the mouse strip (`isMuxMouseStripMode()`), which removes the +alt-screen toggles and the mouse enables and keeps `3J`. Fullscreen is its default since 1.0.0, the +enables are recorded as `cliMouseTracking`, and the wheel is forwarded from that flag, so it survives +a tab switch or reload. A `/settings` flip of `tuiMode` is followed in both directions. + ### 2.3 tmux required, no direct-PTY fallback, no per-mode configurator Same rule as the other external CLIs: `pi` mode throws if tmux is unavailable. Add a fourth block to @@ -340,6 +352,7 @@ because the result lands in a `bash -c "..."` string): - `--tui-mode` (released in 0.84.0): never passed by Codeman. The main-screen default is the friendly case for the browser terminal, and fullscreen remains the user's own runtime choice via `/settings` (§2.2 is designed for that). `--use-theme` (still unreleased) likewise. + **SUPERSEDED (PR #771, 2026-11):** still never passed, but fullscreen is the default since pi 1.0.0. - `--name <name>` (`-n`): nice for `/resume` readability, but names contain spaces and would be the first user-controlled value needing real shell quoting in `buildSpawnCommand`. Defer. - `--no-session`: ephemeral mode fights respawn/resume. Defer. @@ -671,7 +684,7 @@ follow-up surfaces in from the start (login-shell routing in Phase 1, availabili | ------------------------------------------------------------------- | ------------------------------------------------------------------------------ | | `pi` resolves to an unrelated binary | `pi --version` + semver-shape check in the resolver (§2.6); path and version shown in `/api/pi/status` | | Pi's TUI repaints in a way the browser terminal handles badly | Test scrollback and repaint early (step 3 of §7); pi's default is main-screen with terminal-owned scrollback, which is the friendly case | -| Fullscreen TUI mode (shipped 0.84.0, runtime-switchable) | Already designed for: pi stays OUT of the strip list, so a user flipping `/settings` to fullscreen gets opencode-like alt-screen behavior, not corruption. §7 step 7 tests the flip explicitly | +| Fullscreen TUI mode (shipped 0.84.0, runtime-switchable) | Already designed for: pi stays OUT of the strip list, so a user flipping `/settings` to fullscreen gets opencode-like alt-screen behavior, not corruption. §7 step 7 tests the flip explicitly. **SUPERSEDED (PR #771, 2026-11):** fullscreen is the default since pi 1.0.0 and pi now takes the mouse strip, see §2.2 | | The buffer local-echo overlay fights pi's live composer | §2.10: explicit E2E gate (§7 step 4) with the one-line `'off'` fallback; predictive echo for pi is a tracked follow-up, not a v1 blocker | | Pi moves fast (pre-1.0; 9 releases in the 7 weeks before 0.84.1) | Keep the flag surface small; every flag validated and droppable; nothing pinned in the Dockerfile beyond the `--no-cache` rebuild cadence. Live example of the hazard: `--tui-mode` went from main-only docs to released between the two drafts of this plan | | Docker image grows | Pi is an npm package; the layer is modest next to the ~190MB `agy` binary | diff --git a/docs/pi-integration.md b/docs/pi-integration.md index ecd61fe1..8dca655e 100644 --- a/docs/pi-integration.md +++ b/docs/pi-integration.md @@ -91,8 +91,12 @@ bypass, and project trust is a decision the person at the terminal makes. - **`--tui-mode`.** Which TUI you get stays your choice, in pi's `tuiMode` setting. Fullscreen is the default since pi 1.0.0: the wheel and clicks reach pi, and because Codeman strips pi's mouse enables, a drag selects text in the browser rather than - using pi's own selection or scrollbar drag. `tuiMode: regular`, and every pi below - 1.0.0, is inline with terminal-owned scrollback and emits no mouse enables at all. + using pi's own selection or scrollbar drag. With **Wheel Scrolls Local History** on, + the wheel does nothing in fullscreen pi: the setting pins it to a local buffer pi + leaves empty, and pi has no paging fallback. `tuiMode: regular`, the default below + 1.0.0, is inline with terminal-owned scrollback and emits no mouse enables at all. A + pi from 0.84.0 set to fullscreen enables mouse tracking the same way the 1.0.0 + default does, and Codeman treats it the same. - **`--name`, `--no-session`, `-p`/`--print`, `--mode json`, `--mode rpc`, `--tools`/`--exclude-tools`, `-e`/`--extension`, `--skill`, `--system-prompt`.** Tracked as follow-ups in the plan doc. diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 0ba55368..52e76ad0 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -49,7 +49,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See | Keep Size Under Keyboard | On | For local Claude sessions, the on-screen keyboard no longer resizes the session: the terminal keeps its rows and the view scrolls so the prompt sits just above the keyboard. Resizing made Claude reprint its screen and drop transcript lines from history. Codex, Claude's fullscreen mode, remote and Docker sessions resize as before. See [Mobile Guide](Mobile-Guide). | | CJK Input | Off | IME composition through a dedicated text field. | | Extended Keyboard Bar | Per device | Which accessory bar phones get. Shell sessions override it while they are active. | -| Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. | +| Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. Fullscreen pi leaves that buffer empty and has no paging fallback, so with this on the wheel does nothing there. | | Auto Copy Selection | Off | Copies highlighted terminal text to the clipboard the moment you finish selecting it. Ctrl+C still copies on demand. | | Trim The Pane Margin On Copy | On | Takes the left margin a full-screen agent CLI paints down its own edge off a copy, so the text pastes flush. Each CLI declares its own width, and the strip never exceeds the indent every selected line shares, so nesting is kept. Claude Code and Codex declare a margin; a shell does not. | | Normal / Bold font weight | xterm defaults | Per device, each slot from 100 to 900. The bundled JetBrains Mono renders every step, so a lighter normal weight makes Claude's bold headings stand out. Applies live to the terminal, both echo overlays and open team panes. | diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index 5a8fba2e..6b0e6113 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -221,6 +221,17 @@ Things to try: Codex with mouse tracking on gets the wheel forwarded like fullscreen Claude; otherwise it pages its transcript or scrolls locally, while `Shift+Wheel` always uses local scrollback. +### The wheel does nothing in a pi session + +Fullscreen pi, the default since pi 1.0.0, scrolls its own transcript, so the wheel is +forwarded to it while it has mouse tracking on. If the wheel does nothing, turn off +**Wheel Scrolls Local History**: that setting keeps the wheel on a local buffer pi leaves +empty, and pi has no paging fallback. `PageUp` and `PageDown` on a keyboard still reach pi. + +Codeman strips pi's mouse enables, so a drag selects text in the browser rather than using +pi's own selection or scrollbar drag. With `tuiMode: regular` pi is inline and the wheel +scrolls local history. + ### Selected text is invisible on a light skin Update. Every skin named its selection colour under a key xterm renamed in v5, so the four diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index cac33048..59a6d0e5 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -1186,8 +1186,10 @@ const PI: CliEntry = { // 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 reports, one row each at a low rate, faster on a spin. - // `tuiMode: regular` (and every pi below 1.0.0) is inline and emits no mouse DECSET, so the - // strip finds nothing and the flag stays false. + // `tuiMode: regular`, the default below 1.0.0, is inline and emits no mouse DECSET, so the + // strip finds nothing and the flag stays false. A pi from 0.84.0 set to fullscreen does emit + // them (0.84.1 and 0.99.2 measured: alternate_on=1, mouse_any_flag=1, SGR) and the flag + // covers it. // ⚠️ No `transcriptPageKeys`, though fullscreen pi does page on PageUp/PageDown. pi binds // those keys to its editor in regular mode too, where they page nothing and move the caret // of a multi-line draft, so a wheel over a fresh regular pane sent the next keystroke to the diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index a62b2706..357f5b0c 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -6059,17 +6059,20 @@ Object.assign(CodemanApp.prototype, { // option, but it would override an explicit user choice; instead the caller // falls through to _maybePageCliTranscript, so the gesture still pages the // CLI's transcript (or loads unloaded tmux scrollback, see there) and the - // setting keeps meaning exactly what it says. + // setting keeps meaning exactly what it says. Pi is the accepted exception: + // it declares no `transcriptPageKeys`, so with this on its wheel stays dead. if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false; const mode = (target.terminal || this.terminal)?.modes?.mouseTrackingMode; if (mode && mode !== 'none') return false; const session = this.sessions?.get(target.sessionId || this.activeSessionId); const sessionMode = session?.mode || 'claude'; - // GitHub Copilot CLI (and pi, fullscreen by default) 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. + // GitHub Copilot CLI (and pi, fullscreen by default) scrolls its own transcript on SGR wheel + // reports (checked with xterm encoding them natively). Their 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. On Copilot a stale-false flag falls through to the + // PageUp/PageDown fallback like claude's; pi has none on purpose (the `transcriptPageKeys` note + // in stock.ts), so there the wheel does nothing until the flag is back. 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;