From 553fcc41fc0b229d6a50493ec1864a2003c2cb34 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Thu, 18 Jun 2026 13:53:58 +0530 Subject: [PATCH 1/3] fix(frontend): wire clipboard copy/paste in XtermTerminal zellij owns SGR mouse tracking and xterm's selection model is canvas-only, so neither the OS Copy command nor plain drag-to-select worked. Intercept Cmd/Ctrl+C to copy the active selection (falling through when nothing is selected so Ctrl+C still sends SIGINT) and Cmd/Ctrl+V to paste explicitly via the clipboard API rather than relying on xterm's native paste event. Fixes #305 Co-Authored-By: Claude Sonnet 4.6 --- .../src/renderer/components/XtermTerminal.tsx | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/frontend/src/renderer/components/XtermTerminal.tsx b/frontend/src/renderer/components/XtermTerminal.tsx index fe1e3cf0..6fc30650 100644 --- a/frontend/src/renderer/components/XtermTerminal.tsx +++ b/frontend/src/renderer/components/XtermTerminal.tsx @@ -71,6 +71,43 @@ const terminalThemes = buildTerminalThemes(); // events stop reaching zellij. The clear only wipes pixels; modes stay up. const CLEAR_SEQUENCE = "\x1b[3J\x1b[2J\x1b[H"; +// xterm's selection is an internal model rendered to canvas/WebGL, not a DOM +// selection, so the OS/Electron "Copy" command can never see it — we must +// intercept Cmd/Ctrl+C ourselves. When nothing is selected, Ctrl+C must keep +// reaching the PTY (it's SIGINT), so we only swallow the event when there's a +// selection to copy. Cmd/Ctrl+V is wired explicitly too: relying on xterm's +// native DOM 'paste' event is not reliable across platforms under Electron. +function isCopyShortcut(event: KeyboardEvent): boolean { + const modifier = event.metaKey || event.ctrlKey; + return modifier && event.key.toLowerCase() === "c"; +} + +function isPasteShortcut(event: KeyboardEvent): boolean { + const modifier = event.metaKey || event.ctrlKey; + return modifier && event.key.toLowerCase() === "v"; +} + +function attachClipboardHandling(term: Terminal): void { + term.attachCustomKeyEventHandler((event) => { + if (event.type !== "keydown") return true; + + if (isCopyShortcut(event)) { + if (!term.hasSelection()) return true; + void navigator.clipboard.writeText(term.getSelection()); + return false; + } + + if (isPasteShortcut(event)) { + void navigator.clipboard.readText().then((text) => { + if (text) term.paste(text); + }); + return false; + } + + return true; + }); +} + export function XtermTerminal(props: XtermTerminalProps) { const hostRef = useRef(null); const termRef = useRef(null); @@ -142,6 +179,7 @@ export function XtermTerminal(props: XtermTerminalProps) { term.open(host); loadRenderer(term); + attachClipboardHandling(term); const fitTerminal = () => { try { From 6556624bb5c3623d27e135c9f695f27fb1bb9dbe Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Fri, 19 Jun 2026 20:09:17 +0530 Subject: [PATCH 2/3] fix(frontend): enable macOptionClickForcesSelection so Mac users can select text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cmd+C copy appeared to do nothing because hasSelection() was always false on macOS: xterm.js only forces local selection over zellij's SGR mouse tracking on Shift+drag for Windows/Linux — on Mac the override key is Option/Alt, gated behind macOptionClickForcesSelection which defaults to false. Without it there was no way to select text on Mac at all. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/renderer/components/XtermTerminal.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/src/renderer/components/XtermTerminal.tsx b/frontend/src/renderer/components/XtermTerminal.tsx index 6fc30650..2f77b6b0 100644 --- a/frontend/src/renderer/components/XtermTerminal.tsx +++ b/frontend/src/renderer/components/XtermTerminal.tsx @@ -136,6 +136,13 @@ export function XtermTerminal(props: XtermTerminalProps) { // Required for the Unicode 11 width addon below. allowProposedApi: true, cursorBlink: true, + // zellij's SGR mouse tracking otherwise eats every drag as a mouse + // report. xterm.js only overrides this for local selection on Shift+drag + // on Windows/Linux — on macOS the override key is Option/Alt, and it's + // gated behind this option (default false). Without it, Mac users have + // no way to select text at all, so hasSelection() is always false and + // copy silently no-ops. + macOptionClickForcesSelection: true, // Resolve the Nerd Font stack from --font-mono (styles.css) at // construction so terminal glyphs follow the app's font tokens. The // box-drawing grid is rasterized by the WebGL/canvas renderer itself, From a318256e42e810166f252773cfc727a8b7c9e19d Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Sat, 20 Jun 2026 10:43:20 +0530 Subject: [PATCH 3/3] fix(frontend): make clipboard chords platform-extensible, fix SIGINT clobber MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the shared metaKey||ctrlKey copy/paste check with a per-platform chord table (mac/windows/linux), each easy to extend independently. macOS keeps Cmd+C/Cmd+V since Ctrl has no PTY meaning there. Windows/Linux now use Ctrl+Shift+C/V instead of plain Ctrl+C/Ctrl+V, matching GNOME Terminal/Windows Terminal convention — the previous plain-Ctrl+C check would have silently swallowed SIGINT whenever there was a stale selection on those platforms. Co-Authored-By: Claude Sonnet 4.6 --- .../src/renderer/components/XtermTerminal.tsx | 59 ++++++++++++++----- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/frontend/src/renderer/components/XtermTerminal.tsx b/frontend/src/renderer/components/XtermTerminal.tsx index 2f77b6b0..47ae242d 100644 --- a/frontend/src/renderer/components/XtermTerminal.tsx +++ b/frontend/src/renderer/components/XtermTerminal.tsx @@ -71,33 +71,62 @@ const terminalThemes = buildTerminalThemes(); // events stop reaching zellij. The clear only wipes pixels; modes stay up. const CLEAR_SEQUENCE = "\x1b[3J\x1b[2J\x1b[H"; -// xterm's selection is an internal model rendered to canvas/WebGL, not a DOM -// selection, so the OS/Electron "Copy" command can never see it — we must -// intercept Cmd/Ctrl+C ourselves. When nothing is selected, Ctrl+C must keep -// reaching the PTY (it's SIGINT), so we only swallow the event when there's a -// selection to copy. Cmd/Ctrl+V is wired explicitly too: relying on xterm's -// native DOM 'paste' event is not reliable across platforms under Electron. -function isCopyShortcut(event: KeyboardEvent): boolean { - const modifier = event.metaKey || event.ctrlKey; - return modifier && event.key.toLowerCase() === "c"; -} +type ClipboardPlatform = "mac" | "windows" | "linux"; -function isPasteShortcut(event: KeyboardEvent): boolean { - const modifier = event.metaKey || event.ctrlKey; - return modifier && event.key.toLowerCase() === "v"; +function detectClipboardPlatform(): ClipboardPlatform { + const platform = navigator.platform || navigator.userAgent; + if (/mac/i.test(platform)) return "mac"; + if (/win/i.test(platform)) return "windows"; + return "linux"; } +type ClipboardChords = { + isCopy: (event: KeyboardEvent) => boolean; + isPaste: (event: KeyboardEvent) => boolean; +}; + +// Per-platform chords, not one shared chord: on macOS, Ctrl is free for +// copy/paste because Ctrl+C/Ctrl+V have no PTY meaning there — Cmd owns +// shortcuts. On Windows/Linux, Ctrl+C *is* SIGINT and Ctrl+V can be a +// literal-paste control code in some shells, so plain Ctrl+C/Ctrl+V must keep +// reaching the PTY untouched; those platforms use Ctrl+Shift+C/V instead, the +// same convention GNOME Terminal and Windows Terminal use. Add a platform +// here to extend. +const CLIPBOARD_CHORDS: Record = { + mac: { + isCopy: (event) => event.metaKey && !event.shiftKey && event.key.toLowerCase() === "c", + isPaste: (event) => event.metaKey && !event.shiftKey && event.key.toLowerCase() === "v", + }, + windows: { + isCopy: (event) => event.ctrlKey && event.shiftKey && event.key.toLowerCase() === "c", + isPaste: (event) => event.ctrlKey && event.shiftKey && event.key.toLowerCase() === "v", + }, + linux: { + isCopy: (event) => event.ctrlKey && event.shiftKey && event.key.toLowerCase() === "c", + isPaste: (event) => event.ctrlKey && event.shiftKey && event.key.toLowerCase() === "v", + }, +}; + +// xterm's selection is an internal model rendered to canvas/WebGL, not a DOM +// selection, so the OS/Electron "Copy" command can never see it — we must +// intercept the copy chord ourselves. When nothing is selected we let the +// event fall through unchanged (so plain Ctrl+C still reaches the PTY as +// SIGINT on every platform). The paste chord is wired explicitly too: relying +// on xterm's native DOM 'paste' event is not reliable across platforms under +// Electron. function attachClipboardHandling(term: Terminal): void { + const chords = CLIPBOARD_CHORDS[detectClipboardPlatform()]; + term.attachCustomKeyEventHandler((event) => { if (event.type !== "keydown") return true; - if (isCopyShortcut(event)) { + if (chords.isCopy(event)) { if (!term.hasSelection()) return true; void navigator.clipboard.writeText(term.getSelection()); return false; } - if (isPasteShortcut(event)) { + if (chords.isPaste(event)) { void navigator.clipboard.readText().then((text) => { if (text) term.paste(text); });