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

+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).