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
+138 -10
View File
@@ -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);
});
}
}
});
+42 -13
View File
@@ -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');
});
+24 -5
View File
@@ -366,14 +366,33 @@ describe('terminal touch tap mouse guard', () => {
expect(sent).toEqual(['\x1b[<0;1;24M\x1b[<0;1;24m']);
});
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', () => {