diff --git a/CLAUDE.md b/CLAUDE.md index 37f0ec3e..0a386d1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -286,7 +286,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](docs/architecture-invariants.md#terminal-paste-ctrlv) -**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity/omp get a NARROW strip (alt-screen toggles only). ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `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. 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 gets 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 to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `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. 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 0232bb32..f11b9e55 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -37,7 +37,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test. ### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP) -**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `--config model_reasoning_effort=` from `reasoningEffort`, `--config tui.animations=` from `animations`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation ` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume ` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`. +**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `--config model_reasoning_effort=` from `reasoningEffort`, `--config tui.animations=` from `animations`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation ` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume ` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, which is now `isMuxMouseStripMode`, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`. **DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it. @@ -55,7 +55,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test. ⚠️ **The resolver needs the strictest identity probe of any CLI**, because `dsh` is not merely a squattable npm name: Debian ships an unrelated `dsh` (dancer's shell, `apt install dsh`) that would answer a version probe convincingly. `probeDeepSeekVersion()` therefore checks `dsh --help` against `DEEPSEEK_IDENTITY_REGEX` (`DeepSeek Harness`) FIRST and only then reads a version, and `test/deepseek-cli-resolver.test.ts` pins both the rejection and the VITEST hermeticity gate with a real executable fixture. `DEEPSEEK_VERSION_REGEX` keeps the prerelease tail (`0.1.1-rc.2`), since truncating it would report an rc as a release; it is shared with the `dsh` dependency-registry entry so doctor and run mode agree about the version even though the resolver is stricter about identity. -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). ⚠️ `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`. +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 ` / `-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). @@ -217,15 +217,15 @@ Further detail: the `: ` form (`w3-myapp: fix the login redirect` ### Terminal scrollback: strip flavors and wheel/touch forwarding -**Two strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. Every other mode (shell/opencode/antigravity/pi) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same narrow branch; the frontend mirror (`_shouldReportMouseToCli()`) stays claude/codex/gemini because only the FULL strip touches mouse DECSETs. The chunk-boundary carry (`_altScreenSeqCarry`) runs for both flavors. Tests: `test/claude-scrollback-strip.test.ts`. +**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) 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`. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/pi) 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 the mouse-strip branch, 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 the full strip 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. -**Only claude ≥ 2.1.187 with mouse tracking on forwards the wheel; everything else scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. Claude repeats this exactly in its default INLINE renderer (measured on 2.1.280: `alternate_on=0`, `mouse_any_flag=0`, `history_size` grows), and swipes on iOS Safari were dead there while codex scrolled; only fullscreen claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in `~/.claude/settings.json`: alt screen plus modes 1003/1006) pages its transcript on wheel reports. So claude forwards only while the server-observed `cliMouseTracking` flag is true; a stale-false flag after a server restart falls through to the PageUp/PageDown fallback, never a dead wheel. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (strip mode AND the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk. +**Only claude ≥ 2.1.187 with mouse tracking on forwards the wheel; everything else scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. Claude repeats this exactly in its default INLINE renderer (measured on 2.1.280: `alternate_on=0`, `mouse_any_flag=0`, `history_size` grows), and swipes on iOS Safari were dead there while codex scrolled; only fullscreen claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in `~/.claude/settings.json`: alt screen plus modes 1003/1006) pages its transcript on wheel reports. So claude forwards only while the server-observed `cliMouseTracking` flag is true; a stale-false flag after a server restart falls through to the PageUp/PageDown fallback, never a dead wheel. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips, so only ever true for a stripped mode): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk. **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 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`. -**A false gate on a Claude session must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. The 1.12.0 retest reported exactly that: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the triple guard (claude mode + gate false + `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. Tests: `test/terminal-scroll-routing.test.ts`, `test/claude-cli-version-cache.test.ts`. +**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()` — `claude` or `opencode`, 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). ⚠️ `shell`/`pi` own real terminal scrollback and are never paged, and codex/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()`. Tests: `test/terminal-scroll-routing.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|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. @@ -680,7 +680,7 @@ Matching semantics: a query containing `/` matches the relative path, otherwise 2. `copyTerminalSelection` is a registry `action` **deliberately missing from `SHORTCUT_ACTIONS`** (same trick as `command-palette`): the entry stays rebindable and disableable in App Settings, while the generic document-capture loop, which `preventDefault()`s every match it dispatches, skips it and lets the terminal handler decide. 3. The gate is keydown-only (the custom handler also runs for `keypress`/`keyup`; that is safe here only because xterm drops a Ctrl keypress itself, see the keypress rule above), and `Ctrl+Shift+C` never falls through to the PTY: an "explicit copy" chord that interrupts a running agent because the selection happened to be empty is a footgun with no upside. -Copy goes through `_copyText()` (Clipboard API, then hidden-textarea + `execCommand`), not raw `navigator.clipboard`, because `install.sh`'s LAN option serves plain HTTP where `navigator.clipboard` is undefined; the fallback steals focus, so the terminal is refocused afterwards. Related: xterm registers its own `copy` listener on the terminal element gated on `hasSelection()`, which is why right-click → Copy has always worked. Selection itself is unavailable on touch devices by design (`user-select: none` on the terminal subtree), and in `shell`/`opencode`/`antigravity` tabs the TUI owns the mouse, so selecting there needs Shift+drag. Tests: `test/terminal-copy-selection.test.ts` (gate + wiring invariants), `test/terminal-copy-shortcut.test.ts` (browser, real key presses). +Copy goes through `_copyText()` (Clipboard API, then hidden-textarea + `execCommand`), not raw `navigator.clipboard`, because `install.sh`'s LAN option serves plain HTTP where `navigator.clipboard` is undefined; the fallback steals focus, so the terminal is refocused afterwards. Related: xterm registers its own `copy` listener on the terminal element gated on `hasSelection()`, which is why right-click → Copy has always worked. Selection itself is unavailable on touch devices by design (`user-select: none` on the terminal subtree), and in `shell`/`antigravity` tabs the TUI owns the mouse, so selecting there needs Shift+drag. `opencode` used to be in that list and no longer is: its mouse DECSETs are stripped server-side (`isMuxMouseStripMode`, see the strip-flavours invariant), so a plain drag selects there. Tests: `test/terminal-copy-selection.test.ts` (gate + wiring invariants), `test/terminal-copy-shortcut.test.ts` (browser, real key presses). **The main terminal's four copy paths clean the selection first** (`CodemanCopySelection.clean` in constants.js, pure; `cleanedTerminalSelection()` in terminal-ui.js is the half that reads the live terminal). Those four are the `Ctrl+C` chord, right-click, the phone selection button and Auto Copy. ⚠️ Three routes still copy the RAW padded rows, all of them predating the clean: the browser's own Edit → Copy, which xterm's own `copy` listener on the terminal element serves with `selectionText` directly; a `copy-selection` shortcut the user disabled in App Settings, where nothing calls `preventDefault()` and that native listener runs; and the subagent/teammate windows, which build their own `Terminal` in panels-ui.js with no copy wiring at all. xterm hands back whole screen ROWS and its own trim drops only cells that were never written to, so the real spaces a full-screen TUI paints across the unused part of a row count as content and reach the clipboard. Measured against Claude Code in a 282-column pane, single lines arrived carrying 138 trailing spaces on top of the two-space transcript indent. The clean drops each line's trailing run, and strips a LEADING margin when the session's CLI declares one. Four rules keep it honest: diff --git a/docs/scrollback-fix-plan.md b/docs/scrollback-fix-plan.md index 65d8870d..79210941 100644 --- a/docs/scrollback-fix-plan.md +++ b/docs/scrollback-fix-plan.md @@ -278,8 +278,8 @@ Touch is always-local by design, and Claude sessions keep content in the normal - The `terminalWheelLocalScrollback` opt-out setting keeps working (pins plain wheel to local). - The viewport-at-bottom gate stays: once the user scrolled up locally, wheel stays local until they return to bottom. - 40ms SGR coalescing: never send per-event writes to the server. -- Strip parity triangle: `session.ts` live strip ↔ `session-routes.ts` replay strip ↔ `_sessionUsesServerMouseStrip()` in the frontend. If you touch mode lists, update all three. -- Don't add `opencode`/`antigravity` to any strip/forward list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`). +- Strip parity: `session.ts` live strip ↔ `stripReplayBuffer()` in `session-routes.ts`, both driven by the registry's `altScreen` value and pinned together for every stock CLI in `test/claude-scrollback-strip.test.ts`. The frontend keeps no mode list: `_shouldReportMouseToCli()` reads only the server-published `cliMouseTracking`. +- Don't add `opencode`/`antigravity` to the wheel-FORWARD list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`). ⚠️ 2026-09-16: opencode's half is now MEASURED — 1.18.31 ignores SGR wheel reports but pages its transcript on PageUp/PageDown — so it belongs in the **paging** list (`_localScrollbackIsHollow`). ⚠️ It also joined a STRIP list that same day, for a different reason: `isMuxMouseStripMode` removes its mouse DECSETs so a drag selects text again (see `docs/architecture-invariants.md` §Three strip flavors). antigravity/grok/deepseek/omp remain unverified. - The chunk-boundary sequence carry in `_handleTerminalOutput` must not be weakened. ## Testing (per repo rules) diff --git a/docs/terminal-copy-shortcut-plan.md b/docs/terminal-copy-shortcut-plan.md index 898bc096..6598be93 100644 --- a/docs/terminal-copy-shortcut-plan.md +++ b/docs/terminal-copy-shortcut-plan.md @@ -82,6 +82,7 @@ This is exactly how `command-palette` already behaves: it is a full registry ent - The server strips mouse-tracking DECSETs for `claude`, `codex`, and `gemini` (`isAltScreenStripMode`, `src/session.ts:179`), which is why plain drag-select works in those tabs even though the TUI has mouse tracking on. - `shell`, `opencode`, and `antigravity` keep mouse reporting, so xterm requires `Shift`+drag to force a selection there. Worth one line in the docs, it is not a code change. + ⚠️ **Corrected 2026-09-16:** `opencode` no longer keeps mouse reporting in the browser. Its TUI enables tracking DECSETs, tmux `mouse off` passes them through to the tmux client, and xterm then reported DRAGS to the TUI instead of selecting — so `Shift`+drag was the only way to select, and a plain drag silently copied nothing (measured 62 `none` / 18 `any` over 16s; 5/5 dead drags while `any`). The server now strips those DECSETs (`isMuxMouseStripMode`), so a plain drag selects in opencode. `shell` and `antigravity` are unchanged. - Touch devices deliberately disable selection entirely (`body.touch-device .terminal-container .xterm{user-select:none !important}`, `styles.css:3196`), and phones have no Ctrl key. This feature is desktop and hardware-keyboard only, with no mobile regression surface. ### 2.6 Helpers that already exist and should be reused @@ -245,7 +246,7 @@ The shortcut overlay (`Ctrl+?`) and App Settings -> Shortcuts are registry-drive | Whitespace-only or empty selection | `getSelection()` empty string is treated as "no selection", so Ctrl+C still interrupts | | macOS Cmd+C | registry treats ctrl/meta as interchangeable, so with a selection it takes our path (same visible result as today's native copy), without one it falls through | | Chrome/Firefox `Ctrl+Shift+C` is the devtools inspect chord | browser-level and may still toggle devtools, our copy runs regardless. Document as a caveat, `Ctrl+C` is the primary path | -| Selection in a tab whose TUI owns the mouse (`shell`/`opencode`/`antigravity`) | unchanged, `Shift`+drag selects, then Ctrl+C copies | +| Selection in a tab whose TUI owns the mouse (`shell`/`antigravity`; `opencode` left this list on 2026-09-16 — its DECSETs are stripped now) | unchanged, `Shift`+drag selects, then Ctrl+C copies | | Web tab (iframe dashboard) focused | xterm handler never runs, browser-native copy inside the iframe | | Teammate/subagent terminals (`panels-ui.js:2268`, `onData` wired) | same limitation exists there, out of scope for this PR (section 8) | @@ -281,7 +282,7 @@ Against a throwaway session on the live instance (`curl -sk https://localhost:30 3. Type a few characters with local echo on (phone or `localEchoEnabled` forced), press Ctrl+C with no selection, confirm buffered text plus interrupt behave as before. 4. Uncheck the shortcut in App Settings -> Shortcuts, confirm Ctrl+C always interrupts even with a selection. 5. Rebind it, confirm the new chord copies and Ctrl+C reverts to pure interrupt. -6. Repeat 1 and 2 in an `opencode` or `shell` tab using Shift+drag to select. +6. Repeat 1 and 2 in a `shell` or `antigravity` tab using Shift+drag to select (`opencode` selects with a plain drag since 2026-09-16). 7. Load over plain HTTP (`--host` LAN or `http://127.0.0.1:<port>`) and confirm the `execCommand` fallback copies and focus returns to the terminal. 8. Mobile smoke: confirm nothing changed (selection is CSS-disabled, no Ctrl key). diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 619d91b4..90760fb1 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -289,7 +289,7 @@ const capabilitiesSchema = z requiresMux: z.boolean(), hooks: z.enum(['none', 'always', 'supervised']), transcript: z.enum(['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'omp-jsonl', 'none']), - altScreen: z.enum(['strip-full', 'strip-mux-only', 'preserve']), + altScreen: z.enum(['strip-full', 'strip-mux-only', 'strip-mux-and-mouse', 'preserve']), echo: echoSchema, wheelForward: z .object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() }) diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 8c058275..7caf6f8c 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -491,7 +491,7 @@ const OPENCODE: CliEntry = { }, capabilities: { ...agentDefaults(), - altScreen: 'strip-mux-only', + altScreen: 'strip-mux-and-mouse', echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined }, // Measured on a live opencode 1.3.0 pane (capture-pane every 250-300 ms through real // turns at 40, 60, 120 and 200 columns, plus the raw PTY stream, 2026-10-09). Every diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 621edcad..114b4c40 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -439,11 +439,40 @@ export interface CliCapabilities { */ transcript: 'claude-jsonl' | 'codex-rollout' | 'deepseek-zstd' | 'omp-jsonl' | 'none'; /** - * 'strip-full' — alt-screen + erase-scrollback + mouse DECSETs stripped (Ink TUIs). - * 'strip-mux-only' — only tmux's own attach-time smcup (the safe default). - * 'preserve' — leave everything (a direct-PTY shell running vim/less/htop). + * What the server strips from this CLI's output stream before the browser sees it. + * The value encodes three independent choices (predicates in session.ts): + * + * | value | alt-screen toggles | `3J` (erase scrollback) | mouse DECSETs | + * |-----------------------|---------------------|-------------------------|---------------------| + * | `strip-full` | stripped | stripped | stripped | + * | `strip-mux-and-mouse` | stripped under tmux | kept | stripped under tmux | + * | `strip-mux-only` | stripped under tmux | kept | kept | + * | `preserve` | stripped under tmux | kept | kept | + * + * `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. + * + * - 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 + * reach the client (tmux repaints instead). "Under tmux" means `useMux`: on a + * direct-PTY fallback the `?1049h` is the program's own and must stay. + * - `3J`: a user's `clear` is a deliberate scrollback wipe; only an Ink TUI's + * redraw-driven `3J` (strip-full) is noise. + * - mouse DECSETs: stripping them keeps a drag a local selection instead of a report + * to the TUI. The browser then hand-encodes clicks (`_sendSyntheticSgrTap`), gated + * on the `cliMouseTracking` the server records as it strips. Kept where a program's + * 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 (a full-screen TUI that enables tracking itself); `strip-mux-only` = + * antigravity, grok, deepseek, omp; `preserve` = shell, pi. + * + * A fourth combination is the point to split this into flags; three is still cheaper + * as an enum. */ - altScreen: 'strip-full' | 'strip-mux-only' | 'preserve'; + altScreen: 'strip-full' | 'strip-mux-only' | 'strip-mux-and-mouse' | 'preserve'; echo: { policy: 'buffer' | 'predict' | 'off'; /** How the local-echo overlay locates the composer row. */ diff --git a/src/session.ts b/src/session.ts index d2b5ec0c..2cf209de 100644 --- a/src/session.ts +++ b/src/session.ts @@ -277,10 +277,11 @@ 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` (renders its own - * TUI that may rely on it), `pi` (below) and `grok` (a fullscreen alt-screen TUI - * with mouse support, i.e. the opencode case, not the Ink case). Keep parity - * with the replay-side strip in session-routes.ts. + * vim/less/htop legitimately need the alt screen), `opencode` (its own MIDDLE strip, + * isMuxMouseStripMode), `pi` (below) 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 @@ -300,8 +301,10 @@ export function isAltScreenStripMode(mode: SessionMode): boolean { /** * Modes that need the NARROW strip: alt-screen toggles only, leaving `\x1b[3J` - * and the mouse-tracking DECSETs alone. Applies to every mode `isAltScreenStripMode` - * excludes, but ONLY when the session is tmux-backed (`useMux`). + * and the mouse-tracking DECSETs alone. Applies to every mode that is neither + * `strip-full` (isAltScreenStripMode) nor `strip-mux-and-mouse` (isMuxMouseStripMode), + * so `strip-mux-only` and `preserve` alike, but ONLY when the session is tmux-backed + * (`useMux`). * * The bug (issue #205): the tmux CLIENT emits `smcup` (`\x1b[?1049h`) as its first * bytes on attach, before any program has run. Unstripped, xterm.js parks in the @@ -326,7 +329,31 @@ export function isAltScreenStripMode(mode: SessionMode): boolean { * `\x1b[3J` from a user's own `clear` is a deliberate "wipe my scrollback". */ export function isMuxAltScreenOnlyStripMode(mode: SessionMode, useMux: boolean): boolean { - return useMux && !isAltScreenStripMode(mode); + if (!useMux) return false; + const altScreen = getCli(mode)?.capabilities.altScreen; + return altScreen !== 'strip-full' && altScreen !== 'strip-mux-and-mouse'; +} + +/** + * Modes whose mouse-tracking DECSETs must be stripped, leaving `3J` alone: + * `altScreen: 'strip-mux-and-mouse'`, i.e. a mouse-capable full-screen TUI. + * + * Why this exists (opencode, measured 2026-09-16): the TUI enables tracking + * DECSETs, tmux runs with `mouse off` and therefore passes the PANE's DECSETs + * straight through to the tmux client, and the browser's xterm obeyed them — + * `mouseTrackingMode` flipped to `'any'` and xterm then reported DRAGS to the TUI + * instead of selecting locally. "Mark text, copy on select" silently did nothing + * (measured 62 `none` / 18 `any` over 16s, and 5/5 dead drags while `any`), and + * the obvious fallback — Ctrl+C — is opencode's `app_exit`, so the failure also + * ended sessions. Stripping at the source keeps xterm in selection mode; clicks + * still reach the CLI through the browser's hand-encoded tap, which this strip + * publishes as `cliMouseTracking` (`_recordStrippedMouseMode`). + * + * Gated on `useMux` for the same reason as the narrow strip: on the direct-PTY + * fallback the program's own DECSETs really do reach xterm and must be honoured. + */ +export function isMuxMouseStripMode(mode: SessionMode, useMux: boolean): boolean { + return useMux && getCli(mode)?.capabilities.altScreen === 'strip-mux-and-mouse'; } // Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv) @@ -2529,14 +2556,21 @@ 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/opencode/antigravity) gets the NARROW strip when it - // is tmux-backed: alt-screen toggles only, because the sequence that breaks + // Every OTHER mode (shell/antigravity/pi/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). // 3J and the mouse DECSETs stay, so `clear` and mouse-aware TUIs keep working. + // + // The MIDDLE case (isMuxMouseStripMode) is a mouse-capable full-screen TUI: + // it needs smcup AND the mouse DECSETs gone — otherwise the pane's tracking + // reaches xterm and every drag becomes a mouse report instead of a text + // selection, which is what killed mark-and-copy in opencode — while 3J stays, + // because a TUI is not a `clear` consumer. const fullStrip = isAltScreenStripMode(this.mode); - const altOnlyStrip = !fullStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux); - if (fullStrip || altOnlyStrip) { + const mouseStrip = isMuxMouseStripMode(this.mode, this._useMux); + const altOnlyStrip = !fullStrip && !mouseStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux); + if (fullStrip || mouseStrip || altOnlyStrip) { // Reassemble sequences split across PTY chunk boundaries first: a chunk // ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the // strip below and leave xterm stuck in the scrollback-less alt buffer @@ -2555,14 +2589,18 @@ export class Session extends EventEmitter { // eslint-disable-next-line no-control-regex data = data.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, ''); if (fullStrip) { - data = data + // eslint-disable-next-line no-control-regex + data = data.replace(/\x1b\[3J/g, ''); + } + if (fullStrip || mouseStrip) { + data = data.replace( // eslint-disable-next-line no-control-regex - .replace(/\x1b\[3J/g, '') - // eslint-disable-next-line no-control-regex - .replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, (seq) => { + /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, + (seq) => { this._recordStrippedMouseMode(seq); return ''; - }); + } + ); } } diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 17b934cc..1bbe0b99 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1273,7 +1273,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) so the wheel keeps scrolling + // (isAltScreenStripMode, session.ts) and from opencode's (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. @@ -5369,36 +5370,32 @@ Object.assign(CodemanApp.prototype, { } }, - // Mirror of the server's isAltScreenStripMode (session.ts): session modes whose - // output stream has mouse-tracking DECSET sequences stripped before reaching the - // browser. For these, xterm's live mouseTrackingMode is useless as a gate — the - // PTY-side TUI keeps tracking enabled, we just never see the enable sequence. /** - * True when the browser has to hand-encode a click report for the CLI. + * True when the browser has to hand-encode a click report for the CLI: the + * server stripped this session's mouse-tracking DECSETs out of the stream (so + * xterm's own encoder is permanently idle here and something has to stand in + * for it) AND the CLI has a tracking mode on right now. * - * Two conditions, and dropping either one is a bug that has already happened: + * One flag answers both. The server sets `cliMouseTracking` only as it strips a + * tracking DECSET (`_recordStrippedMouseMode` in session.ts, called from the + * mouse-strip branch of `_handleTerminalOutput` and nowhere else), so it can + * only ever be true for a mode whose DECSETs are stripped: whichever modes the + * registry decides to strip, the browser follows, with no mode list here to + * keep in step. For a `preserve` / `strip-mux-only` mode the flag stays false + * and xterm keeps encoding its own reports. That invariant is pinned server-side + * in test/claude-scrollback-strip.test.ts. * - * 1. The session's mode is one whose mouse DECSETs the server STRIPS out of - * the stream (claude/codex/gemini, `isAltScreenStripMode`), which is why - * xterm's own encoder is permanently idle here and something has to stand - * in for it. - * 2. The CLI actually has a mouse-tracking mode on right now. The server - * records that as it strips (`_recordStrippedMouseMode` in session.ts) and - * publishes it as `cliMouseTracking`. Without this half the browser - * reported EVERY click, so a CLI sitting at its composer with no dialog - * open, or a pane that has fallen back to a shell prompt, received mouse - * reports it never asked for. A shell prints those as literal text - * (`[<0;88;20M`) and they garble the next line typed. + * Without the flag the browser reported EVERY click, so a CLI sitting at its + * composer with no dialog open, or a pane that has fallen back to a shell + * prompt, received mouse reports it never asked for. A shell prints those as + * 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. */ _shouldReportMouseToCli() { - const session = this.sessions?.get(this.activeSessionId); - const mode = session?.mode || 'claude'; - if (mode !== 'claude' && mode !== 'codex' && mode !== 'gemini') return false; - return session?.cliMouseTracking === true; + return this.sessions?.get(this.activeSessionId)?.cliMouseTracking === true; }, // True when xterm's viewport shows the live PTY screen (not scrolled up into @@ -5583,15 +5580,29 @@ Object.assign(CodemanApp.prototype, { }, /** - * True when this session's LOCAL scrollback is structurally empty: a Claude - * pane in repaint mode, where tmux reports `history_size≈0` and every frame - * overwrites the last, so xterm's normal buffer never grows past one screen + * True when this session's LOCAL scrollback is structurally empty: a pane whose + * TUI repaints one full screen in place, so tmux keeps no history for it + * (`history_size≈0`) and xterm's normal buffer never grows past one screen * (`baseY === 0`). Scrolling that buffer is a no-op no matter how the gesture * is routed — the "wheel does nothing at all" half of the #205 retest. + * + * Two shapes, measured separately: + * - `claude` in repaint mode (the original, #205 round 2), and + * - `opencode`, whose TUI runs on the ALTERNATE SCREEN (opencode 1.18.31: tmux + * `alternate_on=1`, `history_size=0`) and so pushes nothing into the + * terminal's scrollback at all. It pages its own transcript with the same + * PageUp/PageDown keys (`messages_page_up/down`) but IGNORES SGR wheel + * reports — six `\x1b[<64;…M` reports against an idle pane left the capture + * byte-identical — so paging is the only gesture that reaches it. Without + * this the wheel was silently dead in every opencode tab. + * + * Every other mode is deliberately absent: shell/pi own real terminal + * scrollback, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour + * is unverified (docs/scrollback-fix-plan.md). */ _localScrollbackIsHollow() { const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude'; - if (mode !== 'claude') return false; + if (mode !== 'claude' && mode !== 'opencode') return false; const buf = this.terminal?.buffer?.active; if (!buf || buf.type === 'alternate') return false; return (buf.baseY || 0) === 0; @@ -5602,18 +5613,19 @@ Object.assign(CodemanApp.prototype, { * coalesced PageUp/PageDown key sends so the CLI pages its OWN transcript. * * The rescue path for every way `_shouldForwardWheelToApp` can come back false - * on a Claude session that has no local history to fall back on: the CLI - * version probe failed or is genuinely older than 2.1.187, the CLI's mouse - * tracking flag is unset (the inline renderer, or fullscreen right after a - * server restart), or the user turned on "Wheel scrolls local history" (which - * pins the wheel to a buffer that, for a repaint-mode CLI, is empty: the - * setting's footgun). Before this, all of those produced a completely dead - * gesture; the #205 reporter proved the keyboard route works by paging back - * through intact text with Fn+Up. + * on a session that has no local history to fall back on: the CLI version probe + * failed or is genuinely older than 2.1.187, the CLI's mouse tracking flag is + * unset (the inline renderer, or fullscreen right after a server restart), the + * user turned on "Wheel scrolls local history" (which pins the wheel to a buffer + * that, for a repaint-mode CLI, is empty: the setting's footgun), or the CLI is + * opencode, which never fills the buffer and never accepts the wheel. Before + * this, all of those produced a completely dead gesture; the #205 reporter + * proved the keyboard route works by paging back through intact text with Fn+Up. * - * Triple-guarded (claude mode + gate false + `baseY === 0`), so a session with - * real local scrollback is never touched. Shift is excluded on purpose: it is - * the explicit "give me local scrollback" gesture and must keep that meaning. + * Guarded by `_localScrollbackIsHollow()` plus a false forwarding gate, so a + * session with real local scrollback is never touched. Shift is excluded on + * purpose: it is the explicit "give me local scrollback" gesture and must keep + * that meaning. * * @returns true when the gesture was consumed here (the caller must not also * scroll locally). @@ -5714,7 +5726,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), so xterm's mouseTrackingMode is + * claude/codex/gemini (isAltScreenStripMode) and opencode (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 476b0cfd..51a30dc9 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -36,6 +36,7 @@ import { isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode, + isMuxMouseStripMode, } from '../../session.js'; import type { PaneCaptureOptions } from '../../mux-interface.js'; import { SseEvent } from '../sse-events.js'; @@ -225,6 +226,37 @@ const ERASE_SCROLLBACK_PATTERN = /\x1b\[3J/g; // eslint-disable-next-line no-control-regex const MOUSE_TRACKING_PATTERN = /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g; +/** + * The replay half of the strip parity triangle: what `_handleTerminalOutput` (session.ts) + * removes from the live stream, removed again from a stored buffer before it is replayed, + * because a buffer recorded before the live-side strip existed (or by an older server) + * still carries the sequences, and one replayed enable is enough to re-park xterm. + * + * - `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. + * 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. + * - every other mode, tmux-backed: tmux's own client smcup only (#205). + * + * Exported so the parity with the live strip is a test (claude-scrollback-strip.test.ts). + */ +export function stripReplayBuffer(buffer: string, mode: SessionMode, usesMux: boolean): string { + if (isAltScreenStripMode(mode)) { + return buffer + .replace(ALT_SCREEN_TOGGLE_PATTERN, '') + .replace(ERASE_SCROLLBACK_PATTERN, '') + .replace(MOUSE_TRACKING_PATTERN, ''); + } + if (isMuxMouseStripMode(mode, usesMux)) { + return buffer.replace(ALT_SCREEN_TOGGLE_PATTERN, '').replace(MOUSE_TRACKING_PATTERN, ''); + } + if (isMuxAltScreenOnlyStripMode(mode, usesMux)) return buffer.replace(ALT_SCREEN_TOGGLE_PATTERN, ''); + return buffer; +} + /** * Strip redundant Ink spinner/status-bar redraw frames from the terminal buffer. * Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA) to animate @@ -3111,21 +3143,9 @@ export function registerSessionRoutes( ? rawBuffer : stripInkRedrawBloat(rawBuffer); - // Strip alt-screen toggles and scrollback-erase from Codex/Claude byte - // streams. xterm.js obeys them by switching to its scrollback-less alt - // buffer and wiping saved lines, so conversation history disappears on tab - // switch. Same gate as the live-stream strip in session.ts. - if (isAltScreenStripMode(session.mode)) { - strippedBuffer = strippedBuffer - .replace(ALT_SCREEN_TOGGLE_PATTERN, '') - .replace(ERASE_SCROLLBACK_PATTERN, '') - .replace(MOUSE_TRACKING_PATTERN, ''); - } else if (isMuxAltScreenOnlyStripMode(session.mode, session.usesMux)) { - // tmux-backed shell/opencode/antigravity: drop tmux's own client smcup only. - // A byte buffer recorded before the live-side strip existed can still carry - // it, and one replayed `\x1b[?1049h` re-parks xterm in the alt buffer (#205). - strippedBuffer = strippedBuffer.replace(ALT_SCREEN_TOGGLE_PATTERN, ''); - } + // Same strip as the live stream (session.ts), so a buffer recorded before the + // live-side strip existed cannot re-park xterm on replay. See stripReplayBuffer. + strippedBuffer = stripReplayBuffer(strippedBuffer, session.mode, session.usesMux); if (tailBytes > 0 && strippedBuffer.length > tailBytes) { // Fast path: tail from the end, skip expensive banner search on full 2MB buffer. diff --git a/test/claude-scrollback-strip.test.ts b/test/claude-scrollback-strip.test.ts index 80336109..9abe1323 100644 --- a/test/claude-scrollback-strip.test.ts +++ b/test/claude-scrollback-strip.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../src/session.js'; +import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode, isMuxMouseStripMode } from '../src/session.js'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; +import { stripReplayBuffer } from '../src/web/routes/session-routes.js'; type SessionInternals = { _handleTerminalOutput(data: string): void; @@ -94,18 +96,96 @@ describe('Shell terminal output on a DIRECT PTY is NOT stripped (vim/less/htop n describe('isMuxAltScreenOnlyStripMode', () => { it('covers exactly the modes the full strip does not, and only under tmux', () => { - for (const mode of ['shell', 'opencode', 'antigravity'] as const) { + for (const mode of ['shell', 'antigravity'] as const) { expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(true); // Direct-PTY fallback: the program's own alt screen really does reach xterm. expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false); } - // The full strip already owns these; never double-gate them here. - for (const mode of ['claude', 'codex', 'gemini'] as const) { + // The full strip and the mouse strip already own their modes; never double-gate. + for (const mode of ['claude', 'codex', 'gemini', 'opencode'] as const) { expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false); } }); }); +/** + * opencode's TUI is a mouse-capable full-screen app: it enables tracking DECSETs, + * tmux `mouse off` passes them straight through to the tmux CLIENT, and xterm then + * reports DRAGS to the TUI instead of selecting locally. That killed "mark text, + * copy on select" intermittently — and the obvious fallback, Ctrl+C, is opencode's + * `app_exit`, so the failure also ended sessions. + * + * It needs the alt-screen strip AND the mouse strip, but NOT `3J`: opencode is a + * TUI, not a `clear` consumer, so keeping 3J is the conservative middle ground + * between the full strip and the narrow one. + */ +describe('opencode: alt-screen + mouse DECSETs stripped, 3J kept', () => { + it('is a mouse-strip mode under tmux, and only there', () => { + expect(isMuxMouseStripMode('opencode', true)).toBe(true); + // Direct-PTY fallback: the pane's own alt screen really does reach xterm. + expect(isMuxMouseStripMode('opencode', false)).toBe(false); + for (const mode of ['claude', 'codex', 'gemini', 'shell', 'antigravity'] as const) { + expect(isMuxMouseStripMode(mode, true)).toBe(false); + } + }); + + it('drops mouse tracking so xterm keeps local text selection', () => { + const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true }); + const emitted: string[] = []; + session.on('terminal', (data) => emitted.push(data)); + + handleOutput(session, '\x1b[?1003h\x1b[?1006hTUI\x1b[?1006l\x1b[?1003l'); + + expect(emitted[0]).toBe('TUI'); + expect(session.terminalBuffer).toBe('TUI'); + }); + + it('still drops tmux’s attach-time smcup', () => { + const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true }); + + handleOutput(session, '\x1b[?1049h\x1b[22;0;0t\x1b[H\x1b[2Jprompt'); + + expect(session.terminalBuffer).toBe('\x1b[22;0;0t\x1b[H\x1b[2Jprompt'); + }); + + it('KEEPS 3J, unlike the full strip', () => { + const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true }); + + handleOutput(session, '\x1b[3Jtext'); + + expect(session.terminalBuffer).toBe('\x1b[3Jtext'); + }); + + it('publishes cliMouseTracking so the browser can hand-encode clicks', () => { + // xterm can never see the DECSETs once they are stripped, so the click path + // (_sendSyntheticSgrTap) is the only way a click still reaches opencode. + const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true }); + + handleOutput(session, '\x1b[?1003h\x1b[?1006h'); + + expect(session.toState().cliMouseTracking).toBe(true); + }); + + it('reassembles a mouse DECSET split across PTY chunks', () => { + const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true }); + + handleOutput(session, 'before\x1b[?100'); + handleOutput(session, '3h after'); + + expect(session.terminalBuffer).toBe('before after'); + expect(session.toState().cliMouseTracking).toBe(true); + }); + + it('leaves a direct-PTY opencode pane untouched', () => { + const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: false }); + const out = '\x1b[?1049h\x1b[?1003h'; + + handleOutput(session, out); + + expect(session.terminalBuffer).toBe(out); + }); +}); + describe('tmux-backed shell: strip tmux’s own client smcup, keep everything else (#205)', () => { it('drops alt-screen toggles so xterm keeps a scrollback buffer', () => { const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true }); @@ -127,6 +207,17 @@ describe('tmux-backed shell: strip tmux’s own client smcup, keep everything el expect(session.terminalBuffer).toBe('\x1b[3J\x1b[?1002h\x1b[?1006hhtop\x1b[?1006l\x1b[?1002l'); }); + it('never publishes cliMouseTracking for a mode whose DECSETs it keeps', () => { + // The browser's `_shouldReportMouseToCli()` reads only this flag, with no mode + // list: a flag set for a non-stripping mode would make it hand-encode a second + // report on top of xterm's own. Only the mouse-strip branch may set it. + for (const mode of ['shell', 'antigravity'] as const) { + const session = new Session({ workingDir: '/tmp', mode, useMux: true }); + handleOutput(session, '\x1b[?1002h\x1b[?1006hmouse app'); + expect(session.toState().cliMouseTracking, mode).toBeFalsy(); + } + }); + it('reassembles alt-screen sequences split across PTY chunk boundaries', () => { const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true }); const emitted: string[] = []; @@ -139,12 +230,10 @@ describe('tmux-backed shell: strip tmux’s own client smcup, keep everything el expect(emitted).toEqual(['before', ' after']); }); - it('applies to opencode and antigravity too', () => { - for (const mode of ['opencode', 'antigravity'] as const) { - const session = new Session({ workingDir: '/tmp', mode, useMux: true }); - handleOutput(session, '\x1b[?1049hTUI\x1b[3J'); - expect(session.terminalBuffer).toBe('TUI\x1b[3J'); - } + it('applies to antigravity too (opencode has its own strip — see below)', () => { + const session = new Session({ workingDir: '/tmp', mode: 'antigravity', useMux: true }); + handleOutput(session, '\x1b[?1049hTUI\x1b[3J'); + expect(session.terminalBuffer).toBe('TUI\x1b[3J'); }); }); @@ -238,3 +327,42 @@ describe('stripped mouse-tracking state', () => { expect(session.terminalBuffer).toBe('\x1b[?1002hhtop'); }); }); + +/** + * The decision table in `CliCapabilities.altScreen`'s JSDoc, pinned for every stock CLI + * with and without tmux, on both halves of the parity triangle that can drift apart: the + * live stream (`_handleTerminalOutput`) and the replay of a stored buffer + * (`stripReplayBuffer`, session-routes.ts). The frontend half reads only the published + * `cliMouseTracking`, so the last column is what keeps it right. + */ +describe('strip decision table: live stream = replay, for every stock CLI', () => { + const ALT = '\x1b[?1049h'; + const ERASE = '\x1b[3J'; + const MOUSE = '\x1b[?1002h\x1b[?1006h'; + const input = `A${ALT}B${ERASE}C${MOUSE}D`; + + /** Read straight off the table, not off the predicates under test. */ + function expected(altScreen: string, useMux: boolean): { out: string; tracking: boolean } { + const strip = { alt: false, erase: false, mouse: false }; + if (altScreen === 'strip-full') Object.assign(strip, { alt: true, erase: true, mouse: true }); + else if (useMux && altScreen === 'strip-mux-and-mouse') Object.assign(strip, { alt: true, mouse: true }); + else if (useMux) strip.alt = true; // strip-mux-only and preserve: the same runtime row + return { + out: `A${strip.alt ? '' : ALT}B${strip.erase ? '' : ERASE}C${strip.mouse ? '' : MOUSE}D`, + tracking: strip.mouse, + }; + } + + for (const entry of STOCK_CLIS) { + for (const useMux of [true, false]) { + it(`${entry.id} (${entry.capabilities.altScreen}, ${useMux ? 'tmux' : 'direct PTY'})`, () => { + const want = expected(entry.capabilities.altScreen, useMux); + const session = new Session({ workingDir: '/tmp', mode: entry.id, useMux }); + handleOutput(session, input); + expect(session.terminalBuffer).toBe(want.out); + expect(stripReplayBuffer(input, entry.id, useMux)).toBe(want.out); + expect(Boolean(session.toState().cliMouseTracking)).toBe(want.tracking); + }); + } + } +}); diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index 2321d8fc..060b6668 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -46,13 +46,20 @@ function loadTerminalUiHarness() { return { app: new (CodemanApp as any)(), logs }; } -/** A Claude session whose local buffer holds exactly one screen (baseY 0). */ -function hollowClaudeApp(overrides: { cliVersion?: string; rows?: number; cliMouseTracking?: boolean } = {}) { +/** A session whose local buffer holds exactly one screen (baseY 0) — a hollow pane. */ +function hollowApp(overrides: { mode?: string; cliVersion?: string; rows?: number; cliMouseTracking?: boolean } = {}) { const { app, logs } = loadTerminalUiHarness(); const sent: Array<{ id: string; data: string }> = []; app.activeSessionId = 'sess-1'; app.sessions = new Map([ - ['sess-1', { mode: 'claude', cliVersion: overrides.cliVersion, cliMouseTracking: overrides.cliMouseTracking }], + [ + 'sess-1', + { + mode: overrides.mode ?? 'claude', + cliVersion: overrides.cliVersion, + cliMouseTracking: overrides.cliMouseTracking, + }, + ], ]); app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data }); app.terminal = { @@ -135,7 +142,7 @@ describe('full-history re-pull downgrade guard (issue #205 round 2)', () => { describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2)', () => { it('pages the CLI transcript when the wheel gate is false and there is no scrollback', () => { - const { app, sent } = hollowClaudeApp(); // cliVersion unknown → gate false + const { app, sent } = hollowApp(); // cliVersion unknown → gate false // Half a screen of travel (rows 36 → 18 lines) buys exactly one PageUp. expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true); @@ -149,7 +156,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 }); it('accumulates sub-page travel instead of dropping or over-sending it', () => { - const { app, sent } = hollowClaudeApp(); + const { app, sent } = hollowApp(); expect(app._maybePageCliTranscript({ shiftKey: false }, -10)).toBe(true); // consumed… app._flushWheelSgrQueue(); @@ -161,15 +168,34 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 }); it('caps the keys one gesture batch can emit', () => { - const { app, sent } = hollowClaudeApp(); + const { app, sent } = hollowApp(); app._maybePageCliTranscript({ shiftKey: false }, -1000); // 55 pages of travel app._flushWheelSgrQueue(); expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~'.repeat(3) }]); }); + it('pages an OpenCode pane too, whose TUI never fills the local buffer', () => { + // OpenCode's TUI runs on the ALTERNATE SCREEN (measured on 1.18.31: tmux + // `alternate_on=1`, `history_size=0`), so the browser's normal buffer stays at + // one screen exactly like a repaint-mode Claude pane. The difference is that + // OpenCode IGNORES SGR wheel reports (verified against an idle pane: six + // `\x1b[<64;…M` reports left the capture byte-identical), so PageUp/PageDown + // — its `messages_page_up/down` binds — is the ONLY gesture that reaches its + // transcript. Without this the wheel was silently dead in every OpenCode tab. + const { app, sent } = hollowApp({ mode: 'opencode' }); + + expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true); + app._flushWheelSgrQueue(); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); + + app._maybePageCliTranscript({ shiftKey: false }, 18); + app._flushWheelSgrQueue(); + expect(sent[1]).toEqual({ id: 'sess-1', data: '\x1b[6~' }); + }); + it('leaves every session that has real local scrollback alone', () => { - const { app } = hollowClaudeApp(); + const { app } = hollowApp(); // Shift is the explicit "give me local scrollback" gesture — never paged. expect(app._maybePageCliTranscript({ shiftKey: true }, -18)).toBe(false); @@ -179,12 +205,15 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); app.terminal.buffer.active.baseY = 0; - // Non-Claude modes keep their existing behavior (shell scrolls tmux history - // through the alt-screen strip; codex/gemini page keys are unverified). + // Modes with real terminal scrollback keep their existing behavior (shell/pi + // own tmux history through the alt-screen strip; codex/gemini/antigravity/… + // page-key behaviour is unverified — docs/scrollback-fix-plan.md). app.sessions = new Map([['sess-1', { mode: 'shell' }]]); expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); app.sessions = new Map([['sess-1', { mode: 'codex' }]]); expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); + app.sessions = new Map([['sess-1', { mode: 'antigravity' }]]); + expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); // An alternate-screen pane belongs to xterm's own alt-scroll handling. app.sessions = new Map([['sess-1', { mode: 'claude' }]]); @@ -197,7 +226,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 // repaint-mode CLI, is empty — a user who flipped it while hunting for a fix // on 1.11.x would have ended up with a completely dead wheel on 1.12.0. // Version and tracking both qualify, so the opt-out is the only thing saying no. - const { app, sent } = hollowClaudeApp({ cliVersion: '2.1.223', cliMouseTracking: true }); // gate would forward… + const { app, sent } = hollowApp({ cliVersion: '2.1.223', cliMouseTracking: true }); // gate would forward… app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true }); expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); // …but the opt-out wins @@ -207,7 +236,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 }); it('drops travel accumulated on another tab', () => { - const { app, sent } = hollowClaudeApp(); + const { app, sent } = hollowApp(); app._maybePageCliTranscript({ shiftKey: false }, -17); // just short of a page app.activeSessionId = 'sess-2'; @@ -228,7 +257,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 describe('scroll routing diagnostic (issue #205 round 2)', () => { it('prints the decision and its inputs once per session, and again when it changes', () => { - const { app, logs } = hollowClaudeApp({ cliVersion: '2.1.100' }); + const { app, logs } = hollowApp({ cliVersion: '2.1.100' }); app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false }); app._logScrollRouting('local-scrollback'); @@ -255,7 +284,7 @@ describe('scroll routing diagnostic (issue #205 round 2)', () => { }); it('reports an unknown CLI version, the false-path that disables forwarding', () => { - const { app, logs } = hollowClaudeApp(); // no cliVersion — the probe failed + const { app, logs } = hollowApp(); // no cliVersion — the probe failed app._logScrollRouting('page-keys'); expect(logs[0]).toContain('cliVersion=unknown'); }); diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index f35106b4..6974c39e 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -366,14 +366,33 @@ describe('terminal touch tap mouse guard', () => { expect(sent).toEqual(['\x1b[<0;1;24M\x1b[<0;1;24m']); }); - it('never hand-reports for a shell session, even with tracking somehow set', () => { - // Shell DECSETs are NOT stripped (narrow strip), so xterm's own encoder owns - // the mouse there and a second, hand-encoded report would double-report. + it('follows the server flag alone, with no mode list of its own', () => { + // A shell's DECSETs are not stripped, so xterm's own encoder owns the mouse there + // and a hand-encoded report would double-report. That is kept by the SERVER never + // setting the flag for a non-stripping mode (pinned in claude-scrollback-strip.test.ts), + // not by a mode check here: the browser reads only the flag. const { app } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; - app.sessions = new Map([['sess-1', { mode: 'shell', cliMouseTracking: true }]]); - + app.sessions = new Map([['sess-1', { mode: 'shell' }]]); expect(app._shouldReportMouseToCli()).toBe(false); + + app.sessions = new Map([['sess-1', { mode: 'some-future-cli', cliMouseTracking: true }]]); + expect(app._shouldReportMouseToCli()).toBe(true); + }); + + it('hand-reports for opencode, whose DECSETs the server now strips', () => { + // opencode's TUI enables mouse tracking, tmux passes the DECSETs through, and + // xterm used to report DRAGS to the TUI instead of selecting — so marking text + // copied nothing. The server strips them now (isMuxMouseStripMode), which makes + // the hand-encoded tap the only way a click still reaches opencode. + const { app } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + + app.sessions = new Map([['sess-1', { mode: 'opencode' }]]); + expect(app._shouldReportMouseToCli()).toBe(false); + + app.sessions = new Map([['sess-1', { mode: 'opencode', cliMouseTracking: true }]]); + expect(app._shouldReportMouseToCli()).toBe(true); }); it('hand-reports only while the CLI actually has mouse tracking on', () => {