diff --git a/packages/tui/src/clipboard.ts b/packages/tui/src/clipboard.ts index 08f86f9f7a97..52411e7983ed 100644 --- a/packages/tui/src/clipboard.ts +++ b/packages/tui/src/clipboard.ts @@ -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( @@ -91,6 +92,38 @@ export function copyCommand( "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())", ] } + return undefined +} + +type ClipboardyModule = { + default: { + write(text: string): Promise + } +} + +export function createCopyMethod(input: { + os: NodeJS.Platform + wayland: boolean + has: (name: string) => boolean + run: (command: string, args?: string[], input?: string) => Promise + loadClipboardy: () => Promise +}) { + 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> | undefined @@ -98,22 +131,13 @@ let copyMethod: Promise<(text: string) => Promise> | 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"), + }) })()) } diff --git a/packages/tui/test/clipboard.test.ts b/packages/tui/test/clipboard.test.ts index f2d4994c7e2a..960c38c10068 100644 --- a/packages/tui/test/clipboard.test.ts +++ b/packages/tui/test/clipboard.test.ts @@ -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"]) @@ -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")), + ) +})