Skip to content

Commit ad1bfb0

Browse files
committed
fix(tui): prevent UI freezes from clipboard operations and event loop
- Replace Bun.$ with node:child_process in clipboard.ts to avoid GC crashes - Add debouncing to clipboard copy operations (100ms) - Add yield point in SDK event loop to prevent render thread starvation Addresses Bun shell GC bug: oven-sh/bun#23177
1 parent bd9c13b commit ad1bfb0

File tree

3 files changed

+101
-58
lines changed

3 files changed

+101
-58
lines changed

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,24 @@
11
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
22
import { Clipboard } from "@tui/util/clipboard"
33
import { TextAttributes } from "@opentui/core"
4+
5+
let copyDebounceTimer: Timer | undefined
6+
let lastCopyTime = 0
7+
const COPY_DEBOUNCE_MS = 100
8+
9+
function debouncedCopy(text: string, onSuccess?: () => void, onError?: (e: unknown) => void): void {
10+
const now = Date.now()
11+
if (now - lastCopyTime < COPY_DEBOUNCE_MS) {
12+
if (copyDebounceTimer) clearTimeout(copyDebounceTimer)
13+
copyDebounceTimer = setTimeout(() => {
14+
lastCopyTime = Date.now()
15+
Clipboard.copy(text).then(onSuccess).catch(onError)
16+
}, COPY_DEBOUNCE_MS)
17+
return
18+
}
19+
lastCopyTime = now
20+
Clipboard.copy(text).then(onSuccess).catch(onError)
21+
}
422
import { RouteProvider, useRoute } from "@tui/context/route"
523
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
624
import { Installation } from "@/installation"
@@ -156,9 +174,9 @@ export function tui(input: { url: string; args: Args; directory?: string; onExit
156174
consoleOptions: {
157175
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
158176
onCopySelection: (text) => {
159-
Clipboard.copy(text).catch((error) => {
160-
console.error(`Failed to copy console selection to clipboard: ${error}`)
161-
})
177+
debouncedCopy(text, undefined, (error) =>
178+
console.error(`Failed to copy console selection to clipboard: ${error}`),
179+
)
162180
},
163181
},
164182
},
@@ -182,18 +200,15 @@ function App() {
182200
const exit = useExit()
183201
const promptRef = usePromptRef()
184202

185-
// Wire up console copy-to-clipboard via opentui's onCopySelection callback
186-
renderer.console.onCopySelection = async (text: string) => {
203+
renderer.console.onCopySelection = (text: string) => {
187204
if (!text || text.length === 0) return
188205

189206
const base64 = Buffer.from(text).toString("base64")
190207
const osc52 = `\x1b]52;c;${base64}\x07`
191208
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
192209
// @ts-expect-error writeOut is not in type definitions
193210
renderer.writeOut(finalOsc52)
194-
await Clipboard.copy(text)
195-
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
196-
.catch(toast.error)
211+
debouncedCopy(text, () => toast.show({ message: "Copied to clipboard", variant: "info" }), toast.error)
197212
renderer.clearSelection()
198213
}
199214
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
@@ -615,7 +630,7 @@ function App() {
615630
width={dimensions().width}
616631
height={dimensions().height}
617632
backgroundColor={theme.background}
618-
onMouseUp={async () => {
633+
onMouseUp={() => {
619634
if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) {
620635
renderer.clearSelection()
621636
return
@@ -627,9 +642,7 @@ function App() {
627642
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
628643
/* @ts-expect-error */
629644
renderer.writeOut(finalOsc52)
630-
await Clipboard.copy(text)
631-
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
632-
.catch(toast.error)
645+
debouncedCopy(text, () => toast.show({ message: "Copied to clipboard", variant: "info" }), toast.error)
633646
renderer.clearSelection()
634647
}
635648
}}
@@ -693,9 +706,7 @@ function ErrorComponent(props: {
693706
issueURL.searchParams.set("opencode-version", Installation.VERSION)
694707

695708
const copyIssueURL = () => {
696-
Clipboard.copy(issueURL.toString()).then(() => {
697-
setCopied(true)
698-
})
709+
debouncedCopy(issueURL.toString(), () => setCopied(true))
699710
}
700711

701712
return (

packages/opencode/src/cli/cmd/tui/context/sdk.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
4949
const elapsed = Date.now() - last
5050

5151
if (timer) continue
52-
// If we just flushed recently (within 16ms), batch this with future events
53-
// Otherwise, process immediately to avoid latency
5452
if (elapsed < 16) {
5553
timer = setTimeout(flush, 16)
5654
continue
5755
}
5856
flush()
57+
await new Promise((r) => setTimeout(r, 0))
5958
}
6059

6160
// Flush any remaining events

packages/opencode/src/cli/cmd/tui/util/clipboard.ts

Lines changed: 74 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,57 @@
1-
import { $ } from "bun"
1+
import { spawn, execSync } from "node:child_process"
22
import { platform, release } from "os"
33
import clipboardy from "clipboardy"
44
import { lazy } from "../../../../util/lazy.js"
55
import { tmpdir } from "os"
66
import path from "path"
77

8+
function runCommand(cmd: string, args: string[]): Promise<Buffer | undefined> {
9+
return new Promise((resolve) => {
10+
const proc = spawn(cmd, args, { stdio: ["ignore", "pipe", "ignore"] })
11+
const chunks: Buffer[] = []
12+
proc.stdout.on("data", (chunk) => chunks.push(chunk))
13+
proc.on("close", (code) => {
14+
if (code === 0 && chunks.length > 0) {
15+
resolve(Buffer.concat(chunks))
16+
} else {
17+
resolve(undefined)
18+
}
19+
})
20+
proc.on("error", () => resolve(undefined))
21+
})
22+
}
23+
24+
function runCommandText(cmd: string, args: string[]): Promise<string | undefined> {
25+
return runCommand(cmd, args).then((buf) => buf?.toString("utf-8"))
26+
}
27+
28+
function writeToCommand(cmd: string, args: string[], data: string): Promise<void> {
29+
return new Promise((resolve) => {
30+
const proc = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] })
31+
proc.stdin.write(data)
32+
proc.stdin.end()
33+
proc.on("close", () => resolve())
34+
proc.on("error", () => resolve())
35+
})
36+
}
37+
38+
function execQuiet(cmd: string): Promise<void> {
39+
return new Promise((resolve) => {
40+
const proc = spawn("sh", ["-c", cmd], { stdio: "ignore" })
41+
proc.on("close", () => resolve())
42+
proc.on("error", () => resolve())
43+
})
44+
}
45+
46+
function which(cmd: string): boolean {
47+
try {
48+
execSync(`which ${cmd}`, { stdio: "ignore" })
49+
return true
50+
} catch {
51+
return false
52+
}
53+
}
54+
855
export namespace Clipboard {
956
export interface Content {
1057
data: string
@@ -17,38 +64,42 @@ export namespace Clipboard {
1764
if (os === "darwin") {
1865
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
1966
try {
20-
await $`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'`
21-
.nothrow()
22-
.quiet()
67+
await execQuiet(
68+
`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'`,
69+
)
2370
const file = Bun.file(tmpfile)
24-
const buffer = await file.arrayBuffer()
25-
return { data: Buffer.from(buffer).toString("base64"), mime: "image/png" }
71+
if (await file.exists()) {
72+
const buffer = await file.arrayBuffer()
73+
if (buffer.byteLength > 0) {
74+
return { data: Buffer.from(buffer).toString("base64"), mime: "image/png" }
75+
}
76+
}
2677
} catch {
2778
} finally {
28-
await $`rm -f "${tmpfile}"`.nothrow().quiet()
79+
await execQuiet(`rm -f "${tmpfile}"`)
2980
}
3081
}
3182

3283
if (os === "win32" || release().includes("WSL")) {
3384
const script =
3485
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
35-
const base64 = await $`powershell.exe -NonInteractive -NoProfile -command "${script}"`.nothrow().text()
36-
if (base64) {
37-
const imageBuffer = Buffer.from(base64.trim(), "base64")
86+
const result = await runCommandText("powershell.exe", ["-NonInteractive", "-NoProfile", "-command", script])
87+
if (result) {
88+
const imageBuffer = Buffer.from(result.trim(), "base64")
3889
if (imageBuffer.length > 0) {
3990
return { data: imageBuffer.toString("base64"), mime: "image/png" }
4091
}
4192
}
4293
}
4394

4495
if (os === "linux") {
45-
const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer()
96+
const wayland = await runCommand("wl-paste", ["-t", "image/png"])
4697
if (wayland && wayland.byteLength > 0) {
47-
return { data: Buffer.from(wayland).toString("base64"), mime: "image/png" }
98+
return { data: wayland.toString("base64"), mime: "image/png" }
4899
}
49-
const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().arrayBuffer()
100+
const x11 = await runCommand("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"])
50101
if (x11 && x11.byteLength > 0) {
51-
return { data: Buffer.from(x11).toString("base64"), mime: "image/png" }
102+
return { data: x11.toString("base64"), mime: "image/png" }
52103
}
53104
}
54105

@@ -61,58 +112,40 @@ export namespace Clipboard {
61112
const getCopyMethod = lazy(() => {
62113
const os = platform()
63114

64-
if (os === "darwin" && Bun.which("osascript")) {
115+
if (os === "darwin" && which("osascript")) {
65116
console.log("clipboard: using osascript")
66117
return async (text: string) => {
67118
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
68-
await $`osascript -e 'set the clipboard to "${escaped}"'`.nothrow().quiet()
119+
await execQuiet(`osascript -e 'set the clipboard to "${escaped}"'`)
69120
}
70121
}
71122

72123
if (os === "linux") {
73-
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
124+
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
74125
console.log("clipboard: using wl-copy")
75126
return async (text: string) => {
76-
const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
77-
proc.stdin.write(text)
78-
proc.stdin.end()
79-
await proc.exited.catch(() => {})
127+
await writeToCommand("wl-copy", [], text)
80128
}
81129
}
82-
if (Bun.which("xclip")) {
130+
if (which("xclip")) {
83131
console.log("clipboard: using xclip")
84132
return async (text: string) => {
85-
const proc = Bun.spawn(["xclip", "-selection", "clipboard"], {
86-
stdin: "pipe",
87-
stdout: "ignore",
88-
stderr: "ignore",
89-
})
90-
proc.stdin.write(text)
91-
proc.stdin.end()
92-
await proc.exited.catch(() => {})
133+
await writeToCommand("xclip", ["-selection", "clipboard"], text)
93134
}
94135
}
95-
if (Bun.which("xsel")) {
136+
if (which("xsel")) {
96137
console.log("clipboard: using xsel")
97138
return async (text: string) => {
98-
const proc = Bun.spawn(["xsel", "--clipboard", "--input"], {
99-
stdin: "pipe",
100-
stdout: "ignore",
101-
stderr: "ignore",
102-
})
103-
proc.stdin.write(text)
104-
proc.stdin.end()
105-
await proc.exited.catch(() => {})
139+
await writeToCommand("xsel", ["--clipboard", "--input"], text)
106140
}
107141
}
108142
}
109143

110144
if (os === "win32") {
111145
console.log("clipboard: using powershell")
112146
return async (text: string) => {
113-
// need to escape backticks because powershell uses them as escape code
114147
const escaped = text.replace(/"/g, '""').replace(/`/g, "``")
115-
await $`powershell -NonInteractive -NoProfile -Command "Set-Clipboard -Value \"${escaped}\""`.nothrow().quiet()
148+
await execQuiet(`powershell -NonInteractive -NoProfile -Command "Set-Clipboard -Value \\"${escaped}\\""`)
116149
}
117150
}
118151

0 commit comments

Comments
 (0)