diff --git a/packages/tui/src/clipboard.ts b/packages/tui/src/clipboard.ts index 2ae29da88894..d3802bed7540 100644 --- a/packages/tui/src/clipboard.ts +++ b/packages/tui/src/clipboard.ts @@ -94,32 +94,40 @@ export function copyCommand( } } -let copyMethod: Promise<(text: string) => Promise> | undefined +export type ClipboardDeps = { + os: NodeJS.Platform + wayland: boolean + has: (name: string) => boolean + command: (command: string, args: string[], input?: string) => Promise + clipboardy: { write(text: string): Promise } +} -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 writeWith(deps: ClipboardDeps, text: string): Promise { + const native = copyCommand(deps.os, deps.wayland, deps.has) + if (native?.[0] === "osascript") { + const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + await deps.command("osascript", ["-e", `set the clipboard to "${escaped}"`]) + return + } + if (native) { + await deps.command(native[0], native.slice(1), text) + return + } + await deps.clipboardy.write(text) } export async function write(text: string) { writeOsc52(text) - const method = await getCopyMethod() - await method(text) + const { which } = await import("@opencode-ai/core/util/which") + const { default: clipboardy } = await import("clipboardy") + await writeWith( + { + os: platform(), + wayland: Boolean(process.env.WAYLAND_DISPLAY), + has: (name) => Boolean(which(name)), + command, + clipboardy, + }, + text, + ) } diff --git a/packages/tui/src/component/error-component.tsx b/packages/tui/src/component/error-component.tsx index 1141da83822e..f52ad87574c1 100644 --- a/packages/tui/src/component/error-component.tsx +++ b/packages/tui/src/component/error-component.tsx @@ -45,7 +45,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?: const issueURL = buildIssueURL(message, stack) const copyReport = () => { - void clipboard.write?.(issueURL.toString()).then(() => setCopied(true)) + void clipboard.write?.(issueURL.toString()).then(() => setCopied(true)).catch(() => {}) } const actions = [ diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index b7d01842060b..8a8801e698c6 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -69,7 +69,7 @@ export function DialogMessage(props: { return agg }, "") - await clipboard.write?.(text) + await clipboard.write?.(text).catch(() => {}) dialog.clear() }, }, diff --git a/packages/tui/test/clipboard.test.ts b/packages/tui/test/clipboard.test.ts index f2d4994c7e2a..e172b69f98ec 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, writeWith, type ClipboardDeps } from "../src/clipboard" test("prefers Wayland clipboard when available", () => { expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual(["wl-copy"]) @@ -17,3 +17,70 @@ test("falls back through X11 clipboard commands", () => { test("returns undefined when native clipboard is unavailable", () => { expect(copyCommand("linux", false, () => false)).toBeUndefined() }) + +function writeDeps(overrides?: Partial): ClipboardDeps { + return { + os: "linux", + wayland: false, + has: () => true, + command: async () => {}, + clipboardy: { write: async () => {} }, + ...overrides, + } +} + +test("rejects when the native clipboard command fails", () => { + expect( + writeWith( + writeDeps({ + command: async () => { + throw new Error("xclip failed") + }, + }), + "hello", + ), + ).rejects.toThrow("xclip failed") +}) + +test("rejects when the clipboardy fallback fails", () => { + expect( + writeWith( + writeDeps({ + has: () => false, + clipboardy: { + write: async () => { + throw new Error("xsel missing") + }, + }, + }), + "hello", + ), + ).rejects.toThrow("xsel missing") +}) + +test("resolves when the native clipboard command succeeds", async () => { + const calls: string[] = [] + await writeWith( + writeDeps({ + command: async (cmd, args) => { + calls.push(cmd, ...args) + }, + }), + "hello", + ) + expect(calls[0]).toBe("xclip") +}) + +test("escapes text for osascript", async () => { + let received: string | undefined + await writeWith( + writeDeps({ + os: "darwin", + command: async (_, args) => { + received = args[1] + }, + }), + `say "hi"`, + ) + expect(received).toBe('set the clipboard to "say \\"hi\\""') +})