From 4830d6dc2837a78221415961663a5773cfbdf4b9 Mon Sep 17 00:00:00 2001 From: Travis Xie Date: Fri, 27 Mar 2026 19:11:23 +0800 Subject: [PATCH 1/3] fix(terminal): intercept Cmd+V on macOS to prevent paste-then-edit garbling (#88) On macOS, xterm.js handles Cmd+V paste through its hidden textarea. After pasting, the browser's default action re-inserts clipboard text into the textarea (handlePasteEvent calls stopPropagation but not preventDefault). Subsequent input reads stale textarea content, producing garbled characters from the end of the pasted text. Fix by intercepting Cmd+V in the custom key event handler (mirroring the existing Windows Ctrl+V path): preventDefault blocks the native paste, readTextFromClipboard fetches via Electron IPC, and terminal.paste() sends data directly without touching the textarea. --- .../components/terminalNode/inputBridge.ts | 17 ++- .../unit/contexts/terminalInputBridge.spec.ts | 140 ++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/inputBridge.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/inputBridge.ts index 705b9a03..b19658ae 100644 --- a/src/contexts/workspace/presentation/renderer/components/terminalNode/inputBridge.ts +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/inputBridge.ts @@ -58,6 +58,17 @@ export function isWindowsTerminalPasteShortcut( return event.key === 'Insert' && event.shiftKey && !event.ctrlKey } +export function isMacTerminalPasteShortcut( + event: Pick, + platformInfo: PlatformInfo | undefined = navigator, +): boolean { + if (isWindowsPlatform(platformInfo) || event.ctrlKey || event.altKey) { + return false + } + + return event.key.toLowerCase() === 'v' && event.metaKey && !event.shiftKey +} + function isTerminalFindShortcut( event: Pick, ): boolean { @@ -195,7 +206,11 @@ export function handleTerminalCustomKeyEvent({ } if (event.type !== 'keydown' || !isWindowsTerminalCopyShortcut(event, platformInfo)) { - if (event.type === 'keydown' && isWindowsTerminalPasteShortcut(event, platformInfo)) { + if ( + event.type === 'keydown' && + (isWindowsTerminalPasteShortcut(event, platformInfo) || + isMacTerminalPasteShortcut(event, platformInfo)) + ) { event.preventDefault() event.stopPropagation() void pasteClipboardText({ terminal }) diff --git a/tests/unit/contexts/terminalInputBridge.spec.ts b/tests/unit/contexts/terminalInputBridge.spec.ts index c90f61b2..4b707b93 100644 --- a/tests/unit/contexts/terminalInputBridge.spec.ts +++ b/tests/unit/contexts/terminalInputBridge.spec.ts @@ -2,9 +2,57 @@ import { describe, expect, it, vi } from 'vitest' import { createPtyWriteQueue, handleTerminalCustomKeyEvent, + isMacTerminalPasteShortcut, pasteTextFromClipboard, } from '../../../src/contexts/workspace/presentation/renderer/components/terminalNode/inputBridge' +describe('isMacTerminalPasteShortcut', () => { + it('returns true for Cmd+V on macOS', () => { + expect( + isMacTerminalPasteShortcut( + { key: 'v', metaKey: true, ctrlKey: false, altKey: false, shiftKey: false }, + { platform: 'MacIntel' }, + ), + ).toBe(true) + }) + + it('returns false for Cmd+V on Windows', () => { + expect( + isMacTerminalPasteShortcut( + { key: 'v', metaKey: true, ctrlKey: false, altKey: false, shiftKey: false }, + { platform: 'Win32' }, + ), + ).toBe(false) + }) + + it('returns false for Ctrl+V on macOS', () => { + expect( + isMacTerminalPasteShortcut( + { key: 'v', metaKey: false, ctrlKey: true, altKey: false, shiftKey: false }, + { platform: 'MacIntel' }, + ), + ).toBe(false) + }) + + it('returns false for Cmd+Shift+V', () => { + expect( + isMacTerminalPasteShortcut( + { key: 'v', metaKey: true, ctrlKey: false, altKey: false, shiftKey: true }, + { platform: 'MacIntel' }, + ), + ).toBe(false) + }) + + it('returns true for Meta+V on Linux', () => { + expect( + isMacTerminalPasteShortcut( + { key: 'v', metaKey: true, ctrlKey: false, altKey: false, shiftKey: false }, + { platform: 'Linux x86_64' }, + ), + ).toBe(true) + }) +}) + describe('handleTerminalCustomKeyEvent', () => { it('copies the selected terminal text on Windows Ctrl+C', async () => { const copySelectedText = vi.fn(async () => undefined) @@ -180,6 +228,98 @@ describe('handleTerminalCustomKeyEvent', () => { expect(terminal.paste).toHaveBeenCalledWith('clipboard payload') }) + it('pastes clipboard text on macOS Cmd+V', () => { + const pasteClipboardText = vi.fn() + const event = { + type: 'keydown', + key: 'v', + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: true, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent + const terminal = { + hasSelection: () => false, + getSelection: () => '', + paste: vi.fn(), + } + + const result = handleTerminalCustomKeyEvent({ + event, + pasteClipboardText, + platformInfo: { platform: 'MacIntel' }, + ptyWriteQueue: { + enqueue: vi.fn(), + flush: vi.fn(), + }, + terminal, + }) + + expect(result).toBe(false) + expect(pasteClipboardText).toHaveBeenCalledWith({ terminal }) + expect(event.preventDefault).toHaveBeenCalledTimes(1) + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + }) + + it('does not intercept macOS Cmd+C (lets xterm.js handle copy)', () => { + const pasteClipboardText = vi.fn() + const copySelectedText = vi.fn(async () => undefined) + + const result = handleTerminalCustomKeyEvent({ + copySelectedText, + event: new KeyboardEvent('keydown', { key: 'c', metaKey: true }), + pasteClipboardText, + platformInfo: { platform: 'MacIntel' }, + ptyWriteQueue: { + enqueue: vi.fn(), + flush: vi.fn(), + }, + terminal: { + hasSelection: () => true, + getSelection: () => 'selected output', + paste: vi.fn(), + }, + }) + + expect(result).toBe(true) + expect(pasteClipboardText).not.toHaveBeenCalled() + expect(copySelectedText).not.toHaveBeenCalled() + }) + + it('does not intercept macOS Cmd+Shift+V', () => { + const pasteClipboardText = vi.fn() + const event = { + type: 'keydown', + key: 'v', + ctrlKey: false, + shiftKey: true, + altKey: false, + metaKey: true, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent + + const result = handleTerminalCustomKeyEvent({ + event, + pasteClipboardText, + platformInfo: { platform: 'MacIntel' }, + ptyWriteQueue: { + enqueue: vi.fn(), + flush: vi.fn(), + }, + terminal: { + hasSelection: () => false, + getSelection: () => '', + paste: vi.fn(), + }, + }) + + expect(result).toBe(true) + expect(pasteClipboardText).not.toHaveBeenCalled() + }) + it('preserves binary writes as a separate PTY payload', async () => { const writes: Array<{ data: string; encoding: 'utf8' | 'binary' }> = [] const ptyWriteQueue = createPtyWriteQueue(async payload => { From 5909edec837444f2b0bb1ae8291c6c09b5228759 Mon Sep 17 00:00:00 2001 From: Travis Xie Date: Mon, 30 Mar 2026 10:50:31 +0800 Subject: [PATCH 2/3] fix(terminal): restrict Cmd+V paste interception to macOS only Address PR review: isMacTerminalPasteShortcut was matching Meta+V on Linux (Super key), which is not a standard paste shortcut. Add isMacPlatform() guard so the shortcut only fires on macOS, and remove the Linux Meta+V test assertion. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../renderer/components/terminalNode/inputBridge.ts | 10 +++++++++- tests/unit/contexts/terminalInputBridge.spec.ts | 8 -------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/inputBridge.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/inputBridge.ts index b19658ae..1258a49e 100644 --- a/src/contexts/workspace/presentation/renderer/components/terminalNode/inputBridge.ts +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/inputBridge.ts @@ -58,11 +58,19 @@ export function isWindowsTerminalPasteShortcut( return event.key === 'Insert' && event.shiftKey && !event.ctrlKey } +export function isMacPlatform(platformInfo: PlatformInfo | undefined = navigator): boolean { + if (!platformInfo) { + return false + } + + return /mac/i.test(platformInfo.platform ?? '') || /macintosh/i.test(platformInfo.userAgent ?? '') +} + export function isMacTerminalPasteShortcut( event: Pick, platformInfo: PlatformInfo | undefined = navigator, ): boolean { - if (isWindowsPlatform(platformInfo) || event.ctrlKey || event.altKey) { + if (!isMacPlatform(platformInfo) || event.ctrlKey || event.altKey) { return false } diff --git a/tests/unit/contexts/terminalInputBridge.spec.ts b/tests/unit/contexts/terminalInputBridge.spec.ts index 4b707b93..b2f43312 100644 --- a/tests/unit/contexts/terminalInputBridge.spec.ts +++ b/tests/unit/contexts/terminalInputBridge.spec.ts @@ -43,14 +43,6 @@ describe('isMacTerminalPasteShortcut', () => { ).toBe(false) }) - it('returns true for Meta+V on Linux', () => { - expect( - isMacTerminalPasteShortcut( - { key: 'v', metaKey: true, ctrlKey: false, altKey: false, shiftKey: false }, - { platform: 'Linux x86_64' }, - ), - ).toBe(true) - }) }) describe('handleTerminalCustomKeyEvent', () => { From 5bb8124dc27e9d6cfb92c33957ce9eddffac4e8a Mon Sep 17 00:00:00 2001 From: Travis Xie Date: Mon, 30 Mar 2026 14:05:29 +0800 Subject: [PATCH 3/3] style: format terminalInputBridge.spec.ts with prettier Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/unit/contexts/terminalInputBridge.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/contexts/terminalInputBridge.spec.ts b/tests/unit/contexts/terminalInputBridge.spec.ts index b2f43312..03974169 100644 --- a/tests/unit/contexts/terminalInputBridge.spec.ts +++ b/tests/unit/contexts/terminalInputBridge.spec.ts @@ -42,7 +42,6 @@ describe('isMacTerminalPasteShortcut', () => { ), ).toBe(false) }) - }) describe('handleTerminalCustomKeyEvent', () => {