Skip to content
Closed
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
56 changes: 40 additions & 16 deletions packages/tui/src/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export async function read() {
const { default: clipboardy } = await import("clipboardy")
const text = await clipboardy.read().catch(() => undefined)
if (text) return { data: text, mime: "text/plain" }
return undefined
}

export function copyCommand(
Expand All @@ -91,29 +92,52 @@ export function copyCommand(
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
]
}
return undefined
}

type ClipboardyModule = {
default: {
write(text: string): Promise<void>
}
}

export function createCopyMethod(input: {
os: NodeJS.Platform
wayland: boolean
has: (name: string) => boolean
run: (command: string, args?: string[], input?: string) => Promise<Buffer>
loadClipboardy: () => Promise<ClipboardyModule>
}) {
const native = copyCommand(input.os, input.wayland, input.has)
if (native?.[0] === "osascript") {
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await input.run("osascript", ["-e", `set the clipboard to "${escaped}"`])
}
}
if (native) {
return async (text: string) => {
await input.run(native[0], native.slice(1), text)
}
}
return async (text: string) => {
const { default: clipboardy } = await input.loadClipboardy()
await clipboardy.write(text)
}
}

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)
}
return createCopyMethod({
os: platform(),
wayland: Boolean(process.env.WAYLAND_DISPLAY),
has: (name) => Boolean(which(name)),
run: command,
loadClipboardy: () => import("clipboardy"),
})
})())
}

Expand Down
48 changes: 47 additions & 1 deletion packages/tui/test/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { copyCommand } from "../src/clipboard"
import { copyCommand, createCopyMethod } from "../src/clipboard"

test("prefers Wayland clipboard when available", () => {
expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual(["wl-copy"])
Expand All @@ -17,3 +17,49 @@ test("falls back through X11 clipboard commands", () => {
test("returns undefined when native clipboard is unavailable", () => {
expect(copyCommand("linux", false, () => false)).toBeUndefined()
})

test("propagates native clipboard write failures", async () => {
const write = createCopyMethod({
os: "linux",
wayland: false,
has: (name) => name === "xclip",
run: async () => {
throw new Error("xclip failed")
},
loadClipboardy: async () => ({
default: {
write: async () => {},
},
}),
})

await write("hello").then(
() => {
throw new Error("expected write to fail")
},
(err) => expect(err).toEqual(new Error("xclip failed")),
)
})

test("propagates clipboardy write failures", async () => {
const write = createCopyMethod({
os: "linux",
wayland: false,
has: () => false,
run: async () => Buffer.alloc(0),
loadClipboardy: async () => ({
default: {
write: async () => {
throw new Error("clipboard unavailable")
},
},
}),
})

await write("hello").then(
() => {
throw new Error("expected write to fail")
},
(err) => expect(err).toEqual(new Error("clipboard unavailable")),
)
})
Loading