diff --git a/ui-tui/src/__tests__/clipboard.test.ts b/ui-tui/src/__tests__/clipboard.test.ts new file mode 100644 index 0000000000..a7d2bde468 --- /dev/null +++ b/ui-tui/src/__tests__/clipboard.test.ts @@ -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() + }) +}) diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index f9d00dbfe3..97c7c02868 100644 --- a/ui-tui/src/components/prompts.tsx +++ b/ui-tui/src/components/prompts.tsx @@ -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 {'> '} - + - Enter send · Esc {choices.length ? 'back' : 'cancel'} · Ctrl+C cancel + + Enter send · Esc {choices.length ? 'back' : 'cancel'} · {isMac ? 'Cmd+C copy · Cmd+V paste · Ctrl+C cancel' : 'Ctrl+C cancel'} + ) } diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index fcd13a7b19..95f50d182d 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -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 diff --git a/ui-tui/src/lib/clipboard.ts b/ui-tui/src/lib/clipboard.ts new file mode 100644 index 0000000000..5260e2f4c1 --- /dev/null +++ b/ui-tui/src/lib/clipboard.ts @@ -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 +}