diff --git a/frontend/src/features/terminal/components/TerminalModal.tsx b/frontend/src/features/terminal/components/TerminalModal.tsx index 651635f..227d8d8 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,9 +43,12 @@ 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(); + term.focus(); } socket.emit('join-terminal', { containerId, projectId }); 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; +}