From 3c11187ec5227a2fb621f60e5316ff984774b9ae Mon Sep 17 00:00:00 2001 From: Josh Doe Date: Mon, 9 Mar 2026 14:35:28 -0400 Subject: [PATCH 1/2] Match Linux terminal copy/paste behavior Route terminal clipboard access through Electron so Linux can use both the normal clipboard and the X11/selection buffer. This avoids relying on browser-only clipboard APIs for behaviors that users expect to match native terminal emulators under Linux and WSL2. Update TerminalView to follow the Linux workflow you validated in testing: left drag selects, right click copies the current selection and clears it, and a subsequent right click pastes when no selection is active. Keep middle-click paste from the selection buffer and preserve keyboard copy/paste, including Ctrl+Insert and Shift+Insert. --- electron/ipc/channels.ts | 2 + electron/ipc/register.ts | 32 +++++++++++++- electron/preload.cjs | 2 + src/components/TerminalView.tsx | 77 +++++++++++++++++++++++++++++---- src/lib/platform.ts | 1 + 5 files changed, 105 insertions(+), 9 deletions(-) diff --git a/electron/ipc/channels.ts b/electron/ipc/channels.ts index 4ce913fc..33fce8a6 100644 --- a/electron/ipc/channels.ts +++ b/electron/ipc/channels.ts @@ -58,6 +58,8 @@ export enum IPC { // Dialog DialogConfirm = '__dialog_confirm', DialogOpen = '__dialog_open', + ClipboardRead = '__clipboard_read', + ClipboardWrite = '__clipboard_write', // Shell ShellReveal = '__shell_reveal', diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index 72a071f4..9cf59dc5 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -1,4 +1,4 @@ -import { ipcMain, dialog, shell, app, BrowserWindow } from 'electron'; +import { ipcMain, dialog, shell, app, BrowserWindow, clipboard } from 'electron'; import fs from 'fs'; import { fileURLToPath } from 'url'; import { IPC } from './channels.js'; @@ -338,6 +338,36 @@ export function registerAllHandlers(win: BrowserWindow): void { return args?.multiple ? result.filePaths : (result.filePaths[0] ?? null); }); + // --- Clipboard --- + ipcMain.handle(IPC.ClipboardRead, (_e, args) => { + const target = args?.target; + if (target !== undefined && target !== 'clipboard' && target !== 'selection') { + throw new Error('target must be "clipboard" or "selection"'); + } + if (target === 'selection' && process.platform === 'linux') { + return clipboard.readText('selection'); + } + return clipboard.readText(); + }); + + ipcMain.handle(IPC.ClipboardWrite, (_e, args) => { + assertString(args?.text, 'text'); + const target = args?.target; + if ( + target !== undefined && + target !== 'clipboard' && + target !== 'selection' && + target !== 'both' + ) { + throw new Error('target must be "clipboard", "selection", or "both"'); + } + + if (target !== 'selection') clipboard.writeText(args.text); + if ((target === 'selection' || target === 'both') && process.platform === 'linux') { + clipboard.writeText(args.text, 'selection'); + } + }); + // --- Shell/Opener --- ipcMain.handle(IPC.ShellReveal, (_e, args) => { validatePath(args.filePath, 'filePath'); diff --git a/electron/preload.cjs b/electron/preload.cjs index cda77fc9..966eb0dc 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -58,6 +58,8 @@ const ALLOWED_CHANNELS = new Set([ // Dialog '__dialog_confirm', '__dialog_open', + '__clipboard_read', + '__clipboard_write', // Shell '__shell_reveal', '__shell_open_file', diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index b699be73..84d4146a 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -8,7 +8,7 @@ import { IPC } from '../../electron/ipc/channels'; import { getTerminalFontFamily } from '../lib/fonts'; import { getTerminalTheme } from '../lib/theme'; import { matchesGlobalShortcut } from '../lib/shortcuts'; -import { isMac } from '../lib/platform'; +import { isLinux, isMac } from '../lib/platform'; import { store } from '../store/store'; import { registerTerminal, unregisterTerminal, markDirty } from '../lib/terminalFitManager'; import type { PtyOutput } from '../ipc/types'; @@ -115,6 +115,33 @@ export function TerminalView(props: TerminalViewProps) { return lines.join('\n'); }); + function getSelectionText(): string { + return term?.getSelection() ?? ''; + } + + function clearSelection(): void { + term?.clearSelection(); + } + + function copySelectionToClipboard(): boolean { + const selection = getSelectionText(); + if (!selection) return false; + fireAndForget(IPC.ClipboardWrite, { + text: selection, + target: isLinux ? 'both' : 'clipboard', + }); + return true; + } + + function pasteClipboard(target: 'clipboard' | 'selection' = 'clipboard'): void { + term?.focus(); + invoke(IPC.ClipboardRead, { target }) + .then((text) => { + if (text) enqueueInput(text); + }) + .catch(() => {}); + } + term.attachCustomKeyEventHandler((e: KeyboardEvent) => { if (e.type !== 'keydown') return true; @@ -123,21 +150,20 @@ export function TerminalView(props: TerminalViewProps) { const isCopy = isMac ? e.metaKey && !e.shiftKey && e.key === 'c' - : e.ctrlKey && e.shiftKey && e.key === 'C'; + : (e.ctrlKey && e.shiftKey && e.key === 'C') || + (e.ctrlKey && !e.shiftKey && e.key === 'Insert'); const isPaste = isMac ? e.metaKey && !e.shiftKey && e.key === 'v' - : e.ctrlKey && e.shiftKey && e.key === 'V'; + : (e.ctrlKey && e.shiftKey && e.key === 'V') || + (e.shiftKey && !e.ctrlKey && e.key === 'Insert'); if (isCopy) { - const sel = term?.getSelection(); - if (sel) navigator.clipboard.writeText(sel); + copySelectionToClipboard(); return false; } if (isPaste) { - navigator.clipboard.readText().then((text) => { - if (text) enqueueInput(text); - }); + pasteClipboard(); return false; } @@ -151,6 +177,38 @@ export function TerminalView(props: TerminalViewProps) { term.focus(); } + let handleMouseDown: ((e: MouseEvent) => void) | undefined; + let handleAuxClick: ((e: MouseEvent) => void) | undefined; + let handleContextMenu: ((e: MouseEvent) => void) | undefined; + + if (isLinux) { + // Follow the Linux/WSL terminal flow: + // left drag selects, right-click copies the current selection and clears + // it, and a subsequent right-click pastes. + handleMouseDown = (e: MouseEvent) => { + if (e.button === 1) e.preventDefault(); + }; + handleAuxClick = (e: MouseEvent) => { + if (e.button !== 1) return; + e.preventDefault(); + pasteClipboard('selection'); + }; + handleContextMenu = (e: MouseEvent) => { + e.preventDefault(); + requestAnimationFrame(() => { + if (copySelectionToClipboard()) { + clearSelection(); + return; + } + pasteClipboard(); + }); + }; + + containerRef.addEventListener('mousedown', handleMouseDown); + containerRef.addEventListener('auxclick', handleAuxClick); + containerRef.addEventListener('contextmenu', handleContextMenu); + } + let outputRaf: number | undefined; let outputQueue: Uint8Array[] = []; let outputQueuedBytes = 0; @@ -402,6 +460,9 @@ export function TerminalView(props: TerminalViewProps) { if (inputFlushTimer !== undefined) clearTimeout(inputFlushTimer); if (resizeFlushTimer !== undefined) clearTimeout(resizeFlushTimer); if (outputRaf !== undefined) cancelAnimationFrame(outputRaf); + if (handleMouseDown) containerRef.removeEventListener('mousedown', handleMouseDown); + if (handleAuxClick) containerRef.removeEventListener('auxclick', handleAuxClick); + if (handleContextMenu) containerRef.removeEventListener('contextmenu', handleContextMenu); onOutput.cleanup?.(); webglAddon?.dispose(); webglAddon = undefined; diff --git a/src/lib/platform.ts b/src/lib/platform.ts index 941810b8..42dd6a6c 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -1,4 +1,5 @@ export const isMac = navigator.userAgent.includes('Mac'); +export const isLinux = navigator.userAgent.includes('Linux'); /** Display name for the primary modifier key: "Cmd" on macOS, "Ctrl" elsewhere. */ export const mod = isMac ? 'Cmd' : 'Ctrl'; From c6956c8a9d433e619fbf282ee452d6d12d480dda Mon Sep 17 00:00:00 2001 From: Josh Doe Date: Mon, 9 Mar 2026 15:17:04 -0400 Subject: [PATCH 2/2] test(clipboard): cover Electron clipboard IPC --- electron/ipc/pty.test.ts | 4 - electron/ipc/register.test.ts | 161 ++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 electron/ipc/register.test.ts diff --git a/electron/ipc/pty.test.ts b/electron/ipc/pty.test.ts index 5d883535..eaa27107 100644 --- a/electron/ipc/pty.test.ts +++ b/electron/ipc/pty.test.ts @@ -21,10 +21,6 @@ describe('validateCommand', () => { ); }); - it('does not throw for a bare command found in PATH', () => { - expect(() => validateCommand('sh')).not.toThrow(); - }); - it('throws for an empty command string', () => { expect(() => validateCommand('')).toThrow(/must not be empty/); }); diff --git a/electron/ipc/register.test.ts b/electron/ipc/register.test.ts new file mode 100644 index 00000000..9043d7b5 --- /dev/null +++ b/electron/ipc/register.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { IPC } from './channels.js'; + +type IpcHandler = (event: unknown, args?: unknown) => unknown; + +const mockState = vi.hoisted(() => ({ + handlers: new Map(), + clipboardReadText: vi.fn(), + clipboardWriteText: vi.fn(), +})); + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, handler: IpcHandler) => { + mockState.handlers.set(channel, handler); + }), + }, + dialog: {}, + shell: {}, + app: { + getPath: vi.fn(() => '/tmp'), + }, + BrowserWindow: function BrowserWindow() {}, + clipboard: { + readText: mockState.clipboardReadText, + writeText: mockState.clipboardWriteText, + }, +})); + +vi.mock('./pty.js', () => ({ + spawnAgent: vi.fn(), + writeToAgent: vi.fn(), + resizeAgent: vi.fn(), + pauseAgent: vi.fn(), + resumeAgent: vi.fn(), + killAgent: vi.fn(), + countRunningAgents: vi.fn(), + killAllAgents: vi.fn(), + getAgentMeta: vi.fn(), +})); + +vi.mock('./plans.js', () => ({ + ensurePlansDirectory: vi.fn(), + startPlanWatcher: vi.fn(), + stopAllPlanWatchers: vi.fn(), +})); + +vi.mock('../remote/server.js', () => ({ + startRemoteServer: vi.fn(), +})); + +vi.mock('./git.js', () => ({ + getGitIgnoredDirs: vi.fn(), + getMainBranch: vi.fn(), + getCurrentBranch: vi.fn(), + getChangedFiles: vi.fn(), + getChangedFilesFromBranch: vi.fn(), + getFileDiff: vi.fn(), + getFileDiffFromBranch: vi.fn(), + getWorktreeStatus: vi.fn(), + commitAll: vi.fn(), + discardUncommitted: vi.fn(), + checkMergeStatus: vi.fn(), + mergeTask: vi.fn(), + getBranchLog: vi.fn(), + pushTask: vi.fn(), + rebaseTask: vi.fn(), + createWorktree: vi.fn(), + removeWorktree: vi.fn(), +})); + +vi.mock('./tasks.js', () => ({ + createTask: vi.fn(), + deleteTask: vi.fn(), +})); + +vi.mock('./agents.js', () => ({ + listAgents: vi.fn(), +})); + +vi.mock('./persistence.js', () => ({ + saveAppState: vi.fn(), + loadAppState: vi.fn(), +})); + +import { registerAllHandlers } from './register.js'; + +describe('registerAllHandlers clipboard IPC', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + mockState.handlers.clear(); + mockState.clipboardReadText.mockReset(); + mockState.clipboardWriteText.mockReset(); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + function registerFor(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform }); + registerAllHandlers({ + on: vi.fn(), + isFocused: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(), + minimize: vi.fn(), + maximize: vi.fn(), + unmaximize: vi.fn(), + setSize: vi.fn(), + setPosition: vi.fn(), + getPosition: vi.fn(() => [0, 0] as const), + getSize: vi.fn(() => [1280, 720] as const), + close: vi.fn(), + destroy: vi.fn(), + hide: vi.fn(), + webContents: { + send: vi.fn(), + }, + } as unknown as Parameters[0]); + } + + it('reads from the Linux selection clipboard when requested', () => { + mockState.clipboardReadText.mockReturnValue('from-selection'); + registerFor('linux'); + + const handler = mockState.handlers.get(IPC.ClipboardRead); + expect(handler).toBeTypeOf('function'); + expect(handler?.({}, { target: 'selection' })).toBe('from-selection'); + expect(mockState.clipboardReadText).toHaveBeenCalledWith('selection'); + }); + + it('falls back to the default clipboard for selection reads on non-Linux platforms', () => { + mockState.clipboardReadText.mockReturnValue('from-clipboard'); + registerFor('darwin'); + + const handler = mockState.handlers.get(IPC.ClipboardRead); + expect(handler?.({}, { target: 'selection' })).toBe('from-clipboard'); + expect(mockState.clipboardReadText).toHaveBeenCalledWith(); + }); + + it('writes to both clipboard buffers on Linux when requested', () => { + registerFor('linux'); + + const handler = mockState.handlers.get(IPC.ClipboardWrite); + handler?.({}, { text: 'copied text', target: 'both' }); + + expect(mockState.clipboardWriteText).toHaveBeenNthCalledWith(1, 'copied text'); + expect(mockState.clipboardWriteText).toHaveBeenNthCalledWith(2, 'copied text', 'selection'); + }); + + it('rejects invalid clipboard write targets', () => { + registerFor('linux'); + + const handler = mockState.handlers.get(IPC.ClipboardWrite); + expect(() => handler?.({}, { text: 'copied text', target: 'invalid' })).toThrow( + 'target must be "clipboard", "selection", or "both"', + ); + }); +});