merge: use ctrl shift c for terminal output copy

This commit is contained in:
2026-07-22 13:01:32 +02:00
3 changed files with 38 additions and 25 deletions
@@ -33,25 +33,28 @@ describe("getTerminalScrollbackLimit", () => {
}); });
describe("shouldCopyTerminalSelection", () => { describe("shouldCopyTerminalSelection", () => {
it("copies a selected terminal region with Ctrl+C or Cmd+C", () => { it("copies a selected terminal region with Ctrl+Shift+C", () => {
expect( expect(
shouldCopyTerminalSelection( shouldCopyTerminalSelection(
{ ctrlKey: true, metaKey: false, key: "c" }, { ctrlKey: true, shiftKey: true, key: "c" },
true,
),
).toBe(true);
expect(
shouldCopyTerminalSelection(
{ ctrlKey: false, metaKey: true, key: "C" },
true, true,
), ),
).toBe(true); ).toBe(true);
}); });
it("keeps Ctrl+C as a terminal interrupt without a selection", () => { it("keeps Ctrl+C as a terminal interrupt", () => {
expect( expect(
shouldCopyTerminalSelection( shouldCopyTerminalSelection(
{ ctrlKey: true, metaKey: false, key: "c" }, { ctrlKey: true, shiftKey: false, key: "c" },
true,
),
).toBe(false);
});
it("does not copy without a selection", () => {
expect(
shouldCopyTerminalSelection(
{ ctrlKey: true, shiftKey: true, key: "c" },
false, false,
), ),
).toBe(false); ).toBe(false);
@@ -69,12 +69,13 @@ export function shouldRetryWebSocketClose(code: number, reason: string): boolean
} }
export function shouldCopyTerminalSelection( export function shouldCopyTerminalSelection(
event: Pick<KeyboardEvent, "ctrlKey" | "metaKey" | "key">, event: Pick<KeyboardEvent, "ctrlKey" | "shiftKey" | "key">,
hasSelection: boolean, hasSelection: boolean,
): boolean { ): boolean {
return ( return (
hasSelection && hasSelection &&
(event.ctrlKey || event.metaKey) && event.ctrlKey &&
event.shiftKey &&
event.key.toLowerCase() === "c" event.key.toLowerCase() === "c"
); );
} }
@@ -483,24 +484,26 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
const copySelection = () => { const copySelection = () => {
const selection = term.getSelection(); const selection = term.getSelection();
if (!selection) return; if (!selection) return;
const clipboard = navigator.clipboard;
if (clipboard) { const textarea = document.createElement("textarea");
void clipboard.writeText(selection).catch(() => { textarea.value = selection;
document.execCommand("copy"); textarea.setAttribute("readonly", "");
}); textarea.style.position = "fixed";
} else { textarea.style.opacity = "0";
document.execCommand("copy"); document.body.appendChild(textarea);
} textarea.select();
document.execCommand("copy");
textarea.remove();
}; };
term.attachCustomKeyEventHandler((event) => { const handleBrowserCopyShortcut = (event: KeyboardEvent) => {
if (!shouldCopyTerminalSelection(event, term.hasSelection())) { if (!shouldCopyTerminalSelection(event, term.hasSelection())) {
return true; return;
} }
event.preventDefault(); event.preventDefault();
event.stopPropagation();
copySelection(); copySelection();
return false; };
});
const handleBrowserPaste = (event: ClipboardEvent) => { const handleBrowserPaste = (event: ClipboardEvent) => {
const text = event.clipboardData?.getData("text/plain"); const text = event.clipboardData?.getData("text/plain");
@@ -513,6 +516,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
pasteTextRef.current(text); pasteTextRef.current(text);
}; };
container.addEventListener("copy", handleBrowserCopy, true); container.addEventListener("copy", handleBrowserCopy, true);
container.addEventListener("keydown", handleBrowserCopyShortcut, true);
container.addEventListener("paste", handleBrowserPaste, true); container.addEventListener("paste", handleBrowserPaste, true);
// Mobile touch scroll. // Mobile touch scroll.
@@ -761,6 +765,11 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
); );
if (touchCleanup) touchCleanup(); if (touchCleanup) touchCleanup();
container.removeEventListener("copy", handleBrowserCopy, true); container.removeEventListener("copy", handleBrowserCopy, true);
container.removeEventListener(
"keydown",
handleBrowserCopyShortcut,
true,
);
container.removeEventListener("paste", handleBrowserPaste, true); container.removeEventListener("paste", handleBrowserPaste, true);
pasteTextRef.current = () => {}; pasteTextRef.current = () => {};
bracketedPasteEnabledRef.current = false; bracketedPasteEnabledRef.current = false;
@@ -6,7 +6,8 @@ Users cannot reliably copy terminal output from the browser terminal. Browser co
## Required behavior ## Required behavior
- Copying a selected terminal region with standard browser shortcuts (`Ctrl/Cmd+C`) copies text to the system clipboard without sending an interrupt to the terminal. - Copying a selected terminal region with `Ctrl+Shift+C` copies text to the system clipboard without sending input to the terminal.
- `Ctrl+C` remains a terminal interrupt, including when output is selected.
- Copy requests expose the xterm selection as plain text. - Copy requests expose the xterm selection as plain text.
- Pasting plain text is handled once and follows bracketed-paste mode when enabled. - Pasting plain text is handled once and follows bracketed-paste mode when enabled.
- Normal terminal interrupts still work when there is no active selection. - Normal terminal interrupts still work when there is no active selection.