diff --git a/CLAUDE.md b/CLAUDE.md index d0b3601c..84406db3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -288,6 +288,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 71995cbd..7c3536b5 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1564,6 +1564,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-tile.js b/src/web/public/terminal-tile.js index a0f5373b..4850929b 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -246,6 +246,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 d66f106f..8f5e393a 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) @@ -706,6 +706,8 @@ Object.assign(CodemanApp.prototype, { } } + this._installOsc52Clipboard(this.terminal); + const container = document.getElementById('terminalContainer'); this.terminal.open(container); this._initMobileImePreview(); @@ -4605,8 +4607,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) { @@ -5072,6 +5075,43 @@ 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, 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(';'); + 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; + 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 if (document.hasFocus?.()) 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/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 dc29213f..6e7acfe6 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 new file mode 100644 index 00000000..96f68a7f --- /dev/null +++ b/test/terminal-osc52-clipboard.test.ts @@ -0,0 +1,196 @@ +/** + * 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 gates, and the blocked-write fallback. + * + * 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'; + +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: {}, + 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), + }); + 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[] = []; + 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); + app._chunkedWriteGen = 0; + + 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("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('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);'); + expect(read('terminal-tile.js')).toContain( + 'global.app?._installOsc52Clipboard?.(this.terminal, () => this._bufferLoading);' + ); + }); +}); + +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 0dbcaf08..3001db84 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -52,6 +52,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, });