mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
Merge #555: opencode drags select text and the wheel pages its transcript
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user