From 5a0018fc863a38b5bcc701c1383345adfd1c5670 Mon Sep 17 00:00:00 2001 From: Randalix Date: Wed, 16 Sep 2026 13:28:17 +0200 Subject: [PATCH 1/2] fix(terminal): page the CLI transcript for opencode's hollow local buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opencode's TUI runs on the ALTERNATE SCREEN (measured on 1.18.31: tmux `alternate_on=1`, `history_size=0`), so tmux keeps no history for the pane and the browser's normal buffer never grows past one screen (`baseY === 0`). The plain wheel therefore scrolled a buffer with nothing in it — dead in every opencode tab, on desktop and touch alike. opencode is not a forwarding candidate: it IGNORES SGR wheel reports (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical), but it does page its own transcript on PageUp/PageDown (`messages_page_up/down`, verified on the same pane). The hollow-buffer rescue already sends exactly those keys — it was just gated to `claude`. Widen the gate to opencode so the wheel and touch gestures reach the CLI's own transcript instead of a no-op. Every other mode stays out: shell/pi own real terminal scrollback, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (docs/scrollback-fix-plan.md). Test: test/terminal-scroll-routing.test.ts — new opencode case (Red before the fix, Green after); the "real local scrollback is untouched" case now also pins antigravity as not-paged. --- docs/architecture-invariants.md | 2 +- docs/scrollback-fix-plan.md | 2 +- src/web/public/terminal-ui.js | 45 +++++++++++++++-------- test/terminal-scroll-routing.test.ts | 55 +++++++++++++++++++++------- 4 files changed, 74 insertions(+), 30 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index cd49d869..75bbff65 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -221,7 +221,7 @@ Further detail: the `: ` form (`w3-myapp: fix the login redirect` **Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`. -**A false gate on a Claude session must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. The 1.12.0 retest reported exactly that: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the triple guard (claude mode + gate false + `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. Tests: `test/terminal-scroll-routing.test.ts`, `test/claude-cli-version-cache.test.ts`. +**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()` — `claude` or `opencode`, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ `shell`/`pi` own real terminal scrollback and are never paged, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. Tests: `test/terminal-scroll-routing.test.ts`, `test/claude-cli-version-cache.test.ts`. **Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing. diff --git a/docs/scrollback-fix-plan.md b/docs/scrollback-fix-plan.md index 65d8870d..497d2707 100644 --- a/docs/scrollback-fix-plan.md +++ b/docs/scrollback-fix-plan.md @@ -279,7 +279,7 @@ Touch is always-local by design, and Claude sessions keep content in the normal - The viewport-at-bottom gate stays: once the user scrolled up locally, wheel stays local until they return to bottom. - 40ms SGR coalescing: never send per-event writes to the server. - Strip parity triangle: `session.ts` live strip ↔ `session-routes.ts` replay strip ↔ `_sessionUsesServerMouseStrip()` in the frontend. If you touch mode lists, update all three. -- Don't add `opencode`/`antigravity` to any strip/forward list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`). +- 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. - The chunk-boundary sequence carry in `_handleTerminalOutput` must not be weakened. ## Testing (per repo rules) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index a03bc4d1..9647927e 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -5485,15 +5485,29 @@ Object.assign(CodemanApp.prototype, { }, /** - * True when this session's LOCAL scrollback is structurally empty: a Claude - * pane in repaint mode, where tmux reports `history_size≈0` and every frame - * overwrites the last, so xterm's normal buffer never grows past one screen + * True when this session's LOCAL scrollback is structurally empty: a pane whose + * TUI repaints one full screen in place, so tmux keeps no history for it + * (`history_size≈0`) and xterm's normal buffer never grows past one screen * (`baseY === 0`). Scrolling that buffer is a no-op no matter how the gesture * is routed — the "wheel does nothing at all" half of the #205 retest. + * + * Two shapes, measured separately: + * - `claude` in repaint mode (the original, #205 round 2), and + * - `opencode`, whose TUI runs on the ALTERNATE SCREEN (opencode 1.18.31: tmux + * `alternate_on=1`, `history_size=0`) and so pushes nothing into the + * terminal's scrollback at all. It pages its own transcript with the same + * PageUp/PageDown keys (`messages_page_up/down`) but IGNORES SGR wheel + * reports — six `\x1b[<64;…M` reports against an idle pane left the capture + * byte-identical — so paging is the only gesture that reaches it. Without + * this the wheel was silently dead in every opencode tab. + * + * Every other mode is deliberately absent: shell/pi own real terminal + * scrollback, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour + * is unverified (docs/scrollback-fix-plan.md). */ _localScrollbackIsHollow() { const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude'; - if (mode !== 'claude') return false; + if (mode !== 'claude' && mode !== 'opencode') return false; const buf = this.terminal?.buffer?.active; if (!buf || buf.type === 'alternate') return false; return (buf.baseY || 0) === 0; @@ -5504,18 +5518,19 @@ Object.assign(CodemanApp.prototype, { * coalesced PageUp/PageDown key sends so the CLI pages its OWN transcript. * * The rescue path for every way `_shouldForwardWheelToApp` can come back false - * on a Claude session that has no local history to fall back on: the CLI - * version probe failed or is genuinely older than 2.1.187, the CLI's mouse - * tracking flag is unset (the inline renderer, or fullscreen right after a - * server restart), or the user turned on "Wheel scrolls local history" (which - * pins the wheel to a buffer that, for a repaint-mode CLI, is empty: the - * setting's footgun). Before this, all of those produced a completely dead - * gesture; the #205 reporter proved the keyboard route works by paging back - * through intact text with Fn+Up. + * on a session that has no local history to fall back on: the CLI version probe + * failed or is genuinely older than 2.1.187, the CLI's mouse tracking flag is + * unset (the inline renderer, or fullscreen right after a server restart), the + * user turned on "Wheel scrolls local history" (which pins the wheel to a buffer + * that, for a repaint-mode CLI, is empty: the setting's footgun), or the CLI is + * opencode, which never fills the buffer and never accepts the wheel. Before + * this, all of those produced a completely dead gesture; the #205 reporter + * proved the keyboard route works by paging back through intact text with Fn+Up. * - * Triple-guarded (claude mode + gate false + `baseY === 0`), so a session with - * real local scrollback is never touched. Shift is excluded on purpose: it is - * the explicit "give me local scrollback" gesture and must keep that meaning. + * Guarded by `_localScrollbackIsHollow()` plus a false forwarding gate, so a + * session with real local scrollback is never touched. Shift is excluded on + * purpose: it is the explicit "give me local scrollback" gesture and must keep + * that meaning. * * @returns true when the gesture was consumed here (the caller must not also * scroll locally). diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index 2321d8fc..060b6668 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -46,13 +46,20 @@ function loadTerminalUiHarness() { return { app: new (CodemanApp as any)(), logs }; } -/** A Claude session whose local buffer holds exactly one screen (baseY 0). */ -function hollowClaudeApp(overrides: { cliVersion?: string; rows?: number; cliMouseTracking?: boolean } = {}) { +/** A session whose local buffer holds exactly one screen (baseY 0) — a hollow pane. */ +function hollowApp(overrides: { mode?: string; cliVersion?: string; rows?: number; cliMouseTracking?: boolean } = {}) { const { app, logs } = loadTerminalUiHarness(); const sent: Array<{ id: string; data: string }> = []; app.activeSessionId = 'sess-1'; app.sessions = new Map([ - ['sess-1', { mode: 'claude', cliVersion: overrides.cliVersion, cliMouseTracking: overrides.cliMouseTracking }], + [ + 'sess-1', + { + mode: overrides.mode ?? 'claude', + cliVersion: overrides.cliVersion, + cliMouseTracking: overrides.cliMouseTracking, + }, + ], ]); app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data }); app.terminal = { @@ -135,7 +142,7 @@ describe('full-history re-pull downgrade guard (issue #205 round 2)', () => { describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2)', () => { it('pages the CLI transcript when the wheel gate is false and there is no scrollback', () => { - const { app, sent } = hollowClaudeApp(); // cliVersion unknown → gate false + const { app, sent } = hollowApp(); // cliVersion unknown → gate false // Half a screen of travel (rows 36 → 18 lines) buys exactly one PageUp. expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true); @@ -149,7 +156,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 }); it('accumulates sub-page travel instead of dropping or over-sending it', () => { - const { app, sent } = hollowClaudeApp(); + const { app, sent } = hollowApp(); expect(app._maybePageCliTranscript({ shiftKey: false }, -10)).toBe(true); // consumed… app._flushWheelSgrQueue(); @@ -161,15 +168,34 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 }); it('caps the keys one gesture batch can emit', () => { - const { app, sent } = hollowClaudeApp(); + const { app, sent } = hollowApp(); app._maybePageCliTranscript({ shiftKey: false }, -1000); // 55 pages of travel app._flushWheelSgrQueue(); expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~'.repeat(3) }]); }); + it('pages an OpenCode pane too, whose TUI never fills the local buffer', () => { + // OpenCode's TUI runs on the ALTERNATE SCREEN (measured on 1.18.31: tmux + // `alternate_on=1`, `history_size=0`), so the browser's normal buffer stays at + // one screen exactly like a repaint-mode Claude pane. The difference is that + // OpenCode IGNORES SGR wheel reports (verified against an idle pane: six + // `\x1b[<64;…M` reports left the capture byte-identical), so PageUp/PageDown + // — its `messages_page_up/down` binds — is the ONLY gesture that reaches its + // transcript. Without this the wheel was silently dead in every OpenCode tab. + const { app, sent } = hollowApp({ mode: 'opencode' }); + + expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true); + app._flushWheelSgrQueue(); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); + + app._maybePageCliTranscript({ shiftKey: false }, 18); + app._flushWheelSgrQueue(); + expect(sent[1]).toEqual({ id: 'sess-1', data: '\x1b[6~' }); + }); + it('leaves every session that has real local scrollback alone', () => { - const { app } = hollowClaudeApp(); + const { app } = hollowApp(); // Shift is the explicit "give me local scrollback" gesture — never paged. expect(app._maybePageCliTranscript({ shiftKey: true }, -18)).toBe(false); @@ -179,12 +205,15 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); app.terminal.buffer.active.baseY = 0; - // Non-Claude modes keep their existing behavior (shell scrolls tmux history - // through the alt-screen strip; codex/gemini page keys are unverified). + // Modes with real terminal scrollback keep their existing behavior (shell/pi + // own tmux history through the alt-screen strip; codex/gemini/antigravity/… + // page-key behaviour is unverified — docs/scrollback-fix-plan.md). app.sessions = new Map([['sess-1', { mode: 'shell' }]]); expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); app.sessions = new Map([['sess-1', { mode: 'codex' }]]); expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); + app.sessions = new Map([['sess-1', { mode: 'antigravity' }]]); + expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); // An alternate-screen pane belongs to xterm's own alt-scroll handling. app.sessions = new Map([['sess-1', { mode: 'claude' }]]); @@ -197,7 +226,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 // repaint-mode CLI, is empty — a user who flipped it while hunting for a fix // on 1.11.x would have ended up with a completely dead wheel on 1.12.0. // Version and tracking both qualify, so the opt-out is the only thing saying no. - const { app, sent } = hollowClaudeApp({ cliVersion: '2.1.223', cliMouseTracking: true }); // gate would forward… + const { app, sent } = hollowApp({ cliVersion: '2.1.223', cliMouseTracking: true }); // gate would forward… app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true }); expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); // …but the opt-out wins @@ -207,7 +236,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 }); it('drops travel accumulated on another tab', () => { - const { app, sent } = hollowClaudeApp(); + const { app, sent } = hollowApp(); app._maybePageCliTranscript({ shiftKey: false }, -17); // just short of a page app.activeSessionId = 'sess-2'; @@ -228,7 +257,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 describe('scroll routing diagnostic (issue #205 round 2)', () => { it('prints the decision and its inputs once per session, and again when it changes', () => { - const { app, logs } = hollowClaudeApp({ cliVersion: '2.1.100' }); + const { app, logs } = hollowApp({ cliVersion: '2.1.100' }); app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false }); app._logScrollRouting('local-scrollback'); @@ -255,7 +284,7 @@ describe('scroll routing diagnostic (issue #205 round 2)', () => { }); it('reports an unknown CLI version, the false-path that disables forwarding', () => { - const { app, logs } = hollowClaudeApp(); // no cliVersion — the probe failed + const { app, logs } = hollowApp(); // no cliVersion — the probe failed app._logScrollRouting('page-keys'); expect(logs[0]).toContain('cliVersion=unknown'); }); From 5ba729fcbb768b9d89a9226677511e1b13c1719a Mon Sep 17 00:00:00 2001 From: Randalix <j.heintz.90@gmail.com> Date: Thu, 8 Oct 2026 14:55:02 +0200 Subject: [PATCH 2/2] fix(terminal): strip opencode's mouse DECSETs so a drag selects text again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 10 +- docs/scrollback-fix-plan.md | 4 +- docs/terminal-copy-shortcut-plan.md | 5 +- src/config/cli-registry/schema.ts | 2 +- src/config/cli-registry/stock.ts | 2 +- src/config/cli-registry/types.ts | 37 ++++++- src/session.ts | 70 ++++++++++--- src/web/public/terminal-ui.js | 44 ++++---- src/web/routes/session-routes.ts | 50 ++++++--- test/claude-scrollback-strip.test.ts | 148 +++++++++++++++++++++++++-- test/terminal-touch-tap.test.ts | 29 +++++- 12 files changed, 318 insertions(+), 85 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a665ae61..cb11f11b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) -**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity/omp get a NARROW strip (alt-screen toggles only). ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) +**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode gets the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) **Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install) **Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 75bbff65..6594f653 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -37,7 +37,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test. ### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP) -**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `--config model_reasoning_effort=<level>` from `reasoningEffort`, `--config tui.animations=<bool>` from `animations`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`. +**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `--config model_reasoning_effort=<level>` from `reasoningEffort`, `--config tui.animations=<bool>` from `animations`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, which is now `isMuxMouseStripMode`, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`. **DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it. @@ -55,7 +55,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test. ⚠️ **The resolver needs the strictest identity probe of any CLI**, because `dsh` is not merely a squattable npm name: Debian ships an unrelated `dsh` (dancer's shell, `apt install dsh`) that would answer a version probe convincingly. `probeDeepSeekVersion()` therefore checks `dsh --help` against `DEEPSEEK_IDENTITY_REGEX` (`DeepSeek Harness`) FIRST and only then reads a version, and `test/deepseek-cli-resolver.test.ts` pins both the rejection and the VITEST hermeticity gate with a real executable fixture. `DEEPSEEK_VERSION_REGEX` keeps the prerelease tail (`0.1.1-rc.2`), since truncating it would report an rc as a release; it is shared with the `dsh` dependency-registry entry so doctor and run mode agree about the version even though the resolver is stricter about identity. -Model is NOT a session field: it is a composition entry in the profile's config tree (`agent-default-model`), configured in `~/.dsh/settings.yaml` + `cordis.patch.yml`, so both create paths deliberately resolve no model for this mode. Env allowlist: `DSH_*` + `DEEPSEEK_*`; provider keys named by a settings-file `apiKeyEnv` stay OUT, which is pi's 34-provider-key problem in a new shape and gets the same answer. Docker seeds `~/.dsh` per-file (`.env`, `settings.yaml`, `cordis.patch.yml`) and the image installs its OWN profile, because `profiles/` is a per-profile `node_modules` tree — host-arch-specific and far too large to copy per container start. Stays OUT of `isAltScreenStripMode()` (third-party fullscreen TUI — the opencode case). ⚠️ `classifyProfile()` reads the profile's BUNDLES, and "unknown means launchable" is deliberate (anyone can publish an app bundle), but it has one knowably-wrong case: `readProfile()` returns an empty bundle list for a `package.json` with no `dsh.profile.bundles`, which made the SHIPPED `web`/`headless` profiles look third-party and launchable. The directory name is therefore consulted as a LAST resort (`STOCK_NON_INTERACTIVE_PROFILES`), after the bundle patterns, so real bundle evidence always wins over a name the user chose. The loose `tui` arm carries word boundaries for the same reason: it decides which profile boots by default, and matching the middle of `intuition` is not a rule anyone could predict. ⚠️ The generated shim is written **temp + rename**, not in place: the TUI can be exec'ing that exact path while an upgraded Codeman refreshes it, and a half-written file is a syntax error the caller then retries four times per state change forever. Bump `SHIM_VERSION` whenever `SHIM_SOURCE` changes, or an existing shim keeps matching the embedded marker and is never refreshed. Availability via `GET /api/deepseek/status`, the widest per-CLI status shape (`available`/`runnable`/`path`/`version`/`dshHome`/`defaultProfile`/`profiles`); `POST /api/deepseek/install-profile` bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode, and the held-open request is bounded by a HAND-ROLLED timeout over a `detached: true` process group (negative-pid SIGTERM→SIGKILL, as `runGit()` does in git-clone.ts). ⚠️ Node's own `spawn` `timeout` is NOT enough: a plugin install fans out into package-manager children, the built-in timeout signals only the direct child, and the survivors hold the inherited stdio pipes open so `close` never fires and the request leaks forever. User guide: `docs/deepseek-integration.md`. Tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`. +Model is NOT a session field: it is a composition entry in the profile's config tree (`agent-default-model`), configured in `~/.dsh/settings.yaml` + `cordis.patch.yml`, so both create paths deliberately resolve no model for this mode. Env allowlist: `DSH_*` + `DEEPSEEK_*`; provider keys named by a settings-file `apiKeyEnv` stay OUT, which is pi's 34-provider-key problem in a new shape and gets the same answer. Docker seeds `~/.dsh` per-file (`.env`, `settings.yaml`, `cordis.patch.yml`) and the image installs its OWN profile, because `profiles/` is a per-profile `node_modules` tree — host-arch-specific and far too large to copy per container start. Stays OUT of `isAltScreenStripMode()` (third-party fullscreen TUI — the opencode case, i.e. `isMuxMouseStripMode` is the shape to measure here too). ⚠️ `classifyProfile()` reads the profile's BUNDLES, and "unknown means launchable" is deliberate (anyone can publish an app bundle), but it has one knowably-wrong case: `readProfile()` returns an empty bundle list for a `package.json` with no `dsh.profile.bundles`, which made the SHIPPED `web`/`headless` profiles look third-party and launchable. The directory name is therefore consulted as a LAST resort (`STOCK_NON_INTERACTIVE_PROFILES`), after the bundle patterns, so real bundle evidence always wins over a name the user chose. The loose `tui` arm carries word boundaries for the same reason: it decides which profile boots by default, and matching the middle of `intuition` is not a rule anyone could predict. ⚠️ The generated shim is written **temp + rename**, not in place: the TUI can be exec'ing that exact path while an upgraded Codeman refreshes it, and a half-written file is a syntax error the caller then retries four times per state change forever. Bump `SHIM_VERSION` whenever `SHIM_SOURCE` changes, or an existing shim keeps matching the embedded marker and is never refreshed. Availability via `GET /api/deepseek/status`, the widest per-CLI status shape (`available`/`runnable`/`path`/`version`/`dshHome`/`defaultProfile`/`profiles`); `POST /api/deepseek/install-profile` bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode, and the held-open request is bounded by a HAND-ROLLED timeout over a `detached: true` process group (negative-pid SIGTERM→SIGKILL, as `runGit()` does in git-clone.ts). ⚠️ Node's own `spawn` `timeout` is NOT enough: a plugin install fans out into package-manager children, the built-in timeout signals only the direct child, and the survivors hold the inherited stdio pipes open so `close` never fires and the request leaks forever. User guide: `docs/deepseek-integration.md`. Tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`. **Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session <id>` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: its default TUI renders into the main screen with terminal-owned scrollback (nothing to strip), and since 0.84.0 the user can flip to a fullscreen TUI at runtime via `/settings`, where the alt screen is load-bearing — being out of the list is exactly what makes that switch safe. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp). @@ -213,11 +213,11 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect` ### Terminal scrollback: strip flavors and wheel/touch forwarding -**Two strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. Every other mode (shell/opencode/antigravity/pi) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same narrow branch; the frontend mirror (`_shouldReportMouseToCli()`) stays claude/codex/gemini because only the FULL strip touches mouse DECSETs. The chunk-boundary carry (`_altScreenSeqCarry`) runs for both flavors. Tests: `test/claude-scrollback-strip.test.ts`. +**Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/pi) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in the mouse-strip branch, so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`. ⚠️ **What the full strip removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach. -**Only claude ≥ 2.1.187 with mouse tracking on forwards the wheel; everything else scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. Claude repeats this exactly in its default INLINE renderer (measured on 2.1.280: `alternate_on=0`, `mouse_any_flag=0`, `history_size` grows), and swipes on iOS Safari were dead there while codex scrolled; only fullscreen claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in `~/.claude/settings.json`: alt screen plus modes 1003/1006) pages its transcript on wheel reports. So claude forwards only while the server-observed `cliMouseTracking` flag is true; a stale-false flag after a server restart falls through to the PageUp/PageDown fallback, never a dead wheel. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (strip mode AND the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk. +**Only claude ≥ 2.1.187 with mouse tracking on forwards the wheel; everything else scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. Claude repeats this exactly in its default INLINE renderer (measured on 2.1.280: `alternate_on=0`, `mouse_any_flag=0`, `history_size` grows), and swipes on iOS Safari were dead there while codex scrolled; only fullscreen claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in `~/.claude/settings.json`: alt screen plus modes 1003/1006) pages its transcript on wheel reports. So claude forwards only while the server-observed `cliMouseTracking` flag is true; a stale-false flag after a server restart falls through to the PageUp/PageDown fallback, never a dead wheel. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips, so only ever true for a stripped mode): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk. **Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`. @@ -674,7 +674,7 @@ Matching semantics: a query containing `/` matches the relative path, otherwise 2. `copyTerminalSelection` is a registry `action` **deliberately missing from `SHORTCUT_ACTIONS`** (same trick as `command-palette`): the entry stays rebindable and disableable in App Settings, while the generic document-capture loop, which `preventDefault()`s every match it dispatches, skips it and lets the terminal handler decide. 3. The gate is keydown-only (the custom handler also runs for `keypress`/`keyup`; that is safe here only because xterm drops a Ctrl keypress itself, see the keypress rule above), and `Ctrl+Shift+C` never falls through to the PTY: an "explicit copy" chord that interrupts a running agent because the selection happened to be empty is a footgun with no upside. -Copy goes through `_copyText()` (Clipboard API, then hidden-textarea + `execCommand`), not raw `navigator.clipboard`, because `install.sh`'s LAN option serves plain HTTP where `navigator.clipboard` is undefined; the fallback steals focus, so the terminal is refocused afterwards. Related: xterm registers its own `copy` listener on the terminal element gated on `hasSelection()`, which is why right-click → Copy has always worked. Selection itself is unavailable on touch devices by design (`user-select: none` on the terminal subtree), and in `shell`/`opencode`/`antigravity` tabs the TUI owns the mouse, so selecting there needs Shift+drag. Tests: `test/terminal-copy-selection.test.ts` (gate + wiring invariants), `test/terminal-copy-shortcut.test.ts` (browser, real key presses). +Copy goes through `_copyText()` (Clipboard API, then hidden-textarea + `execCommand`), not raw `navigator.clipboard`, because `install.sh`'s LAN option serves plain HTTP where `navigator.clipboard` is undefined; the fallback steals focus, so the terminal is refocused afterwards. Related: xterm registers its own `copy` listener on the terminal element gated on `hasSelection()`, which is why right-click → Copy has always worked. Selection itself is unavailable on touch devices by design (`user-select: none` on the terminal subtree), and in `shell`/`antigravity` tabs the TUI owns the mouse, so selecting there needs Shift+drag. `opencode` used to be in that list and no longer is: its mouse DECSETs are stripped server-side (`isMuxMouseStripMode`, see the strip-flavours invariant), so a plain drag selects there. Tests: `test/terminal-copy-selection.test.ts` (gate + wiring invariants), `test/terminal-copy-shortcut.test.ts` (browser, real key presses). **The main terminal's four copy paths clean the selection first** (`CodemanCopySelection.clean` in constants.js, pure; `cleanedTerminalSelection()` in terminal-ui.js is the half that reads the live terminal). Those four are the `Ctrl+C` chord, right-click, the phone selection button and Auto Copy. ⚠️ Three routes still copy the RAW padded rows, all of them predating the clean: the browser's own Edit → Copy, which xterm's own `copy` listener on the terminal element serves with `selectionText` directly; a `copy-selection` shortcut the user disabled in App Settings, where nothing calls `preventDefault()` and that native listener runs; and the subagent/teammate windows, which build their own `Terminal` in panels-ui.js with no copy wiring at all. xterm hands back whole screen ROWS and its own trim drops only cells that were never written to, so the real spaces a full-screen TUI paints across the unused part of a row count as content and reach the clipboard. Measured against Claude Code in a 282-column pane, single lines arrived carrying 138 trailing spaces on top of the two-space transcript indent. The clean drops each line's trailing run, and strips a LEADING margin when the session's CLI declares one. Four rules keep it honest: diff --git a/docs/scrollback-fix-plan.md b/docs/scrollback-fix-plan.md index 497d2707..79210941 100644 --- a/docs/scrollback-fix-plan.md +++ b/docs/scrollback-fix-plan.md @@ -278,8 +278,8 @@ Touch is always-local by design, and Claude sessions keep content in the normal - The `terminalWheelLocalScrollback` opt-out setting keeps working (pins plain wheel to local). - The viewport-at-bottom gate stays: once the user scrolled up locally, wheel stays local until they return to bottom. - 40ms SGR coalescing: never send per-event writes to the server. -- Strip parity triangle: `session.ts` live strip ↔ `session-routes.ts` replay strip ↔ `_sessionUsesServerMouseStrip()` in the frontend. If you touch mode lists, update all three. -- Don't add `opencode`/`antigravity` to any strip/forward list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`). ⚠️ 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. +- Strip parity: `session.ts` live strip ↔ `stripReplayBuffer()` in `session-routes.ts`, both driven by the registry's `altScreen` value and pinned together for every stock CLI in `test/claude-scrollback-strip.test.ts`. The frontend keeps no mode list: `_shouldReportMouseToCli()` reads only the server-published `cliMouseTracking`. +- Don't add `opencode`/`antigravity` to the wheel-FORWARD list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`). ⚠️ 2026-09-16: opencode's half is now MEASURED — 1.18.31 ignores SGR wheel reports but pages its transcript on PageUp/PageDown — so it belongs in the **paging** list (`_localScrollbackIsHollow`). ⚠️ It also joined a STRIP list that same day, for a different reason: `isMuxMouseStripMode` removes its mouse DECSETs so a drag selects text again (see `docs/architecture-invariants.md` §Three strip flavors). antigravity/grok/deepseek/omp remain unverified. - The chunk-boundary sequence carry in `_handleTerminalOutput` must not be weakened. ## Testing (per repo rules) diff --git a/docs/terminal-copy-shortcut-plan.md b/docs/terminal-copy-shortcut-plan.md index 898bc096..6598be93 100644 --- a/docs/terminal-copy-shortcut-plan.md +++ b/docs/terminal-copy-shortcut-plan.md @@ -82,6 +82,7 @@ This is exactly how `command-palette` already behaves: it is a full registry ent - The server strips mouse-tracking DECSETs for `claude`, `codex`, and `gemini` (`isAltScreenStripMode`, `src/session.ts:179`), which is why plain drag-select works in those tabs even though the TUI has mouse tracking on. - `shell`, `opencode`, and `antigravity` keep mouse reporting, so xterm requires `Shift`+drag to force a selection there. Worth one line in the docs, it is not a code change. + ⚠️ **Corrected 2026-09-16:** `opencode` no longer keeps mouse reporting in the browser. Its TUI enables tracking DECSETs, tmux `mouse off` passes them through to the tmux client, and xterm then reported DRAGS to the TUI instead of selecting — so `Shift`+drag was the only way to select, and a plain drag silently copied nothing (measured 62 `none` / 18 `any` over 16s; 5/5 dead drags while `any`). The server now strips those DECSETs (`isMuxMouseStripMode`), so a plain drag selects in opencode. `shell` and `antigravity` are unchanged. - Touch devices deliberately disable selection entirely (`body.touch-device .terminal-container .xterm{user-select:none !important}`, `styles.css:3196`), and phones have no Ctrl key. This feature is desktop and hardware-keyboard only, with no mobile regression surface. ### 2.6 Helpers that already exist and should be reused @@ -245,7 +246,7 @@ The shortcut overlay (`Ctrl+?`) and App Settings -> Shortcuts are registry-drive | Whitespace-only or empty selection | `getSelection()` empty string is treated as "no selection", so Ctrl+C still interrupts | | macOS Cmd+C | registry treats ctrl/meta as interchangeable, so with a selection it takes our path (same visible result as today's native copy), without one it falls through | | Chrome/Firefox `Ctrl+Shift+C` is the devtools inspect chord | browser-level and may still toggle devtools, our copy runs regardless. Document as a caveat, `Ctrl+C` is the primary path | -| Selection in a tab whose TUI owns the mouse (`shell`/`opencode`/`antigravity`) | unchanged, `Shift`+drag selects, then Ctrl+C copies | +| Selection in a tab whose TUI owns the mouse (`shell`/`antigravity`; `opencode` left this list on 2026-09-16 — its DECSETs are stripped now) | unchanged, `Shift`+drag selects, then Ctrl+C copies | | Web tab (iframe dashboard) focused | xterm handler never runs, browser-native copy inside the iframe | | Teammate/subagent terminals (`panels-ui.js:2268`, `onData` wired) | same limitation exists there, out of scope for this PR (section 8) | @@ -281,7 +282,7 @@ Against a throwaway session on the live instance (`curl -sk https://localhost:30 3. Type a few characters with local echo on (phone or `localEchoEnabled` forced), press Ctrl+C with no selection, confirm buffered text plus interrupt behave as before. 4. Uncheck the shortcut in App Settings -> Shortcuts, confirm Ctrl+C always interrupts even with a selection. 5. Rebind it, confirm the new chord copies and Ctrl+C reverts to pure interrupt. -6. Repeat 1 and 2 in an `opencode` or `shell` tab using Shift+drag to select. +6. Repeat 1 and 2 in a `shell` or `antigravity` tab using Shift+drag to select (`opencode` selects with a plain drag since 2026-09-16). 7. Load over plain HTTP (`--host` LAN or `http://127.0.0.1:<port>`) and confirm the `execCommand` fallback copies and focus returns to the terminal. 8. Mobile smoke: confirm nothing changed (selection is CSS-disabled, no Ctrl key). diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 5391c5ac..4abc8a6f 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -289,7 +289,7 @@ const capabilitiesSchema = z requiresMux: z.boolean(), hooks: z.enum(['none', 'always', 'supervised']), transcript: z.enum(['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'omp-jsonl', 'none']), - altScreen: z.enum(['strip-full', 'strip-mux-only', 'preserve']), + altScreen: z.enum(['strip-full', 'strip-mux-only', 'strip-mux-and-mouse', 'preserve']), echo: echoSchema, wheelForward: z .object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() }) diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 45c40c7a..75d4c739 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -491,7 +491,7 @@ const OPENCODE: CliEntry = { }, capabilities: { ...agentDefaults(), - altScreen: 'strip-mux-only', + altScreen: 'strip-mux-and-mouse', echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined }, // opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`. mcpConfig: { diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 147188c5..721e882b 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -436,11 +436,40 @@ export interface CliCapabilities { */ transcript: 'claude-jsonl' | 'codex-rollout' | 'deepseek-zstd' | 'omp-jsonl' | 'none'; /** - * 'strip-full' — alt-screen + erase-scrollback + mouse DECSETs stripped (Ink TUIs). - * 'strip-mux-only' — only tmux's own attach-time smcup (the safe default). - * 'preserve' — leave everything (a direct-PTY shell running vim/less/htop). + * What the server strips from this CLI's output stream before the browser sees it. + * The value encodes three independent choices (predicates in session.ts): + * + * | value | alt-screen toggles | `3J` (erase scrollback) | mouse DECSETs | + * |-----------------------|---------------------|-------------------------|---------------------| + * | `strip-full` | stripped | stripped | stripped | + * | `strip-mux-and-mouse` | stripped under tmux | kept | stripped under tmux | + * | `strip-mux-only` | stripped under tmux | kept | kept | + * | `preserve` | stripped under tmux | kept | kept | + * + * `strip-full` is `isAltScreenStripMode`; `strip-mux-and-mouse` is `isMuxMouseStripMode`; + * every other value takes `isMuxAltScreenOnlyStripMode`, so at runtime `preserve` and + * `strip-mux-only` are the same row — `preserve` only says what such a CLI's pane + * holds (terminal-owned scrollback: a shell, pi), not a different strip. + * + * - alt-screen: the tmux CLIENT emits `smcup` as its first bytes at attach, parking + * xterm in the scrollback-less alternate buffer; a pane program's own toggles never + * reach the client (tmux repaints instead). "Under tmux" means `useMux`: on a + * direct-PTY fallback the `?1049h` is the program's own and must stay. + * - `3J`: a user's `clear` is a deliberate scrollback wipe; only an Ink TUI's + * redraw-driven `3J` (strip-full) is noise. + * - mouse DECSETs: stripping them keeps a drag a local selection instead of a report + * to the TUI. The browser then hand-encodes clicks (`_sendSyntheticSgrTap`), gated + * on the `cliMouseTracking` the server records as it strips. Kept where a program's + * own mouse support must work in the pane (htop/vim in a shell). + * + * Stock CLIs: `strip-full` = claude, codex, gemini (Ink TUIs); `strip-mux-and-mouse` = + * opencode (a full-screen TUI that enables tracking itself); `strip-mux-only` = + * antigravity, grok, deepseek, omp; `preserve` = shell, pi. + * + * A fourth combination is the point to split this into flags; three is still cheaper + * as an enum. */ - altScreen: 'strip-full' | 'strip-mux-only' | 'preserve'; + altScreen: 'strip-full' | 'strip-mux-only' | 'strip-mux-and-mouse' | 'preserve'; echo: { policy: 'buffer' | 'predict' | 'off'; /** How the local-echo overlay locates the composer row. */ diff --git a/src/session.ts b/src/session.ts index 7e92df81..075ff2a5 100644 --- a/src/session.ts +++ b/src/session.ts @@ -265,10 +265,11 @@ function cliExportsTruecolor(mode: SessionMode): boolean { * Codex, Claude Code, and Gemini are known, controlled (Ink/React) TUIs that * repaint via cursor positioning, so dropping the alt-screen switch is safe — * content stays in the normal buffer. Excluded: `shell` (arbitrary programs like - * vim/less/htop legitimately need the alt screen), `opencode` (renders its own - * TUI that may rely on it), `pi` (below) and `grok` (a fullscreen alt-screen TUI - * with mouse support, i.e. the opencode case, not the Ink case). Keep parity - * with the replay-side strip in session-routes.ts. + * vim/less/htop legitimately need the alt screen), `opencode` (its own MIDDLE strip, + * isMuxMouseStripMode), `pi` (below) and `grok` (a fullscreen alt-screen TUI with + * mouse support). Keep parity with the replay-side strip (`stripReplayBuffer` in + * session-routes.ts); the table in `CliCapabilities.altScreen` is pinned for every + * stock CLI in test/claude-scrollback-strip.test.ts. * * ⚠️ Being excluded here does NOT preserve the alt screen. Every excluded mode * falls through to isMuxAltScreenOnlyStripMode(), which strips the alt-screen @@ -288,8 +289,10 @@ export function isAltScreenStripMode(mode: SessionMode): boolean { /** * Modes that need the NARROW strip: alt-screen toggles only, leaving `\x1b[3J` - * and the mouse-tracking DECSETs alone. Applies to every mode `isAltScreenStripMode` - * excludes, but ONLY when the session is tmux-backed (`useMux`). + * and the mouse-tracking DECSETs alone. Applies to every mode that is neither + * `strip-full` (isAltScreenStripMode) nor `strip-mux-and-mouse` (isMuxMouseStripMode), + * so `strip-mux-only` and `preserve` alike, but ONLY when the session is tmux-backed + * (`useMux`). * * The bug (issue #205): the tmux CLIENT emits `smcup` (`\x1b[?1049h`) as its first * bytes on attach, before any program has run. Unstripped, xterm.js parks in the @@ -314,7 +317,31 @@ export function isAltScreenStripMode(mode: SessionMode): boolean { * `\x1b[3J` from a user's own `clear` is a deliberate "wipe my scrollback". */ export function isMuxAltScreenOnlyStripMode(mode: SessionMode, useMux: boolean): boolean { - return useMux && !isAltScreenStripMode(mode); + if (!useMux) return false; + const altScreen = getCli(mode)?.capabilities.altScreen; + return altScreen !== 'strip-full' && altScreen !== 'strip-mux-and-mouse'; +} + +/** + * Modes whose mouse-tracking DECSETs must be stripped, leaving `3J` alone: + * `altScreen: 'strip-mux-and-mouse'`, i.e. a mouse-capable full-screen TUI. + * + * Why this exists (opencode, measured 2026-09-16): the TUI enables tracking + * DECSETs, tmux runs with `mouse off` and therefore passes the PANE's DECSETs + * straight through to the tmux client, and the browser's xterm obeyed them — + * `mouseTrackingMode` flipped to `'any'` and xterm then reported DRAGS to the TUI + * instead of selecting locally. "Mark text, copy on select" silently did nothing + * (measured 62 `none` / 18 `any` over 16s, and 5/5 dead drags while `any`), and + * the obvious fallback — Ctrl+C — is opencode's `app_exit`, so the failure also + * ended sessions. Stripping at the source keeps xterm in selection mode; clicks + * still reach the CLI through the browser's hand-encoded tap, which this strip + * publishes as `cliMouseTracking` (`_recordStrippedMouseMode`). + * + * Gated on `useMux` for the same reason as the narrow strip: on the direct-PTY + * fallback the program's own DECSETs really do reach xterm and must be honoured. + */ +export function isMuxMouseStripMode(mode: SessionMode, useMux: boolean): boolean { + return useMux && getCli(mode)?.capabilities.altScreen === 'strip-mux-and-mouse'; } // Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv) @@ -2489,14 +2516,21 @@ export class Session extends EventEmitter { // redraws overwrite only the cells they target, so non-erased rows keep // their content. Gated to Codex/Claude/Gemini (isAltScreenStripMode). // - // Every OTHER mode (shell/opencode/antigravity) gets the NARROW strip when it - // is tmux-backed: alt-screen toggles only, because the sequence that breaks + // Every OTHER mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip + // when it is tmux-backed: alt-screen toggles only, because the sequence that breaks // scrollback there is tmux's own client-side smcup at attach, not anything the // program in the pane emitted (issue #205, see isMuxAltScreenOnlyStripMode). // 3J and the mouse DECSETs stay, so `clear` and mouse-aware TUIs keep working. + // + // The MIDDLE case (isMuxMouseStripMode) is a mouse-capable full-screen TUI: + // it needs smcup AND the mouse DECSETs gone — otherwise the pane's tracking + // reaches xterm and every drag becomes a mouse report instead of a text + // selection, which is what killed mark-and-copy in opencode — while 3J stays, + // because a TUI is not a `clear` consumer. const fullStrip = isAltScreenStripMode(this.mode); - const altOnlyStrip = !fullStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux); - if (fullStrip || altOnlyStrip) { + const mouseStrip = isMuxMouseStripMode(this.mode, this._useMux); + const altOnlyStrip = !fullStrip && !mouseStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux); + if (fullStrip || mouseStrip || altOnlyStrip) { // Reassemble sequences split across PTY chunk boundaries first: a chunk // ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the // strip below and leave xterm stuck in the scrollback-less alt buffer @@ -2515,14 +2549,18 @@ export class Session extends EventEmitter { // eslint-disable-next-line no-control-regex data = data.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, ''); if (fullStrip) { - data = data + // eslint-disable-next-line no-control-regex + data = data.replace(/\x1b\[3J/g, ''); + } + if (fullStrip || mouseStrip) { + data = data.replace( // eslint-disable-next-line no-control-regex - .replace(/\x1b\[3J/g, '') - // eslint-disable-next-line no-control-regex - .replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, (seq) => { + /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, + (seq) => { this._recordStrippedMouseMode(seq); return ''; - }); + } + ); } } diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 9647927e..030a3643 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1265,7 +1265,8 @@ Object.assign(CodemanApp.prototype, { // A real mouse click normally reaches the PTY through xterm's own mouse // encoder, but that encoder only runs while mouseTrackingMode is ON — and // the server strips the enabling DECSETs from claude/codex/gemini output - // (isAltScreenStripMode, session.ts) so the wheel keeps scrolling + // (isAltScreenStripMode, session.ts) and from opencode's (isMuxMouseStripMode, + // so a drag selects text) so the wheel keeps scrolling // scrollback. Desktop clicks therefore stopped reporting entirely (the // same breakage the mobile touchend tap branch above works around). // Hand-encode the SGR report for plain left-clicks on those sessions. @@ -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 - * the stream (claude/codex/gemini, `isAltScreenStripMode`), which is why - * xterm's own encoder is permanently idle here and something has to stand - * in for it. - * 2. The CLI actually has a mouse-tracking mode on right now. The server - * records that as it strips (`_recordStrippedMouseMode` in session.ts) and - * publishes it as `cliMouseTracking`. Without this half the browser - * reported EVERY click, so a CLI sitting at its composer with no dialog - * open, or a pane that has fallen back to a shell prompt, received mouse - * reports it never asked for. A shell prints those as literal text - * (`[<0;88;20M`) and they garble the next line typed. + * Without the flag the browser reported EVERY click, so a CLI sitting at its + * composer with no dialog open, or a pane that has fallen back to a shell + * prompt, received mouse reports it never asked for. A shell prints those as + * literal text (`[<0;88;20M`) and they garble the next line typed. * * Fails toward silence: an unknown or stale flag reports nothing rather than * injecting bytes. After a server restart the flag is false until the CLI * re-emits its DECSET, which closing and reopening a dialog does. */ _shouldReportMouseToCli() { - const session = this.sessions?.get(this.activeSessionId); - const mode = session?.mode || 'claude'; - if (mode !== 'claude' && mode !== 'codex' && mode !== 'gemini') return false; - return session?.cliMouseTracking === true; + return this.sessions?.get(this.activeSessionId)?.cliMouseTracking === true; }, // True when xterm's viewport shows the live PTY screen (not scrolled up into @@ -5631,7 +5628,8 @@ Object.assign(CodemanApp.prototype, { * The reason is that the habit and xterm's Shift mean different things once * the DECSETs are stripped. xterm reads Shift as "force selection" ONLY while * the app actually has mouse tracking on; the server strips those DECSETs for - * claude/codex/gemini (isAltScreenStripMode), so xterm's mouseTrackingMode is + * claude/codex/gemini (isAltScreenStripMode) and opencode (isMuxMouseStripMode), + * so xterm's mouseTrackingMode is * permanently `none`, that branch is unreachable, and Shift instead falls into * `_onIncrementalClick` — EXTEND an existing selection. Extending is a no-op * when `selectionStart` is null, so the drag never anchors and no selection is diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index a21b19f4..664002bf 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -36,6 +36,7 @@ import { isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode, + isMuxMouseStripMode, } from '../../session.js'; import type { PaneCaptureOptions } from '../../mux-interface.js'; import { SseEvent } from '../sse-events.js'; @@ -224,6 +225,37 @@ const ERASE_SCROLLBACK_PATTERN = /\x1b\[3J/g; // eslint-disable-next-line no-control-regex const MOUSE_TRACKING_PATTERN = /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g; +/** + * The replay half of the strip parity triangle: what `_handleTerminalOutput` (session.ts) + * removes from the live stream, removed again from a stored buffer before it is replayed, + * because a buffer recorded before the live-side strip existed (or by an older server) + * still carries the sequences, and one replayed enable is enough to re-park xterm. + * + * - `strip-full` (claude/codex/gemini): alt-screen toggles, `3J` and mouse DECSETs. + * xterm obeys the toggles by switching to its scrollback-less alt buffer and wiping + * saved lines, so history disappeared on tab switch. + * - `strip-mux-and-mouse` (opencode, tmux-backed): alt-screen toggles and mouse DECSETs. + * A replayed tracking enable parks xterm in report mode, where a drag goes to the CLI + * instead of selecting text (and Ctrl+C without a selection is opencode's app_exit). + * `3J` stays: a TUI is not a `clear` consumer. + * - every other mode, tmux-backed: tmux's own client smcup only (#205). + * + * Exported so the parity with the live strip is a test (claude-scrollback-strip.test.ts). + */ +export function stripReplayBuffer(buffer: string, mode: SessionMode, usesMux: boolean): string { + if (isAltScreenStripMode(mode)) { + return buffer + .replace(ALT_SCREEN_TOGGLE_PATTERN, '') + .replace(ERASE_SCROLLBACK_PATTERN, '') + .replace(MOUSE_TRACKING_PATTERN, ''); + } + if (isMuxMouseStripMode(mode, usesMux)) { + return buffer.replace(ALT_SCREEN_TOGGLE_PATTERN, '').replace(MOUSE_TRACKING_PATTERN, ''); + } + if (isMuxAltScreenOnlyStripMode(mode, usesMux)) return buffer.replace(ALT_SCREEN_TOGGLE_PATTERN, ''); + return buffer; +} + /** * Strip redundant Ink spinner/status-bar redraw frames from the terminal buffer. * Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA) to animate @@ -3087,21 +3119,9 @@ export function registerSessionRoutes( ? rawBuffer : stripInkRedrawBloat(rawBuffer); - // Strip alt-screen toggles and scrollback-erase from Codex/Claude byte - // streams. xterm.js obeys them by switching to its scrollback-less alt - // buffer and wiping saved lines, so conversation history disappears on tab - // switch. Same gate as the live-stream strip in session.ts. - if (isAltScreenStripMode(session.mode)) { - strippedBuffer = strippedBuffer - .replace(ALT_SCREEN_TOGGLE_PATTERN, '') - .replace(ERASE_SCROLLBACK_PATTERN, '') - .replace(MOUSE_TRACKING_PATTERN, ''); - } else if (isMuxAltScreenOnlyStripMode(session.mode, session.usesMux)) { - // tmux-backed shell/opencode/antigravity: drop tmux's own client smcup only. - // A byte buffer recorded before the live-side strip existed can still carry - // it, and one replayed `\x1b[?1049h` re-parks xterm in the alt buffer (#205). - strippedBuffer = strippedBuffer.replace(ALT_SCREEN_TOGGLE_PATTERN, ''); - } + // Same strip as the live stream (session.ts), so a buffer recorded before the + // live-side strip existed cannot re-park xterm on replay. See stripReplayBuffer. + strippedBuffer = stripReplayBuffer(strippedBuffer, session.mode, session.usesMux); if (tailBytes > 0 && strippedBuffer.length > tailBytes) { // Fast path: tail from the end, skip expensive banner search on full 2MB buffer. diff --git a/test/claude-scrollback-strip.test.ts b/test/claude-scrollback-strip.test.ts index 80336109..9abe1323 100644 --- a/test/claude-scrollback-strip.test.ts +++ b/test/claude-scrollback-strip.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../src/session.js'; +import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode, isMuxMouseStripMode } from '../src/session.js'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; +import { stripReplayBuffer } from '../src/web/routes/session-routes.js'; type SessionInternals = { _handleTerminalOutput(data: string): void; @@ -94,18 +96,96 @@ describe('Shell terminal output on a DIRECT PTY is NOT stripped (vim/less/htop n describe('isMuxAltScreenOnlyStripMode', () => { it('covers exactly the modes the full strip does not, and only under tmux', () => { - for (const mode of ['shell', 'opencode', 'antigravity'] as const) { + for (const mode of ['shell', 'antigravity'] as const) { expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(true); // Direct-PTY fallback: the program's own alt screen really does reach xterm. expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false); } - // The full strip already owns these; never double-gate them here. - for (const mode of ['claude', 'codex', 'gemini'] as const) { + // The full strip and the mouse strip already own their modes; never double-gate. + for (const mode of ['claude', 'codex', 'gemini', 'opencode'] as const) { expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false); } }); }); +/** + * opencode's TUI is a mouse-capable full-screen app: it enables tracking DECSETs, + * tmux `mouse off` passes them straight through to the tmux CLIENT, and xterm then + * reports DRAGS to the TUI instead of selecting locally. That killed "mark text, + * copy on select" intermittently — and the obvious fallback, Ctrl+C, is opencode's + * `app_exit`, so the failure also ended sessions. + * + * It needs the alt-screen strip AND the mouse strip, but NOT `3J`: opencode is a + * TUI, not a `clear` consumer, so keeping 3J is the conservative middle ground + * between the full strip and the narrow one. + */ +describe('opencode: alt-screen + mouse DECSETs stripped, 3J kept', () => { + it('is a mouse-strip mode under tmux, and only there', () => { + expect(isMuxMouseStripMode('opencode', true)).toBe(true); + // Direct-PTY fallback: the pane's own alt screen really does reach xterm. + expect(isMuxMouseStripMode('opencode', false)).toBe(false); + for (const mode of ['claude', 'codex', 'gemini', 'shell', 'antigravity'] as const) { + expect(isMuxMouseStripMode(mode, true)).toBe(false); + } + }); + + it('drops mouse tracking so xterm keeps local text selection', () => { + const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true }); + const emitted: string[] = []; + session.on('terminal', (data) => emitted.push(data)); + + handleOutput(session, '\x1b[?1003h\x1b[?1006hTUI\x1b[?1006l\x1b[?1003l'); + + expect(emitted[0]).toBe('TUI'); + expect(session.terminalBuffer).toBe('TUI'); + }); + + it('still drops tmux’s attach-time smcup', () => { + const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true }); + + handleOutput(session, '\x1b[?1049h\x1b[22;0;0t\x1b[H\x1b[2Jprompt'); + + expect(session.terminalBuffer).toBe('\x1b[22;0;0t\x1b[H\x1b[2Jprompt'); + }); + + it('KEEPS 3J, unlike the full strip', () => { + const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true }); + + handleOutput(session, '\x1b[3Jtext'); + + expect(session.terminalBuffer).toBe('\x1b[3Jtext'); + }); + + it('publishes cliMouseTracking so the browser can hand-encode clicks', () => { + // xterm can never see the DECSETs once they are stripped, so the click path + // (_sendSyntheticSgrTap) is the only way a click still reaches opencode. + const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true }); + + handleOutput(session, '\x1b[?1003h\x1b[?1006h'); + + expect(session.toState().cliMouseTracking).toBe(true); + }); + + it('reassembles a mouse DECSET split across PTY chunks', () => { + const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true }); + + handleOutput(session, 'before\x1b[?100'); + handleOutput(session, '3h after'); + + expect(session.terminalBuffer).toBe('before after'); + expect(session.toState().cliMouseTracking).toBe(true); + }); + + it('leaves a direct-PTY opencode pane untouched', () => { + const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: false }); + const out = '\x1b[?1049h\x1b[?1003h'; + + handleOutput(session, out); + + expect(session.terminalBuffer).toBe(out); + }); +}); + describe('tmux-backed shell: strip tmux’s own client smcup, keep everything else (#205)', () => { it('drops alt-screen toggles so xterm keeps a scrollback buffer', () => { const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true }); @@ -127,6 +207,17 @@ describe('tmux-backed shell: strip tmux’s own client smcup, keep everything el expect(session.terminalBuffer).toBe('\x1b[3J\x1b[?1002h\x1b[?1006hhtop\x1b[?1006l\x1b[?1002l'); }); + it('never publishes cliMouseTracking for a mode whose DECSETs it keeps', () => { + // The browser's `_shouldReportMouseToCli()` reads only this flag, with no mode + // list: a flag set for a non-stripping mode would make it hand-encode a second + // report on top of xterm's own. Only the mouse-strip branch may set it. + for (const mode of ['shell', 'antigravity'] as const) { + const session = new Session({ workingDir: '/tmp', mode, useMux: true }); + handleOutput(session, '\x1b[?1002h\x1b[?1006hmouse app'); + expect(session.toState().cliMouseTracking, mode).toBeFalsy(); + } + }); + it('reassembles alt-screen sequences split across PTY chunk boundaries', () => { const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true }); const emitted: string[] = []; @@ -139,12 +230,10 @@ describe('tmux-backed shell: strip tmux’s own client smcup, keep everything el expect(emitted).toEqual(['before', ' after']); }); - it('applies to opencode and antigravity too', () => { - for (const mode of ['opencode', 'antigravity'] as const) { - const session = new Session({ workingDir: '/tmp', mode, useMux: true }); - handleOutput(session, '\x1b[?1049hTUI\x1b[3J'); - expect(session.terminalBuffer).toBe('TUI\x1b[3J'); - } + it('applies to antigravity too (opencode has its own strip — see below)', () => { + const session = new Session({ workingDir: '/tmp', mode: 'antigravity', useMux: true }); + handleOutput(session, '\x1b[?1049hTUI\x1b[3J'); + expect(session.terminalBuffer).toBe('TUI\x1b[3J'); }); }); @@ -238,3 +327,42 @@ describe('stripped mouse-tracking state', () => { expect(session.terminalBuffer).toBe('\x1b[?1002hhtop'); }); }); + +/** + * The decision table in `CliCapabilities.altScreen`'s JSDoc, pinned for every stock CLI + * with and without tmux, on both halves of the parity triangle that can drift apart: the + * live stream (`_handleTerminalOutput`) and the replay of a stored buffer + * (`stripReplayBuffer`, session-routes.ts). The frontend half reads only the published + * `cliMouseTracking`, so the last column is what keeps it right. + */ +describe('strip decision table: live stream = replay, for every stock CLI', () => { + const ALT = '\x1b[?1049h'; + const ERASE = '\x1b[3J'; + const MOUSE = '\x1b[?1002h\x1b[?1006h'; + const input = `A${ALT}B${ERASE}C${MOUSE}D`; + + /** Read straight off the table, not off the predicates under test. */ + function expected(altScreen: string, useMux: boolean): { out: string; tracking: boolean } { + const strip = { alt: false, erase: false, mouse: false }; + if (altScreen === 'strip-full') Object.assign(strip, { alt: true, erase: true, mouse: true }); + else if (useMux && altScreen === 'strip-mux-and-mouse') Object.assign(strip, { alt: true, mouse: true }); + else if (useMux) strip.alt = true; // strip-mux-only and preserve: the same runtime row + return { + out: `A${strip.alt ? '' : ALT}B${strip.erase ? '' : ERASE}C${strip.mouse ? '' : MOUSE}D`, + tracking: strip.mouse, + }; + } + + for (const entry of STOCK_CLIS) { + for (const useMux of [true, false]) { + it(`${entry.id} (${entry.capabilities.altScreen}, ${useMux ? 'tmux' : 'direct PTY'})`, () => { + const want = expected(entry.capabilities.altScreen, useMux); + const session = new Session({ workingDir: '/tmp', mode: entry.id, useMux }); + handleOutput(session, input); + expect(session.terminalBuffer).toBe(want.out); + expect(stripReplayBuffer(input, entry.id, useMux)).toBe(want.out); + expect(Boolean(session.toState().cliMouseTracking)).toBe(want.tracking); + }); + } + } +}); diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index f35106b4..6974c39e 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -366,14 +366,33 @@ describe('terminal touch tap mouse guard', () => { expect(sent).toEqual(['\x1b[<0;1;24M\x1b[<0;1;24m']); }); - it('never hand-reports for a shell session, even with tracking somehow set', () => { - // Shell DECSETs are NOT stripped (narrow strip), so xterm's own encoder owns - // the mouse there and a second, hand-encoded report would double-report. + it('follows the server flag alone, with no mode list of its own', () => { + // A shell's DECSETs are not stripped, so xterm's own encoder owns the mouse there + // and a hand-encoded report would double-report. That is kept by the SERVER never + // setting the flag for a non-stripping mode (pinned in claude-scrollback-strip.test.ts), + // not by a mode check here: the browser reads only the flag. const { app } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; - app.sessions = new Map([['sess-1', { mode: 'shell', cliMouseTracking: true }]]); - + app.sessions = new Map([['sess-1', { mode: 'shell' }]]); expect(app._shouldReportMouseToCli()).toBe(false); + + app.sessions = new Map([['sess-1', { mode: 'some-future-cli', cliMouseTracking: true }]]); + expect(app._shouldReportMouseToCli()).toBe(true); + }); + + it('hand-reports for opencode, whose DECSETs the server now strips', () => { + // opencode's TUI enables mouse tracking, tmux passes the DECSETs through, and + // xterm used to report DRAGS to the TUI instead of selecting — so marking text + // copied nothing. The server strips them now (isMuxMouseStripMode), which makes + // the hand-encoded tap the only way a click still reaches opencode. + const { app } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + + app.sessions = new Map([['sess-1', { mode: 'opencode' }]]); + expect(app._shouldReportMouseToCli()).toBe(false); + + app.sessions = new Map([['sess-1', { mode: 'opencode', cliMouseTracking: true }]]); + expect(app._shouldReportMouseToCli()).toBe(true); }); it('hand-reports only while the CLI actually has mouse tracking on', () => {