mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5aa3fbdf7 | ||
|
|
58dae1e182 |
No files matched your search
@@ -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.
|
||||
@@ -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 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)
|
||||
|
||||
@@ -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,14 +231,16 @@ 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 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/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** (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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1180,7 +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`).
|
||||
altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback
|
||||
// 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' },
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
+11
-13
@@ -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`, `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.
|
||||
*
|
||||
* ⚠️ 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 (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.
|
||||
*/
|
||||
export function isAltScreenStripMode(mode: SessionMode): boolean {
|
||||
return getCli(mode)?.capabilities.altScreen === 'strip-full';
|
||||
@@ -3003,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).
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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, 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.
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -633,20 +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. 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
|
||||
// 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']);
|
||||
});
|
||||
|
||||
@@ -898,6 +898,35 @@ describe('GitHub Copilot wheel forwarding', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
// `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', () => {
|
||||
expect(hollowApp({ mode: 'pi', cliMouseTracking: true }).app._shouldForwardWheelToApp({ shiftKey: true })).toBe(
|
||||
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._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
|
||||
app._flushWheelSgrQueue();
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user