diff --git a/frontend/src/renderer/components/XtermTerminal.tsx b/frontend/src/renderer/components/XtermTerminal.tsx index fe1e3cf0..47ae242d 100644 --- a/frontend/src/renderer/components/XtermTerminal.tsx +++ b/frontend/src/renderer/components/XtermTerminal.tsx @@ -71,6 +71,72 @@ const terminalThemes = buildTerminalThemes(); // events stop reaching zellij. The clear only wipes pixels; modes stay up. const CLEAR_SEQUENCE = "\x1b[3J\x1b[2J\x1b[H"; +type ClipboardPlatform = "mac" | "windows" | "linux"; + +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 (chords.isCopy(event)) { + if (!term.hasSelection()) return true; + void navigator.clipboard.writeText(term.getSelection()); + return false; + } + + if (chords.isPaste(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); @@ -99,6 +165,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, @@ -142,6 +215,7 @@ export function XtermTerminal(props: XtermTerminalProps) { term.open(host); loadRenderer(term); + attachClipboardHandling(term); const fitTerminal = () => { try {