fix(terminal): an OSC 52 copy hands focus back to the terminal (#611 merge-time fix)

When navigator.clipboard is missing (plain HTTP, install.sh's LAN option) or
refuses the write (an unfocused window), _copyText falls back to a hidden
textarea whose select() takes focus, and removing it left focus on <body>:
the next keystrokes after Claude Code's /copy went nowhere. The handler now
restores the element focused before the copy, as Auto Copy does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-13 18:16:56 +02:00
1 parent f3d7a86c16
commit f073742cf7
3 files changed
+34 -3

No files matched your search

+1 -1
View File
@@ -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)
+8
View File
@@ -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);
});
+25 -2
View File
@@ -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<string, unknown> = {
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 <body>.
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).