Merge pull request #73705 from NousResearch/bb/terminal-clipboard

feat(desktop): copy and paste in the GUI terminal
This commit is contained in:
brooklyn! 2026-07-28 19:58:31 -05:00 committed by GitHub
commit 392f1b9477
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 242 additions and 2 deletions

View file

@ -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) => {

View file

@ -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'),

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

@ -124,6 +124,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'] }
]