Compare commits

...

6 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
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
3 changed files with 61 additions and 49 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,28 +32,12 @@ describe("getTerminalScrollbackLimit", () => {
}); });
}); });
describe("shouldCopyTerminalSelection", () => { describe("shouldShowTerminalCopyMenu", () => {
it("copies a selected terminal region with Ctrl+C or Cmd+C", () => { it("shows Copy for selected terminal output", () => {
expect( expect(shouldShowTerminalCopyMenu("selected output")).toBe(true);
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", () => { it("keeps the native context menu when no output is selected", () => {
expect( expect(shouldShowTerminalCopyMenu("")).toBe(false);
shouldCopyTerminalSelection(
{ ctrlKey: true, metaKey: false, key: "c" },
false,
),
).toBe(false);
}); });
}); });
@@ -68,15 +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" | "metaKey" | "key">, return selection.length > 0;
hasSelection: boolean, }
): boolean {
return ( function copyTextWithFallback(text: string): void {
hasSelection && const textarea = document.createElement("textarea");
(event.ctrlKey || event.metaKey) && textarea.value = text;
event.key.toLowerCase() === "c" textarea.setAttribute("readonly", "");
); textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
} }
function matchesByteSequence( function matchesByteSequence(
@@ -118,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(() => {
@@ -480,27 +490,16 @@ 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)) {
const clipboard = navigator.clipboard; setCopyMenu(null);
if (clipboard) { return;
void clipboard.writeText(selection).catch(() => {
document.execCommand("copy");
});
} else {
document.execCommand("copy");
}
};
term.attachCustomKeyEventHandler((event) => {
if (!shouldCopyTerminalSelection(event, term.hasSelection())) {
return true;
} }
event.preventDefault(); event.preventDefault();
copySelection(); setCopyMenu({ x: event.clientX, y: event.clientY, text: selection });
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 +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("contextmenu", handleTerminalContextMenu, true);
container.addEventListener("paste", handleBrowserPaste, true); container.addEventListener("paste", handleBrowserPaste, true);
// Mobile touch scroll. // Mobile touch scroll.
@@ -761,6 +761,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(
"contextmenu",
handleTerminalContextMenu,
true,
);
container.removeEventListener("paste", handleBrowserPaste, true); container.removeEventListener("paste", handleBrowserPaste, true);
pasteTextRef.current = () => {}; pasteTextRef.current = () => {};
bracketedPasteEnabledRef.current = false; bracketedPasteEnabledRef.current = false;
@@ -895,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();
} }
@@ -1009,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,7 +6,9 @@ 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. - Right-clicking a selected terminal region presents an xterm-aware Copy action that writes the selection to the system clipboard.
- 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.