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
+}