Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions frontend/src/renderer/components/XtermTerminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClipboardPlatform, ClipboardChords> = {
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<HTMLDivElement | null>(null);
const termRef = useRef<Terminal | null>(null);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -142,6 +215,7 @@ export function XtermTerminal(props: XtermTerminalProps) {

term.open(host);
loadRenderer(term);
attachClipboardHandling(term);

const fitTerminal = () => {
try {
Expand Down
Loading