diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 0c6d64b19e96..2d43eeb2a453 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -10282,6 +10282,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 31e9fa4159cc..1a6253ff6672 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -106,6 +106,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 df69e04b5a60..336f95b54ae2 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -124,6 +124,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 277ad635fa52..3b317fe57615 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 58e0915ea265..f96aa1592cbc 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'] } ]