From 807dc0c45f6dd081b3458e0afb5ffe0572939c14 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 19:15:04 -0500 Subject: [PATCH] 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. --- apps/desktop/electron/main.ts | 6 ++ apps/desktop/electron/preload.ts | 1 + .../right-sidebar/terminal/clipboard.test.ts | 77 +++++++++++++++++++ .../app/right-sidebar/terminal/clipboard.ts | 69 +++++++++++++++++ .../terminal/use-agent-terminal.ts | 30 +++++++- .../terminal/use-terminal-session.ts | 47 +++++++++++ apps/desktop/src/global.d.ts | 1 + apps/desktop/src/i18n/ar.ts | 2 + apps/desktop/src/i18n/en.ts | 2 + apps/desktop/src/i18n/zh.ts | 2 + apps/desktop/src/lib/keybinds/actions.ts | 7 +- 11 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/app/right-sidebar/terminal/clipboard.test.ts create mode 100644 apps/desktop/src/app/right-sidebar/terminal/clipboard.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 59bc792c7c6d..db63efb00df1 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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) => { diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index b110eb1670b7..8e36b4854d04 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -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'), diff --git a/apps/desktop/src/app/right-sidebar/terminal/clipboard.test.ts b/apps/desktop/src/app/right-sidebar/terminal/clipboard.test.ts new file mode 100644 index 000000000000..b39e3f9e4500 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/clipboard.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' + +import { mirrorSelection, terminalClipboardIntent } from './clipboard' + +const key = (init: Partial & { 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() + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/terminal/clipboard.ts b/apps/desktop/src/app/right-sidebar/terminal/clipboard.ts new file mode 100644 index 000000000000..4269a2efb50f --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/clipboard.ts @@ -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('.xterm-helper-textarea') + + if (!textarea) { + return + } + + if (!text) { + textarea.value = '' + + return + } + + textarea.value = text + textarea.select() +} diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts b/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts index cec977c5d4de..bee037094f66 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts @@ -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 diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts index 9ec53a48aef8..170823ad4a9a 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -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 diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 4a7eea4dbd8e..d0a653a324b6 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -122,6 +122,7 @@ declare global { readFileText: (filePath: string) => Promise selectPaths: (options?: HermesSelectPathsOptions) => Promise writeClipboard: (text: string) => Promise + readClipboard: () => Promise saveImageFromUrl: (url: string) => Promise saveImageBuffer: (data: ArrayBuffer | Uint8Array, ext: string) => Promise saveClipboardImage: () => Promise diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 4fa1e65e8ddc..a0c2fe06c05d 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -230,6 +230,8 @@ export const ar = defineLocale({ 'view.closeTab': 'إغلاق علامة التبويب', 'view.reopenTab': 'إعادة فتح علامة التبويب المغلقة', 'view.terminalSelection': 'إرسال تحديد الطرفية إلى المحرّر', + 'view.terminalCopy': 'نسخ تحديد الطرفية', + 'view.terminalPaste': 'لصق في الطرفية', 'view.closePreviewTab': 'إغلاق علامة تبويب المعاينة', 'view.flipPanes': 'تبديل جانبي الشريط الجانبي', 'appearance.toggleMode': 'تبديل الفاتح / الداكن', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index c1f2f832cf1d..324caf241c22 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index cbb6bd72fb10..d97bf389625b 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -255,6 +255,8 @@ export const zh: Translations = { 'view.showFiles': '显示文件浏览器', 'view.showTerminal': '显示终端', 'view.terminalSelection': '将终端选区发送到输入框', + 'view.terminalCopy': '复制终端选区', + 'view.terminalPaste': '粘贴到终端', 'view.closeTab': '关闭标签', 'view.reopenTab': '重新打开已关闭的标签', 'view.flipPanes': '交换侧边栏位置', diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts index 368ab48e6bd9..799d2d282c31 100644 --- a/apps/desktop/src/lib/keybinds/actions.ts +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -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'] } ]