diff --git a/ui-tui/src/__tests__/clipboard.test.ts b/ui-tui/src/__tests__/clipboard.test.ts index b073861d1c..e9bf4f5a7c 100644 --- a/ui-tui/src/__tests__/clipboard.test.ts +++ b/ui-tui/src/__tests__/clipboard.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { readClipboardText } from '../lib/clipboard.js' +import { readClipboardText, writeClipboardText } from '../lib/clipboard.js' describe('readClipboardText', () => { it('does nothing off macOS', async () => { @@ -23,3 +23,47 @@ describe('readClipboardText', () => { await expect(readClipboardText('darwin', run)).resolves.toBeNull() }) }) + +describe('writeClipboardText', () => { + it('does nothing off macOS', async () => { + const start = vi.fn() + + await expect(writeClipboardText('hello', 'linux', start)).resolves.toBe(false) + expect(start).not.toHaveBeenCalled() + }) + + it('writes text to pbcopy on macOS', async () => { + const stdin = { end: vi.fn() } + const child = { + once: vi.fn((event: string, cb: (code?: number) => void) => { + if (event === 'close') { + cb(0) + } + + return child + }), + stdin + } + const start = vi.fn().mockReturnValue(child) + + await expect(writeClipboardText('hello world', 'darwin', start as any)).resolves.toBe(true) + expect(start).toHaveBeenCalledWith('pbcopy', [], expect.objectContaining({ stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })) + expect(stdin.end).toHaveBeenCalledWith('hello world') + }) + + it('returns false when pbcopy fails', async () => { + const child = { + once: vi.fn((event: string, cb: () => void) => { + if (event === 'error') { + cb() + } + + return child + }), + stdin: { end: vi.fn() } + } + const start = vi.fn().mockReturnValue(child) + + await expect(writeClipboardText('hello world', 'darwin', start as any)).resolves.toBe(false) + }) +}) diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 2473a49bf2..83fd3385e4 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -7,6 +7,8 @@ import type { SudoRespondResponse, VoiceRecordResponse } from '../gatewayTypes.js' + +import { writeClipboardText } from '../lib/clipboard.js' import { writeOsc52Clipboard } from '../lib/osc52.js' import { isAction, isMac } from '../lib/platform.js' @@ -30,9 +32,17 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { const copySelection = () => { const text = terminal.selection.copySelection() - if (text) { - actions.sys(`copied ${text.length} chars`) + if (!text) { + return } + + void writeClipboardText(text).then(copied => { + if (!copied) { + writeOsc52Clipboard(text) + } + }) + + actions.sys(`copied ${text.length} chars`) } const clearSelection = () => { @@ -249,7 +259,14 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { const inputSel = getInputSelection() if (inputSel && inputSel.end > inputSel.start) { - writeOsc52Clipboard(inputSel.value.slice(inputSel.start, inputSel.end)) + const text = inputSel.value.slice(inputSel.start, inputSel.end) + + void writeClipboardText(text).then(copied => { + if (!copied) { + writeOsc52Clipboard(text) + } + }) + inputSel.clear() } diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index ebe3a97c2b..1d16bb21a4 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -3,7 +3,7 @@ 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 { readClipboardText, writeClipboardText } from '../lib/clipboard.js' import { isActionMod, isMac } from '../lib/platform.js' import { writeOsc52Clipboard } from '../lib/osc52.js' @@ -528,7 +528,13 @@ export function TextInput({ const range = selRange() if (range) { - writeOsc52Clipboard(vRef.current.slice(range.start, range.end)) + const text = vRef.current.slice(range.start, range.end) + + void writeClipboardText(text).then(copied => { + if (!copied) { + writeOsc52Clipboard(text) + } + }) } return diff --git a/ui-tui/src/lib/clipboard.ts b/ui-tui/src/lib/clipboard.ts index 79bbbb11a3..64dccc5b49 100644 --- a/ui-tui/src/lib/clipboard.ts +++ b/ui-tui/src/lib/clipboard.ts @@ -1,4 +1,4 @@ -import { execFile } from 'node:child_process' +import { execFile, spawn } from 'node:child_process' import { promisify } from 'node:util' const execFileAsync = promisify(execFile) @@ -26,3 +26,33 @@ export async function readClipboardText( return null } } + +/** + * Write plain text to the system clipboard. + * + * On macOS this uses `pbcopy`. On other platforms we intentionally return + * false for now; non-mac copy still falls back to OSC52. + */ +export async function writeClipboardText( + text: string, + platform: NodeJS.Platform = process.platform, + start: typeof spawn = spawn +): Promise { + if (platform !== 'darwin') { + return false + } + + try { + const ok = await new Promise(resolve => { + const child = start('pbcopy', [], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true }) + + child.once('error', () => resolve(false)) + child.once('close', code => resolve(code === 0)) + child.stdin.end(text) + }) + + return ok + } catch { + return false + } +}