mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
fix(tui): restore clipboard hotkeys in clarify mode
This commit is contained in:
29
ui-tui/src/__tests__/clipboard.test.ts
Normal file
29
ui-tui/src/__tests__/clipboard.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { readClipboardText } from '../lib/clipboard.js'
|
||||
|
||||
describe('readClipboardText', () => {
|
||||
it('does nothing off macOS', () => {
|
||||
const run = vi.fn()
|
||||
|
||||
expect(readClipboardText('linux', run)).toBeNull()
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads text from pbpaste on macOS', () => {
|
||||
const run = vi.fn().mockReturnValue({ status: 0, stdout: 'hello world\n' })
|
||||
|
||||
expect(readClipboardText('darwin', run)).toBe('hello world\n')
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
'pbpaste',
|
||||
[],
|
||||
expect.objectContaining({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null when pbpaste fails', () => {
|
||||
const run = vi.fn().mockReturnValue({ status: 1, stdout: '' })
|
||||
|
||||
expect(readClipboardText('darwin', run)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import type { Theme } from '../theme.js'
|
||||
import type { ApprovalReq, ClarifyReq, ConfirmReq } from '../types.js'
|
||||
|
||||
import { TextInput } from './textInput.js'
|
||||
import { isMac } from '../lib/platform.js'
|
||||
|
||||
const OPTS = ['once', 'session', 'always', 'deny'] as const
|
||||
const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
|
||||
@@ -125,10 +126,12 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify
|
||||
|
||||
<Box>
|
||||
<Text color={t.color.label}>{'> '}</Text>
|
||||
<TextInput columns={Math.max(20, cols - 6)} onChange={setCustom} onSubmit={onAnswer} value={custom} />
|
||||
<TextInput columns={Math.max(20, cols - 6)} allowClipboardHotkeys={isMac} onChange={setCustom} onSubmit={onAnswer} value={custom} />
|
||||
</Box>
|
||||
|
||||
<Text color={t.color.dim}>Enter send · Esc {choices.length ? 'back' : 'cancel'} · Ctrl+C cancel</Text>
|
||||
<Text color={t.color.dim}>
|
||||
Enter send · Esc {choices.length ? 'back' : 'cancel'} · {isMac ? 'Cmd+C copy · Cmd+V paste · Ctrl+C cancel' : 'Ctrl+C cancel'}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import * as Ink from '@hermes/ink'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { setInputSelection } from '../app/inputSelectionStore.js'
|
||||
import { readClipboardText } from '../lib/clipboard.js'
|
||||
import { isMac } from '../lib/platform.js'
|
||||
import { writeOsc52Clipboard } from '../lib/osc52.js'
|
||||
|
||||
type InkExt = typeof Ink & {
|
||||
stringWidth: (s: string) => number
|
||||
@@ -279,6 +282,7 @@ export function TextInput({
|
||||
onChange,
|
||||
onPaste,
|
||||
onSubmit,
|
||||
allowClipboardHotkeys = false,
|
||||
mask,
|
||||
placeholder = '',
|
||||
focus = true
|
||||
@@ -484,12 +488,50 @@ export function TextInput({
|
||||
|
||||
const ins = (v: string, c: number, s: string) => v.slice(0, c) + s + v.slice(c)
|
||||
|
||||
const pastePlainText = (text: string) => {
|
||||
const cleaned = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
|
||||
if (!cleaned) {
|
||||
return
|
||||
}
|
||||
|
||||
const range = selRange()
|
||||
const nextValue = range
|
||||
? vRef.current.slice(0, range.start) + cleaned + vRef.current.slice(range.end)
|
||||
: vRef.current.slice(0, curRef.current) + cleaned + vRef.current.slice(curRef.current)
|
||||
const nextCursor = range ? range.start + cleaned.length : curRef.current + cleaned.length
|
||||
|
||||
commit(nextValue, nextCursor)
|
||||
}
|
||||
|
||||
useInput(
|
||||
(inp: string, k: Key, event: InputEvent) => {
|
||||
const eventRaw = event.keypress.raw
|
||||
|
||||
if (eventRaw === '\x1bv' || eventRaw === '\x1bV' || eventRaw === '\x16') {
|
||||
return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
|
||||
if (eventRaw === '\x1bv' || eventRaw === '\x1bV' || eventRaw === '\x16' || (allowClipboardHotkeys && isMac && k.meta && inp.toLowerCase() === 'v')) {
|
||||
if (cbPaste.current) {
|
||||
return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
|
||||
}
|
||||
|
||||
if (allowClipboardHotkeys) {
|
||||
const text = readClipboardText()
|
||||
|
||||
if (text) {
|
||||
return pastePlainText(text)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (allowClipboardHotkeys && isMac && k.meta && inp.toLowerCase() === 'c') {
|
||||
const range = selRange()
|
||||
|
||||
if (range) {
|
||||
writeOsc52Clipboard(vRef.current.slice(range.start, range.end))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -687,6 +729,7 @@ export interface PasteEvent {
|
||||
}
|
||||
|
||||
interface TextInputProps {
|
||||
allowClipboardHotkeys?: boolean
|
||||
columns?: number
|
||||
focus?: boolean
|
||||
mask?: string
|
||||
|
||||
30
ui-tui/src/lib/clipboard.ts
Normal file
30
ui-tui/src/lib/clipboard.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { spawnSync, type SpawnSyncOptions } from 'node:child_process'
|
||||
|
||||
const DEFAULT_SPAWN_OPTS: SpawnSyncOptions = {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
encoding: 'utf8'
|
||||
}
|
||||
|
||||
/**
|
||||
* Read plain text from the system clipboard.
|
||||
*
|
||||
* On macOS this uses `pbpaste`. On other platforms we intentionally return
|
||||
* null for now; the TUI's text-paste hotkeys are primarily targeted at the
|
||||
* macOS clarify/input flow.
|
||||
*/
|
||||
export function readClipboardText(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
run = spawnSync
|
||||
): string | null {
|
||||
if (platform !== 'darwin') {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = run('pbpaste', [], DEFAULT_SPAWN_OPTS)
|
||||
|
||||
if (result.status !== 0 || typeof result.stdout !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.stdout
|
||||
}
|
||||
Reference in New Issue
Block a user