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 type { ApprovalReq, ClarifyReq, ConfirmReq } from '../types.js'
|
||||||
|
|
||||||
import { TextInput } from './textInput.js'
|
import { TextInput } from './textInput.js'
|
||||||
|
import { isMac } from '../lib/platform.js'
|
||||||
|
|
||||||
const OPTS = ['once', 'session', 'always', 'deny'] as const
|
const OPTS = ['once', 'session', 'always', 'deny'] as const
|
||||||
const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } 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>
|
<Box>
|
||||||
<Text color={t.color.label}>{'> '}</Text>
|
<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>
|
</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>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import * as Ink from '@hermes/ink'
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { setInputSelection } from '../app/inputSelectionStore.js'
|
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 & {
|
type InkExt = typeof Ink & {
|
||||||
stringWidth: (s: string) => number
|
stringWidth: (s: string) => number
|
||||||
@@ -279,6 +282,7 @@ export function TextInput({
|
|||||||
onChange,
|
onChange,
|
||||||
onPaste,
|
onPaste,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
allowClipboardHotkeys = false,
|
||||||
mask,
|
mask,
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
focus = true
|
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 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(
|
useInput(
|
||||||
(inp: string, k: Key, event: InputEvent) => {
|
(inp: string, k: Key, event: InputEvent) => {
|
||||||
const eventRaw = event.keypress.raw
|
const eventRaw = event.keypress.raw
|
||||||
|
|
||||||
if (eventRaw === '\x1bv' || eventRaw === '\x1bV' || eventRaw === '\x16') {
|
if (eventRaw === '\x1bv' || eventRaw === '\x1bV' || eventRaw === '\x16' || (allowClipboardHotkeys && isMac && k.meta && inp.toLowerCase() === 'v')) {
|
||||||
return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
|
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 (
|
if (
|
||||||
@@ -687,6 +729,7 @@ export interface PasteEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TextInputProps {
|
interface TextInputProps {
|
||||||
|
allowClipboardHotkeys?: boolean
|
||||||
columns?: number
|
columns?: number
|
||||||
focus?: boolean
|
focus?: boolean
|
||||||
mask?: string
|
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