mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
Merge #555: opencode drags select text and the wheel pages its transcript
This commit is contained in:
@@ -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)
|
**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
@@ -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`).
|
- 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)
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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() })
|
||||||
|
|||||||
@@ -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 },
|
||||||
// Measured on a live opencode 1.3.0 pane (capture-pane every 250-300 ms through real
|
// 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
|
// turns at 40, 60, 120 and 200 columns, plus the raw PTY stream, 2026-10-09). Every
|
||||||
|
|||||||
@@ -439,11 +439,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. */
|
||||||
|
|||||||
+53
-15
@@ -277,10 +277,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
|
||||||
@@ -300,8 +301,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
|
||||||
@@ -326,7 +329,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)
|
||||||
@@ -2529,14 +2556,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
|
||||||
@@ -2555,14 +2589,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
|
// eslint-disable-next-line no-control-regex
|
||||||
.replace(/\x1b\[3J/g, '')
|
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\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, (seq) => {
|
/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g,
|
||||||
|
(seq) => {
|
||||||
this._recordStrippedMouseMode(seq);
|
this._recordStrippedMouseMode(seq);
|
||||||
return '';
|
return '';
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1273,7 +1273,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.
|
||||||
@@ -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
|
* 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
|
||||||
@@ -5583,15 +5580,29 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when this session's LOCAL scrollback is structurally empty: a Claude
|
* True when this session's LOCAL scrollback is structurally empty: a pane whose
|
||||||
* pane in repaint mode, where tmux reports `history_size≈0` and every frame
|
* TUI repaints one full screen in place, so tmux keeps no history for it
|
||||||
* overwrites the last, so xterm's normal buffer never grows past one screen
|
* (`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
|
* (`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.
|
* 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() {
|
_localScrollbackIsHollow() {
|
||||||
const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude';
|
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;
|
const buf = this.terminal?.buffer?.active;
|
||||||
if (!buf || buf.type === 'alternate') return false;
|
if (!buf || buf.type === 'alternate') return false;
|
||||||
return (buf.baseY || 0) === 0;
|
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.
|
* coalesced PageUp/PageDown key sends so the CLI pages its OWN transcript.
|
||||||
*
|
*
|
||||||
* The rescue path for every way `_shouldForwardWheelToApp` can come back false
|
* 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
|
* on a session that has no local history to fall back on: the CLI version probe
|
||||||
* version probe failed or is genuinely older than 2.1.187, the CLI's mouse
|
* failed or is genuinely older than 2.1.187, the CLI's mouse tracking flag is
|
||||||
* tracking flag is unset (the inline renderer, or fullscreen right after a
|
* unset (the inline renderer, or fullscreen right after a server restart), the
|
||||||
* server restart), or the user turned on "Wheel scrolls local history" (which
|
* user turned on "Wheel scrolls local history" (which pins the wheel to a buffer
|
||||||
* pins the wheel to a buffer that, for a repaint-mode CLI, is empty: the
|
* that, for a repaint-mode CLI, is empty: the setting's footgun), or the CLI is
|
||||||
* setting's footgun). Before this, all of those produced a completely dead
|
* opencode, which never fills the buffer and never accepts the wheel. Before
|
||||||
* gesture; the #205 reporter proved the keyboard route works by paging back
|
* this, all of those produced a completely dead gesture; the #205 reporter
|
||||||
* through intact text with Fn+Up.
|
* 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
|
* Guarded by `_localScrollbackIsHollow()` plus a false forwarding gate, so a
|
||||||
* real local scrollback is never touched. Shift is excluded on purpose: it is
|
* session with real local scrollback is never touched. Shift is excluded on
|
||||||
* the explicit "give me local scrollback" gesture and must keep that meaning.
|
* 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
|
* @returns true when the gesture was consumed here (the caller must not also
|
||||||
* scroll locally).
|
* 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 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
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -225,6 +226,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
|
||||||
@@ -3111,21 +3143,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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -46,13 +46,20 @@ function loadTerminalUiHarness() {
|
|||||||
return { app: new (CodemanApp as any)(), logs };
|
return { app: new (CodemanApp as any)(), logs };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A Claude session whose local buffer holds exactly one screen (baseY 0). */
|
/** A session whose local buffer holds exactly one screen (baseY 0) — a hollow pane. */
|
||||||
function hollowClaudeApp(overrides: { cliVersion?: string; rows?: number; cliMouseTracking?: boolean } = {}) {
|
function hollowApp(overrides: { mode?: string; cliVersion?: string; rows?: number; cliMouseTracking?: boolean } = {}) {
|
||||||
const { app, logs } = loadTerminalUiHarness();
|
const { app, logs } = loadTerminalUiHarness();
|
||||||
const sent: Array<{ id: string; data: string }> = [];
|
const sent: Array<{ id: string; data: string }> = [];
|
||||||
app.activeSessionId = 'sess-1';
|
app.activeSessionId = 'sess-1';
|
||||||
app.sessions = new Map([
|
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._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data });
|
||||||
app.terminal = {
|
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)', () => {
|
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', () => {
|
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.
|
// Half a screen of travel (rows 36 → 18 lines) buys exactly one PageUp.
|
||||||
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
|
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', () => {
|
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…
|
expect(app._maybePageCliTranscript({ shiftKey: false }, -10)).toBe(true); // consumed…
|
||||||
app._flushWheelSgrQueue();
|
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', () => {
|
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._maybePageCliTranscript({ shiftKey: false }, -1000); // 55 pages of travel
|
||||||
app._flushWheelSgrQueue();
|
app._flushWheelSgrQueue();
|
||||||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~'.repeat(3) }]);
|
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', () => {
|
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.
|
// Shift is the explicit "give me local scrollback" gesture — never paged.
|
||||||
expect(app._maybePageCliTranscript({ shiftKey: true }, -18)).toBe(false);
|
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);
|
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
|
||||||
app.terminal.buffer.active.baseY = 0;
|
app.terminal.buffer.active.baseY = 0;
|
||||||
|
|
||||||
// Non-Claude modes keep their existing behavior (shell scrolls tmux history
|
// Modes with real terminal scrollback keep their existing behavior (shell/pi
|
||||||
// through the alt-screen strip; codex/gemini page keys are unverified).
|
// 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' }]]);
|
app.sessions = new Map([['sess-1', { mode: 'shell' }]]);
|
||||||
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
|
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
|
||||||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||||||
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
|
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.
|
// An alternate-screen pane belongs to xterm's own alt-scroll handling.
|
||||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
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
|
// 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.
|
// 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.
|
// 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 });
|
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
|
||||||
|
|
||||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); // …but the opt-out wins
|
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', () => {
|
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._maybePageCliTranscript({ shiftKey: false }, -17); // just short of a page
|
||||||
app.activeSessionId = 'sess-2';
|
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)', () => {
|
describe('scroll routing diagnostic (issue #205 round 2)', () => {
|
||||||
it('prints the decision and its inputs once per session, and again when it changes', () => {
|
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.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false });
|
||||||
|
|
||||||
app._logScrollRouting('local-scrollback');
|
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', () => {
|
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');
|
app._logScrollRouting('page-keys');
|
||||||
expect(logs[0]).toContain('cliVersion=unknown');
|
expect(logs[0]).toContain('cliVersion=unknown');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user