mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
fix(terminal): strip OSC 52 from replays and copy each clipboard write once
xterm parses write() data after chunkedTerminalWrite has already cleared _isLoadingBuffer, so the main terminal's replay gate never held: a tab switch or reconnect re-copied the newest OSC 52 in the replayed history. Strip every OSC 52 from the replay beside the DEC 2026 markers (OSC52_STRIP_RE). Tiles keep their own _bufferLoading gate, which holds there. One Claude Code copy arrives up to three times (its own OSC 52, tmux load-buffer -w, set-clipboard forwarding), so identical text within OSC52_DEDUPE_MS is dropped. The manual-copy fallback opens only in the focused tab, so a background tab on the same session stays quiet. The replay tests now drive the real chunkedTerminalWrite into @xterm/headless and fail without the strip. Test harnesses that supply DEC_SYNC_STRIP_RE by hand get OSC52_STRIP_RE too, and CLAUDE.md records the rules next to Auto Copy.
This commit is contained in:
1 parent
435a56be10
commit
4ecf7fa228
11 files changed
+127
-29
No files matched your search
@@ -286,6 +286,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Auto Copy (copy-on-select)** (`autoCopySelection`, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ Copy at the END of a gesture, never in `onSelectionChange` (per-cell); it only arms `_autoCopyPending` and a document-level `mouseup` flushes. ⚠️ The flush must be SYNCHRONOUS in the handler (both clipboard paths need user activation); never defer it to a timer. ⚠️ Touch needs its own calls from `_endTouchSelectionGesture()`/`_selectTouchSelectionLine()` (no mouseup arrives). ⚠️ Unlike `copyTerminalSelection()`, never clear the selection or focus the terminal; restore prior focus. Guards are pure in `decideAutoCopy()` (constants.js, 1M-char cap, refused not truncated). Tests: `test/terminal-auto-copy.test.ts`. → [architecture-invariants#auto-copy-copy-on-select](docs/architecture-invariants.md#auto-copy-copy-on-select)
|
**Auto Copy (copy-on-select)** (`autoCopySelection`, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ Copy at the END of a gesture, never in `onSelectionChange` (per-cell); it only arms `_autoCopyPending` and a document-level `mouseup` flushes. ⚠️ The flush must be SYNCHRONOUS in the handler (both clipboard paths need user activation); never defer it to a timer. ⚠️ Touch needs its own calls from `_endTouchSelectionGesture()`/`_selectTouchSelectionLine()` (no mouseup arrives). ⚠️ Unlike `copyTerminalSelection()`, never clear the selection or focus the terminal; restore prior focus. Guards are pure in `decideAutoCopy()` (constants.js, 1M-char cap, refused not truncated). Tests: `test/terminal-auto-copy.test.ts`. → [architecture-invariants#auto-copy-copy-on-select](docs/architecture-invariants.md#auto-copy-copy-on-select)
|
||||||
|
|
||||||
|
**OSC 52 clipboard** (`_installOsc52Clipboard`, terminal-ui.js; main terminal + every `TerminalTile`): a program in the PTY (Claude Code's `/copy`, tmux yanks, vim) sets the browser clipboard with `ESC ] 52`. ⚠️ Writes only: a `52;?` read query is always refused, never answered. ⚠️ A replay must never copy: `chunkedTerminalWrite` strips every OSC 52 (`OSC52_STRIP_RE`, constants.js) because xterm parses after `_isLoadingBuffer` is cleared, so the flag alone does not hold; a tile gates on its own `_bufferLoading`, which does. ⚠️ One copy arrives up to three times (Claude's own OSC 52, `tmux load-buffer -w`, set-clipboard forwarding), so identical text within `OSC52_DEDUPE_MS` is dropped. ⚠️ Every tab on the session receives it; only the focused one opens the manual-copy fallback. Tests: `test/terminal-osc52-clipboard.test.ts` (replays drive the real `chunkedTerminalWrite` over `@xterm/headless`).
|
||||||
|
|
||||||
**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)
|
**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 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. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. 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. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. 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)
|
||||||
|
|||||||
@@ -1507,6 +1507,14 @@ function scheduleBackground(fn) {
|
|||||||
// but server-sent terminal buffers may still contain markers from Claude CLI.
|
// but server-sent terminal buffers may still contain markers from Claude CLI.
|
||||||
const DEC_SYNC_STRIP_RE = /\x1b\[\?2026[hl]/g;
|
const DEC_SYNC_STRIP_RE = /\x1b\[\?2026[hl]/g;
|
||||||
|
|
||||||
|
// OSC 52 clipboard writes stripping — a replay re-feeds the raw byte history, and
|
||||||
|
// xterm parses it after chunkedTerminalWrite has already released the load
|
||||||
|
// gate, so an OSC 52 left in would re-copy old text on every tab switch.
|
||||||
|
const OSC52_STRIP_RE = /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
||||||
|
// The same OSC 52 text arriving again within this window is one copy delivered
|
||||||
|
// several ways (direct, tmux load-buffer -w, tmux set-clipboard forwarding).
|
||||||
|
const OSC52_DEDUPE_MS = 1000;
|
||||||
|
|
||||||
// Built-in respawn configuration presets
|
// Built-in respawn configuration presets
|
||||||
const BUILTIN_RESPAWN_PRESETS = [
|
const BUILTIN_RESPAWN_PRESETS = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*
|
*
|
||||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||||
* @dependency app.js (CodemanApp class, this.terminal, this.fitAddon, this.sessions)
|
* @dependency app.js (CodemanApp class, this.terminal, this.fitAddon, this.sessions)
|
||||||
* @dependency constants.js (DEC_SYNC_STRIP_RE, TIMING constants)
|
* @dependency constants.js (DEC_SYNC_STRIP_RE, OSC52_STRIP_RE, OSC52_DEDUPE_MS, TIMING constants)
|
||||||
* @dependency mobile-handlers.js (MobileDetection)
|
* @dependency mobile-handlers.js (MobileDetection)
|
||||||
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js, vendor/xterm-addon-webgl.js
|
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js, vendor/xterm-addon-webgl.js
|
||||||
* @dependency vendor/xterm-zerolag-input.js (LocalEchoOverlay)
|
* @dependency vendor/xterm-zerolag-input.js (LocalEchoOverlay)
|
||||||
@@ -4458,8 +4458,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Strip any DEC 2026 markers that might be in the buffer
|
// Strip any DEC 2026 markers that might be in the buffer
|
||||||
// (from historical SSE data that was stored with markers)
|
// (from historical SSE data that was stored with markers), and every
|
||||||
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
|
// OSC 52 clipboard write: a replay must never copy (see OSC52_STRIP_RE).
|
||||||
|
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '').replace(OSC52_STRIP_RE, '');
|
||||||
|
|
||||||
// For small buffers, write directly — single-frame render is fast enough
|
// For small buffers, write directly — single-frame render is fast enough
|
||||||
if (cleanBuffer.length <= chunkSize) {
|
if (cleanBuffer.length <= chunkSize) {
|
||||||
@@ -4926,12 +4927,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// OSC 52 (`ESC ] 52 ; <sel> ; <base64> BEL`) is how programs inside the PTY —
|
// OSC 52 (`ESC ] 52 ; <sel> ; <base64> BEL`) is how programs inside the PTY —
|
||||||
// Claude Code's /copy, tmux yanks with set-clipboard on, vim — set the
|
// Claude Code's /copy, tmux yanks, vim — set the clipboard. The server has no
|
||||||
// clipboard. The server has no display, so this is the only route to the
|
// display, so this is the only route to the browser's clipboard. Writes only:
|
||||||
// browser's clipboard. Writes only: a `?` query (clipboard read) is ignored,
|
// a `?` query (clipboard read) is ignored. A replay must never copy: the main
|
||||||
// and so is anything arriving during a buffer replay, which would otherwise
|
// terminal's replays strip OSC 52 in chunkedTerminalWrite (xterm parses after
|
||||||
// re-copy every old OSC 52 on each tab switch or reconnect. `isReplaying`
|
// _isLoadingBuffer is already cleared, so that flag alone cannot hold), and a
|
||||||
// lets a TerminalTile (split Pane B, tile grid) supply its own load flag.
|
// TerminalTile passes `isReplaying`, its own load flag, which does hold there.
|
||||||
|
// Every tab watching the session gets the sequence, so only the focused one
|
||||||
|
// opens the manual-copy dialog when the browser refuses the write.
|
||||||
|
// One copy usually arrives several times: Claude Code writes OSC 52 itself and
|
||||||
|
// through `tmux load-buffer -w`, and tmux forwards it with set-clipboard on,
|
||||||
|
// so the same text lands up to three times within a few ms. Identical text
|
||||||
|
// inside OSC52_DEDUPE_MS is that one copy, not a new one.
|
||||||
_installOsc52Clipboard(terminal, isReplaying = () => this._isLoadingBuffer) {
|
_installOsc52Clipboard(terminal, isReplaying = () => this._isLoadingBuffer) {
|
||||||
terminal.parser?.registerOscHandler?.(52, (data) => {
|
terminal.parser?.registerOscHandler?.(52, (data) => {
|
||||||
const sep = data.indexOf(';');
|
const sep = data.indexOf(';');
|
||||||
@@ -4945,9 +4952,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (!text) return true;
|
if (!text) return true;
|
||||||
|
const now = Date.now();
|
||||||
|
if (this._lastOsc52?.text === text && now - this._lastOsc52.at < OSC52_DEDUPE_MS) return true;
|
||||||
|
this._lastOsc52 = { text, at: now };
|
||||||
this._copyText(text).then((ok) => {
|
this._copyText(text).then((ok) => {
|
||||||
if (ok) this.showToast(`Copied to clipboard (${text.length} chars)`, 'success');
|
if (ok) this.showToast(`Copied to clipboard (${text.length} chars)`, 'success');
|
||||||
else this._showClipboardFallback?.(text);
|
else if (document.hasFocus?.()) this._showClipboardFallback?.(text);
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ function loadApp() {
|
|||||||
setTimeout: () => 1,
|
setTimeout: () => 1,
|
||||||
MobileDetection: { isTouchDevice: () => false },
|
MobileDetection: { isTouchDevice: () => false },
|
||||||
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||||
|
OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g,
|
||||||
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||||
});
|
});
|
||||||
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ function loadHarness() {
|
|||||||
MobileDetection: { isTouchDevice: () => false },
|
MobileDetection: { isTouchDevice: () => false },
|
||||||
KeyboardHandler: { keyboardVisible: false },
|
KeyboardHandler: { keyboardVisible: false },
|
||||||
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||||
|
OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g,
|
||||||
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ function loadHarness(
|
|||||||
MobileDetection: { isTouchDevice: () => false, getDeviceType: () => 'desktop' },
|
MobileDetection: { isTouchDevice: () => false, getDeviceType: () => 'desktop' },
|
||||||
KeyboardHandler: { keyboardVisible: false },
|
KeyboardHandler: { keyboardVisible: false },
|
||||||
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||||
|
OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g,
|
||||||
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function loadTerminalUiHarness(mode: string) {
|
|||||||
this.postMessage = () => {};
|
this.postMessage = () => {};
|
||||||
},
|
},
|
||||||
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||||
|
OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g,
|
||||||
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ function loadHarness() {
|
|||||||
setTimeout: () => 1,
|
setTimeout: () => 1,
|
||||||
MobileDetection: { isTouchDevice: () => false },
|
MobileDetection: { isTouchDevice: () => false },
|
||||||
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||||
|
OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g,
|
||||||
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||||
});
|
});
|
||||||
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
|
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
|
||||||
|
|||||||
@@ -5,33 +5,48 @@
|
|||||||
* on, vim) set the clipboard this way. xterm.js registers no handler for 52,
|
* on, vim) set the clipboard this way. xterm.js registers no handler for 52,
|
||||||
* and a headless host has no OS clipboard, so without this handler every such
|
* and a headless host has no OS clipboard, so without this handler every such
|
||||||
* copy was dropped. These tests pin the handler itself: decode, UTF-8, the
|
* copy was dropped. These tests pin the handler itself: decode, UTF-8, the
|
||||||
* read-query refusal, the replay gate, and the blocked-write fallback.
|
* read-query refusal, the replay gates, and the blocked-write fallback.
|
||||||
*
|
*
|
||||||
* Strategy: run terminal-ui.js in a vm with a stub CodemanApp (the harness
|
* Strategy: run constants.js + terminal-ui.js in a vm with a stub CodemanApp
|
||||||
* shape test/terminal-copy-selection.test.ts uses) and a fake terminal whose
|
* (the harness shape test/terminal-copy-selection.test.ts uses). The handler
|
||||||
* parser records the registered handler. No DOM, no xterm.
|
* tests use a fake terminal whose parser records the registered handler; the
|
||||||
|
* replay tests drive the REAL chunkedTerminalWrite into a real
|
||||||
|
* @xterm/headless terminal, because xterm parses write() data asynchronously
|
||||||
|
* and a synchronous stub hides exactly the ordering the replay gate depends on.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import vm from 'node:vm';
|
import vm from 'node:vm';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import xtermHeadless from '@xterm/headless';
|
||||||
|
|
||||||
function loadHarness(copyResult = true) {
|
const { Terminal } = xtermHeadless as unknown as {
|
||||||
|
Terminal: new (opts: Record<string, unknown>) => { write(data: string, cb?: () => void): void };
|
||||||
|
};
|
||||||
|
|
||||||
|
const read = (rel: string) => readFileSync(resolve(import.meta.dirname, '..', rel), 'utf8');
|
||||||
|
|
||||||
|
function loadHarness(copyResult = true, focused = true) {
|
||||||
const CodemanApp = function CodemanApp(this: unknown) {};
|
const CodemanApp = function CodemanApp(this: unknown) {};
|
||||||
const context = vm.createContext({
|
const context = vm.createContext({
|
||||||
CodemanApp,
|
CodemanApp,
|
||||||
window: {},
|
window: {},
|
||||||
document: { getElementById: () => null, querySelector: () => null },
|
globalThis: {},
|
||||||
|
document: { getElementById: () => null, querySelector: () => null, hasFocus: () => focused },
|
||||||
console,
|
console,
|
||||||
MobileDetection: { isTouchDevice: () => false, getDeviceType: () => 'desktop' },
|
MobileDetection: { isTouchDevice: () => false, getDeviceType: () => 'desktop' },
|
||||||
Object,
|
Object,
|
||||||
atob,
|
atob,
|
||||||
TextDecoder,
|
TextDecoder,
|
||||||
Uint8Array,
|
Uint8Array,
|
||||||
|
performance,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
requestAnimationFrame: (cb: () => void) => setTimeout(cb, 0),
|
||||||
});
|
});
|
||||||
const terminalUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
vm.runInContext(read('src/web/public/constants.js'), context, { filename: 'constants.js' });
|
||||||
vm.runInContext(terminalUi, context, { filename: 'terminal-ui.js' });
|
vm.runInContext(read('src/web/public/terminal-ui.js'), context, { filename: 'terminal-ui.js' });
|
||||||
|
|
||||||
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
|
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
|
||||||
const copied: string[] = [];
|
const copied: string[] = [];
|
||||||
@@ -43,6 +58,7 @@ function loadHarness(copyResult = true) {
|
|||||||
};
|
};
|
||||||
app._showClipboardFallback = (text: string) => fallbacks.push(text);
|
app._showClipboardFallback = (text: string) => fallbacks.push(text);
|
||||||
app.showToast = (msg: string) => toasts.push(msg);
|
app.showToast = (msg: string) => toasts.push(msg);
|
||||||
|
app._chunkedWriteGen = 0;
|
||||||
|
|
||||||
const handlers = new Map<number, (data: string) => boolean>();
|
const handlers = new Map<number, (data: string) => boolean>();
|
||||||
const terminal = {
|
const terminal = {
|
||||||
@@ -84,17 +100,6 @@ describe('OSC 52 clipboard handler', () => {
|
|||||||
expect(h.copied).toEqual([]);
|
expect(h.copied).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never copies during a buffer replay (main terminal flag)', async () => {
|
|
||||||
const h = loadHarness();
|
|
||||||
h.app._installOsc52Clipboard(h.terminal);
|
|
||||||
h.app._isLoadingBuffer = true;
|
|
||||||
h.handlers.get(52)!(`c;${b64('old')}`);
|
|
||||||
h.app._isLoadingBuffer = false;
|
|
||||||
h.handlers.get(52)!(`c;${b64('new')}`);
|
|
||||||
await flush();
|
|
||||||
expect(h.copied).toEqual(['new']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the caller's replay check when one is given (TerminalTile)", async () => {
|
it("uses the caller's replay check when one is given (TerminalTile)", async () => {
|
||||||
const h = loadHarness();
|
const h = loadHarness();
|
||||||
let tileLoading = true;
|
let tileLoading = true;
|
||||||
@@ -115,6 +120,29 @@ describe('OSC 52 clipboard handler', () => {
|
|||||||
expect(h.toasts).toEqual([]);
|
expect(h.toasts).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('copies one Claude Code copy once, though it arrives three times', async () => {
|
||||||
|
// What a single /copy in a Codeman tmux session actually delivers: Claude's
|
||||||
|
// own OSC 52 plus tmux forwarding it twice (captured from a live session).
|
||||||
|
const h = loadHarness();
|
||||||
|
h.app._installOsc52Clipboard(h.terminal);
|
||||||
|
const handler = h.handlers.get(52)!;
|
||||||
|
for (const data of [`;${b64('same')}`, `c;${b64('same')}`, `c;${b64('same')}`]) handler(data);
|
||||||
|
handler(`c;${b64('different')}`);
|
||||||
|
await flush();
|
||||||
|
expect(h.copied).toEqual(['same', 'different']);
|
||||||
|
expect(h.toasts).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the manual-copy dialog only in the focused tab', async () => {
|
||||||
|
// Every tab watching the session receives the OSC 52; a background tab (a
|
||||||
|
// phone left on the session) must not pop a dialog for a copy made elsewhere.
|
||||||
|
const h = loadHarness(false, false);
|
||||||
|
h.app._installOsc52Clipboard(h.terminal);
|
||||||
|
h.handlers.get(52)!(`c;${b64('elsewhere')}`);
|
||||||
|
await flush();
|
||||||
|
expect(h.fallbacks).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('is wired into the main terminal and every TerminalTile', () => {
|
it('is wired into the main terminal and every TerminalTile', () => {
|
||||||
const read = (f: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', f), 'utf8');
|
const read = (f: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', f), 'utf8');
|
||||||
expect(read('terminal-ui.js')).toContain('this._installOsc52Clipboard(this.terminal);');
|
expect(read('terminal-ui.js')).toContain('this._installOsc52Clipboard(this.terminal);');
|
||||||
@@ -123,3 +151,46 @@ describe('OSC 52 clipboard handler', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('main-terminal replay over a real xterm', () => {
|
||||||
|
const osc52 = (text: string) => `\x1b]52;c;${b64(text)}\x07`;
|
||||||
|
|
||||||
|
async function replay(buffer: string) {
|
||||||
|
const h = loadHarness();
|
||||||
|
const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true, scrollback: 1000 });
|
||||||
|
h.app.terminal = term;
|
||||||
|
h.app._installOsc52Clipboard(term);
|
||||||
|
await h.app.chunkedTerminalWrite(buffer);
|
||||||
|
// Everything queued so far has been parsed once this callback fires.
|
||||||
|
await new Promise<void>((done) => term.write('', () => done()));
|
||||||
|
await flush();
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does not re-copy an OSC 52 in a small (single-write) replay', async () => {
|
||||||
|
const h = await replay(`before\r\n${osc52('old copy')}after\r\n`);
|
||||||
|
expect(h.copied).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not re-copy an OSC 52 in the last chunk of a large replay', async () => {
|
||||||
|
const filler = 'x'.repeat(79) + '\r\n';
|
||||||
|
const history = filler.repeat(Math.ceil((100 * 1024) / filler.length));
|
||||||
|
const h = await replay(`${history}${osc52('recent copy')}tail\r\n`);
|
||||||
|
expect(h.copied).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips both BEL- and ST-terminated sequences and leaves the rest intact', () => {
|
||||||
|
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||||
|
vm.runInContext(`${read('src/web/public/constants.js')}\nglobalThis.__re = OSC52_STRIP_RE;`, context);
|
||||||
|
const re = (context.globalThis as { __re: RegExp }).__re;
|
||||||
|
const input = `a\x1b]52;c;${b64('x')}\x07b\x1b]52;;${b64('y')}\x1b\\c\x1b]0;title\x07d`;
|
||||||
|
expect(input.replace(re, '')).toBe('abc\x1b]0;title\x07d');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still copies a live OSC 52 written after the replay', async () => {
|
||||||
|
const h = await replay('history\r\n');
|
||||||
|
await new Promise<void>((done) => h.app.terminal.write(osc52('live'), () => done()));
|
||||||
|
await flush();
|
||||||
|
expect(h.copied).toEqual(['live']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,6 +40,7 @@ function loadTerminalUiHarness() {
|
|||||||
},
|
},
|
||||||
MobileDetection: { isTouchDevice: () => true },
|
MobileDetection: { isTouchDevice: () => true },
|
||||||
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||||
|
OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g,
|
||||||
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ function loadTerminalUiHarness() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||||
|
OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g,
|
||||||
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user