Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/tui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@mariozechner/clipboard": "0.3.9",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
Expand Down
117 changes: 9 additions & 108 deletions packages/tui/src/clipboard.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,4 @@
import { execFile, spawn } from "node:child_process"
import { readFile, rm } from "node:fs/promises"
import { platform, release, tmpdir } from "node:os"
import path from "node:path"
import { promisify } from "node:util"

const exec = promisify(execFile)

function command(command: string, args: string[] = [], input?: string) {
return new Promise<Buffer>((resolve, reject) => {
const child = spawn(command, args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"] })
const output: Buffer[] = []
child.on("error", reject)
child.stdout?.on("data", (chunk: Buffer) => output.push(chunk))
child.on("close", (code) => {
if (code === 0) return resolve(Buffer.concat(output))
reject(new Error(`${command} exited with code ${code}`))
})
if (input !== undefined) child.stdin?.end(input)
})
}
import Clipboard from "@mariozechner/clipboard"

function writeOsc52(text: string) {
if (!process.stdout.isTTY) return
Expand All @@ -27,98 +7,19 @@ function writeOsc52(text: string) {
}

export async function read() {
if (platform() === "darwin") {
const file = path.join(tmpdir(), "opencode-clipboard.png")
try {
await exec("osascript", [
"-e",
'set imageData to the clipboard as "PNGf"',
"-e",
`set fileRef to open for access POSIX file "${file}" with write permission`,
"-e",
"set eof fileRef to 0",
"-e",
"write imageData to fileRef",
"-e",
"close access fileRef",
])
return { data: (await readFile(file)).toString("base64"), mime: "image/png" }
} catch {
// Fall through to text clipboard.
} finally {
await rm(file, { force: true }).catch(() => {})
try {
if (Clipboard.hasImage()) {
const data = await Clipboard.getImageBase64()
return { data, mime: "image/png" }
}
} catch {
// Fall through to text.
}

if (platform() === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const image = await command("powershell.exe", ["-NonInteractive", "-NoProfile", "-command", script]).catch(() =>
Buffer.alloc(0),
)
if (image.length) return { data: image.toString().trim(), mime: "image/png" }
}

if (platform() === "linux") {
const wayland = await command("wl-paste", ["-t", "image/png"]).catch(() => Buffer.alloc(0))
if (wayland.length) return { data: wayland.toString("base64"), mime: "image/png" }
const x11 = await command("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]).catch(() =>
Buffer.alloc(0),
)
if (x11.length) return { data: x11.toString("base64"), mime: "image/png" }
}

const { default: clipboardy } = await import("clipboardy")
const text = await clipboardy.read().catch(() => undefined)
const text = await Clipboard.getText().catch(() => undefined)
if (text) return { data: text, mime: "text/plain" }
}

export function copyCommand(
os: NodeJS.Platform,
wayland: boolean,
has: (name: string) => boolean,
): string[] | undefined {
if (os === "darwin" && has("osascript")) return ["osascript"]
if (os === "linux" && wayland && has("wl-copy")) return ["wl-copy"]
if (os === "linux" && has("xclip")) return ["xclip", "-selection", "clipboard"]
if (os === "linux" && has("xsel")) return ["xsel", "--clipboard", "--input"]
if (os === "win32" && has("powershell.exe")) {
return [
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
]
}
}

let copyMethod: Promise<(text: string) => Promise<void>> | undefined

function getCopyMethod() {
return (copyMethod ??= (async () => {
const { which } = await import("@opencode-ai/core/util/which")
const native = copyCommand(platform(), Boolean(process.env.WAYLAND_DISPLAY), (name) => Boolean(which(name)))
if (native?.[0] === "osascript") {
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await command("osascript", ["-e", `set the clipboard to "${escaped}"`]).catch(() => undefined)
}
}
if (native) {
return async (text: string) => {
await command(native[0], native.slice(1), text).catch(() => undefined)
}
}
return async (text: string) => {
const { default: clipboardy } = await import("clipboardy")
await clipboardy.write(text).catch(() => undefined)
}
})())
}

export async function write(text: string) {
writeOsc52(text)
const method = await getCopyMethod()
await method(text)
await Clipboard.setText(text).catch(() => undefined)
}
43 changes: 32 additions & 11 deletions packages/tui/test/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,40 @@
import { expect, test } from "bun:test"
import { copyCommand } from "../src/clipboard"
import { afterAll, beforeAll, expect, test } from "bun:test"
import Clipboard from "@mariozechner/clipboard"
import { read, write } from "../src/clipboard"

test("prefers Wayland clipboard when available", () => {
expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual(["wl-copy"])
const redPixel1x1 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"

beforeAll(async () => {
await Clipboard.clear().catch(() => {})
})

afterAll(async () => {
await Clipboard.clear().catch(() => {})
})

test("write and read text round-trip", async () => {
await write("hello clipboard")
const result = await read()
expect(result).toEqual({ data: "hello clipboard", mime: "text/plain" })
})

test("uses osascript on macOS", () => {
expect(copyCommand("darwin", false, (name) => name === "osascript")).toEqual(["osascript"])
test("read returns undefined on empty clipboard", async () => {
await Clipboard.clear()
const result = await read()
expect(result).toBeUndefined()
})

test("falls back through X11 clipboard commands", () => {
expect(copyCommand("linux", true, (name) => name === "xclip")).toEqual(["xclip", "-selection", "clipboard"])
expect(copyCommand("linux", false, (name) => name === "xsel")).toEqual(["xsel", "--clipboard", "--input"])
test("read returns image when clipboard has image", async () => {
await Clipboard.setImageBase64(redPixel1x1)
const result = await read()
expect(result?.mime).toBe("image/png")
expect(typeof result?.data).toBe("string")
expect(result!.data.length).toBeGreaterThan(0)
})

test("returns undefined when native clipboard is unavailable", () => {
expect(copyCommand("linux", false, () => false)).toBeUndefined()
test("read falls through to text when image read fails", async () => {
await Clipboard.setText("fallback text")
const result = await read()
expect(result).toEqual({ data: "fallback text", mime: "text/plain" })
})
Loading