feat(desktop): copy and paste in the GUI terminal

xterm paints to a canvas, so its selection is not a DOM selection: the Edit
menu's Copy and the right-click Copy both call webContents.copy(), find
nothing, and copy nothing. On macOS the menu also swallows the Cmd+C
accelerator before the renderer sees it. That left Ctrl+C as the only key that
did anything, and xterm correctly forwards it to the PTY as SIGINT.

Mirror the selection into xterm's hidden helper textarea (the mechanism xterm
already uses for Linux middle-click paste) so the OS sees a real selection and
every platform copy path works, and add explicit chords on top: Cmd+C/Cmd+V on
macOS, Ctrl+Shift+C/V elsewhere, matching VS Code. Plain Ctrl+C copies only
when text is selected -- the behavior Windows Terminal and Tabby ship -- and
stays SIGINT otherwise, so interrupting a process never breaks.

Reads go through a new hermes:readClipboard IPC handler for the same reason
writes already do: the renderer's clipboard API throws whenever the document
isn't focused.
This commit is contained in:
Brooklyn Nicholson 2026-07-28 19:15:04 -05:00
parent 3d0e543aa7
commit 807dc0c45f
11 changed files with 242 additions and 2 deletions

View file

@ -10271,6 +10271,12 @@ ipcMain.handle('hermes:writeClipboard', (_event, text) => {
return true
})
// Paired reader for the GUI terminal's paste chord: the renderer's
// navigator.clipboard.readText() throws "Document is not focused" whenever a
// portaled overlay has focus, and there's no way to route a read through the
// canvas. The main process has no such gate.
ipcMain.handle('hermes:readClipboard', () => clipboard.readText())
ipcMain.handle('hermes:saveImageFromUrl', (_event, url) => saveImageFromUrl(String(url || '')))
ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => {

View file

@ -105,6 +105,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
readFileText: filePath => ipcRenderer.invoke('hermes:readFileText', filePath),
selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options),
writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text),
readClipboard: () => ipcRenderer.invoke('hermes:readClipboard'),
saveImageFromUrl: url => ipcRenderer.invoke('hermes:saveImageFromUrl', url),
saveImageBuffer: (data, ext) => ipcRenderer.invoke('hermes:saveImageBuffer', { data, ext }),
saveClipboardImage: () => ipcRenderer.invoke('hermes:saveClipboardImage'),

View file

@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import { mirrorSelection, terminalClipboardIntent } from './clipboard'
const key = (init: Partial<KeyboardEvent> & { key: string }) =>
({ altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, type: 'keydown', ...init }) as KeyboardEvent
describe('terminalClipboardIntent', () => {
it('never claims a bare Ctrl+C with nothing selected, on either platform', () => {
for (const isMac of [true, false]) {
expect(terminalClipboardIntent(key({ ctrlKey: true, key: 'c' }), { hasSelection: false, isMac })).toBeNull()
}
})
it('copies on Ctrl+C when text is selected, so a selection is never lost to SIGINT', () => {
expect(terminalClipboardIntent(key({ ctrlKey: true, key: 'c' }), { hasSelection: true, isMac: false })).toBe('copy')
})
it('reserves plain Ctrl+C for the shell on macOS, where ⌘C is the copy chord', () => {
expect(terminalClipboardIntent(key({ ctrlKey: true, key: 'c' }), { hasSelection: true, isMac: true })).toBeNull()
expect(terminalClipboardIntent(key({ key: 'c', metaKey: true }), { hasSelection: true, isMac: true })).toBe('copy')
})
it('only claims copy when there is something to copy', () => {
expect(terminalClipboardIntent(key({ key: 'c', metaKey: true }), { hasSelection: false, isMac: true })).toBeNull()
expect(
terminalClipboardIntent(key({ ctrlKey: true, key: 'c', shiftKey: true }), { hasSelection: false, isMac: false })
).toBeNull()
})
it('claims paste regardless of selection, since paste has nothing to do with one', () => {
expect(terminalClipboardIntent(key({ key: 'v', metaKey: true }), { hasSelection: false, isMac: true })).toBe('paste')
expect(
terminalClipboardIntent(key({ ctrlKey: true, key: 'v', shiftKey: true }), { hasSelection: false, isMac: false })
).toBe('paste')
})
it('leaves shell chords alone: bare Ctrl+V, Alt combos, and keyup', () => {
expect(terminalClipboardIntent(key({ ctrlKey: true, key: 'v' }), { hasSelection: false, isMac: false })).toBeNull()
expect(
terminalClipboardIntent(key({ altKey: true, ctrlKey: true, key: 'c' }), { hasSelection: true, isMac: false })
).toBeNull()
expect(
terminalClipboardIntent(key({ key: 'c', metaKey: true, type: 'keyup' }), { hasSelection: true, isMac: true })
).toBeNull()
})
})
describe('mirrorSelection', () => {
const host = () => {
const el = document.createElement('div')
const textarea = document.createElement('textarea')
textarea.className = 'xterm-helper-textarea'
el.appendChild(textarea)
return { el, textarea }
}
it('puts the selection where the OS copy command can find it', () => {
const { el, textarea } = host()
mirrorSelection(el, 'npm run check')
expect(textarea.value).toBe('npm run check')
})
it('clears the mirror when the selection goes away', () => {
const { el, textarea } = host()
mirrorSelection(el, 'something')
mirrorSelection(el, '')
expect(textarea.value).toBe('')
})
it('is a no-op before xterm has mounted its textarea', () => {
expect(() => mirrorSelection(document.createElement('div'), 'text')).not.toThrow()
})
})

View file

@ -0,0 +1,69 @@
// Clipboard keybindings for the GUI terminal.
//
// xterm renders to a canvas, so its selection is not a DOM selection and the
// platform's own copy command has nothing to grab. Two mechanisms fix that:
// this key map (explicit chords) and `mirrorSelection` below (which hands the
// selection to the OS through xterm's hidden helper textarea, so the Edit menu,
// ⌘C on macOS — swallowed by the menu before the renderer sees it — and the
// right-click menu all work).
//
// The chords follow VS Code (terminal.clipboard.contribution.ts): ⌘C/⌘V on
// macOS, Ctrl+Shift+C/V elsewhere, plus plain Ctrl+C as copy ONLY when text is
// selected — the "intelligent Ctrl-C" of Windows Terminal and Tabby. With no
// selection Ctrl+C stays SIGINT, so interrupting a process never breaks.
export type TerminalClipboardIntent = 'copy' | 'paste' | null
export function terminalClipboardIntent(
event: KeyboardEvent,
{ hasSelection, isMac }: { hasSelection: boolean; isMac: boolean }
): TerminalClipboardIntent {
if (event.type !== 'keydown' || event.altKey) {
return null
}
const key = event.key.toLowerCase()
if (isMac) {
if (!event.metaKey || event.ctrlKey || event.shiftKey) {
return null
}
// ⌘C with nothing selected falls through to the shell (⌘ isn't a terminal
// modifier, so it's a no-op there rather than a lost keystroke).
return key === 'c' ? (hasSelection ? 'copy' : null) : key === 'v' ? 'paste' : null
}
if (!event.ctrlKey || event.metaKey) {
return null
}
if (event.shiftKey) {
return key === 'c' ? (hasSelection ? 'copy' : null) : key === 'v' ? 'paste' : null
}
// Bare Ctrl+C: copy only when there's a selection to copy, else SIGINT.
return key === 'c' && hasSelection ? 'copy' : null
}
// Hand the terminal's selection to the OS by mirroring it into xterm's hidden
// helper textarea (the same trick xterm uses for Linux middle-click paste,
// CoreBrowserTerminal.ts:531). Without it `webContents.copy()` — what the Edit
// menu, ⌘C, and the right-click Copy item all call — finds no DOM selection and
// copies nothing.
export function mirrorSelection(host: HTMLElement, text: string) {
const textarea = host.querySelector<HTMLTextAreaElement>('.xterm-helper-textarea')
if (!textarea) {
return
}
if (!text) {
textarea.value = ''
return
}
textarea.value = text
textarea.select()
}

View file

@ -5,11 +5,14 @@ import { WebglAddon } from '@xterm/addon-webgl'
import { Terminal } from '@xterm/xterm'
import { useEffect, useRef } from 'react'
import { writeClipboardText } from '@/components/ui/copy-button'
import { triggerHaptic } from '@/lib/haptics'
import { useTheme } from '@/themes/context'
import { registerAgentTerminalWriter } from './agent-terminal-stream'
import { makeTerminalReader, registerTerminalReader } from './buffer'
import { resolveSurfaceColor, terminalTheme } from './selection'
import { mirrorSelection, terminalClipboardIntent } from './clipboard'
import { isMacPlatform, resolveSurfaceColor, terminalTheme } from './selection'
// Read-only terminal for an agent background process: a write-only xterm (no PTY,
// no input) fed live by the backend output stream, keyed by process id. Shares
@ -61,6 +64,30 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id:
term.open(host)
termRef.current = term
// Read-only mirror, but the output is exactly what people want to copy.
// No paste path: this terminal has no PTY to paste into.
const selectionDisposable = term.onSelectionChange(() => mirrorSelection(host, term.getSelection()))
term.attachCustomKeyEventHandler(event => {
const intent = terminalClipboardIntent(event, {
hasSelection: Boolean(term.getSelection()),
isMac: isMacPlatform()
})
if (intent !== 'copy') {
return true
}
event.preventDefault()
void writeClipboardText(term.getSelection()).catch(() => {
// Clipboard unavailable — leave the selection so the user can retry.
})
term.clearSelection()
triggerHaptic('selection')
return false
})
fitRef.current = () => {
if (host.clientWidth > 0 && host.clientHeight > 0) {
try {
@ -94,6 +121,7 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id:
return () => {
unregister()
unregisterReader()
selectionDisposable.dispose()
observer.disconnect()
term.dispose()
termRef.current = null

View file

@ -7,6 +7,7 @@ import { Terminal } from '@xterm/xterm'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { writeClipboardText } from '@/components/ui/copy-button'
import { triggerHaptic } from '@/lib/haptics'
import { $previewTarget } from '@/store/preview'
import { useTheme } from '@/themes/context'
@ -14,8 +15,10 @@ import { useTheme } from '@/themes/context'
import { $terminalInjection } from '../store'
import { makeTerminalReader, registerTerminalReader } from './buffer'
import { mirrorSelection, terminalClipboardIntent } from './clipboard'
import {
isAddSelectionShortcut,
isMacPlatform,
resolveSurfaceColor,
terminalSelectionAnchor,
terminalSelectionLabel,
@ -792,12 +795,56 @@ export function useTerminalSession({
const next = term.getSelection()
selectionRef.current = next
selectionLabelRef.current = next.trim() ? terminalSelectionLabel(term, shellNameRef.current, next) : ''
// Mirror into xterm's helper textarea so the OS sees a real selection —
// that's what makes the Edit menu, ⌘C, and right-click Copy work over a
// canvas that has no DOM selection of its own.
mirrorSelection(host, next)
setSelection(next)
setSelectionStyle(next.trim() ? terminalSelectionAnchor(host) : null)
})
cleanup.push(() => selectionDisposable.dispose())
// Copy/paste chords. Returning false stops xterm from also sending the key
// to the PTY; every path that doesn't copy or paste returns true, so plain
// Ctrl+C with no selection still interrupts the running process.
term.attachCustomKeyEventHandler(event => {
const intent = terminalClipboardIntent(event, {
hasSelection: Boolean(term.getSelection()),
isMac: isMacPlatform()
})
if (!intent) {
return true
}
event.preventDefault()
if (intent === 'copy') {
const text = term.getSelection()
// Write through the main process: the renderer's clipboard API throws
// "Write permission denied" whenever the document isn't focused.
void writeClipboardText(text).catch(() => {
// Clipboard unavailable — the selection stays put so the user can retry.
})
term.clearSelection()
triggerHaptic('selection')
return false
}
void (async () => {
const text = (await window.hermesDesktop?.readClipboard?.()) ?? ''
if (text) {
hasSessionActivityRef.current = true
term.paste(text)
}
})()
return false
})
const startSession = () =>
void terminalApi
// Prefer the prior session's last cwd so a reopened tab lands where the

View file

@ -122,6 +122,7 @@ declare global {
readFileText: (filePath: string) => Promise<HermesReadFileTextResult>
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
writeClipboard: (text: string) => Promise<boolean>
readClipboard: () => Promise<string>
saveImageFromUrl: (url: string) => Promise<boolean>
saveImageBuffer: (data: ArrayBuffer | Uint8Array, ext: string) => Promise<string>
saveClipboardImage: () => Promise<string>

View file

@ -230,6 +230,8 @@ export const ar = defineLocale({
'view.closeTab': 'إغلاق علامة التبويب',
'view.reopenTab': 'إعادة فتح علامة التبويب المغلقة',
'view.terminalSelection': 'إرسال تحديد الطرفية إلى المحرّر',
'view.terminalCopy': 'نسخ تحديد الطرفية',
'view.terminalPaste': 'لصق في الطرفية',
'view.closePreviewTab': 'إغلاق علامة تبويب المعاينة',
'view.flipPanes': 'تبديل جانبي الشريط الجانبي',
'appearance.toggleMode': 'تبديل الفاتح / الداكن',

View file

@ -264,6 +264,8 @@ export const en: Translations = {
'view.prevTerminal': 'Previous terminal',
'view.closeTerminal': 'Close terminal',
'view.terminalSelection': 'Send terminal selection to composer',
'view.terminalCopy': 'Copy terminal selection',
'view.terminalPaste': 'Paste into terminal',
'view.closeTab': 'Close tab',
'view.reopenTab': 'Reopen closed tab',
'view.flipPanes': 'Swap sidebar sides',

View file

@ -255,6 +255,8 @@ export const zh: Translations = {
'view.showFiles': '显示文件浏览器',
'view.showTerminal': '显示终端',
'view.terminalSelection': '将终端选区发送到输入框',
'view.terminalCopy': '复制终端选区',
'view.terminalPaste': '粘贴到终端',
'view.closeTab': '关闭标签',
'view.reopenTab': '重新打开已关闭的标签',
'view.flipPanes': '交换侧边栏位置',

View file

@ -226,5 +226,10 @@ export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
{ id: 'composer.history', category: 'composer', keys: ['up', 'down'] },
{ id: 'composer.cancel', category: 'composer', keys: ['escape'] },
// Fixed, context-local shortcuts surfaced for discoverability.
{ id: 'view.terminalSelection', category: 'view', keys: ['mod+l'] }
{ id: 'view.terminalSelection', category: 'view', keys: ['mod+l'] },
// Terminal clipboard. ⌘C/⌘V on macOS, Ctrl+Shift+C/V elsewhere — matching VS
// Code. Plain Ctrl+C also copies when text is selected (Windows Terminal /
// Tabby behavior); with no selection it stays SIGINT, so it isn't listed.
{ id: 'view.terminalCopy', category: 'view', keys: IS_MAC ? ['mod+c'] : ['mod+shift+c'] },
{ id: 'view.terminalPaste', category: 'view', keys: IS_MAC ? ['mod+v'] : ['mod+shift+v'] }
]