Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion main.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { app, BrowserWindow, dialog, ipcMain, Menu, screen, shell } = require('electron');
const { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, screen, shell } = require('electron');
const { Worker } = require('worker_threads');
const path = require('path');
const fs = require('fs');
Expand Down Expand Up @@ -348,6 +348,14 @@ ipcMain.handle('open-external', (_event, url) => {
if (/^https?:\/\//i.test(url)) return shell.openExternal(url);
});

// --- IPC: clipboard write ---
// The renderer's navigator.clipboard.writeText is gated on focus/user-activation and
// is flaky-to-dead on Linux/Wayland (Ozone). The main-process clipboard has no such
// strings attached, so all terminal copies go through here.
ipcMain.handle('clipboard-write-text', (_event, text) => {
if (typeof text === 'string') clipboard.writeText(text);
});

// --- IPC: MCP bridge ---
ipcMain.on('mcp-diff-response', (_event, sessionId, diffId, action, editedContent) => {
resolvePendingDiff(sessionId, diffId, action, editedContent);
Expand Down
1 change: 1 addition & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ contextBridge.exposeInMainWorld('api', {
addProject: (projectPath) => ipcRenderer.invoke('add-project', projectPath),
removeProject: (projectPath) => ipcRenderer.invoke('remove-project', projectPath),
openExternal: (url) => ipcRenderer.invoke('open-external', url),
writeClipboard: (text) => ipcRenderer.invoke('clipboard-write-text', text),

// Send (fire-and-forget)
sendInput: (id, data) => ipcRenderer.send('terminal-input', id, data),
Expand Down
41 changes: 39 additions & 2 deletions public/terminal-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,25 @@ function shouldSendSpaceDirectly(e) {
&& !isImeComposing(e);
}

// Decode an OSC 52 payload into the text the program wants on the clipboard.
// Payload is "<selection>;<base64>", e.g. "c;aGVsbG8=".
//
// Returns null when there is nothing to write — an empty payload, or a read-back
// query ("<selection>;?"). The read-back case is a deliberate refusal, not a gap:
// answering it would write the user's clipboard contents back into the terminal,
// letting any program running in the session exfiltrate whatever they last
// copied. We consume the sequence and stay silent. Do not "finish" this by
// implementing the query response.
//
// Throws on malformed base64 (atob), which the caller reports as unhandled.
function decodeOsc52Payload(payload) {
const sep = payload.indexOf(';');
const b64 = sep === -1 ? payload : payload.slice(sep + 1);
if (!b64 || b64 === '?') return null;
const bytes = Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0));
return new TextDecoder().decode(bytes);
}

function setupTerminalKeyBindings(terminal, container, getSessionId, { onFind } = {}) {
terminal.attachCustomKeyEventHandler((e) => {
// Cmd/Ctrl+F → open terminal search bar
Expand Down Expand Up @@ -83,7 +102,7 @@ function setupTerminalKeyBindings(terminal, container, getSessionId, { onFind }
if (!isMac && e.key === 'c' && e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
if (terminal.hasSelection()) {
if (e.type === 'keydown') {
navigator.clipboard.writeText(terminal.getSelection()).catch(() => {});
window.api.writeClipboard(terminal.getSelection());
}
return false;
}
Expand Down Expand Up @@ -221,6 +240,24 @@ function createTerminalEntry(session) {
},
});

// OSC 52 — let the program inside the terminal set the system clipboard (this is how
// Claude Code copies). xterm doesn't wire this up itself, so we do.
// Route through the main process — see writeClipboard — because the renderer clipboard
// is unreliable on Wayland.
terminal.parser.registerOscHandler(52, (payload) => {
let text;
try {
text = decodeOsc52Payload(payload);
} catch {
return false;
}
// null = read-back query or empty payload: consumed, and deliberately not
// answered. See decodeOsc52Payload.
if (text === null) return true;
window.api.writeClipboard(text).catch(() => {});
return true;
});

const fitAddon = new FitAddon.FitAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(new WebLinksAddon.WebLinksAddon((_event, url) => {
Expand Down Expand Up @@ -400,5 +437,5 @@ function setupDragAndDrop(container, getSessionId) {
// Expose pure key-handling predicates to Node for unit testing. No-op in the
// browser, where this file is loaded as a plain <script> and `module` is undefined.
if (typeof module !== 'undefined' && module.exports) {
module.exports = { isImeComposing, shouldSendSpaceDirectly };
module.exports = { isImeComposing, shouldSendSpaceDirectly, decodeOsc52Payload };
}
39 changes: 39 additions & 0 deletions test/clipboard-osc52.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');

const { decodeOsc52Payload } = require('../public/terminal-manager');

const b64 = (s) => Buffer.from(s, 'utf8').toString('base64');

test('decodes a normal OSC 52 write into clipboard text', () => {
// "c" = clipboard selection; this is the path Claude Code's copy takes.
assert.equal(decodeOsc52Payload(`c;${b64('hello world')}`), 'hello world');
});

test('decodes multi-byte UTF-8 rather than mangling it', () => {
assert.equal(decodeOsc52Payload(`c;${b64('안녕하세요 — café 🎉')}`), '안녕하세요 — café 🎉');
});

test('a read-back query is REFUSED, not answered', () => {
// Security property, not an unimplemented case: answering "?" would write the
// user's clipboard back into the terminal, letting any program in the session
// exfiltrate whatever they last copied. null means "consume and stay silent".
//
// If you are here because you are implementing the query response: don't.
assert.equal(decodeOsc52Payload('c;?'), null);
assert.equal(decodeOsc52Payload('p;?'), null);
assert.equal(decodeOsc52Payload('?'), null);
});

test('empty payloads write nothing', () => {
assert.equal(decodeOsc52Payload(''), null);
assert.equal(decodeOsc52Payload('c;'), null);
});

test('a payload with no selection separator is treated as base64', () => {
assert.equal(decodeOsc52Payload(b64('bare')), 'bare');
});

test('malformed base64 throws so the caller can report the sequence unhandled', () => {
assert.throws(() => decodeOsc52Payload('c;@@@not base64@@@'));
});
Loading