mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
feat(terminal): copy OSC 52 clipboard writes from the PTY into the browser clipboard
Programs inside the terminal (Claude Code /copy, tmux yanks with set-clipboard on, vim) set the clipboard with OSC 52. xterm.js registers no handler for 52, so on a headless host (no xclip, no DISPLAY) those copies were silently dropped and never reached the remote browser. Register one handler on the main terminal and on every TerminalTile (split Pane B, tile grid). It decodes the base64 payload as UTF-8 and copies it through _copyText, falling back to the manual-copy dialog when the browser blocks the write. Read queries (`?`) are refused, and nothing is copied during a buffer replay, so a tab switch or reconnect never re-copies old OSC 52 sequences.
This commit is contained in:
1 parent
aa3730aace
commit
435a56be10
3 files changed
+158
No files matched your search
@@ -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();
|
||||
|
||||
|
||||
@@ -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 ; <sel> ; <base64> 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.
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* OSC 52 clipboard writes (`ESC ] 52 ; <sel> ; <base64> 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<string, any>)();
|
||||
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<number, (data: string) => 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 `;<base64>`)', 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);'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user