fix(terminal): support browser clipboard shortcuts

Copy selected terminal output with Ctrl/Cmd+C without sending an interrupt, and route browser paste consistently through the terminal transport.
This commit is contained in:
2026-07-22 12:52:19 +02:00
parent c80dbf9737
commit d14cdc1151
4 changed files with 87 additions and 1 deletions
@@ -4,6 +4,7 @@ import {
getTerminalScrollbackLimit,
isCurrentWebSocket,
shouldRetryWebSocketClose,
shouldCopyTerminalSelection,
} from "./terminal.tsx";
describe("getTerminalScrollbackLimit", () => {
@@ -30,3 +31,29 @@ describe("getTerminalScrollbackLimit", () => {
);
});
});
describe("shouldCopyTerminalSelection", () => {
it("copies a selected terminal region with Ctrl+C or Cmd+C", () => {
expect(
shouldCopyTerminalSelection(
{ ctrlKey: true, metaKey: false, key: "c" },
true,
),
).toBe(true);
expect(
shouldCopyTerminalSelection(
{ ctrlKey: false, metaKey: true, key: "C" },
true,
),
).toBe(true);
});
it("keeps Ctrl+C as a terminal interrupt without a selection", () => {
expect(
shouldCopyTerminalSelection(
{ ctrlKey: true, metaKey: false, key: "c" },
false,
),
).toBe(false);
});
});
@@ -68,6 +68,17 @@ export function shouldRetryWebSocketClose(code: number, reason: string): boolean
return code !== 1000 && !(code === 4000 && reason === "New connection established");
}
export function shouldCopyTerminalSelection(
event: Pick<KeyboardEvent, "ctrlKey" | "metaKey" | "key">,
hasSelection: boolean,
): boolean {
return (
hasSelection &&
(event.ctrlKey || event.metaKey) &&
event.key.toLowerCase() === "c"
);
}
function matchesByteSequence(
data: Uint8Array,
start: number,
@@ -462,8 +473,36 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
term.paste(text);
};
const handleBrowserCopy = (event: ClipboardEvent) => {
const selection = term.getSelection();
if (!selection) return;
event.preventDefault();
event.clipboardData?.setData("text/plain", selection);
};
const copySelection = () => {
const selection = term.getSelection();
if (!selection) return;
const clipboard = navigator.clipboard;
if (clipboard) {
void clipboard.writeText(selection).catch(() => {
document.execCommand("copy");
});
} else {
document.execCommand("copy");
}
};
term.attachCustomKeyEventHandler((event) => {
if (!shouldCopyTerminalSelection(event, term.hasSelection())) {
return true;
}
event.preventDefault();
copySelection();
return false;
});
const handleBrowserPaste = (event: ClipboardEvent) => {
if (!bracketedPasteEnabledRef.current) return;
const text = event.clipboardData?.getData("text/plain");
if (text === undefined || wsRef.current?.readyState !== WebSocket.OPEN) {
return;
@@ -473,6 +512,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
event.stopImmediatePropagation();
pasteTextRef.current(text);
};
container.addEventListener("copy", handleBrowserCopy, true);
container.addEventListener("paste", handleBrowserPaste, true);
// Mobile touch scroll.
@@ -720,6 +760,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
handleVisibilityChange,
);
if (touchCleanup) touchCleanup();
container.removeEventListener("copy", handleBrowserCopy, true);
container.removeEventListener("paste", handleBrowserPaste, true);
pasteTextRef.current = () => {};
bracketedPasteEnabledRef.current = false;