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);' + ); + }); +});