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: 7 additions & 3 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -804,9 +804,13 @@ function ErrorComponent(props: {
issueURL.searchParams.set("opencode-version", Installation.VERSION)

const copyIssueURL = () => {
Clipboard.copy(issueURL.toString()).then(() => {
setCopied(true)
})
Clipboard.copy(issueURL.toString())
.then(() => {
setCopied(true)
})
.catch((error) => {
console.error(`Failed to copy issue URL to clipboard: ${error}`)
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch branch logs ${error} which often becomes "[object Object]" and drops stack traces. Consider normalizing the error (e.g., error instanceof Error ? error.stack ?? error.message : String(error)) so clipboard failures are diagnosable from logs.

Suggested change
console.error(`Failed to copy issue URL to clipboard: ${error}`)
const normalizedError =
error instanceof Error ? error.stack ?? error.message : String(error)
console.error(`Failed to copy issue URL to clipboard: ${normalizedError}`)

Copilot uses AI. Check for mistakes.
})
}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { DialogSelect } from "@tui/ui/dialog-select"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "@tui/ui/toast"
import type { PromptInfo } from "@tui/component/prompt/history"

export function DialogMessage(props: {
Expand All @@ -15,6 +16,7 @@ export function DialogMessage(props: {
const sdk = useSDK()
const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID))
const route = useRoute()
const toast = useToast()

return (
<DialogSelect
Expand Down Expand Up @@ -67,7 +69,13 @@ export function DialogMessage(props: {
return agg
}, "")

await Clipboard.copy(text)
const ok = await Clipboard.copy(text)
.then(() => true)
.catch((error) => {
toast.error(error)
return false
})
if (!ok) return
dialog.clear()
},
},
Expand Down
191 changes: 130 additions & 61 deletions packages/opencode/src/cli/cmd/tui/util/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,26 @@ function writeOsc52(text: string): void {
process.stdout.write(sequence)
}

type CopyMethod = {
name: string
copy: (text: string) => Promise<void>
}

async function spawnCopy(name: string, cmd: string[], text: string): Promise<void> {
const proc = Bun.spawn(cmd, {
stdin: "pipe",
stdout: "ignore",
stderr: "pipe",
})
proc.stdin.write(text)
proc.stdin.end()

const err = proc.stderr ? await new Response(proc.stderr).text().catch(() => "") : ""
const code = await proc.exited
if (code === 0) return
throw new Error(`${name} exited with code ${code}${err ? `: ${err.trim()}` : ""}`)
}

export namespace Clipboard {
export interface Content {
data: string
Expand Down Expand Up @@ -72,88 +92,137 @@ export namespace Clipboard {
}
}

const getCopyMethod = lazy(() => {
const getCopyMethods = lazy(() => {
const os = platform()
const list: CopyMethod[] = []

if (os === "darwin" && Bun.which("osascript")) {
console.log("clipboard: using osascript")
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await $`osascript -e 'set the clipboard to "${escaped}"'`.nothrow().quiet()
}
console.log("clipboard: enabled osascript")
list.push({
name: "osascript",
copy: async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
const result = await $`osascript -e 'set the clipboard to "${escaped}"'`.nothrow().quiet()
if (result.exitCode === 0) return
throw new Error("osascript failed")
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When osascript fails, the thrown error message is always just "osascript failed". This loses useful context (exit code and stderr) that would help users/debugging when copy fails. Consider including result.exitCode and any stderr output in the error message (similar to spawnCopy).

Suggested change
throw new Error("osascript failed")
const stderr =
result.stderr && result.stderr.length
? new TextDecoder().decode(result.stderr).trim()
: ""
throw new Error(
`osascript exited with code ${result.exitCode}${stderr ? `: ${stderr}` : ""}`,
)

Copilot uses AI. Check for mistakes.
},
})
}

if (os === "linux") {
const wsl = release().includes("WSL")
if (wsl && Bun.which("clip.exe")) {
console.log("clipboard: enabled clip.exe")
list.push({
name: "clip.exe",
copy: (text: string) => spawnCopy("clip.exe", ["clip.exe"], text),
})
}
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
console.log("clipboard: using wl-copy")
return async (text: string) => {
const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
console.log("clipboard: enabled wl-copy")
list.push({
name: "wl-copy",
copy: (text: string) => spawnCopy("wl-copy", ["wl-copy"], text),
})
}
if (Bun.which("xclip")) {
console.log("clipboard: using xclip")
return async (text: string) => {
const proc = Bun.spawn(["xclip", "-selection", "clipboard"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
console.log("clipboard: enabled xclip")
list.push({
name: "xclip",
copy: (text: string) => spawnCopy("xclip", ["xclip", "-selection", "clipboard"], text),
})
}
if (Bun.which("xsel")) {
console.log("clipboard: using xsel")
return async (text: string) => {
const proc = Bun.spawn(["xsel", "--clipboard", "--input"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
console.log("clipboard: enabled xsel")
list.push({
name: "xsel",
copy: (text: string) => spawnCopy("xsel", ["xsel", "--clipboard", "--input"], text),
})
}
}

if (os === "win32") {
console.log("clipboard: using powershell")
return async (text: string) => {
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
const proc = Bun.spawn(
[
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
],
{
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
},
)

proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
if (Bun.which("powershell.exe")) {
console.log("clipboard: enabled powershell.exe")
list.push({
name: "powershell.exe",
copy: (text: string) =>
spawnCopy(
"powershell.exe",
[
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
],
text,
),
})
}
if (Bun.which("pwsh.exe")) {
console.log("clipboard: enabled pwsh.exe")
list.push({
name: "pwsh.exe",
copy: (text: string) =>
spawnCopy(
"pwsh.exe",
[
"pwsh.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
],
text,
),
})
}
if (Bun.which("clip.exe")) {
console.log("clipboard: enabled clip.exe")
list.push({
name: "clip.exe",
copy: (text: string) => spawnCopy("clip.exe", ["clip.exe"], text),
})
}
}

console.log("clipboard: no native support")
return async (text: string) => {
await clipboardy.write(text).catch(() => {})
}
list.push({
name: "clipboardy",
copy: async (text: string) => {
await clipboardy.write(text)
},
})
Comment on lines +190 to +195
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clipboardy is always added as a copy method, even when a native method (wl-copy/xclip/xsel/powershell/etc.) is enabled. Since clipboardy typically shells out to the same underlying tools, this can cause redundant attempts (and duplicated errors) before failing, and can add extra latency. Consider only adding the clipboardy fallback when no OS-specific methods were added, or otherwise de-duplicating so each backend is tried at most once.

Suggested change
list.push({
name: "clipboardy",
copy: async (text: string) => {
await clipboardy.write(text)
},
})
if (list.length === 0) {
list.push({
name: "clipboardy",
copy: async (text: string) => {
await clipboardy.write(text)
},
})
}

Copilot uses AI. Check for mistakes.

console.log(`clipboard: enabled ${list.map((x) => x.name).join(", ")}`)
return list
})

export async function copy(text: string): Promise<void> {
writeOsc52(text)
await getCopyMethod()(text)
const os = platform()
const list = getCopyMethods()
const errs: string[] = []

for (const method of list) {
const err = await method
.copy(text)
.then(() => undefined)
.catch((error) => error)
if (!err) return
errs.push(err instanceof Error ? `${method.name}: ${err.message}` : `${method.name}: ${String(err)}`)
}

const hint = (() => {
if (os === "linux") {
return "Install wl-clipboard, xclip, or xsel and verify DISPLAY/WAYLAND_DISPLAY is set."
}
if (os === "win32") {
return "Ensure clip.exe or PowerShell is available and the terminal session can access the Windows clipboard."
}
return ""
})()

throw new Error(`Failed to copy to clipboard. ${hint} Tried: ${errs.join(" | ")}`.trim())
}
}
Loading