From dbfb31a92682eab973bb91308060ccc357c2f070 Mon Sep 17 00:00:00 2001 From: ymajoros Date: Fri, 29 May 2026 13:39:45 +0200 Subject: [PATCH] fix: route terminal copy through main-process clipboard + handle OSC 52 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copying out of the terminal did nothing on Linux/Wayland. Two separate holes: - Ctrl+C copy used navigator.clipboard.writeText in the renderer. Chromium gates that on window focus + a user gesture, and it's effectively dead under Ozone/Wayland. The trailing .catch(() => {}) ate the rejection, so it failed in total silence. - OSC 52 (how Claude Code itself copies) wasn't wired up at all — xterm doesn't do it for you, so those copies just evaporated. Both now go through the main-process clipboard over IPC, which doesn't care about focus or gestures. Paste was never affected, so it's left alone. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.js | 10 +++++++++- preload.js | 1 + public/terminal-manager.js | 20 +++++++++++++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/main.js b/main.js index 2c587b7..2b26431 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 91d8b5e..68030ca 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 6863b22..ea401ca 100644 --- a/public/terminal-manager.js +++ b/public/terminal-manager.js @@ -63,7 +63,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; } @@ -191,6 +191,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. Payload is + // ";" (or ";?" for a read-back query, which we ignore). + // Route through the main process — see writeClipboard — because the renderer clipboard + // is unreliable on Wayland. + terminal.parser.registerOscHandler(52, (payload) => { + const sep = payload.indexOf(';'); + const b64 = sep === -1 ? payload : payload.slice(sep + 1); + if (!b64 || b64 === '?') return true; + try { + const bytes = Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0)); + window.api.writeClipboard(new TextDecoder().decode(bytes)); + } catch { + return false; + } + return true; + }); + const fitAddon = new FitAddon.FitAddon(); terminal.loadAddon(fitAddon); terminal.loadAddon(new WebLinksAddon.WebLinksAddon((_event, url) => {