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
54 changes: 31 additions & 23 deletions packages/tui/src/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,32 +94,40 @@ export function copyCommand(
}
}

let copyMethod: Promise<(text: string) => Promise<void>> | undefined
export type ClipboardDeps = {
os: NodeJS.Platform
wayland: boolean
has: (name: string) => boolean
command: (command: string, args: string[], input?: string) => Promise<unknown>
clipboardy: { write(text: string): Promise<void> }
}

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<void> {
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,
)
}
2 changes: 1 addition & 1 deletion packages/tui/src/component/error-component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/src/routes/session/dialog-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function DialogMessage(props: {
return agg
}, "")

await clipboard.write?.(text)
await clipboard.write?.(text).catch(() => {})
dialog.clear()
},
},
Expand Down
69 changes: 68 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, writeWith, type ClipboardDeps } 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,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>): 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\\""')
})
Loading