Skip to content

Commit 280b4e0

Browse files
fix(clipboard): route terminal copy through main process for Wayland (#18)
navigator.clipboard.writeText is gated on focus/user-activation and silently fails on Linux/Wayland (Ozone). Replace with a main-process clipboard.writeText via a new clipboard-write-text IPC. Also register an xterm OSC 52 handler so programs inside the terminal (notably Claude Code's copy-to-clipboard) can set the system clipboard; xterm doesn't wire OSC 52 itself. Port of upstream PR doctly#55 (ymajoros). Closes upstream issue #54 from our fork's perspective; the upstream PR will close it fully when merged.
1 parent 1cc2510 commit 280b4e0

3 files changed

Lines changed: 29 additions & 2 deletions

File tree

main.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const { app, BrowserWindow, dialog, ipcMain, Menu, screen, shell } = require('electron');
1+
const { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, screen, shell } = require('electron');
22
const { Worker } = require('worker_threads');
33
const { execFile } = require('child_process');
44
const path = require('path');
@@ -471,6 +471,14 @@ ipcMain.handle('open-external', (_event, url) => {
471471
if (/^https?:\/\//i.test(url)) return shell.openExternal(url);
472472
});
473473

474+
// --- IPC: clipboard write ---
475+
// The renderer's navigator.clipboard.writeText is gated on focus/user-activation and
476+
// is flaky-to-dead on Linux/Wayland (Ozone). The main-process clipboard has no such
477+
// strings attached, so all terminal copies go through here.
478+
ipcMain.handle('clipboard-write-text', (_event, text) => {
479+
if (typeof text === 'string') clipboard.writeText(text);
480+
});
481+
474482
// --- IPC: MCP bridge ---
475483
ipcMain.on('mcp-diff-response', (_event, sessionId, diffId, action, editedContent) => {
476484
resolvePendingDiff(sessionId, diffId, action, editedContent);

preload.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ contextBridge.exposeInMainWorld('api', {
4747
deleteWorktree: (worktreePath) => ipcRenderer.invoke('delete-worktree', worktreePath),
4848
worktreeStatus: (worktreePath) => ipcRenderer.invoke('worktree-status', worktreePath),
4949
openExternal: (url) => ipcRenderer.invoke('open-external', url),
50+
writeClipboard: (text) => ipcRenderer.invoke('clipboard-write-text', text),
5051

5152
// Send (fire-and-forget)
5253
sendInput: (id, data) => ipcRenderer.send('terminal-input', id, data),

public/terminal-manager.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ function setupTerminalKeyBindings(terminal, container, getSessionId, { onFind }
6363
if (!isMac && e.key === 'c' && e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
6464
if (terminal.hasSelection()) {
6565
if (e.type === 'keydown') {
66-
navigator.clipboard.writeText(terminal.getSelection()).catch(() => {});
66+
window.api.writeClipboard(terminal.getSelection());
6767
}
6868
return false;
6969
}
@@ -191,6 +191,24 @@ function createTerminalEntry(session) {
191191
},
192192
});
193193

194+
// OSC 52 — let the program inside the terminal set the system clipboard (this is how
195+
// Claude Code copies). xterm doesn't wire this up itself, so we do. Payload is
196+
// "<selection>;<base64>" (or "<selection>;?" for a read-back query, which we ignore).
197+
// Route through the main process — see writeClipboard — because the renderer clipboard
198+
// is unreliable on Wayland.
199+
terminal.parser.registerOscHandler(52, (payload) => {
200+
const sep = payload.indexOf(';');
201+
const b64 = sep === -1 ? payload : payload.slice(sep + 1);
202+
if (!b64 || b64 === '?') return true;
203+
try {
204+
const bytes = Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0));
205+
window.api.writeClipboard(new TextDecoder().decode(bytes));
206+
} catch {
207+
return false;
208+
}
209+
return true;
210+
});
211+
194212
const fitAddon = new FitAddon.FitAddon();
195213
terminal.loadAddon(fitAddon);
196214
terminal.loadAddon(new WebLinksAddon.WebLinksAddon((_event, url) => {

0 commit comments

Comments
 (0)