diff --git a/main.js b/main.js index 9bfc8971..d3a54833 100644 --- a/main.js +++ b/main.js @@ -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'); @@ -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); diff --git a/preload.js b/preload.js index 91d8b5e5..68030ca7 100644 --- a/preload.js +++ b/preload.js @@ -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), diff --git a/public/terminal-manager.js b/public/terminal-manager.js index d4a1ac0b..e2375492 100644 --- a/public/terminal-manager.js +++ b/public/terminal-manager.js @@ -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 ";", e.g. "c;aGVsbG8=". +// +// Returns null when there is nothing to write — an empty payload, or a read-back +// query (";?"). 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 @@ -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; } @@ -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) => { @@ -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