mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Merge pull request #73705 from NousResearch/bb/terminal-clipboard
feat(desktop): copy and paste in the GUI terminal
This commit is contained in:
commit
392f1b9477
11 changed files with 242 additions and 2 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
apps/desktop/src/app/right-sidebar/terminal/clipboard.ts
Normal file
69
apps/desktop/src/app/right-sidebar/terminal/clipboard.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
apps/desktop/src/global.d.ts
vendored
1
apps/desktop/src/global.d.ts
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -230,6 +230,8 @@ export const ar = defineLocale({
|
|||
'view.closeTab': 'إغلاق علامة التبويب',
|
||||
'view.reopenTab': 'إعادة فتح علامة التبويب المغلقة',
|
||||
'view.terminalSelection': 'إرسال تحديد الطرفية إلى المحرّر',
|
||||
'view.terminalCopy': 'نسخ تحديد الطرفية',
|
||||
'view.terminalPaste': 'لصق في الطرفية',
|
||||
'view.closePreviewTab': 'إغلاق علامة تبويب المعاينة',
|
||||
'view.flipPanes': 'تبديل جانبي الشريط الجانبي',
|
||||
'appearance.toggleMode': 'تبديل الفاتح / الداكن',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -255,6 +255,8 @@ export const zh: Translations = {
|
|||
'view.showFiles': '显示文件浏览器',
|
||||
'view.showTerminal': '显示终端',
|
||||
'view.terminalSelection': '将终端选区发送到输入框',
|
||||
'view.terminalCopy': '复制终端选区',
|
||||
'view.terminalPaste': '粘贴到终端',
|
||||
'view.closeTab': '关闭标签',
|
||||
'view.reopenTab': '重新打开已关闭的标签',
|
||||
'view.flipPanes': '交换侧边栏位置',
|
||||
|
|
|
|||
|
|
@ -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…
Add table
Add a link
Reference in a new issue