fix(tui): make mac copy use pbcopy

This commit is contained in:
kshitijk4poor
2026-04-19 13:54:18 +05:30
committed by kshitij
parent 1d0b94a1b9
commit e388910fe6
4 changed files with 104 additions and 7 deletions

View File

@@ -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)
})
})

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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<boolean> {
if (platform !== 'darwin') {
return false
}
try {
const ok = await new Promise<boolean>(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
}
}