Compare commits

..

4 Commits

Author SHA1 Message Date
alex 7603b739cb fix(terminal): prioritize selected copy menu
Handle selected-output context menus during capture so xterm and the browser cannot open a competing menu.
2026-07-22 14:25:20 +02:00
alex ff8efdd887 merge: add terminal right click copy action 2026-07-22 14:15:47 +02:00
alex b289edf5a9 fix(terminal): add right click copy action
Replace the conflicting keyboard copy shortcut with an xterm-aware Copy action shown for selected output.
2026-07-22 14:15:46 +02:00
alex 75b67f2a6f merge: use ctrl shift c for terminal output copy 2026-07-22 13:01:32 +02:00
3 changed files with 56 additions and 57 deletions
@@ -4,7 +4,7 @@ import {
getTerminalScrollbackLimit, getTerminalScrollbackLimit,
isCurrentWebSocket, isCurrentWebSocket,
shouldRetryWebSocketClose, shouldRetryWebSocketClose,
shouldCopyTerminalSelection, shouldShowTerminalCopyMenu,
} from "./terminal.tsx"; } from "./terminal.tsx";
describe("getTerminalScrollbackLimit", () => { describe("getTerminalScrollbackLimit", () => {
@@ -32,31 +32,12 @@ describe("getTerminalScrollbackLimit", () => {
}); });
}); });
describe("shouldCopyTerminalSelection", () => { describe("shouldShowTerminalCopyMenu", () => {
it("copies a selected terminal region with Ctrl+Shift+C", () => { it("shows Copy for selected terminal output", () => {
expect( expect(shouldShowTerminalCopyMenu("selected output")).toBe(true);
shouldCopyTerminalSelection(
{ ctrlKey: true, shiftKey: true, key: "c" },
true,
),
).toBe(true);
}); });
it("keeps Ctrl+C as a terminal interrupt", () => { it("keeps the native context menu when no output is selected", () => {
expect( expect(shouldShowTerminalCopyMenu("")).toBe(false);
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,16 +68,20 @@ export function shouldRetryWebSocketClose(code: number, reason: string): boolean
return code !== 1000 && !(code === 4000 && reason === "New connection established"); return code !== 1000 && !(code === 4000 && reason === "New connection established");
} }
export function shouldCopyTerminalSelection( export function shouldShowTerminalCopyMenu(selection: string): boolean {
event: Pick<KeyboardEvent, "ctrlKey" | "shiftKey" | "key">, return selection.length > 0;
hasSelection: boolean, }
): boolean {
return ( function copyTextWithFallback(text: string): void {
hasSelection && const textarea = document.createElement("textarea");
event.ctrlKey && textarea.value = text;
event.shiftKey && textarea.setAttribute("readonly", "");
event.key.toLowerCase() === "c" textarea.style.position = "fixed";
); textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
} }
function matchesByteSequence( function matchesByteSequence(
@@ -119,6 +123,11 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
>("connecting"); >("connecting");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false);
const [copyMenu, setCopyMenu] = useState<{
x: number;
y: number;
text: string;
} | null>(null);
const activeModifierRef = useRef(activeModifier); const activeModifierRef = useRef(activeModifier);
activeModifierRef.current = activeModifier; activeModifierRef.current = activeModifier;
const [fontSize, setFontSize] = useState(() => { const [fontSize, setFontSize] = useState(() => {
@@ -481,28 +490,15 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
event.preventDefault(); event.preventDefault();
event.clipboardData?.setData("text/plain", selection); event.clipboardData?.setData("text/plain", selection);
}; };
const copySelection = () => { const handleTerminalContextMenu = (event: MouseEvent) => {
const selection = term.getSelection(); const selection = term.getSelection();
if (!selection) return; if (!shouldShowTerminalCopyMenu(selection)) {
setCopyMenu(null);
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; return;
} }
event.preventDefault(); event.preventDefault();
event.stopPropagation(); setCopyMenu({ x: event.clientX, y: event.clientY, text: selection });
copySelection();
}; };
const handleBrowserPaste = (event: ClipboardEvent) => { const handleBrowserPaste = (event: ClipboardEvent) => {
@@ -516,7 +512,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("contextmenu", handleTerminalContextMenu, true);
container.addEventListener("paste", handleBrowserPaste, true); container.addEventListener("paste", handleBrowserPaste, true);
// Mobile touch scroll. // Mobile touch scroll.
@@ -766,8 +762,8 @@ 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( container.removeEventListener(
"keydown", "contextmenu",
handleBrowserCopyShortcut, handleTerminalContextMenu,
true, true,
); );
container.removeEventListener("paste", handleBrowserPaste, true); container.removeEventListener("paste", handleBrowserPaste, true);
@@ -904,6 +900,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// Focus terminal on mobile to keep keyboard open // Focus terminal on mobile to keep keyboard open
const handleTerminalClick = () => { const handleTerminalClick = () => {
setCopyMenu(null);
if (isMobile && termRef.current) { if (isMobile && termRef.current) {
termRef.current.focus(); termRef.current.focus();
} }
@@ -1018,6 +1015,26 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
</div> </div>
</div> </div>
)} )}
{copyMenu && (
<button
aria-label="Copy selected terminal text"
className="terminal-context-copy"
onMouseDown={(event) => {
event.preventDefault();
copyTextWithFallback(copyMenu.text);
setCopyMenu(null);
}}
style={{
left: copyMenu.x,
position: "fixed",
top: copyMenu.y,
zIndex: 1000,
}}
type="button"
>
Copy
</button>
)}
{error && ( {error && (
<div className="terminal-error"> <div className="terminal-error">
{error} {error}
@@ -6,8 +6,9 @@ Users cannot reliably copy terminal output from the browser terminal. Browser co
## Required behavior ## Required behavior
- Copying a selected terminal region with `Ctrl+Shift+C` copies text to the system clipboard without sending input to the terminal. - Right-clicking a selected terminal region presents an xterm-aware Copy action that writes the selection to the system clipboard.
- `Ctrl+C` remains a terminal interrupt, including when output is selected. - Browser native context menus remain available when no terminal output is selected.
- Terminal keyboard input, including `Ctrl+C`, remains unchanged.
- 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.