diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 50f4f1ae5c6..d28acee09f9 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -1,6 +1,24 @@ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { Clipboard } from "@tui/util/clipboard" import { TextAttributes } from "@opentui/core" + +let copyDebounceTimer: Timer | undefined +let lastCopyTime = 0 +const COPY_DEBOUNCE_MS = 100 + +function debouncedCopy(text: string, onSuccess?: () => void, onError?: (e: unknown) => void): void { + const now = Date.now() + if (now - lastCopyTime < COPY_DEBOUNCE_MS) { + if (copyDebounceTimer) clearTimeout(copyDebounceTimer) + copyDebounceTimer = setTimeout(() => { + lastCopyTime = Date.now() + Clipboard.copy(text).then(onSuccess).catch(onError) + }, COPY_DEBOUNCE_MS) + return + } + lastCopyTime = now + Clipboard.copy(text).then(onSuccess).catch(onError) +} import { RouteProvider, useRoute } from "@tui/context/route" import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" import { Installation } from "@/installation" @@ -156,9 +174,9 @@ export function tui(input: { url: string; args: Args; directory?: string; onExit consoleOptions: { keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], onCopySelection: (text) => { - Clipboard.copy(text).catch((error) => { - console.error(`Failed to copy console selection to clipboard: ${error}`) - }) + debouncedCopy(text, undefined, (error) => + console.error(`Failed to copy console selection to clipboard: ${error}`), + ) }, }, }, @@ -182,8 +200,7 @@ function App() { const exit = useExit() const promptRef = usePromptRef() - // Wire up console copy-to-clipboard via opentui's onCopySelection callback - renderer.console.onCopySelection = async (text: string) => { + renderer.console.onCopySelection = (text: string) => { if (!text || text.length === 0) return const base64 = Buffer.from(text).toString("base64") @@ -191,9 +208,7 @@ function App() { const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 // @ts-expect-error writeOut is not in type definitions renderer.writeOut(finalOsc52) - await Clipboard.copy(text) - .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) - .catch(toast.error) + debouncedCopy(text, () => toast.show({ message: "Copied to clipboard", variant: "info" }), toast.error) renderer.clearSelection() } const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true)) @@ -615,7 +630,7 @@ function App() { width={dimensions().width} height={dimensions().height} backgroundColor={theme.background} - onMouseUp={async () => { + onMouseUp={() => { if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) { renderer.clearSelection() return @@ -627,9 +642,7 @@ function App() { const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 /* @ts-expect-error */ renderer.writeOut(finalOsc52) - await Clipboard.copy(text) - .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) - .catch(toast.error) + debouncedCopy(text, () => toast.show({ message: "Copied to clipboard", variant: "info" }), toast.error) renderer.clearSelection() } }} @@ -693,9 +706,7 @@ function ErrorComponent(props: { issueURL.searchParams.set("opencode-version", Installation.VERSION) const copyIssueURL = () => { - Clipboard.copy(issueURL.toString()).then(() => { - setCopied(true) - }) + debouncedCopy(issueURL.toString(), () => setCopied(true)) } return ( diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 4bcabaeaf54..9a88e782301 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -49,13 +49,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ const elapsed = Date.now() - last if (timer) continue - // If we just flushed recently (within 16ms), batch this with future events - // Otherwise, process immediately to avoid latency if (elapsed < 16) { timer = setTimeout(flush, 16) continue } flush() + await new Promise((r) => setTimeout(r, 0)) } // Flush any remaining events diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 9c91cf3055a..0f40f1fe84d 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -1,10 +1,57 @@ -import { $ } from "bun" +import { spawn, execSync } from "node:child_process" import { platform, release } from "os" import clipboardy from "clipboardy" import { lazy } from "../../../../util/lazy.js" import { tmpdir } from "os" import path from "path" +function runCommand(cmd: string, args: string[]): Promise { + return new Promise((resolve) => { + const proc = spawn(cmd, args, { stdio: ["ignore", "pipe", "ignore"] }) + const chunks: Buffer[] = [] + proc.stdout.on("data", (chunk) => chunks.push(chunk)) + proc.on("close", (code) => { + if (code === 0 && chunks.length > 0) { + resolve(Buffer.concat(chunks)) + } else { + resolve(undefined) + } + }) + proc.on("error", () => resolve(undefined)) + }) +} + +function runCommandText(cmd: string, args: string[]): Promise { + return runCommand(cmd, args).then((buf) => buf?.toString("utf-8")) +} + +function writeToCommand(cmd: string, args: string[], data: string): Promise { + return new Promise((resolve) => { + const proc = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] }) + proc.stdin.write(data) + proc.stdin.end() + proc.on("close", () => resolve()) + proc.on("error", () => resolve()) + }) +} + +function execQuiet(cmd: string): Promise { + return new Promise((resolve) => { + const proc = spawn("sh", ["-c", cmd], { stdio: "ignore" }) + proc.on("close", () => resolve()) + proc.on("error", () => resolve()) + }) +} + +function which(cmd: string): boolean { + try { + execSync(`which ${cmd}`, { stdio: "ignore" }) + return true + } catch { + return false + } +} + export namespace Clipboard { export interface Content { data: string @@ -17,24 +64,28 @@ export namespace Clipboard { if (os === "darwin") { const tmpfile = path.join(tmpdir(), "opencode-clipboard.png") try { - 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'` - .nothrow() - .quiet() + await execQuiet( + `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'`, + ) const file = Bun.file(tmpfile) - const buffer = await file.arrayBuffer() - return { data: Buffer.from(buffer).toString("base64"), mime: "image/png" } + if (await file.exists()) { + const buffer = await file.arrayBuffer() + if (buffer.byteLength > 0) { + return { data: Buffer.from(buffer).toString("base64"), mime: "image/png" } + } + } } catch { } finally { - await $`rm -f "${tmpfile}"`.nothrow().quiet() + await execQuiet(`rm -f "${tmpfile}"`) } } if (os === "win32" || release().includes("WSL")) { const script = "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()) }" - const base64 = await $`powershell.exe -NonInteractive -NoProfile -command "${script}"`.nothrow().text() - if (base64) { - const imageBuffer = Buffer.from(base64.trim(), "base64") + const result = await runCommandText("powershell.exe", ["-NonInteractive", "-NoProfile", "-command", script]) + if (result) { + const imageBuffer = Buffer.from(result.trim(), "base64") if (imageBuffer.length > 0) { return { data: imageBuffer.toString("base64"), mime: "image/png" } } @@ -42,13 +93,13 @@ export namespace Clipboard { } if (os === "linux") { - const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer() + const wayland = await runCommand("wl-paste", ["-t", "image/png"]) if (wayland && wayland.byteLength > 0) { - return { data: Buffer.from(wayland).toString("base64"), mime: "image/png" } + return { data: wayland.toString("base64"), mime: "image/png" } } - const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().arrayBuffer() + const x11 = await runCommand("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]) if (x11 && x11.byteLength > 0) { - return { data: Buffer.from(x11).toString("base64"), mime: "image/png" } + return { data: x11.toString("base64"), mime: "image/png" } } } @@ -61,48 +112,31 @@ export namespace Clipboard { const getCopyMethod = lazy(() => { const os = platform() - if (os === "darwin" && Bun.which("osascript")) { + if (os === "darwin" && 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() + await execQuiet(`osascript -e 'set the clipboard to "${escaped}"'`) } } if (os === "linux") { - if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) { + if (process.env["WAYLAND_DISPLAY"] && 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(() => {}) + await writeToCommand("wl-copy", [], text) } } - if (Bun.which("xclip")) { + if (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(() => {}) + await writeToCommand("xclip", ["-selection", "clipboard"], text) } } - if (Bun.which("xsel")) { + if (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(() => {}) + await writeToCommand("xsel", ["--clipboard", "--input"], text) } } } @@ -110,9 +144,8 @@ export namespace Clipboard { if (os === "win32") { console.log("clipboard: using powershell") return async (text: string) => { - // need to escape backticks because powershell uses them as escape code const escaped = text.replace(/"/g, '""').replace(/`/g, "``") - await $`powershell -NonInteractive -NoProfile -Command "Set-Clipboard -Value \"${escaped}\""`.nothrow().quiet() + await execQuiet(`powershell -NonInteractive -NoProfile -Command "Set-Clipboard -Value \\"${escaped}\\""`) } }