fix(terminal): restore terminal text copy behavior (#452)

* fix(terminal): restore terminal text copy behavior

* fix(desktop): route Cmd+C through terminal-aware copy handler on macOS

- Use a custom macOS Copy menu action to dispatch app copy events first, so terminal selections copy without native “no target” beep, while preserving DOM copy fallback for non-terminal contexts.

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
shekohex
2026-02-20 13:23:01 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 7bcf848035
commit 881e8bdf4f
4 changed files with 73 additions and 39 deletions
@@ -339,29 +339,55 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
};
}, [useHiddenInputOverlay, focusHiddenInput]);
const copySelectionToClipboard = React.useCallback(async () => {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
const getTerminalSelectionText = React.useCallback((): string => {
const terminal = terminalRef.current as unknown as {
getSelection?: () => string;
} | null;
if (!terminal || typeof terminal.getSelection !== 'function') {
return '';
}
const text = terminal.getSelection();
return typeof text === 'string' ? text : '';
}, []);
const getDomSelectionTextInViewport = React.useCallback((): string => {
if (typeof window === 'undefined') {
return '';
}
const selection = window.getSelection();
if (!selection) {
return;
return '';
}
const text = selection.toString();
if (!text.trim()) {
return;
return '';
}
const container = containerRef.current;
if (!container) {
return;
return '';
}
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
if (anchorNode && !container.contains(anchorNode)) {
return;
return '';
}
if (focusNode && !container.contains(focusNode)) {
return '';
}
return text;
}, []);
const copySelectionToClipboard = React.useCallback(async () => {
if (typeof document === 'undefined') {
return;
}
const text = getTerminalSelectionText() || getDomSelectionTextInViewport();
if (!text.trim()) {
return;
}
@@ -388,37 +414,34 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
} catch {
return;
}
}, []);
}, [getDomSelectionTextInViewport, getTerminalSelectionText]);
const hasCopyableSelectionInViewport = React.useCallback((): boolean => {
const terminalSelection = getTerminalSelectionText();
if (terminalSelection.trim()) {
return true;
}
return Boolean(getDomSelectionTextInViewport().trim());
}, [getDomSelectionTextInViewport, getTerminalSelectionText]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return false;
}
const selection = window.getSelection();
if (!selection) {
return false;
}
const text = selection.toString();
if (!text.trim()) {
return false;
return;
}
const container = containerRef.current;
if (!container) {
return false;
}
const handleMenuCopy = (event: Event) => {
if (!hasCopyableSelectionInViewport()) {
return;
}
event.preventDefault();
void copySelectionToClipboard();
};
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
if (anchorNode && !container.contains(anchorNode)) {
return false;
}
if (focusNode && !container.contains(focusNode)) {
return false;
}
return true;
}, []);
window.addEventListener('openchamber:copy', handleMenuCopy);
return () => {
window.removeEventListener('openchamber:copy', handleMenuCopy);
};
}, [copySelectionToClipboard, hasCopyableSelectionInViewport]);
const resetWriteState = React.useCallback(() => {
pendingWriteRef.current = '';
+10
View File
@@ -35,6 +35,7 @@ type MenuAction =
| 'open-diff-tab'
| 'open-files-tab'
| 'open-terminal-tab'
| 'copy'
| 'theme-light'
| 'theme-dark'
| 'theme-system'
@@ -152,6 +153,15 @@ export const useMenuActions = (
break;
}
case 'copy': {
const copyEvent = new Event('openchamber:copy', { cancelable: true });
const wasHandled = !window.dispatchEvent(copyEvent);
if (!wasHandled) {
document.execCommand('copy');
}
break;
}
case 'theme-light':
setThemeMode('light');
break;
-6
View File
@@ -888,11 +888,8 @@ html:not(.dark) .chat-scroll {
/* Keep Ghostty's internal focus textarea fully non-painting on desktop. */
.terminal-viewport-container textarea:not([data-terminal-hidden-input="true"]) {
opacity: 0 !important;
width: 0 !important;
height: 0 !important;
font-size: 0 !important;
line-height: 0 !important;
clip-path: inset(100%) !important;
color: transparent !important;
-webkit-text-fill-color: transparent !important;
background: transparent !important;
@@ -908,9 +905,6 @@ input[aria-label="Terminal input"] {
color: transparent !important;
-webkit-text-fill-color: transparent !important;
opacity: 0 !important;
width: 0 !important;
height: 0 !important;
clip-path: inset(100%) !important;
outline: none !important;
}