Skip to content

Commit 0daad3c

Browse files
ChrisPeiclaude
andauthored
feat: improve terminal copy/paste support on Windows (#91)
- Add right-click context menu for terminal with copy/paste support - If text is selected: copies to clipboard and shows toast notification - If no selection: pastes from clipboard - Fix Ctrl+V paste by intercepting the key to prevent xterm showing ^v and letting Electron's menu paste role handle it via paste event - Works on both Windows and macOS Co-authored-by: Claude <noreply@anthropic.com>
1 parent c4e478e commit 0daad3c

2 files changed

Lines changed: 80 additions & 0 deletions

File tree

src/renderer/features/terminal/helpers.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ export interface KeyboardHandlerOptions {
205205
* Setup keyboard handling for xterm including:
206206
* - Shift+Enter: Sends ESC+CR sequence
207207
* - Cmd+K: Clear terminal
208+
* - Ctrl+V / Cmd+V: Intercept to allow browser paste event
208209
*
209210
* Returns a cleanup function to remove the handler.
210211
*/
@@ -239,6 +240,19 @@ export function setupKeyboardHandler(
239240
return false // Prevent xterm from processing
240241
}
241242

243+
// Ctrl+V (Windows/Linux) or Cmd+V (macOS) - let Electron menu handle paste
244+
// Return false to prevent xterm from showing ^v character
245+
// The Electron menu's "paste" role will trigger a paste event on the textarea
246+
const isPasteShortcut =
247+
event.key === "v" &&
248+
!event.shiftKey &&
249+
!event.altKey &&
250+
(isMac() ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey)
251+
252+
if (isPasteShortcut) {
253+
return false // Prevent xterm from showing ^v, let Electron menu handle it
254+
}
255+
242256
return true // Let xterm process the key
243257
}
244258

@@ -420,3 +434,57 @@ export function setupClickToMoveCursor(
420434
xterm.element?.removeEventListener("click", handleClick)
421435
}
422436
}
437+
438+
export interface ContextMenuHandlerOptions {
439+
/** Callback when text is copied via context menu */
440+
onCopy?: (text: string) => void
441+
/** Callback when text is pasted via context menu */
442+
onPaste?: (text: string) => void
443+
}
444+
445+
/**
446+
* Setup right-click context menu for terminal with copy/paste support.
447+
* - If text is selected: copies to clipboard
448+
* - If no selection: pastes from clipboard
449+
*
450+
* Returns a cleanup function to remove the handler.
451+
*/
452+
export function setupContextMenuHandler(
453+
xterm: XTerm,
454+
options: ContextMenuHandlerOptions = {}
455+
): () => void {
456+
const handleContextMenu = async (event: MouseEvent) => {
457+
event.preventDefault()
458+
459+
const selection = xterm.getSelection()
460+
461+
if (selection) {
462+
// Has selection - copy to clipboard
463+
try {
464+
await navigator.clipboard.writeText(selection)
465+
options.onCopy?.(selection)
466+
// Clear selection after copy (optional, mimics typical terminal behavior)
467+
xterm.clearSelection()
468+
} catch (err) {
469+
console.warn("[Terminal] Failed to copy to clipboard:", err)
470+
}
471+
} else {
472+
// No selection - paste from clipboard
473+
try {
474+
const text = await navigator.clipboard.readText()
475+
if (text) {
476+
options.onPaste?.(text)
477+
xterm.paste(text)
478+
}
479+
} catch (err) {
480+
console.warn("[Terminal] Failed to paste from clipboard:", err)
481+
}
482+
}
483+
}
484+
485+
xterm.element?.addEventListener("contextmenu", handleContextMenu)
486+
487+
return () => {
488+
xterm.element?.removeEventListener("contextmenu", handleContextMenu)
489+
}
490+
}

src/renderer/features/terminal/terminal.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import type { SearchAddon } from "@xterm/addon-search"
55
import type { SerializeAddon } from "@xterm/addon-serialize"
66
import { useTheme } from "next-themes"
77
import { useSetAtom, useAtomValue } from "jotai"
8+
import { toast } from "sonner"
89
import { trpc } from "@/lib/trpc"
910
import { terminalCwdAtom } from "./atoms"
1011
import { fullThemeDataAtom } from "@/lib/atoms"
1112
import {
1213
createTerminalInstance,
1314
getDefaultTerminalBg,
1415
setupClickToMoveCursor,
16+
setupContextMenuHandler,
1517
setupFocusListener,
1618
setupKeyboardHandler,
1719
setupPasteHandler,
@@ -304,6 +306,15 @@ export function Terminal({
304306
},
305307
})
306308

309+
const cleanupContextMenu = setupContextMenuHandler(xterm, {
310+
onCopy: () => {
311+
toast.success("Copied to clipboard")
312+
},
313+
onPaste: (text) => {
314+
commandBufferRef.current += text
315+
},
316+
})
317+
307318
// Cleanup on unmount
308319
return () => {
309320
console.log("[Terminal:useEffect] UNMOUNT - paneId:", paneId)
@@ -315,6 +326,7 @@ export function Terminal({
315326
cleanupFocus?.()
316327
cleanupResize()
317328
cleanupPaste()
329+
cleanupContextMenu()
318330
cleanup()
319331

320332
// Serialize terminal state before detaching

0 commit comments

Comments
 (0)