diff --git a/CLAUDE.md b/CLAUDE.md index 84406db3..d09df78d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -288,7 +288,7 @@ 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`). +**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. ⚠️ Restore prior focus after `_copyText` (its execCommand fallback focuses a temp textarea). 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) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 8f5e393a..8da235b1 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -5104,7 +5104,15 @@ Object.assign(CodemanApp.prototype, { const now = Date.now(); if (this._lastOsc52?.text === text && now - this._lastOsc52.at < OSC52_DEDUPE_MS) return true; this._lastOsc52 = { text, at: now }; + // _copyText's execCommand fallback (plain HTTP, or a refused Clipboard API + // write) focuses a temporary textarea; hand focus back as Auto Copy does. + const focusedBefore = document.activeElement; this._copyText(text).then((ok) => { + if (focusedBefore && focusedBefore !== document.activeElement && focusedBefore.isConnected) { + try { + focusedBefore.focus(); + } catch {} + } if (ok) this.showToast(`Copied to clipboard (${text.length} chars)`, 'success'); else if (document.hasFocus?.()) this._showClipboardFallback?.(text); }); diff --git a/test/terminal-osc52-clipboard.test.ts b/test/terminal-osc52-clipboard.test.ts index 96f68a7f..67bb369d 100644 --- a/test/terminal-osc52-clipboard.test.ts +++ b/test/terminal-osc52-clipboard.test.ts @@ -29,11 +29,17 @@ const read = (rel: string) => readFileSync(resolve(import.meta.dirname, '..', re function loadHarness(copyResult = true, focused = true) { const CodemanApp = function CodemanApp(this: unknown) {}; + const document: Record = { + getElementById: () => null, + querySelector: () => null, + hasFocus: () => focused, + activeElement: null, + }; const context = vm.createContext({ CodemanApp, window: {}, globalThis: {}, - document: { getElementById: () => null, querySelector: () => null, hasFocus: () => focused }, + document, console, MobileDetection: { isTouchDevice: () => false, getDeviceType: () => 'desktop' }, Object, @@ -64,7 +70,7 @@ function loadHarness(copyResult = true, focused = true) { const terminal = { parser: { registerOscHandler: (id: number, fn: (data: string) => boolean) => handlers.set(id, fn) }, }; - return { app, terminal, handlers, copied, fallbacks, toasts }; + return { app, terminal, handlers, copied, fallbacks, toasts, document }; } const b64 = (text: string) => Buffer.from(text, 'utf8').toString('base64'); @@ -120,6 +126,23 @@ describe('OSC 52 clipboard handler', () => { expect(h.toasts).toEqual([]); }); + it('hands focus back when the copy fallback took it (plain HTTP, a refused write)', async () => { + const h = loadHarness(); + let focusCalls = 0; + const terminalTextarea = { isConnected: true, focus: () => focusCalls++ }; + h.document.activeElement = terminalTextarea; + // _copyText's execCommand fallback selects a temporary textarea, then removes + // it, which leaves focus on . + h.app._copyText = async () => { + h.document.activeElement = { tagName: 'BODY' }; + return true; + }; + h.app._installOsc52Clipboard(h.terminal); + h.handlers.get(52)!(`c;${b64('copied')}`); + await flush(); + expect(focusCalls).toBe(1); + }); + 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).