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
10 changes: 5 additions & 5 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
},
},
Expand Down Expand Up @@ -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()
},
Expand Down Expand Up @@ -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
Expand Down
93 changes: 57 additions & 36 deletions packages/opencode/src/cli/cmd/tui/util/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<void> {
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<void> = writeToCopyCommand,
): Promise<void> {
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
Expand Down Expand Up @@ -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)}`,
)
}
}
}
Expand Down Expand Up @@ -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)
}
})

Expand Down
42 changes: 42 additions & 0 deletions packages/opencode/test/cli/tui/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})