From 435a56be10f3c3b5c66275831c8a8339718f7e32 Mon Sep 17 00:00:00 2001 From: Zoltan Zombory <13768039+zomby1@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:26:06 +0200 Subject: [PATCH 1/2] feat(terminal): copy OSC 52 clipboard writes from the PTY into the browser clipboard Programs inside the terminal (Claude Code /copy, tmux yanks with set-clipboard on, vim) set the clipboard with OSC 52. xterm.js registers no handler for 52, so on a headless host (no xclip, no DISPLAY) those copies were silently dropped and never reached the remote browser. Register one handler on the main terminal and on every TerminalTile (split Pane B, tile grid). It decodes the base64 payload as UTF-8 and copies it through _copyText, falling back to the manual-copy dialog when the browser blocks the write. Read queries (`?`) are refused, and nothing is copied during a buffer replay, so a tab switch or reconnect never re-copies old OSC 52 sequences. --- src/web/public/terminal-tile.js | 3 + src/web/public/terminal-ui.js | 30 +++++++ test/terminal-osc52-clipboard.test.ts | 125 ++++++++++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 test/terminal-osc52-clipboard.test.ts diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 5ac18139..9547924a 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -243,6 +243,9 @@ this.fitAddon = new FitAddon.FitAddon(); this.terminal.loadAddon(this.fitAddon); + // OSC 52 clipboard writes from this pane's PTY, gated on THIS pane's + // buffer load so a replay never re-copies (terminal-ui.js). + global.app?._installOsc52Clipboard?.(this.terminal, () => this._bufferLoading); this.terminal.open(this.mountEl); this.fitAddon.fit(); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 5a8f659b..e6fdc573 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -612,6 +612,8 @@ Object.assign(CodemanApp.prototype, { } } + this._installOsc52Clipboard(this.terminal); + const container = document.getElementById('terminalContainer'); this.terminal.open(container); this._initMobileImePreview(); @@ -4923,6 +4925,34 @@ Object.assign(CodemanApp.prototype, { return Number.isInteger(columns) && columns > 0 ? columns : 0; }, + // OSC 52 (`ESC ] 52 ; ; BEL`) is how programs inside the PTY — + // Claude Code's /copy, tmux yanks with set-clipboard on, vim — set the + // clipboard. The server has no display, so this is the only route to the + // browser's clipboard. Writes only: a `?` query (clipboard read) is ignored, + // and so is anything arriving during a buffer replay, which would otherwise + // re-copy every old OSC 52 on each tab switch or reconnect. `isReplaying` + // lets a TerminalTile (split Pane B, tile grid) supply its own load flag. + _installOsc52Clipboard(terminal, isReplaying = () => this._isLoadingBuffer) { + terminal.parser?.registerOscHandler?.(52, (data) => { + const sep = data.indexOf(';'); + const payload = sep >= 0 ? data.slice(sep + 1) : data; + if (!payload || payload === '?' || isReplaying()) return true; + let text; + try { + const bytes = Uint8Array.from(atob(payload), (c) => c.charCodeAt(0)); + text = new TextDecoder().decode(bytes); + } catch { + return true; + } + if (!text) return true; + this._copyText(text).then((ok) => { + if (ok) this.showToast(`Copied to clipboard (${text.length} chars)`, 'success'); + else this._showClipboardFallback?.(text); + }); + return true; + }); + }, + // Copy the current terminal selection. Goes through _copyText (Clipboard API, // then a hidden-textarea + execCommand fallback) because install.sh's LAN // option serves plain HTTP, where navigator.clipboard is undefined. diff --git a/test/terminal-osc52-clipboard.test.ts b/test/terminal-osc52-clipboard.test.ts new file mode 100644 index 00000000..48057574 --- /dev/null +++ b/test/terminal-osc52-clipboard.test.ts @@ -0,0 +1,125 @@ +/** + * OSC 52 clipboard writes (`ESC ] 52 ; ; BEL`). + * + * Programs inside the PTY (Claude Code's /copy, tmux yanks with set-clipboard + * 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 + * copy was dropped. These tests pin the handler itself: decode, UTF-8, the + * read-query refusal, the replay gate, and the blocked-write fallback. + * + * Strategy: run terminal-ui.js in a vm with a stub CodemanApp (the harness + * shape test/terminal-copy-selection.test.ts uses) and a fake terminal whose + * parser records the registered handler. No DOM, no xterm. + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it } from 'vitest'; + +function loadHarness(copyResult = true) { + const CodemanApp = function CodemanApp(this: unknown) {}; + const context = vm.createContext({ + CodemanApp, + window: {}, + document: { getElementById: () => null, querySelector: () => null }, + console, + MobileDetection: { isTouchDevice: () => false, getDeviceType: () => 'desktop' }, + Object, + atob, + TextDecoder, + Uint8Array, + }); + const terminalUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + vm.runInContext(terminalUi, context, { filename: 'terminal-ui.js' }); + + const app = new (CodemanApp as unknown as new () => Record)(); + const copied: string[] = []; + const fallbacks: string[] = []; + const toasts: string[] = []; + app._copyText = async (text: string) => { + copied.push(text); + return copyResult; + }; + app._showClipboardFallback = (text: string) => fallbacks.push(text); + app.showToast = (msg: string) => toasts.push(msg); + + const handlers = new Map boolean>(); + const terminal = { + parser: { registerOscHandler: (id: number, fn: (data: string) => boolean) => handlers.set(id, fn) }, + }; + return { app, terminal, handlers, copied, fallbacks, toasts }; +} + +const b64 = (text: string) => Buffer.from(text, 'utf8').toString('base64'); +const flush = () => new Promise((r) => setTimeout(r, 0)); + +describe('OSC 52 clipboard handler', () => { + it('registers for OSC 52 and copies the decoded UTF-8 payload', async () => { + const h = loadHarness(); + h.app._installOsc52Clipboard(h.terminal); + const handler = h.handlers.get(52)!; + expect(handler).toBeTypeOf('function'); + + expect(handler(`c;${b64('héllo — 世界')}`)).toBe(true); + await flush(); + expect(h.copied).toEqual(['héllo — 世界']); + expect(h.toasts).toHaveLength(1); + }); + + it('accepts an empty selection field (tmux sends `;`)', async () => { + const h = loadHarness(); + h.app._installOsc52Clipboard(h.terminal); + h.handlers.get(52)!(`;${b64('x')}`); + await flush(); + expect(h.copied).toEqual(['x']); + }); + + it('ignores clipboard read queries, empty payloads and invalid base64', async () => { + const h = loadHarness(); + h.app._installOsc52Clipboard(h.terminal); + const handler = h.handlers.get(52)!; + for (const data of ['c;?', 'c;', 'c;***not base64***']) expect(handler(data)).toBe(true); + await flush(); + 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 () => { + const h = loadHarness(); + let tileLoading = true; + h.app._installOsc52Clipboard(h.terminal, () => tileLoading); + h.handlers.get(52)!(`c;${b64('old')}`); + tileLoading = false; + h.handlers.get(52)!(`c;${b64('new')}`); + await flush(); + expect(h.copied).toEqual(['new']); + }); + + it('falls back to the manual-copy dialog when the browser blocks the write', async () => { + const h = loadHarness(false); + h.app._installOsc52Clipboard(h.terminal); + h.handlers.get(52)!(`c;${b64('blocked')}`); + await flush(); + expect(h.fallbacks).toEqual(['blocked']); + expect(h.toasts).toEqual([]); + }); + + it('is wired into the main terminal and every TerminalTile', () => { + 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-tile.js')).toContain( + 'global.app?._installOsc52Clipboard?.(this.terminal, () => this._bufferLoading);' + ); + }); +}); From 4ecf7fa2285da92469da793a7a8d06bef25e0d4e Mon Sep 17 00:00:00 2001 From: Zoltan Zombory <13768039+zomby1@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:54:15 +0200 Subject: [PATCH 2/2] 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. --- CLAUDE.md | 2 + src/web/public/constants.js | 8 ++ src/web/public/terminal-ui.js | 30 ++++-- test/focused-pane-shortcuts.test.ts | 1 + test/terminal-auto-copy.test.ts | 1 + test/terminal-copy-clean.test.ts | 1 + test/terminal-flush-budget.test.ts | 1 + test/terminal-link-provider-target.test.ts | 1 + test/terminal-osc52-clipboard.test.ts | 109 +++++++++++++++++---- test/terminal-scroll-routing.test.ts | 1 + test/terminal-touch-tap.test.ts | 1 + 11 files changed, 127 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 15d32bfc..5c15e159 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) +**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) **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) diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 8a1d500f..4ed2b290 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1507,6 +1507,14 @@ function scheduleBackground(fn) { // but server-sent terminal buffers may still contain markers from Claude CLI. 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 const BUILTIN_RESPAWN_PRESETS = [ { diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index e6fdc573..893f1f91 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -5,7 +5,7 @@ * * @mixin Extends CodemanApp.prototype via Object.assign * @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 vendor/xterm.js, vendor/xterm-addon-fit.js, vendor/xterm-addon-webgl.js * @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 - // (from historical SSE data that was stored with markers) - const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, ''); + // (from historical SSE data that was stored with markers), and every + // 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 if (cleanBuffer.length <= chunkSize) { @@ -4926,12 +4927,18 @@ Object.assign(CodemanApp.prototype, { }, // OSC 52 (`ESC ] 52 ; ; BEL`) is how programs inside the PTY — - // Claude Code's /copy, tmux yanks with set-clipboard on, vim — set the - // clipboard. The server has no display, so this is the only route to the - // browser's clipboard. Writes only: a `?` query (clipboard read) is ignored, - // and so is anything arriving during a buffer replay, which would otherwise - // re-copy every old OSC 52 on each tab switch or reconnect. `isReplaying` - // lets a TerminalTile (split Pane B, tile grid) supply its own load flag. + // Claude Code's /copy, tmux yanks, vim — set the clipboard. The server has no + // display, so this is the only route to the browser's clipboard. Writes only: + // a `?` query (clipboard read) is ignored. A replay must never copy: the main + // terminal's replays strip OSC 52 in chunkedTerminalWrite (xterm parses after + // _isLoadingBuffer is already cleared, so that flag alone cannot hold), and a + // 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) { terminal.parser?.registerOscHandler?.(52, (data) => { const sep = data.indexOf(';'); @@ -4945,9 +4952,12 @@ Object.assign(CodemanApp.prototype, { 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) => { if (ok) this.showToast(`Copied to clipboard (${text.length} chars)`, 'success'); - else this._showClipboardFallback?.(text); + else if (document.hasFocus?.()) this._showClipboardFallback?.(text); }); return true; }); diff --git a/test/focused-pane-shortcuts.test.ts b/test/focused-pane-shortcuts.test.ts index caccfb9a..0f163067 100644 --- a/test/focused-pane-shortcuts.test.ts +++ b/test/focused-pane-shortcuts.test.ts @@ -39,6 +39,7 @@ function loadApp() { setTimeout: () => 1, MobileDetection: { isTouchDevice: () => false }, DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g, + OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g, TERMINAL_CHUNK_SIZE: 32 * 1024, }); vm.runInContext(read('constants.js'), context, { filename: 'constants.js' }); diff --git a/test/terminal-auto-copy.test.ts b/test/terminal-auto-copy.test.ts index af41240c..a6e9a070 100644 --- a/test/terminal-auto-copy.test.ts +++ b/test/terminal-auto-copy.test.ts @@ -57,6 +57,7 @@ function loadHarness() { MobileDetection: { isTouchDevice: () => false }, KeyboardHandler: { keyboardVisible: false }, DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g, + OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g, TERMINAL_CHUNK_SIZE: 32 * 1024, }); diff --git a/test/terminal-copy-clean.test.ts b/test/terminal-copy-clean.test.ts index 7f5e4549..3cda81cb 100644 --- a/test/terminal-copy-clean.test.ts +++ b/test/terminal-copy-clean.test.ts @@ -61,6 +61,7 @@ function loadHarness( MobileDetection: { isTouchDevice: () => false, getDeviceType: () => 'desktop' }, KeyboardHandler: { keyboardVisible: false }, DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g, + OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g, TERMINAL_CHUNK_SIZE: 32 * 1024, }); diff --git a/test/terminal-flush-budget.test.ts b/test/terminal-flush-budget.test.ts index e5120ca6..1e20527f 100644 --- a/test/terminal-flush-budget.test.ts +++ b/test/terminal-flush-budget.test.ts @@ -22,6 +22,7 @@ function loadTerminalUiHarness(mode: string) { this.postMessage = () => {}; }, DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g, + OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g, TERMINAL_CHUNK_SIZE: 32 * 1024, }); diff --git a/test/terminal-link-provider-target.test.ts b/test/terminal-link-provider-target.test.ts index 288d346f..323daa8c 100644 --- a/test/terminal-link-provider-target.test.ts +++ b/test/terminal-link-provider-target.test.ts @@ -38,6 +38,7 @@ function loadHarness() { setTimeout: () => 1, MobileDetection: { isTouchDevice: () => false }, DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g, + OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g, TERMINAL_CHUNK_SIZE: 32 * 1024, }); const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8'); diff --git a/test/terminal-osc52-clipboard.test.ts b/test/terminal-osc52-clipboard.test.ts index 48057574..96f68a7f 100644 --- a/test/terminal-osc52-clipboard.test.ts +++ b/test/terminal-osc52-clipboard.test.ts @@ -5,33 +5,48 @@ * 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 * 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 - * shape test/terminal-copy-selection.test.ts uses) and a fake terminal whose - * parser records the registered handler. No DOM, no xterm. + * Strategy: run constants.js + terminal-ui.js in a vm with a stub CodemanApp + * (the harness shape test/terminal-copy-selection.test.ts uses). The handler + * 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 { resolve } from 'node:path'; import vm from 'node:vm'; 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) => { 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 context = vm.createContext({ CodemanApp, window: {}, - document: { getElementById: () => null, querySelector: () => null }, + globalThis: {}, + document: { getElementById: () => null, querySelector: () => null, hasFocus: () => focused }, console, MobileDetection: { isTouchDevice: () => false, getDeviceType: () => 'desktop' }, Object, atob, TextDecoder, 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(terminalUi, context, { filename: 'terminal-ui.js' }); + vm.runInContext(read('src/web/public/constants.js'), context, { filename: 'constants.js' }); + vm.runInContext(read('src/web/public/terminal-ui.js'), context, { filename: 'terminal-ui.js' }); const app = new (CodemanApp as unknown as new () => Record)(); const copied: string[] = []; @@ -43,6 +58,7 @@ function loadHarness(copyResult = true) { }; app._showClipboardFallback = (text: string) => fallbacks.push(text); app.showToast = (msg: string) => toasts.push(msg); + app._chunkedWriteGen = 0; const handlers = new Map boolean>(); const terminal = { @@ -84,17 +100,6 @@ describe('OSC 52 clipboard handler', () => { 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 () => { const h = loadHarness(); let tileLoading = true; @@ -115,6 +120,29 @@ describe('OSC 52 clipboard handler', () => { 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', () => { const read = (f: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', f), 'utf8'); 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((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((done) => h.app.terminal.write(osc52('live'), () => done())); + await flush(); + expect(h.copied).toEqual(['live']); + }); +}); diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index 8550b7b4..b4eaa566 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -40,6 +40,7 @@ function loadTerminalUiHarness() { }, MobileDetection: { isTouchDevice: () => true }, DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g, + OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g, TERMINAL_CHUNK_SIZE: 32 * 1024, }); diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 7b91e93b..1d15c960 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -43,6 +43,7 @@ function loadTerminalUiHarness() { }, }, DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g, + OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g, TERMINAL_CHUNK_SIZE: 32 * 1024, });