Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
77 changes: 77 additions & 0 deletions apps/desktop/src/app/right-sidebar/terminal/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
69 changes: 69 additions & 0 deletions apps/desktop/src/app/right-sidebar/terminal/clipboard.ts
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ 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'

import { $terminalInjection } from '../store'

import { makeTerminalReader, registerTerminalReader } from './buffer'
import { mirrorSelection, terminalClipboardIntent } from './clipboard'
import {
isAddSelectionShortcut,
isMacPlatform,
resolveSurfaceColor,
terminalSelectionAnchor,
terminalSelectionLabel,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ export const ar = defineLocale({
'view.closeTab': 'إغلاق علامة التبويب',
'view.reopenTab': 'إعادة فتح علامة التبويب المغلقة',
'view.terminalSelection': 'إرسال تحديد الطرفية إلى المحرّر',
'view.terminalCopy': 'نسخ تحديد الطرفية',
'view.terminalPaste': 'لصق في الطرفية',
'view.closePreviewTab': 'إغلاق علامة تبويب المعاينة',
'view.flipPanes': 'تبديل جانبي الشريط الجانبي',
'appearance.toggleMode': 'تبديل الفاتح / الداكن',
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,8 @@ export const zh: Translations = {
'view.showFiles': '显示文件浏览器',
'view.showTerminal': '显示终端',
'view.terminalSelection': '将终端选区发送到输入框',
'view.terminalCopy': '复制终端选区',
'view.terminalPaste': '粘贴到终端',
'view.closeTab': '关闭标签',
'view.reopenTab': '重新打开已关闭的标签',
'view.flipPanes': '交换侧边栏位置',
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/lib/keybinds/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] }
]
Loading