Merge #555: opencode drags select text and the wheel pages its transcript

This commit is contained in:
Codeman maintainer
2026-10-09 05:28:51 +02:00
13 changed files with 391 additions and 114 deletions
+1 -1
View File
@@ -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() })
+1 -1
View File
@@ -491,7 +491,7 @@ const OPENCODE: CliEntry = {
},
capabilities: {
...agentDefaults(),
altScreen: 'strip-mux-only',
altScreen: 'strip-mux-and-mouse',
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
// Measured on a live opencode 1.3.0 pane (capture-pane every 250-300 ms through real
// turns at 40, 60, 120 and 200 columns, plus the raw PTY stream, 2026-10-09). Every
+33 -4
View File
@@ -439,11 +439,40 @@ export interface CliCapabilities {
*/
transcript: 'claude-jsonl' | 'codex-rollout' | 'deepseek-zstd' | 'omp-jsonl' | 'none';
/**
* 'strip-full' — alt-screen + erase-scrollback + mouse DECSETs stripped (Ink TUIs).
* 'strip-mux-only' — only tmux's own attach-time smcup (the safe default).
* 'preserve' — leave everything (a direct-PTY shell running vim/less/htop).
* What the server strips from this CLI's output stream before the browser sees it.
* The value encodes three independent choices (predicates in session.ts):
*
* | value | alt-screen toggles | `3J` (erase scrollback) | mouse DECSETs |
* |-----------------------|---------------------|-------------------------|---------------------|
* | `strip-full` | stripped | stripped | stripped |
* | `strip-mux-and-mouse` | stripped under tmux | kept | stripped under tmux |
* | `strip-mux-only` | stripped under tmux | kept | kept |
* | `preserve` | stripped under tmux | kept | kept |
*
* `strip-full` is `isAltScreenStripMode`; `strip-mux-and-mouse` is `isMuxMouseStripMode`;
* every other value takes `isMuxAltScreenOnlyStripMode`, so at runtime `preserve` and
* `strip-mux-only` are the same row — `preserve` only says what such a CLI's pane
* holds (terminal-owned scrollback: a shell, pi), not a different strip.
*
* - alt-screen: the tmux CLIENT emits `smcup` as its first bytes at attach, parking
* xterm in the scrollback-less alternate buffer; a pane program's own toggles never
* reach the client (tmux repaints instead). "Under tmux" means `useMux`: on a
* direct-PTY fallback the `?1049h` is the program's own and must stay.
* - `3J`: a user's `clear` is a deliberate scrollback wipe; only an Ink TUI's
* redraw-driven `3J` (strip-full) is noise.
* - mouse DECSETs: stripping them keeps a drag a local selection instead of a report
* to the TUI. The browser then hand-encodes clicks (`_sendSyntheticSgrTap`), gated
* on the `cliMouseTracking` the server records as it strips. Kept where a program's
* own mouse support must work in the pane (htop/vim in a shell).
*
* Stock CLIs: `strip-full` = claude, codex, gemini (Ink TUIs); `strip-mux-and-mouse` =
* opencode (a full-screen TUI that enables tracking itself); `strip-mux-only` =
* antigravity, grok, deepseek, omp; `preserve` = shell, pi.
*
* A fourth combination is the point to split this into flags; three is still cheaper
* as an enum.
*/
altScreen: 'strip-full' | 'strip-mux-only' | 'preserve';
altScreen: 'strip-full' | 'strip-mux-only' | 'strip-mux-and-mouse' | 'preserve';
echo: {
policy: 'buffer' | 'predict' | 'off';
/** How the local-echo overlay locates the composer row. */
+54 -16
View File
@@ -277,10 +277,11 @@ function cliExportsTruecolor(mode: SessionMode): boolean {
* Codex, Claude Code, and Gemini are known, controlled (Ink/React) TUIs that
* repaint via cursor positioning, so dropping the alt-screen switch is safe —
* content stays in the normal buffer. Excluded: `shell` (arbitrary programs like
* vim/less/htop legitimately need the alt screen), `opencode` (renders its own
* TUI that may rely on it), `pi` (below) and `grok` (a fullscreen alt-screen TUI
* with mouse support, i.e. the opencode case, not the Ink case). Keep parity
* with the replay-side strip in session-routes.ts.
* vim/less/htop legitimately need the alt screen), `opencode` (its own MIDDLE strip,
* isMuxMouseStripMode), `pi` (below) and `grok` (a fullscreen alt-screen TUI with
* mouse support). Keep parity with the replay-side strip (`stripReplayBuffer` in
* session-routes.ts); the table in `CliCapabilities.altScreen` is pinned for every
* stock CLI in test/claude-scrollback-strip.test.ts.
*
* ⚠️ Being excluded here does NOT preserve the alt screen. Every excluded mode
* falls through to isMuxAltScreenOnlyStripMode(), which strips the alt-screen
@@ -300,8 +301,10 @@ export function isAltScreenStripMode(mode: SessionMode): boolean {
/**
* Modes that need the NARROW strip: alt-screen toggles only, leaving `\x1b[3J`
* and the mouse-tracking DECSETs alone. Applies to every mode `isAltScreenStripMode`
* excludes, but ONLY when the session is tmux-backed (`useMux`).
* and the mouse-tracking DECSETs alone. Applies to every mode that is neither
* `strip-full` (isAltScreenStripMode) nor `strip-mux-and-mouse` (isMuxMouseStripMode),
* so `strip-mux-only` and `preserve` alike, but ONLY when the session is tmux-backed
* (`useMux`).
*
* The bug (issue #205): the tmux CLIENT emits `smcup` (`\x1b[?1049h`) as its first
* bytes on attach, before any program has run. Unstripped, xterm.js parks in the
@@ -326,7 +329,31 @@ export function isAltScreenStripMode(mode: SessionMode): boolean {
* `\x1b[3J` from a user's own `clear` is a deliberate "wipe my scrollback".
*/
export function isMuxAltScreenOnlyStripMode(mode: SessionMode, useMux: boolean): boolean {
return useMux && !isAltScreenStripMode(mode);
if (!useMux) return false;
const altScreen = getCli(mode)?.capabilities.altScreen;
return altScreen !== 'strip-full' && altScreen !== 'strip-mux-and-mouse';
}
/**
* Modes whose mouse-tracking DECSETs must be stripped, leaving `3J` alone:
* `altScreen: 'strip-mux-and-mouse'`, i.e. a mouse-capable full-screen TUI.
*
* Why this exists (opencode, measured 2026-09-16): the TUI enables tracking
* DECSETs, tmux runs with `mouse off` and therefore passes the PANE's DECSETs
* straight through to the tmux client, and the browser's xterm obeyed them —
* `mouseTrackingMode` flipped to `'any'` and xterm then reported DRAGS to the TUI
* instead of selecting locally. "Mark text, copy on select" silently did nothing
* (measured 62 `none` / 18 `any` over 16s, and 5/5 dead drags while `any`), and
* the obvious fallback — Ctrl+C — is opencode's `app_exit`, so the failure also
* ended sessions. Stripping at the source keeps xterm in selection mode; clicks
* still reach the CLI through the browser's hand-encoded tap, which this strip
* publishes as `cliMouseTracking` (`_recordStrippedMouseMode`).
*
* Gated on `useMux` for the same reason as the narrow strip: on the direct-PTY
* fallback the program's own DECSETs really do reach xterm and must be honoured.
*/
export function isMuxMouseStripMode(mode: SessionMode, useMux: boolean): boolean {
return useMux && getCli(mode)?.capabilities.altScreen === 'strip-mux-and-mouse';
}
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
@@ -2529,14 +2556,21 @@ export class Session extends EventEmitter {
// redraws overwrite only the cells they target, so non-erased rows keep
// their content. Gated to Codex/Claude/Gemini (isAltScreenStripMode).
//
// Every OTHER mode (shell/opencode/antigravity) gets the NARROW strip when it
// is tmux-backed: alt-screen toggles only, because the sequence that breaks
// Every OTHER mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip
// when it is tmux-backed: alt-screen toggles only, because the sequence that breaks
// scrollback there is tmux's own client-side smcup at attach, not anything the
// program in the pane emitted (issue #205, see isMuxAltScreenOnlyStripMode).
// 3J and the mouse DECSETs stay, so `clear` and mouse-aware TUIs keep working.
//
// The MIDDLE case (isMuxMouseStripMode) is a mouse-capable full-screen TUI:
// it needs smcup AND the mouse DECSETs gone — otherwise the pane's tracking
// reaches xterm and every drag becomes a mouse report instead of a text
// selection, which is what killed mark-and-copy in opencode — while 3J stays,
// because a TUI is not a `clear` consumer.
const fullStrip = isAltScreenStripMode(this.mode);
const altOnlyStrip = !fullStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux);
if (fullStrip || altOnlyStrip) {
const mouseStrip = isMuxMouseStripMode(this.mode, this._useMux);
const altOnlyStrip = !fullStrip && !mouseStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux);
if (fullStrip || mouseStrip || altOnlyStrip) {
// Reassemble sequences split across PTY chunk boundaries first: a chunk
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
// strip below and leave xterm stuck in the scrollback-less alt buffer
@@ -2555,14 +2589,18 @@ export class Session extends EventEmitter {
// eslint-disable-next-line no-control-regex
data = data.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '');
if (fullStrip) {
data = data
// eslint-disable-next-line no-control-regex
data = data.replace(/\x1b\[3J/g, '');
}
if (fullStrip || mouseStrip) {
data = data.replace(
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[3J/g, '')
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, (seq) => {
/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g,
(seq) => {
this._recordStrippedMouseMode(seq);
return '';
});
}
);
}
}
+51 -38
View File
@@ -1273,7 +1273,8 @@ Object.assign(CodemanApp.prototype, {
// A real mouse click normally reaches the PTY through xterm's own mouse
// encoder, but that encoder only runs while mouseTrackingMode is ON — and
// the server strips the enabling DECSETs from claude/codex/gemini output
// (isAltScreenStripMode, session.ts) so the wheel keeps scrolling
// (isAltScreenStripMode, session.ts) and from opencode's (isMuxMouseStripMode,
// so a drag selects text) so the wheel keeps scrolling
// scrollback. Desktop clicks therefore stopped reporting entirely (the
// same breakage the mobile touchend tap branch above works around).
// Hand-encode the SGR report for plain left-clicks on those sessions.
@@ -5369,36 +5370,32 @@ Object.assign(CodemanApp.prototype, {
}
},
// Mirror of the server's isAltScreenStripMode (session.ts): session modes whose
// output stream has mouse-tracking DECSET sequences stripped before reaching the
// browser. For these, xterm's live mouseTrackingMode is useless as a gate — the
// PTY-side TUI keeps tracking enabled, we just never see the enable sequence.
/**
* True when the browser has to hand-encode a click report for the CLI.
* True when the browser has to hand-encode a click report for the CLI: the
* server stripped this session's mouse-tracking DECSETs out of the stream (so
* xterm's own encoder is permanently idle here and something has to stand in
* for it) AND the CLI has a tracking mode on right now.
*
* Two conditions, and dropping either one is a bug that has already happened:
* One flag answers both. The server sets `cliMouseTracking` only as it strips a
* tracking DECSET (`_recordStrippedMouseMode` in session.ts, called from the
* mouse-strip branch of `_handleTerminalOutput` and nowhere else), so it can
* only ever be true for a mode whose DECSETs are stripped: whichever modes the
* registry decides to strip, the browser follows, with no mode list here to
* keep in step. For a `preserve` / `strip-mux-only` mode the flag stays false
* and xterm keeps encoding its own reports. That invariant is pinned server-side
* in test/claude-scrollback-strip.test.ts.
*
* 1. The session's mode is one whose mouse DECSETs the server STRIPS out of
* the stream (claude/codex/gemini, `isAltScreenStripMode`), which is why
* xterm's own encoder is permanently idle here and something has to stand
* in for it.
* 2. The CLI actually has a mouse-tracking mode on right now. The server
* records that as it strips (`_recordStrippedMouseMode` in session.ts) and
* publishes it as `cliMouseTracking`. Without this half the browser
* reported EVERY click, so a CLI sitting at its composer with no dialog
* open, or a pane that has fallen back to a shell prompt, received mouse
* reports it never asked for. A shell prints those as literal text
* (`[<0;88;20M`) and they garble the next line typed.
* Without the flag the browser reported EVERY click, so a CLI sitting at its
* composer with no dialog open, or a pane that has fallen back to a shell
* prompt, received mouse reports it never asked for. A shell prints those as
* literal text (`[<0;88;20M`) and they garble the next line typed.
*
* Fails toward silence: an unknown or stale flag reports nothing rather than
* injecting bytes. After a server restart the flag is false until the CLI
* re-emits its DECSET, which closing and reopening a dialog does.
*/
_shouldReportMouseToCli() {
const session = this.sessions?.get(this.activeSessionId);
const mode = session?.mode || 'claude';
if (mode !== 'claude' && mode !== 'codex' && mode !== 'gemini') return false;
return session?.cliMouseTracking === true;
return this.sessions?.get(this.activeSessionId)?.cliMouseTracking === true;
},
// True when xterm's viewport shows the live PTY screen (not scrolled up into
@@ -5583,15 +5580,29 @@ Object.assign(CodemanApp.prototype, {
},
/**
* True when this session's LOCAL scrollback is structurally empty: a Claude
* pane in repaint mode, where tmux reports `history_size≈0` and every frame
* overwrites the last, so xterm's normal buffer never grows past one screen
* True when this session's LOCAL scrollback is structurally empty: a pane whose
* TUI repaints one full screen in place, so tmux keeps no history for it
* (`history_size≈0`) and xterm's normal buffer never grows past one screen
* (`baseY === 0`). Scrolling that buffer is a no-op no matter how the gesture
* is routed — the "wheel does nothing at all" half of the #205 retest.
*
* Two shapes, measured separately:
* - `claude` in repaint mode (the original, #205 round 2), and
* - `opencode`, whose TUI runs on the ALTERNATE SCREEN (opencode 1.18.31: tmux
* `alternate_on=1`, `history_size=0`) and so pushes nothing into the
* terminal's scrollback at all. It pages its own transcript with the same
* PageUp/PageDown keys (`messages_page_up/down`) but IGNORES SGR wheel
* reports — six `\x1b[<64;…M` reports against an idle pane left the capture
* byte-identical — so paging is the only gesture that reaches it. Without
* this the wheel was silently dead in every opencode tab.
*
* Every other mode is deliberately absent: shell/pi own real terminal
* scrollback, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour
* is unverified (docs/scrollback-fix-plan.md).
*/
_localScrollbackIsHollow() {
const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude';
if (mode !== 'claude') return false;
if (mode !== 'claude' && mode !== 'opencode') return false;
const buf = this.terminal?.buffer?.active;
if (!buf || buf.type === 'alternate') return false;
return (buf.baseY || 0) === 0;
@@ -5602,18 +5613,19 @@ Object.assign(CodemanApp.prototype, {
* coalesced PageUp/PageDown key sends so the CLI pages its OWN transcript.
*
* The rescue path for every way `_shouldForwardWheelToApp` can come back false
* on a Claude session that has no local history to fall back on: the CLI
* version probe failed or is genuinely older than 2.1.187, the CLI's mouse
* tracking flag is unset (the inline renderer, or fullscreen right after a
* server restart), or the user turned on "Wheel scrolls local history" (which
* pins the wheel to a buffer that, for a repaint-mode CLI, is empty: the
* setting's footgun). Before this, all of those produced a completely dead
* gesture; the #205 reporter proved the keyboard route works by paging back
* through intact text with Fn+Up.
* on a session that has no local history to fall back on: the CLI version probe
* failed or is genuinely older than 2.1.187, the CLI's mouse tracking flag is
* unset (the inline renderer, or fullscreen right after a server restart), the
* user turned on "Wheel scrolls local history" (which pins the wheel to a buffer
* that, for a repaint-mode CLI, is empty: the setting's footgun), or the CLI is
* opencode, which never fills the buffer and never accepts the wheel. Before
* this, all of those produced a completely dead gesture; the #205 reporter
* proved the keyboard route works by paging back through intact text with Fn+Up.
*
* Triple-guarded (claude mode + gate false + `baseY === 0`), so a session with
* real local scrollback is never touched. Shift is excluded on purpose: it is
* the explicit "give me local scrollback" gesture and must keep that meaning.
* Guarded by `_localScrollbackIsHollow()` plus a false forwarding gate, so a
* session with real local scrollback is never touched. Shift is excluded on
* purpose: it is the explicit "give me local scrollback" gesture and must keep
* that meaning.
*
* @returns true when the gesture was consumed here (the caller must not also
* scroll locally).
@@ -5714,7 +5726,8 @@ Object.assign(CodemanApp.prototype, {
* The reason is that the habit and xterm's Shift mean different things once
* the DECSETs are stripped. xterm reads Shift as "force selection" ONLY while
* the app actually has mouse tracking on; the server strips those DECSETs for
* claude/codex/gemini (isAltScreenStripMode), so xterm's mouseTrackingMode is
* claude/codex/gemini (isAltScreenStripMode) and opencode (isMuxMouseStripMode),
* so xterm's mouseTrackingMode is
* permanently `none`, that branch is unreachable, and Shift instead falls into
* `_onIncrementalClick` — EXTEND an existing selection. Extending is a no-op
* when `selectionStart` is null, so the drag never anchors and no selection is
+35 -15
View File
@@ -36,6 +36,7 @@ import {
isAltScreenStripMode,
isExternalCliMode,
isMuxAltScreenOnlyStripMode,
isMuxMouseStripMode,
} from '../../session.js';
import type { PaneCaptureOptions } from '../../mux-interface.js';
import { SseEvent } from '../sse-events.js';
@@ -225,6 +226,37 @@ const ERASE_SCROLLBACK_PATTERN = /\x1b\[3J/g;
// eslint-disable-next-line no-control-regex
const MOUSE_TRACKING_PATTERN = /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g;
/**
* The replay half of the strip parity triangle: what `_handleTerminalOutput` (session.ts)
* removes from the live stream, removed again from a stored buffer before it is replayed,
* because a buffer recorded before the live-side strip existed (or by an older server)
* still carries the sequences, and one replayed enable is enough to re-park xterm.
*
* - `strip-full` (claude/codex/gemini): alt-screen toggles, `3J` and mouse DECSETs.
* xterm obeys the toggles by switching to its scrollback-less alt buffer and wiping
* saved lines, so history disappeared on tab switch.
* - `strip-mux-and-mouse` (opencode, tmux-backed): alt-screen toggles and mouse DECSETs.
* A replayed tracking enable parks xterm in report mode, where a drag goes to the CLI
* instead of selecting text (and Ctrl+C without a selection is opencode's app_exit).
* `3J` stays: a TUI is not a `clear` consumer.
* - every other mode, tmux-backed: tmux's own client smcup only (#205).
*
* Exported so the parity with the live strip is a test (claude-scrollback-strip.test.ts).
*/
export function stripReplayBuffer(buffer: string, mode: SessionMode, usesMux: boolean): string {
if (isAltScreenStripMode(mode)) {
return buffer
.replace(ALT_SCREEN_TOGGLE_PATTERN, '')
.replace(ERASE_SCROLLBACK_PATTERN, '')
.replace(MOUSE_TRACKING_PATTERN, '');
}
if (isMuxMouseStripMode(mode, usesMux)) {
return buffer.replace(ALT_SCREEN_TOGGLE_PATTERN, '').replace(MOUSE_TRACKING_PATTERN, '');
}
if (isMuxAltScreenOnlyStripMode(mode, usesMux)) return buffer.replace(ALT_SCREEN_TOGGLE_PATTERN, '');
return buffer;
}
/**
* Strip redundant Ink spinner/status-bar redraw frames from the terminal buffer.
* Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA) to animate
@@ -3111,21 +3143,9 @@ export function registerSessionRoutes(
? rawBuffer
: stripInkRedrawBloat(rawBuffer);
// Strip alt-screen toggles and scrollback-erase from Codex/Claude byte
// streams. xterm.js obeys them by switching to its scrollback-less alt
// buffer and wiping saved lines, so conversation history disappears on tab
// switch. Same gate as the live-stream strip in session.ts.
if (isAltScreenStripMode(session.mode)) {
strippedBuffer = strippedBuffer
.replace(ALT_SCREEN_TOGGLE_PATTERN, '')
.replace(ERASE_SCROLLBACK_PATTERN, '')
.replace(MOUSE_TRACKING_PATTERN, '');
} else if (isMuxAltScreenOnlyStripMode(session.mode, session.usesMux)) {
// tmux-backed shell/opencode/antigravity: drop tmux's own client smcup only.
// A byte buffer recorded before the live-side strip existed can still carry
// it, and one replayed `\x1b[?1049h` re-parks xterm in the alt buffer (#205).
strippedBuffer = strippedBuffer.replace(ALT_SCREEN_TOGGLE_PATTERN, '');
}
// Same strip as the live stream (session.ts), so a buffer recorded before the
// live-side strip existed cannot re-park xterm on replay. See stripReplayBuffer.
strippedBuffer = stripReplayBuffer(strippedBuffer, session.mode, session.usesMux);
if (tailBytes > 0 && strippedBuffer.length > tailBytes) {
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer.