mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
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>
369 lines
15 KiB
TypeScript
369 lines
15 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
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;
|
||
};
|
||
|
||
function handleOutput(session: Session, data: string): void {
|
||
(session as unknown as SessionInternals)._handleTerminalOutput(data);
|
||
}
|
||
|
||
describe('isAltScreenStripMode', () => {
|
||
it('strips for the controlled TUIs (codex + claude), not shell/opencode', () => {
|
||
expect(isAltScreenStripMode('codex')).toBe(true);
|
||
expect(isAltScreenStripMode('claude')).toBe(true);
|
||
expect(isAltScreenStripMode('shell')).toBe(false);
|
||
expect(isAltScreenStripMode('opencode')).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('Claude terminal scrollback strip', () => {
|
||
it('strips alt-screen toggles, scrollback-erase, and mouse-tracking', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||
const emitted: string[] = [];
|
||
session.on('terminal', (data) => emitted.push(data));
|
||
|
||
handleOutput(session, '\x1b[?1049h\x1b[55;1Hdialog\x1b[3J\x1b[?1006h\x1b[?1049l');
|
||
|
||
expect(emitted[0]).toBe('\x1b[55;1Hdialog');
|
||
expect(session.terminalBuffer).toBe('\x1b[55;1Hdialog');
|
||
});
|
||
|
||
it('keeps the visible-screen erase (2J / [J) — only scrollback-erase (3J) is dropped', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||
|
||
handleOutput(session, '\x1b[?1049h\x1b[2Jvisible\x1b[3Jscrollback\x1b[?1049l');
|
||
|
||
expect(session.terminalBuffer).toBe('\x1b[2Jvisiblescrollback');
|
||
});
|
||
|
||
it('preserves an ordinary erase-display redraw (no scrollback sequences)', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||
|
||
handleOutput(session, '\x1b[H\x1b[Jclaude redraw');
|
||
|
||
expect(session.terminalBuffer).toBe('\x1b[H\x1b[Jclaude redraw');
|
||
});
|
||
|
||
it('strips sequences split across PTY chunk boundaries (carry reassembly)', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||
const emitted: string[] = [];
|
||
session.on('terminal', (data) => emitted.push(data));
|
||
|
||
handleOutput(session, 'before\x1b[?104');
|
||
handleOutput(session, '9h\x1b[2Jafter\x1b[3');
|
||
handleOutput(session, 'Jtail');
|
||
|
||
expect(session.terminalBuffer).toBe('before\x1b[2Jaftertail');
|
||
expect(emitted).toEqual(['before', '\x1b[2Jafter', 'tail']);
|
||
});
|
||
|
||
it('emits nothing for a chunk that is only a partial CSI, then completes it', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||
const emitted: string[] = [];
|
||
session.on('terminal', (data) => emitted.push(data));
|
||
|
||
handleOutput(session, '\x1b[?100'); // pure partial — held, nothing emitted
|
||
handleOutput(session, '6h done'); // completes ?1006h (stripped); rest passes
|
||
|
||
expect(emitted).toEqual([' done']);
|
||
expect(session.terminalBuffer).toBe(' done');
|
||
});
|
||
|
||
it('does not touch ordinary Claude conversation output', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||
|
||
const text = 'Here is line one\r\nHere is line two\r\n\x1b[2mdim status\x1b[0m';
|
||
handleOutput(session, text);
|
||
|
||
expect(session.terminalBuffer).toBe(text);
|
||
});
|
||
});
|
||
|
||
describe('Shell terminal output on a DIRECT PTY is NOT stripped (vim/less/htop need the alt screen)', () => {
|
||
it('leaves alt-screen toggles, scrollback-erase, and mouse-tracking intact for shell', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||
|
||
const vimLike = '\x1b[?1049h\x1b[?1002h\x1b[2J~ editing\x1b[3J\x1b[?1002l\x1b[?1049l';
|
||
handleOutput(session, vimLike);
|
||
|
||
expect(session.terminalBuffer).toBe(vimLike);
|
||
});
|
||
});
|
||
|
||
describe('isMuxAltScreenOnlyStripMode', () => {
|
||
it('covers exactly the modes the full strip does not, and only under tmux', () => {
|
||
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 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 });
|
||
|
||
// What a real `tmux attach` emits as its first bytes.
|
||
handleOutput(session, '\x1b[?1049h\x1b[22;0;0t\x1b[?1h\x1b=\x1b[H\x1b[2Jprompt$ ');
|
||
|
||
expect(session.terminalBuffer).toBe('\x1b[22;0;0t\x1b[?1h\x1b=\x1b[H\x1b[2Jprompt$ ');
|
||
expect(session.terminalBuffer).not.toContain('\x1b[?1049h');
|
||
});
|
||
|
||
it('KEEPS 3J and mouse-tracking, unlike the full strip', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
|
||
|
||
// `clear` legitimately wipes scrollback; htop/vim mouse modes are passed
|
||
// through by tmux even with `mouse off` and must keep working.
|
||
handleOutput(session, '\x1b[3J\x1b[?1002h\x1b[?1006hhtop\x1b[?1006l\x1b[?1002l');
|
||
|
||
expect(session.terminalBuffer).toBe('\x1b[3J\x1b[?1002h\x1b[?1006hhtop\x1b[?1006l\x1b[?1002l');
|
||
});
|
||
|
||
it('never publishes cliMouseTracking for a mode whose DECSETs it keeps', () => {
|
||
// The browser's `_shouldReportMouseToCli()` reads only this flag, with no mode
|
||
// list: a flag set for a non-stripping mode would make it hand-encode a second
|
||
// report on top of xterm's own. Only the mouse-strip branch may set it.
|
||
for (const mode of ['shell', 'antigravity'] as const) {
|
||
const session = new Session({ workingDir: '/tmp', mode, useMux: true });
|
||
handleOutput(session, '\x1b[?1002h\x1b[?1006hmouse app');
|
||
expect(session.toState().cliMouseTracking, mode).toBeFalsy();
|
||
}
|
||
});
|
||
|
||
it('reassembles alt-screen sequences split across PTY chunk boundaries', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
|
||
const emitted: string[] = [];
|
||
session.on('terminal', (data) => emitted.push(data));
|
||
|
||
handleOutput(session, 'before\x1b[?104');
|
||
handleOutput(session, '9h after');
|
||
|
||
expect(session.terminalBuffer).toBe('before after');
|
||
expect(emitted).toEqual(['before', ' after']);
|
||
});
|
||
|
||
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');
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Whatever the strip removes, the server has to remember, because after it runs
|
||
* nothing downstream can ever see it. The browser hand-encodes click reports for
|
||
* these modes (`_sendSyntheticSgrTap`), and with no state to consult it did that
|
||
* on EVERY click, delivering mouse reports to a CLI that never asked for them.
|
||
*/
|
||
describe('stripped mouse-tracking state', () => {
|
||
const trackingOf = (session: Session) => session.toState().cliMouseTracking;
|
||
|
||
it('starts off, and stays off for output that never enables tracking', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||
expect(trackingOf(session)).toBeUndefined();
|
||
|
||
handleOutput(session, 'plain output\x1b[?1049h\x1b[3J');
|
||
|
||
expect(trackingOf(session)).toBeUndefined();
|
||
});
|
||
|
||
it('follows the CLI enabling and disabling a tracking mode', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||
const changes: boolean[] = [];
|
||
session.on('mouseTrackingChanged', (active: boolean) => changes.push(active));
|
||
|
||
handleOutput(session, '\x1b[?1002hdialog');
|
||
expect(trackingOf(session)).toBe(true);
|
||
|
||
handleOutput(session, '\x1b[?1002ldismissed');
|
||
expect(trackingOf(session)).toBeUndefined();
|
||
expect(changes).toEqual([true, false]);
|
||
});
|
||
|
||
it('ignores encoding and alt-scroll modes, which do not ask about clicks', () => {
|
||
// 1005/1006 pick an ENCODING and 1007 is alt-scroll. Counting them would put
|
||
// the stray reports straight back: a CLI can select SGR encoding without ever
|
||
// asking to be told where the user clicked.
|
||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||
|
||
handleOutput(session, '\x1b[?1006h\x1b[?1005h\x1b[?1007h');
|
||
|
||
expect(trackingOf(session)).toBeUndefined();
|
||
});
|
||
|
||
it('stays on until the LAST tracking mode goes away', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||
|
||
handleOutput(session, '\x1b[?1000h\x1b[?1002h\x1b[?1006h');
|
||
expect(trackingOf(session)).toBe(true);
|
||
|
||
// A TUI may disable a mode it never enabled; that must not clear the rest.
|
||
handleOutput(session, '\x1b[?1003l');
|
||
expect(trackingOf(session)).toBe(true);
|
||
|
||
handleOutput(session, '\x1b[?1000l');
|
||
expect(trackingOf(session)).toBe(true);
|
||
|
||
handleOutput(session, '\x1b[?1002l');
|
||
expect(trackingOf(session)).toBeUndefined();
|
||
});
|
||
|
||
it('emits only on a real transition, so a repainting TUI costs nothing', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||
const changes: boolean[] = [];
|
||
session.on('mouseTrackingChanged', (active: boolean) => changes.push(active));
|
||
|
||
handleOutput(session, '\x1b[?1002h\x1b[?1002h\x1b[?1002h');
|
||
|
||
expect(changes).toEqual([true]);
|
||
});
|
||
|
||
it('sees a sequence split across PTY chunks, like the strip that carries it', () => {
|
||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||
|
||
handleOutput(session, 'before\x1b[?100');
|
||
handleOutput(session, '2h after');
|
||
|
||
expect(session.terminalBuffer).toBe('before after');
|
||
expect(trackingOf(session)).toBe(true);
|
||
});
|
||
|
||
it('tracks nothing for a mode whose DECSETs are never stripped', () => {
|
||
// shell keeps its mouse DECSETs, so xterm sees them and owns the reporting.
|
||
// A flag set here would mean a SECOND, hand-encoded report on every click.
|
||
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
|
||
|
||
handleOutput(session, '\x1b[?1002hhtop');
|
||
|
||
expect(trackingOf(session)).toBeUndefined();
|
||
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);
|
||
});
|
||
}
|
||
}
|
||
});
|