From 76d31ce508bde725bf27d1c5937ecb5e0738d29c Mon Sep 17 00:00:00 2001 From: almajd3713 Date: Thu, 6 Aug 2026 13:38:58 +0400 Subject: [PATCH 1/2] fix(terminal): allow browser clipboard paste shortcuts --- .../terminal/components/TerminalModal.tsx | 9 +++++++ .../terminal/terminalKeyboard.test.ts | 26 +++++++++++++++++++ .../src/features/terminal/terminalKeyboard.ts | 19 ++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 frontend/src/features/terminal/terminalKeyboard.test.ts create mode 100644 frontend/src/features/terminal/terminalKeyboard.ts diff --git a/frontend/src/features/terminal/components/TerminalModal.tsx b/frontend/src/features/terminal/components/TerminalModal.tsx index 651635f..2c912f3 100644 --- a/frontend/src/features/terminal/components/TerminalModal.tsx +++ b/frontend/src/features/terminal/components/TerminalModal.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { X, Terminal as TermIcon, BookOpen } from 'lucide-react'; import '@xterm/xterm/css/xterm.css'; import LinuxCheatSheet from './LinuxCheatSheet'; +import { shouldProcessKeyInTerminal } from '../terminalKeyboard'; import { API_BASE } from '../../../shared/types'; interface TerminalModalProps { @@ -42,6 +43,8 @@ export default function TerminalModal({ containerId, projectId, nodeName, onClos const fitAddon = new FitAddon(); term.loadAddon(fitAddon); + term.attachCustomKeyEventHandler(shouldProcessKeyInTerminal); + if (terminalRef.current) { term.open(terminalRef.current); fitAddon.fit(); @@ -75,6 +78,12 @@ export default function TerminalModal({ containerId, projectId, nodeName, onClos }; }, [containerId, projectId]); + useEffect(() => { + if (activeTab === 'terminal') { + termRef.current?.focus(); + } + }, [activeTab]); + return (
diff --git a/frontend/src/features/terminal/terminalKeyboard.test.ts b/frontend/src/features/terminal/terminalKeyboard.test.ts new file mode 100644 index 0000000..71e470d --- /dev/null +++ b/frontend/src/features/terminal/terminalKeyboard.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { shouldProcessKeyInTerminal } from './terminalKeyboard'; + +function keydown(key: string, modifiers: KeyboardEventInit = {}): KeyboardEvent { + return new KeyboardEvent('keydown', { key, ...modifiers }); +} + +describe('shouldProcessKeyInTerminal', () => { + it.each([ + ['Ctrl+V', keydown('v', { ctrlKey: true })], + ['Ctrl+Shift+V', keydown('V', { ctrlKey: true, shiftKey: true })], + ['Cmd+V', keydown('v', { metaKey: true })], + ['Shift+Insert', keydown('Insert', { shiftKey: true })], + ])('leaves %s to the browser clipboard handler', (_shortcut, event) => { + expect(shouldProcessKeyInTerminal(event)).toBe(false); + }); + + it.each([ + ['ordinary input', keydown('v')], + ['terminal interrupt', keydown('c', { ctrlKey: true })], + ['Alt-modified input', keydown('v', { ctrlKey: true, altKey: true })], + ['paste shortcut keyup', new KeyboardEvent('keyup', { key: 'v', ctrlKey: true })], + ])('keeps processing %s in xterm', (_case, event) => { + expect(shouldProcessKeyInTerminal(event)).toBe(true); + }); +}); diff --git a/frontend/src/features/terminal/terminalKeyboard.ts b/frontend/src/features/terminal/terminalKeyboard.ts new file mode 100644 index 0000000..3b5d733 --- /dev/null +++ b/frontend/src/features/terminal/terminalKeyboard.ts @@ -0,0 +1,19 @@ +/** + * Returns whether xterm should process a key event itself. + * + * Browser-owned paste shortcuts must bypass xterm's key mapping so the browser + * can dispatch a `paste` event with clipboard data to xterm's hidden textarea. + * xterm already turns that event into terminal input, including newline and + * bracketed-paste handling. + */ +export function shouldProcessKeyInTerminal(event: KeyboardEvent): boolean { + if (event.type !== 'keydown' || event.altKey) { + return true; + } + + const modifierPaste = + (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v'; + const insertPaste = event.shiftKey && event.key === 'Insert'; + + return !modifierPaste && !insertPaste; +} From 7d56a1550ea88e50aa0c3b316b5cb550dc381ba4 Mon Sep 17 00:00:00 2001 From: almajd3713 Date: Thu, 6 Aug 2026 13:57:08 +0400 Subject: [PATCH 2/2] fix(terminal): remove unnecessary useEffect call --- .../src/features/terminal/components/TerminalModal.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/frontend/src/features/terminal/components/TerminalModal.tsx b/frontend/src/features/terminal/components/TerminalModal.tsx index 2c912f3..227d8d8 100644 --- a/frontend/src/features/terminal/components/TerminalModal.tsx +++ b/frontend/src/features/terminal/components/TerminalModal.tsx @@ -48,6 +48,7 @@ export default function TerminalModal({ containerId, projectId, nodeName, onClos if (terminalRef.current) { term.open(terminalRef.current); fitAddon.fit(); + term.focus(); } socket.emit('join-terminal', { containerId, projectId }); @@ -78,12 +79,6 @@ export default function TerminalModal({ containerId, projectId, nodeName, onClos }; }, [containerId, projectId]); - useEffect(() => { - if (activeTab === 'terminal') { - termRef.current?.focus(); - } - }, [activeTab]); - return (