diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index b9b2e2a6..20209301 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -430,7 +430,7 @@ export function Session() { const copy = (url: string) => Clipboard.copy(url) .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" })) - .catch(() => toast.show({ message: "Failed to copy URL to clipboard", variant: "error" })) + .catch(toast.error) const url = session()?.share?.url if (url) { await copy(url) @@ -893,7 +893,7 @@ export function Session() { Clipboard.copy(text) .then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" })) - .catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" })) + .catch(toast.error) dialog.clear() }, }, @@ -921,8 +921,8 @@ export function Session() { ) await Clipboard.copy(transcript) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) - } catch { - toast.show({ message: "Failed to copy session transcript", variant: "error" }) + } catch (error) { + toast.error(error) } dialog.clear() }, @@ -1432,7 +1432,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las if (!text) return Clipboard.copy(text) .then(() => toast.show({ message: t("tui.toast.copied_to_clipboard"), variant: "success" })) - .catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" })) + .catch(toast.error) } // Goal judge verdict for this specific turn, if the stop-condition judge diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 8c535833..9f2d37de 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -5,6 +5,7 @@ import path from "path" import fs from "fs/promises" import * as Filesystem from "../../../../util/filesystem" import * as Process from "../../../../util/process" +import { errorMessage } from "../../../../util/error" // Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup const getWhich = lazy(async () => { @@ -36,6 +37,47 @@ export interface Content { mime: string } +export type LinuxCopyCommand = { + name: string + command: string[] +} + +export function getLinuxCopyCommands(env: NodeJS.ProcessEnv, which: (command: string) => unknown): LinuxCopyCommand[] { + return [ + ...(env["WAYLAND_DISPLAY"] && which("wl-copy") ? [{ name: "wl-copy", command: ["wl-copy"] }] : []), + ...(which("xclip") ? [{ name: "xclip", command: ["xclip", "-selection", "clipboard"] }] : []), + ...(which("xsel") ? [{ name: "xsel", command: ["xsel", "--clipboard", "--input"] }] : []), + ] +} + +async function writeToCopyCommand(command: LinuxCopyCommand, text: string): Promise { + const proc = Process.spawn(command.command, { stdin: "pipe", stdout: "ignore", stderr: "ignore" }) + if (!proc.stdin) throw new Error(`${command.name} did not accept stdin`) + proc.stdin.write(text) + proc.stdin.end() + const code = await proc.exited + if (code !== 0) throw new Error(`${command.name} exited with code ${code}`) +} + +export async function runLinuxCopyCommands( + text: string, + commands: LinuxCopyCommand[], + write: (command: LinuxCopyCommand, text: string) => Promise = writeToCopyCommand, +): Promise { + const failures: string[] = [] + for (const command of commands) { + try { + await write(command, text) + return + } catch (error) { + failures.push(`${command.name}: ${errorMessage(error)}`) + } + } + + const details = failures.length ? ` Tried ${failures.join("; ")}.` : "" + throw new Error(`Failed to copy to the clipboard on Linux. Install wl-clipboard, xclip, or xsel.${details}`) +} + // Checks clipboard for images first, then falls back to text. // // On Windows prompt/ can call this from multiple paste signals because @@ -123,42 +165,21 @@ const getCopyMethod = lazy(async () => { } if (os === "linux") { - if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) { - console.log("clipboard: using wl-copy") - return async (text: string) => { - const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" }) - if (!proc.stdin) return - proc.stdin.write(text) - proc.stdin.end() - await proc.exited.catch(() => {}) - } - } - if (which("xclip")) { - console.log("clipboard: using xclip") - return async (text: string) => { - const proc = Process.spawn(["xclip", "-selection", "clipboard"], { - stdin: "pipe", - stdout: "ignore", - stderr: "ignore", - }) - if (!proc.stdin) return - proc.stdin.write(text) - proc.stdin.end() - await proc.exited.catch(() => {}) - } + const commands = getLinuxCopyCommands(process.env, (command) => which(command)) + if (commands.length) { + console.log(`clipboard: using ${commands.map((command) => command.name).join(", ")}`) + return async (text: string) => runLinuxCopyCommands(text, commands) } - if (which("xsel")) { - console.log("clipboard: using xsel") - return async (text: string) => { - const proc = Process.spawn(["xsel", "--clipboard", "--input"], { - stdin: "pipe", - stdout: "ignore", - stderr: "ignore", - }) - if (!proc.stdin) return - proc.stdin.write(text) - proc.stdin.end() - await proc.exited.catch(() => {}) + + console.log("clipboard: using clipboardy") + return async (text: string) => { + try { + const clipboardy = await getClipboardy() + await clipboardy.write(text) + } catch (error) { + throw new Error( + `Failed to copy to the clipboard on Linux. Install wl-clipboard, xclip, or xsel. ${errorMessage(error)}`, + ) } } } @@ -192,7 +213,7 @@ const getCopyMethod = lazy(async () => { console.log("clipboard: no native support") return async (text: string) => { const clipboardy = await getClipboardy() - await clipboardy.write(text).catch(() => {}) + await clipboardy.write(text) } }) diff --git a/packages/opencode/test/cli/tui/clipboard.test.ts b/packages/opencode/test/cli/tui/clipboard.test.ts new file mode 100644 index 00000000..172c916c --- /dev/null +++ b/packages/opencode/test/cli/tui/clipboard.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test" +import { getLinuxCopyCommands, runLinuxCopyCommands } from "../../../src/cli/cmd/tui/util/clipboard" + +describe("clipboard", () => { + test("prefers wl-copy on Wayland before X11 helpers", () => { + const commands = getLinuxCopyCommands({ WAYLAND_DISPLAY: "wayland-1" }, () => true) + + expect(commands.map((command) => command.name)).toEqual(["wl-copy", "xclip", "xsel"]) + }) + + test("uses X11 helpers when Wayland is unavailable", () => { + const commands = getLinuxCopyCommands({}, (command) => command !== "wl-copy") + + expect(commands.map((command) => command.name)).toEqual(["xclip", "xsel"]) + }) + + test("falls back to the next Linux helper after a copy failure", async () => { + const attempts: string[] = [] + + await runLinuxCopyCommands( + "hello", + [ + { name: "xclip", command: ["xclip", "-selection", "clipboard"] }, + { name: "xsel", command: ["xsel", "--clipboard", "--input"] }, + ], + async (command) => { + attempts.push(command.name) + if (command.name === "xclip") throw new Error("display unavailable") + }, + ) + + expect(attempts).toEqual(["xclip", "xsel"]) + }) + + test("throws an actionable error when no Linux clipboard helper works", async () => { + await expect( + runLinuxCopyCommands("hello", [{ name: "xclip", command: ["xclip", "-selection", "clipboard"] }], async () => { + throw new Error("display unavailable") + }), + ).rejects.toThrow("Install wl-clipboard, xclip, or xsel") + }) +})