fix(terminal): strip opencode's mouse DECSETs so a drag selects text again

opencode's TUI enables mouse tracking. tmux runs with `mouse off`, so it passes
the PANE's DECSETs straight through to the tmux client, and the browser's xterm
obeyed them: `mouseTrackingMode` flipped to 'any' (measured 62 none / 18 any over
16s) and xterm then reported DRAGS to the TUI instead of selecting locally.

In that state marking text produced no selection at all, so copy-on-select
silently did nothing (5/5 dead drags while `any`), and the obvious fallback —
Ctrl+C — is opencode's `app_exit`, which ended the session. Both were hit here.

opencode needs the middle strip: alt-screen toggles AND mouse DECSETs, but NOT
`3J` (a TUI is not a `clear` consumer). That is `altScreen: 'strip-mux-and-mouse'`
+ `isMuxMouseStripMode`, applied to the live stream (session.ts) and the replay
of a stored buffer, now the exported `stripReplayBuffer()` (session-routes.ts).

The browser's mouse-report gate keeps no mode list any more:
`_shouldReportMouseToCli()` reads only `cliMouseTracking`. The server sets that
flag solely in the mouse-strip branch (`_recordStrippedMouseMode`, one caller),
so it can only be true for a mode whose DECSETs are stripped, and whichever modes
the registry strips, the browser follows. Clicks still reach opencode through the
hand-encoded SGR tap it gates.

The `altScreen` JSDoc gets the decision table its three independent choices need
(alt-screen / `3J` / mouse DECSETs), written from the predicates, including that
`preserve` and `strip-mux-only` take the same runtime row. The table is pinned for
every stock CLI, with and without tmux, on both the live strip and the replay
strip, plus the published flag (test/claude-scrollback-strip.test.ts), so the two
halves cannot drift and a mis-ordered replay branch fails.

Docs and comments that still said opencode keeps its mouse reporting or gets the
narrow strip are updated (CLAUDE.md, architecture-invariants, scrollback and
copy-shortcut plans, session.ts, terminal-ui.js).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Randalix
2026-10-08 14:55:02 +02:00
co-authored by Claude Opus 5.5
parent 5a0018fc86
commit 5ba729fcbb
12 changed files with 318 additions and 85 deletions
+1 -1
View File
@@ -284,7 +284,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) **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) **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) **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)
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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 `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. - 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. - 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. - 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 any strip/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`), which is not a strip/forward list. antigravity/grok/deepseek/omp remain unverified. - 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. - The chunk-boundary sequence carry in `_handleTerminalOutput` must not be weakened.
## Testing (per repo rules) ## Testing (per repo rules)
+3 -2
View File
@@ -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. - 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. - `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. - 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 ### 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 | | 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 | | 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 | | 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 | | 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) | | 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. 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. 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. 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. 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). 8. Mobile smoke: confirm nothing changed (selection is CSS-disabled, no Ctrl key).
+1 -1
View File
@@ -289,7 +289,7 @@ const capabilitiesSchema = z
requiresMux: z.boolean(), requiresMux: z.boolean(),
hooks: z.enum(['none', 'always', 'supervised']), hooks: z.enum(['none', 'always', 'supervised']),
transcript: z.enum(['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'omp-jsonl', 'none']), 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, echo: echoSchema,
wheelForward: z wheelForward: z
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() }) .object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
+1 -1
View File
@@ -491,7 +491,7 @@ const OPENCODE: CliEntry = {
}, },
capabilities: { capabilities: {
...agentDefaults(), ...agentDefaults(),
altScreen: 'strip-mux-only', altScreen: 'strip-mux-and-mouse',
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined }, echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
// opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`. // opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`.
mcpConfig: { mcpConfig: {
+33 -4
View File
@@ -436,11 +436,40 @@ export interface CliCapabilities {
*/ */
transcript: 'claude-jsonl' | 'codex-rollout' | 'deepseek-zstd' | 'omp-jsonl' | 'none'; transcript: 'claude-jsonl' | 'codex-rollout' | 'deepseek-zstd' | 'omp-jsonl' | 'none';
/** /**
* 'strip-full' — alt-screen + erase-scrollback + mouse DECSETs stripped (Ink TUIs). * What the server strips from this CLI's output stream before the browser sees it.
* 'strip-mux-only' — only tmux's own attach-time smcup (the safe default). * The value encodes three independent choices (predicates in session.ts):
* 'preserve' — leave everything (a direct-PTY shell running vim/less/htop). *
* | 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: { echo: {
policy: 'buffer' | 'predict' | 'off'; policy: 'buffer' | 'predict' | 'off';
/** How the local-echo overlay locates the composer row. */ /** How the local-echo overlay locates the composer row. */
+54 -16
View File
@@ -265,10 +265,11 @@ function cliExportsTruecolor(mode: SessionMode): boolean {
* Codex, Claude Code, and Gemini are known, controlled (Ink/React) TUIs that * Codex, Claude Code, and Gemini are known, controlled (Ink/React) TUIs that
* repaint via cursor positioning, so dropping the alt-screen switch is safe — * repaint via cursor positioning, so dropping the alt-screen switch is safe —
* content stays in the normal buffer. Excluded: `shell` (arbitrary programs like * content stays in the normal buffer. Excluded: `shell` (arbitrary programs like
* vim/less/htop legitimately need the alt screen), `opencode` (renders its own * vim/less/htop legitimately need the alt screen), `opencode` (its own MIDDLE strip,
* TUI that may rely on it), `pi` (below) and `grok` (a fullscreen alt-screen TUI * isMuxMouseStripMode), `pi` (below) and `grok` (a fullscreen alt-screen TUI with
* with mouse support, i.e. the opencode case, not the Ink case). Keep parity * mouse support). Keep parity with the replay-side strip (`stripReplayBuffer` in
* with the replay-side strip in session-routes.ts. * 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 * ⚠️ Being excluded here does NOT preserve the alt screen. Every excluded mode
* falls through to isMuxAltScreenOnlyStripMode(), which strips the alt-screen * falls through to isMuxAltScreenOnlyStripMode(), which strips the alt-screen
@@ -288,8 +289,10 @@ export function isAltScreenStripMode(mode: SessionMode): boolean {
/** /**
* Modes that need the NARROW strip: alt-screen toggles only, leaving `\x1b[3J` * Modes that need the NARROW strip: alt-screen toggles only, leaving `\x1b[3J`
* and the mouse-tracking DECSETs alone. Applies to every mode `isAltScreenStripMode` * and the mouse-tracking DECSETs alone. Applies to every mode that is neither
* excludes, but ONLY when the session is tmux-backed (`useMux`). * `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 * 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 * bytes on attach, before any program has run. Unstripped, xterm.js parks in the
@@ -314,7 +317,31 @@ export function isAltScreenStripMode(mode: SessionMode): boolean {
* `\x1b[3J` from a user's own `clear` is a deliberate "wipe my scrollback". * `\x1b[3J` from a user's own `clear` is a deliberate "wipe my scrollback".
*/ */
export function isMuxAltScreenOnlyStripMode(mode: SessionMode, useMux: boolean): boolean { 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) // Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
@@ -2489,14 +2516,21 @@ export class Session extends EventEmitter {
// redraws overwrite only the cells they target, so non-erased rows keep // redraws overwrite only the cells they target, so non-erased rows keep
// their content. Gated to Codex/Claude/Gemini (isAltScreenStripMode). // their content. Gated to Codex/Claude/Gemini (isAltScreenStripMode).
// //
// Every OTHER mode (shell/opencode/antigravity) gets the NARROW strip when it // Every OTHER mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip
// is tmux-backed: alt-screen toggles only, because the sequence that breaks // 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 // scrollback there is tmux's own client-side smcup at attach, not anything the
// program in the pane emitted (issue #205, see isMuxAltScreenOnlyStripMode). // program in the pane emitted (issue #205, see isMuxAltScreenOnlyStripMode).
// 3J and the mouse DECSETs stay, so `clear` and mouse-aware TUIs keep working. // 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 fullStrip = isAltScreenStripMode(this.mode);
const altOnlyStrip = !fullStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux); const mouseStrip = isMuxMouseStripMode(this.mode, this._useMux);
if (fullStrip || altOnlyStrip) { const altOnlyStrip = !fullStrip && !mouseStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux);
if (fullStrip || mouseStrip || altOnlyStrip) {
// Reassemble sequences split across PTY chunk boundaries first: a chunk // Reassemble sequences split across PTY chunk boundaries first: a chunk
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the // ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
// strip below and leave xterm stuck in the scrollback-less alt buffer // strip below and leave xterm stuck in the scrollback-less alt buffer
@@ -2515,14 +2549,18 @@ export class Session extends EventEmitter {
// eslint-disable-next-line no-control-regex // eslint-disable-next-line no-control-regex
data = data.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, ''); data = data.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '');
if (fullStrip) { 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 // eslint-disable-next-line no-control-regex
.replace(/\x1b\[3J/g, '') /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g,
// eslint-disable-next-line no-control-regex (seq) => {
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, (seq) => {
this._recordStrippedMouseMode(seq); this._recordStrippedMouseMode(seq);
return ''; return '';
}); }
);
} }
} }
+21 -23
View File
@@ -1265,7 +1265,8 @@ Object.assign(CodemanApp.prototype, {
// A real mouse click normally reaches the PTY through xterm's own mouse // A real mouse click normally reaches the PTY through xterm's own mouse
// encoder, but that encoder only runs while mouseTrackingMode is ON — and // encoder, but that encoder only runs while mouseTrackingMode is ON — and
// the server strips the enabling DECSETs from claude/codex/gemini output // 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 // scrollback. Desktop clicks therefore stopped reporting entirely (the
// same breakage the mobile touchend tap branch above works around). // same breakage the mobile touchend tap branch above works around).
// Hand-encode the SGR report for plain left-clicks on those sessions. // Hand-encode the SGR report for plain left-clicks on those sessions.
@@ -5271,36 +5272,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 * Without the flag the browser reported EVERY click, so a CLI sitting at its
* the stream (claude/codex/gemini, `isAltScreenStripMode`), which is why * composer with no dialog open, or a pane that has fallen back to a shell
* xterm's own encoder is permanently idle here and something has to stand * prompt, received mouse reports it never asked for. A shell prints those as
* in for it. * literal text (`[<0;88;20M`) and they garble the next line typed.
* 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.
* *
* Fails toward silence: an unknown or stale flag reports nothing rather than * 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 * injecting bytes. After a server restart the flag is false until the CLI
* re-emits its DECSET, which closing and reopening a dialog does. * re-emits its DECSET, which closing and reopening a dialog does.
*/ */
_shouldReportMouseToCli() { _shouldReportMouseToCli() {
const session = this.sessions?.get(this.activeSessionId); return this.sessions?.get(this.activeSessionId)?.cliMouseTracking === true;
const mode = session?.mode || 'claude';
if (mode !== 'claude' && mode !== 'codex' && mode !== 'gemini') return false;
return session?.cliMouseTracking === true;
}, },
// True when xterm's viewport shows the live PTY screen (not scrolled up into // True when xterm's viewport shows the live PTY screen (not scrolled up into
@@ -5631,7 +5628,8 @@ Object.assign(CodemanApp.prototype, {
* The reason is that the habit and xterm's Shift mean different things once * 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 DECSETs are stripped. xterm reads Shift as "force selection" ONLY while
* the app actually has mouse tracking on; the server strips those DECSETs for * 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 * permanently `none`, that branch is unreachable, and Shift instead falls into
* `_onIncrementalClick` — EXTEND an existing selection. Extending is a no-op * `_onIncrementalClick` — EXTEND an existing selection. Extending is a no-op
* when `selectionStart` is null, so the drag never anchors and no selection is * when `selectionStart` is null, so the drag never anchors and no selection is
+35 -15
View File
@@ -36,6 +36,7 @@ import {
isAltScreenStripMode, isAltScreenStripMode,
isExternalCliMode, isExternalCliMode,
isMuxAltScreenOnlyStripMode, isMuxAltScreenOnlyStripMode,
isMuxMouseStripMode,
} from '../../session.js'; } from '../../session.js';
import type { PaneCaptureOptions } from '../../mux-interface.js'; import type { PaneCaptureOptions } from '../../mux-interface.js';
import { SseEvent } from '../sse-events.js'; import { SseEvent } from '../sse-events.js';
@@ -224,6 +225,37 @@ const ERASE_SCROLLBACK_PATTERN = /\x1b\[3J/g;
// eslint-disable-next-line no-control-regex // eslint-disable-next-line no-control-regex
const MOUSE_TRACKING_PATTERN = /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g; 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. * 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 * Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA) to animate
@@ -3087,21 +3119,9 @@ export function registerSessionRoutes(
? rawBuffer ? rawBuffer
: stripInkRedrawBloat(rawBuffer); : stripInkRedrawBloat(rawBuffer);
// Strip alt-screen toggles and scrollback-erase from Codex/Claude byte // Same strip as the live stream (session.ts), so a buffer recorded before the
// streams. xterm.js obeys them by switching to its scrollback-less alt // live-side strip existed cannot re-park xterm on replay. See stripReplayBuffer.
// buffer and wiping saved lines, so conversation history disappears on tab strippedBuffer = stripReplayBuffer(strippedBuffer, session.mode, session.usesMux);
// 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, '');
}
if (tailBytes > 0 && strippedBuffer.length > tailBytes) { if (tailBytes > 0 && strippedBuffer.length > tailBytes) {
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer. // Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
+138 -10
View File
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest'; 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 = { type SessionInternals = {
_handleTerminalOutput(data: string): void; _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', () => { describe('isMuxAltScreenOnlyStripMode', () => {
it('covers exactly the modes the full strip does not, and only under tmux', () => { 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); expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(true);
// Direct-PTY fallback: the program's own alt screen really does reach xterm. // Direct-PTY fallback: the program's own alt screen really does reach xterm.
expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false); expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false);
} }
// The full strip already owns these; never double-gate them here. // The full strip and the mouse strip already own their modes; never double-gate.
for (const mode of ['claude', 'codex', 'gemini'] as const) { for (const mode of ['claude', 'codex', 'gemini', 'opencode'] as const) {
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false); 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)', () => { 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', () => { it('drops alt-screen toggles so xterm keeps a scrollback buffer', () => {
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true }); 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'); 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', () => { it('reassembles alt-screen sequences split across PTY chunk boundaries', () => {
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true }); const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
const emitted: string[] = []; 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']); expect(emitted).toEqual(['before', ' after']);
}); });
it('applies to opencode and antigravity too', () => { it('applies to antigravity too (opencode has its own strip — see below)', () => {
for (const mode of ['opencode', 'antigravity'] as const) { const session = new Session({ workingDir: '/tmp', mode: 'antigravity', useMux: true });
const session = new Session({ workingDir: '/tmp', mode, useMux: true }); handleOutput(session, '\x1b[?1049hTUI\x1b[3J');
handleOutput(session, '\x1b[?1049hTUI\x1b[3J'); expect(session.terminalBuffer).toBe('TUI\x1b[3J');
expect(session.terminalBuffer).toBe('TUI\x1b[3J');
}
}); });
}); });
@@ -238,3 +327,42 @@ describe('stripped mouse-tracking state', () => {
expect(session.terminalBuffer).toBe('\x1b[?1002hhtop'); 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);
});
}
}
});
+24 -5
View File
@@ -366,14 +366,33 @@ describe('terminal touch tap mouse guard', () => {
expect(sent).toEqual(['\x1b[<0;1;24M\x1b[<0;1;24m']); expect(sent).toEqual(['\x1b[<0;1;24M\x1b[<0;1;24m']);
}); });
it('never hand-reports for a shell session, even with tracking somehow set', () => { it('follows the server flag alone, with no mode list of its own', () => {
// Shell DECSETs are NOT stripped (narrow strip), so xterm's own encoder owns // A shell's DECSETs are not stripped, so xterm's own encoder owns the mouse there
// the mouse there and a second, hand-encoded report would double-report. // 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(); const { app } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1'; 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); 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', () => { it('hand-reports only while the CLI actually has mouse tracking on', () => {