Compare commits

...

4 Commits

Author SHA1 Message Date
alex bcc7486b59 fix(terminal): use ctrl shift c for output copy
Capture Ctrl+Shift+C before xterm input handling and copy selected output through a browser-compatible fallback. Preserve Ctrl+C as terminal input.
2026-07-22 13:01:31 +02:00
alex 29e765b2be merge: support terminal browser clipboard shortcuts 2026-07-22 12:52:20 +02:00
alex d14cdc1151 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.
2026-07-22 12:52:19 +02:00
alex c80dbf9737 merge: preserve mounted profile file updates 2026-07-22 11:52:43 +02:00
4 changed files with 100 additions and 1 deletions
@@ -4,6 +4,7 @@ import {
getTerminalScrollbackLimit,
isCurrentWebSocket,
shouldRetryWebSocketClose,
shouldCopyTerminalSelection,
} from "./terminal.tsx";
describe("getTerminalScrollbackLimit", () => {
@@ -30,3 +31,32 @@ describe("getTerminalScrollbackLimit", () => {
);
});
});
describe("shouldCopyTerminalSelection", () => {
it("copies a selected terminal region with Ctrl+Shift+C", () => {
expect(
shouldCopyTerminalSelection(
{ ctrlKey: true, shiftKey: true, key: "c" },
true,
),
).toBe(true);
});
it("keeps Ctrl+C as a terminal interrupt", () => {
expect(
shouldCopyTerminalSelection(
{ 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,
),
).toBe(false);
});
});
@@ -68,6 +68,18 @@ export function shouldRetryWebSocketClose(code: number, reason: string): boolean
return code !== 1000 && !(code === 4000 && reason === "New connection established");
}
export function shouldCopyTerminalSelection(
event: Pick<KeyboardEvent, "ctrlKey" | "shiftKey" | "key">,
hasSelection: boolean,
): boolean {
return (
hasSelection &&
event.ctrlKey &&
event.shiftKey &&
event.key.toLowerCase() === "c"
);
}
function matchesByteSequence(
data: Uint8Array,
start: number,
@@ -462,8 +474,38 @@ 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 textarea = document.createElement("textarea");
textarea.value = selection;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
};
const handleBrowserCopyShortcut = (event: KeyboardEvent) => {
if (!shouldCopyTerminalSelection(event, term.hasSelection())) {
return;
}
event.preventDefault();
event.stopPropagation();
copySelection();
};
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 +515,8 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
event.stopImmediatePropagation();
pasteTextRef.current(text);
};
container.addEventListener("copy", handleBrowserCopy, true);
container.addEventListener("keydown", handleBrowserCopyShortcut, true);
container.addEventListener("paste", handleBrowserPaste, true);
// Mobile touch scroll.
@@ -720,6 +764,12 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
handleVisibilityChange,
);
if (touchCleanup) touchCleanup();
container.removeEventListener("copy", handleBrowserCopy, true);
container.removeEventListener(
"keydown",
handleBrowserCopyShortcut,
true,
);
container.removeEventListener("paste", handleBrowserPaste, true);
pasteTextRef.current = () => {};
bracketedPasteEnabledRef.current = false;
@@ -0,0 +1,13 @@
# Fix Web Terminal Clipboard
## Problem
Users cannot reliably copy terminal output from the browser terminal. Browser copy shortcuts may be forwarded to the terminal as input instead of copying the xterm selection, and paste behavior differs by bracketed-paste mode.
## Required behavior
- 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.
- Pasting plain text is handled once and follows bracketed-paste mode when enabled.
- Normal terminal interrupts still work when there is no active selection.
@@ -0,0 +1,6 @@
# Web Terminal Clipboard Tasks
- [x] Add selected-text copy interception without suppressing unselected terminal interrupts.
- [x] Normalize browser paste handling through the existing paste transport.
- [x] Add focused frontend coverage for copy shortcut decisions.
- [x] Run frontend tests, typecheck/build, lint, and diagnostics.