tui: use OpenTUI clipboard service

This commit is contained in:
Simon Klee 2026-07-05 20:56:06 +02:00
parent c8a454197f
commit 4514ed67b7
No known key found for this signature in database
GPG key ID: B91696044D47BEA3
18 changed files with 1102 additions and 277 deletions

View file

@ -954,7 +954,6 @@
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
@ -3332,8 +3331,6 @@
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
"clipboardy": ["clipboardy@4.0.0", "", { "dependencies": { "execa": "^8.0.1", "is-wsl": "^3.1.0", "is64bit": "^2.0.0" } }, "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w=="],
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
@ -3694,8 +3691,6 @@
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
"execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="],
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
@ -3980,8 +3975,6 @@
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
@ -4120,8 +4113,6 @@
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="],
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
@ -4368,8 +4359,6 @@
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
@ -4454,8 +4443,6 @@
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
@ -4576,8 +4563,6 @@
"npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="],
"npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"nypm": ["nypm@0.6.6", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.1.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q=="],
@ -4606,8 +4591,6 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
@ -5220,8 +5203,6 @@
"strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
"strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"stripe": ["stripe@18.0.0", "", { "dependencies": { "@types/node": ">=8.1.0", "qs": "^6.11.0" } }, "sha512-3Fs33IzKUby//9kCkCa1uRpinAoTvj6rJgQ2jrBEysoxEvfsclvXdna1amyEYbA2EKkjynuB4+L/kleCCaWTpA=="],
@ -5248,8 +5229,6 @@
"sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="],
"system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
@ -6294,10 +6273,6 @@
"estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="],
"execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@ -6380,8 +6355,6 @@
"node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"nypm/citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
"nypm/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],

View file

@ -55,7 +55,6 @@
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",

View file

@ -86,6 +86,7 @@ import * as TuiAudio from "./audio"
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
import { createTuiClipboard, formatClipboardWriteNotification } from "./clipboard"
registerOpencodeSpinner()
@ -211,6 +212,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
destroyRenderer(renderer)
}),
)
const clipboard = yield* Effect.acquireRelease(
Effect.sync(() => createTuiClipboard(renderer)),
(clipboard) =>
Effect.tryPromise(() => clipboard.dispose()).pipe(
Effect.catchCause((cause) => Effect.logError("Failed to dispose TUI clipboard", { cause })),
),
)
win32DisableProcessedInput()
const keymap = createDefaultOpenTuiKeymap(renderer)
yield* Effect.acquireRelease(
@ -252,33 +260,37 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
}}
>
<EpilogueProvider set={(value) => (exit.epilogue = value)}>
<ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}>
<TuiPathsProvider
value={{
cwd: process.cwd(),
home: global.home,
state: global.state,
worktree: global.data + "/worktree",
}}
>
<TuiTerminalEnvironmentProvider
<ClipboardProvider value={clipboard}>
<ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}>
<TuiPathsProvider
value={{
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
: undefined,
cwd: process.cwd(),
home: global.home,
state: global.state,
worktree: global.data + "/worktree",
}}
>
<TuiStartupProvider
<TuiTerminalEnvironmentProvider
value={{
initialRoute: process.env.OPENCODE_ROUTE ? JSON.parse(process.env.OPENCODE_ROUTE) : undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
: undefined,
}}
>
<ClipboardProvider>
<TuiStartupProvider
value={{
initialRoute: process.env.OPENCODE_SCRAP
? { type: "plugin", id: "scrap" }
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
: undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<OpencodeKeymapProvider keymap={keymap}>
<ArgsProvider {...input.args}>
<KVProvider>
@ -340,11 +352,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
</KVProvider>
</ArgsProvider>
</OpencodeKeymapProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiPathsProvider>
</ErrorBoundary>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiPathsProvider>
</ErrorBoundary>
</ClipboardProvider>
</EpilogueProvider>
</ExitProvider>
)
@ -438,8 +450,12 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
if (!text || text.length === 0) return
await clipboard
.write?.(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.write(text)
.then((outcome) =>
toast.show(
formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" }),
),
)
.catch(toast.error)
renderer.clearSelection()
@ -601,8 +617,12 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
const workspace = currentWorktreeWorkspace()
if (!workspace?.directory) return
await clipboard
.write?.(workspace.directory)
.then(() => toast.show({ message: "Copied worktree path", variant: "info" }))
.write(workspace.directory)
.then((outcome) =>
toast.show(
formatClipboardWriteNotification(outcome, { message: "Copied worktree path", variant: "info" }),
),
)
.catch(toast.error)
dialog.clear()
},

View file

@ -1,124 +1,136 @@
import { execFile, spawn } from "node:child_process"
import { readFile, rm } from "node:fs/promises"
import { platform, release, tmpdir } from "node:os"
import path from "node:path"
import { promisify } from "node:util"
import {
createClipboard,
createHostClipboard,
createRendererClipboardAdapter,
decodePasteBytes,
type ClipboardService as CoreClipboardService,
type ClipboardWriteResult,
type RendererClipboardBoundary,
} from "@opentui/core"
import type { ClipboardService, ClipboardWriteOutcome } from "./context/clipboard"
const exec = promisify(execFile)
const timeoutMs = 1_000
const maxReadBytes = 8 * 1024 * 1024
function command(command: string, args: string[] = [], input?: string) {
return new Promise<Buffer>((resolve, reject) => {
const child = spawn(command, args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"] })
const output: Buffer[] = []
child.on("error", reject)
child.stdout?.on("data", (chunk: Buffer) => output.push(chunk))
child.on("close", (code) => {
if (code === 0) return resolve(Buffer.concat(output))
reject(new Error(`${command} exited with code ${code}`))
})
if (input !== undefined) child.stdin?.end(input)
})
}
export type ClipboardNotification = Readonly<{
message: string
variant: "info" | "success" | "warning"
}>
function writeOsc52(text: string) {
if (!process.stdout.isTTY) return
const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`
process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${sequence}\x1b\\` : sequence)
}
export type ClipboardCopyState =
| "idle"
| "confirmed"
| "confirmed-partial"
| "attempted"
| "attempted-partial"
| "failed"
export async function read() {
if (platform() === "darwin") {
const file = path.join(tmpdir(), "opencode-clipboard.png")
try {
await exec("osascript", [
"-e",
'set imageData to the clipboard as "PNGf"',
"-e",
`set fileRef to open for access POSIX file "${file}" with write permission`,
"-e",
"set eof fileRef to 0",
"-e",
"write imageData to fileRef",
"-e",
"close access fileRef",
])
return { data: (await readFile(file)).toString("base64"), mime: "image/png" }
} catch {
// Fall through to text clipboard.
} finally {
await rm(file, { force: true }).catch(() => {})
}
}
export type OwnedClipboardService = ClipboardService & Readonly<{ dispose(): Promise<void> }>
if (platform() === "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 image = await command("powershell.exe", ["-NonInteractive", "-NoProfile", "-command", script]).catch(() =>
Buffer.alloc(0),
export class ClipboardWriteError extends Error {
readonly result: ClipboardWriteResult
constructor(result: ClipboardWriteResult) {
super(
`Clipboard write failed (host: ${result.host.status}, terminal: ${result.terminal.status})`,
result.host.status === "failed" ? { cause: result.host.error } : undefined,
)
if (image.length) return { data: image.toString().trim(), mime: "image/png" }
}
if (platform() === "linux") {
const wayland = await command("wl-paste", ["-t", "image/png"]).catch(() => Buffer.alloc(0))
if (wayland.length) return { data: wayland.toString("base64"), mime: "image/png" }
const x11 = await command("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]).catch(() =>
Buffer.alloc(0),
)
if (x11.length) return { data: x11.toString("base64"), mime: "image/png" }
}
const { default: clipboardy } = await import("clipboardy")
const text = await clipboardy.read().catch(() => undefined)
if (text) return { data: text, mime: "text/plain" }
}
export function copyCommand(
os: NodeJS.Platform,
wayland: boolean,
has: (name: string) => boolean,
): string[] | undefined {
if (os === "darwin" && has("osascript")) return ["osascript"]
if (os === "linux" && wayland && has("wl-copy")) return ["wl-copy"]
if (os === "linux" && has("xclip")) return ["xclip", "-selection", "clipboard"]
if (os === "linux" && has("xsel")) return ["xsel", "--clipboard", "--input"]
if (os === "win32" && has("powershell.exe")) {
return [
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
]
this.name = "ClipboardWriteError"
this.result = result
}
}
let copyMethod: Promise<(text: string) => Promise<void>> | undefined
export function createTuiClipboard(renderer: RendererClipboardBoundary): OwnedClipboardService {
return createClipboardAdapter(
createClipboard({
host: createHostClipboard({
timeoutMs,
maxReadBytes,
maxWriteBytes: 8 * 1024 * 1024,
maxImagePixels: 64 * 1024 * 1024,
maxConversionBytes: 512 * 1024 * 1024,
maxConcurrentOperations: 16,
maxProviderTransfers: 16,
maxWorkUnitsPerDrain: 64,
}),
terminal: createRendererClipboardAdapter(renderer),
}),
)
}
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)
export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedClipboardService {
return {
async read() {
const result = await clipboard.read({
preferredTypes: ["image/png", "text/plain"],
selection: "clipboard",
})
if (result.status !== "read") {
if (result.status === "empty" || result.status === "unsupported" || result.status === "cancelled") return
if (result.status === "failed") throw result.error
if (result.status === "timed-out") throw new Error(`Clipboard read timed out after ${timeoutMs}ms`)
if (result.status === "limit-exceeded") {
throw new RangeError(`Clipboard read exceeded the ${maxReadBytes}-byte limit`)
}
throw new Error(`Unexpected clipboard read status: ${result.status}`)
}
}
if (native) {
return async (text: string) => {
await command(native[0], native.slice(1), text).catch(() => undefined)
if (result.representation.mimeType === "image/png") {
return {
data: Buffer.from(result.representation.bytes).toString("base64"),
mime: result.representation.mimeType,
}
}
}
return async (text: string) => {
const { default: clipboardy } = await import("clipboardy")
await clipboardy.write(text).catch(() => undefined)
}
})())
if (result.representation.mimeType === "text/plain") {
if (result.representation.bytes.length === 0) return
return {
data: decodePasteBytes(result.representation.bytes),
mime: result.representation.mimeType,
}
}
throw new Error(`Unexpected clipboard MIME type: ${result.representation.mimeType}`)
},
async write(text) {
return classifyClipboardWriteResult(
await clipboard.writeText(text, {
destination: "all-available",
selection: "clipboard",
}),
)
},
dispose() {
return clipboard.dispose()
},
}
}
export async function write(text: string) {
writeOsc52(text)
const method = await getCopyMethod()
await method(text)
export function classifyClipboardWriteResult(result: ClipboardWriteResult): ClipboardWriteOutcome {
const partial =
result.host.status === "failed" ||
result.host.status === "timed-out" ||
result.host.status === "cancelled" ||
result.terminal.status === "local-failure"
if (result.host.status === "written") return { delivery: "confirmed", partial, result }
if (result.terminal.status === "attempted") return { delivery: "attempted", partial, result }
throw new ClipboardWriteError(result)
}
export function formatClipboardWriteNotification(
outcome: ClipboardWriteOutcome,
confirmed: ClipboardNotification,
): ClipboardNotification {
if (outcome.delivery === "confirmed" && !outcome.partial) return confirmed
if (outcome.delivery === "attempted" && !outcome.partial) {
return { message: "Sent to terminal clipboard (acceptance unconfirmed)", variant: "info" }
}
if (outcome.delivery === "confirmed") {
return { message: "Copied to host clipboard; terminal clipboard dispatch failed", variant: "warning" }
}
return { message: "Sent to terminal clipboard; host clipboard write failed", variant: "warning" }
}
export function clipboardCopyState(outcome: ClipboardWriteOutcome): ClipboardCopyState {
if (!outcome.partial) return outcome.delivery
return `${outcome.delivery}-partial`
}

View file

@ -15,6 +15,7 @@ import { isConsoleManagedProvider } from "../util/provider-origin"
import { useConnected } from "./use-connected"
import { useBindings } from "../keymap"
import { useClipboard } from "../context/clipboard"
import { formatClipboardWriteNotification } from "../clipboard"
const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0,
@ -254,8 +255,12 @@ function AutoMethod(props: AutoMethodProps) {
const code =
props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
clipboard
.write?.(code)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.write(code)
.then((outcome) =>
toast.show(
formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" }),
),
)
.catch(toast.error)
},
},

View file

@ -6,12 +6,13 @@ import { useClipboard } from "../context/clipboard"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { useExit } from "../context/exit"
import { describeOS, describeTerminal } from "../util/system"
import { clipboardCopyState, type ClipboardCopyState } from "../clipboard"
export function ErrorComponent(props: { error: Error; reset: () => void; mode?: "dark" | "light" }) {
const term = useTerminalDimensions()
const exit = useExit()
const clipboard = useClipboard()
const [copied, setCopied] = createSignal(false)
const [copyState, setCopyState] = createSignal<ClipboardCopyState>("idle")
// Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the
// theme context may be the thing that crashed.
@ -27,6 +28,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
onPrimary: "#ffffff",
error: "#d1383d",
success: "#3d9a57",
warning: "#c18401",
}
: {
bg: "#0a0a0a",
@ -38,6 +40,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
onPrimary: "#0a0a0a",
error: "#e06c75",
success: "#7fd88f",
warning: "#e5c07b",
}
const message = props.error.message || "An unknown error occurred."
@ -45,11 +48,27 @@ 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((outcome) => setCopyState(clipboardCopyState(outcome)))
.catch(() => setCopyState("failed"))
}
const actions = [
{ key: "c", label: () => (copied() ? "✓ Copied" : "Copy report"), copy: true, onUse: copyReport },
{
key: "c",
label: () =>
({
idle: "Copy report",
confirmed: "Copied",
"confirmed-partial": "Copied (terminal failed)",
attempted: "Sent",
"attempted-partial": "Sent (host failed)",
failed: "Copy failed",
})[copyState()],
copy: true,
onUse: copyReport,
},
{ key: "r", label: () => "Restart", onUse: props.reset },
{ key: "q", label: () => "Quit", onUse: () => exit() },
]
@ -134,13 +153,18 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
<For each={actions}>
{(action, index) => {
const isSelected = () => selected() === index()
const isCopied = () => action.copy && copied()
const copyColor = () => {
if (!action.copy) return
if (copyState() === "confirmed") return colors.success
if (copyState() === "confirmed-partial" || copyState() === "attempted-partial") return colors.warning
if (copyState() === "failed") return colors.error
}
return (
<box flexDirection="column" alignItems="center" flexShrink={0}>
<box
onMouseDown={() => setSelected(index())}
onMouseUp={() => action.onUse()}
backgroundColor={isCopied() ? colors.success : isSelected() ? colors.primary : colors.element}
backgroundColor={copyColor() ?? (isSelected() ? colors.primary : colors.element)}
minWidth={15}
alignItems="center"
paddingLeft={2}
@ -148,7 +172,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
>
<text
attributes={TextAttributes.BOLD}
fg={isCopied() || isSelected() ? colors.onPrimary : colors.text}
fg={copyColor() || isSelected() ? colors.onPrimary : colors.text}
>
{action.label()}
</text>
@ -188,9 +212,14 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
<Show when={showFooter()}>
<box flexDirection="column" alignItems="center" flexShrink={0}>
<text fg={colors.muted}>
{copied()
? "Report copied — paste it into a new GitHub issue."
: "Copy the report and open a GitHub issue to help us fix this."}
{{
idle: "Copy the report and open a GitHub issue to help us fix this.",
confirmed: "Report copied. Paste it into a new GitHub issue.",
"confirmed-partial": "Report copied to host clipboard; terminal dispatch failed.",
attempted: "Report sent to terminal clipboard; acceptance is unconfirmed.",
"attempted-partial": "Report sent to terminal clipboard; host clipboard write failed.",
failed: "Clipboard write failed. Try again or report the crash manually.",
}[copyState()]}
</text>
<text fg={colors.muted}>opencode {InstallationVersion}</text>
</box>

View file

@ -372,17 +372,21 @@ export function Prompt(props: PromptProps) {
run: async (ctx: CommandContext<Renderable, KeyEvent>) => {
ctx.event.preventDefault()
ctx.event.stopPropagation()
const content = await clipboard.read?.()
if (content?.mime.startsWith("image/")) {
await pasteAttachment({
filename: "clipboard",
mime: content.mime,
content: content.data,
})
return
}
if (content?.mime === "text/plain") {
await pasteInputText(content.data)
try {
const content = await clipboard.read()
if (content?.mime.startsWith("image/")) {
await pasteAttachment({
filename: "clipboard",
mime: content.mime,
content: content.data,
})
return
}
if (content?.mime === "text/plain") {
await pasteInputText(content.data)
}
} catch (error) {
toast.error(error)
}
},
},

View file

@ -1,18 +1,25 @@
import type { ClipboardWriteResult } from "@opentui/core"
import { createContext, type JSX, useContext } from "solid-js"
import { read, write } from "../clipboard"
export type ClipboardContent = Readonly<{ data: string; mime: string }>
export type ClipboardService = Readonly<{
read?(): Promise<ClipboardContent | undefined>
write?(text: string): Promise<void>
export type ClipboardWriteOutcome = Readonly<{
delivery: "confirmed" | "attempted"
partial: boolean
result: ClipboardWriteResult
}>
export type ClipboardService = Readonly<{
read(): Promise<ClipboardContent | undefined>
write(text: string): Promise<ClipboardWriteOutcome>
}>
const clipboard = { read, write }
const ClipboardContext = createContext<ClipboardService>(clipboard)
export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
return <ClipboardContext.Provider value={props.value ?? clipboard}>{props.children}</ClipboardContext.Provider>
const ClipboardContext = createContext<ClipboardService>()
export function ClipboardProvider(props: { value: ClipboardService; children: JSX.Element }) {
return <ClipboardContext.Provider value={props.value}>{props.children}</ClipboardContext.Provider>
}
export function useClipboard() {
return useContext(ClipboardContext)
const value = useContext(ClipboardContext)
if (!value) throw new Error("useClipboard must be used within a ClipboardProvider")
return value
}

View file

@ -6,6 +6,8 @@ import { useRoute } from "../../context/route"
import { useClipboard } from "../../context/clipboard"
import type { PromptInfo } from "../../component/prompt/history"
import { stripPromptPartIDs as strip } from "../../prompt/part"
import { formatClipboardWriteNotification } from "../../clipboard"
import { useToast } from "../../ui/toast"
export function DialogMessage(props: {
messageID: string
@ -17,6 +19,7 @@ export function DialogMessage(props: {
const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID))
const route = useRoute()
const clipboard = useClipboard()
const toast = useToast()
return (
<DialogSelect
@ -69,8 +72,19 @@ export function DialogMessage(props: {
return agg
}, "")
await clipboard.write?.(text)
dialog.clear()
if (!text) {
toast.show({ message: "No text content found in message", variant: "error" })
return
}
try {
const outcome = await clipboard.write(text)
toast.show(
formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" }),
)
dialog.clear()
} catch (error) {
toast.error(error)
}
},
},
{

View file

@ -73,6 +73,7 @@ import { sessionEpilogue } from "../../util/presentation"
import { setPreLayoutSiblingMargin } from "../../util/layout"
import { useTuiConfig } from "../../config"
import { useClipboard } from "../../context/clipboard"
import { formatClipboardWriteNotification } from "../../clipboard"
import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
import { collapseToolOutput } from "../../util/collapse-tool-output"
@ -904,8 +905,15 @@ export function Session() {
}
clipboard
.write?.(text)
.then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
.write(text)
.then((outcome) =>
toast.show(
formatClipboardWriteNotification(outcome, {
message: "Message copied to clipboard!",
variant: "success",
}),
),
)
.catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
dialog.clear()
},
@ -932,8 +940,13 @@ export function Session() {
providers: sync.data.provider,
},
)
await clipboard.write?.(transcript)
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
const outcome = await clipboard.write(transcript)
toast.show(
formatClipboardWriteNotification(outcome, {
message: "Session transcript copied to clipboard!",
variant: "success",
}),
)
} catch {
toast.show({ message: "Failed to copy session transcript", variant: "error" })
}

View file

@ -7,6 +7,7 @@ import { useToast } from "./toast"
import { Flag } from "@opencode-ai/core/flag/flag"
import { useBindings, useOpencodeModeStack } from "../keymap"
import { useClipboard } from "../context/clipboard"
import { formatClipboardWriteNotification } from "../clipboard"
export function Dialog(
props: ParentProps<{
@ -187,9 +188,10 @@ export function DialogProvider(props: ParentProps) {
function copySelection() {
const text = renderer.getSelection()?.getSelectedText()
if (!text || !clipboard.write) return false
if (!text) return false
void clipboard.write(text).then(
() => toast.show({ message: "Copied to clipboard", variant: "info" }),
(outcome) =>
toast.show(formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" })),
(error) => toast.error(error),
)
renderer.clearSelection()

View file

@ -1,4 +1,5 @@
import type { ClipboardService } from "../context/clipboard"
import { formatClipboardWriteNotification } from "../clipboard"
type Toast = {
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
@ -35,8 +36,10 @@ export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardServi
focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text
clipboard
?.write?.(clipboardText)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.write(clipboardText)
.then((outcome) =>
toast.show(formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" })),
)
.catch(toast.error)
renderer.clearSelection()

View file

@ -1,70 +1,191 @@
import { expect, mock, test } from "bun:test"
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect } from "effect"
import type {
ClipboardOptions,
ClipboardService,
HostClipboardOptions,
HostClipboardService,
RendererClipboardBoundary,
} from "@opentui/core"
import { Effect, Logger } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global"
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
import { createEventSource, createFetch, directory, json } from "./fixture/tui-sdk"
import type { TuiInput } from "../src/app"
const openTui = { ...(await import("@opentui/core")) }
function restoreOpenTui() {
mock.restore()
mock.module("@opentui/core", () => openTui)
}
async function mockOpenTuiClipboard(
renderer: RendererClipboardBoundary,
options: {
dispose?: () => Promise<void>
constructionError?: Error
} = {},
) {
const calls = {
host: [] as (HostClipboardOptions | undefined)[],
adapter: [] as RendererClipboardBoundary[],
service: [] as ClipboardOptions[],
dispose: 0,
hostDispose: 0,
hostWrite: 0,
}
const host: HostClipboardService = {
maxWriteBytes: 8 * 1024 * 1024,
async read() {
return { status: "empty" }
},
async writeText() {
calls.hostWrite++
return { status: "written" }
},
async clear() {
return { status: "cleared" }
},
async dispose() {
calls.hostDispose++
await options.dispose?.()
},
}
mock.module("@opentui/core", () => ({
...openTui,
createCliRenderer: async () => renderer,
createHostClipboard: (input?: HostClipboardOptions) => {
if (options.constructionError) throw options.constructionError
calls.host.push(input)
return host
},
createRendererClipboardAdapter: (input: RendererClipboardBoundary) => {
calls.adapter.push(input)
return openTui.createRendererClipboardAdapter(input)
},
createClipboard: (input: ClipboardOptions) => {
calls.service.push(input)
const service = openTui.createClipboard(input)
return {
read: service.read,
writeText: service.writeText,
clear: service.clear,
async dispose() {
calls.dispose++
await service.dispose()
},
} satisfies ClipboardService
},
}))
return calls
}
async function launch(
calls: ReturnType<typeof createFetch>,
options: {
args?: TuiInput["args"]
onStart?: (api: TuiPluginApi) => void
onDispose?: () => void | Promise<void>
logs?: unknown[]
} = {},
) {
const { run } = await import("../src/app")
let started!: () => void
const ready = new Promise<void>((resolve) => {
started = resolve
})
const effect = run({
url: "http://test",
directory,
config: createTuiResolvedConfig({ plugin_enabled: {} }),
fetch: calls.fetch,
events: createEventSource().source,
args: options.args ?? {},
pluginHost: {
async start(input) {
options.onStart?.(input.api)
started()
},
async dispose() {
await options.onDispose?.()
},
},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
const task = Effect.runPromise(
options.logs
? effect.pipe(
Effect.provide(Logger.layer([Logger.make(({ message }) => void options.logs?.push(message))])),
)
: effect,
)
await ready
return { task }
}
test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const clipboard = await mockOpenTuiClipboard(setup.renderer)
const titles: string[] = []
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
setup.renderer.setTerminalTitle = (title) => {
titles.push(title)
setTitle(title)
}
const listeners = new Set(process.listeners("SIGHUP"))
const events = createEventSource()
const listeners = process.listeners("SIGHUP")
const calls = createFetch()
let started!: () => void
const ready = new Promise<void>((resolve) => {
started = resolve
})
let disposes = 0
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
url: "http://test",
directory,
config: createTuiResolvedConfig({ plugin_enabled: {} }),
fetch: calls.fetch,
events: events.source,
args: {},
pluginHost: {
async start() {
started()
},
async dispose() {
disposes++
},
},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node))),
)
await ready
const { task } = await launch(calls, { onDispose: () => void disposes++ })
process.emit("SIGHUP")
await task
expect(setup.renderer.isDestroyed).toBe(true)
expect(titles.at(-1)).toBe("")
expect(disposes).toBe(1)
expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true)
expect(clipboard.host).toEqual([
{
timeoutMs: 1_000,
maxReadBytes: 8 * 1024 * 1024,
maxWriteBytes: 8 * 1024 * 1024,
maxImagePixels: 64 * 1024 * 1024,
maxConversionBytes: 512 * 1024 * 1024,
maxConcurrentOperations: 16,
maxProviderTransfers: 16,
maxWorkUnitsPerDrain: 64,
},
])
expect(clipboard.adapter).toEqual([setup.renderer])
expect(clipboard.service).toHaveLength(1)
expect(clipboard.dispose).toBe(1)
expect(clipboard.hostDispose).toBe(1)
expect(process.listeners("SIGHUP").every((listener) => listeners.includes(listener))).toBe(true)
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
mock.restore()
restoreOpenTui()
}
})
test("app.exit prints the session epilogue after scoped cleanup", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const events = createEventSource()
let releaseDispose!: () => void
let startDispose!: () => void
const disposeStarted = new Promise<void>((resolve) => {
startDispose = resolve
})
const disposeReady = new Promise<void>((resolve) => {
releaseDispose = resolve
})
const clipboard = await mockOpenTuiClipboard(setup.renderer, {
dispose: async () => {
startDispose()
await disposeReady
},
})
const calls = createFetch((url) => {
if (url.pathname === "/session")
return json([
@ -82,10 +203,6 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
const originalWrite = process.stdout.write.bind(process.stdout)
let stdout = ""
let api: TuiPluginApi | undefined
let started!: () => void
const ready = new Promise<void>((resolve) => {
started = resolve
})
process.stdout.write = ((chunk: string | Uint8Array) => {
stdout += String(chunk)
@ -93,36 +210,113 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
}) as typeof process.stdout.write
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
url: "http://test",
directory,
config: createTuiResolvedConfig({ plugin_enabled: {} }),
fetch: calls.fetch,
events: events.source,
args: { continue: true },
pluginHost: {
async start(input) {
api = input.api
started()
},
async dispose() {},
},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node))),
const { task } = await launch(calls, { args: { continue: true }, onStart: (value) => (api = value) })
let settled = false
void task.then(
() => (settled = true),
() => (settled = true),
)
await ready
await setup.renderOnce()
await setup.renderOnce()
api?.keymap.dispatchCommand("app.exit")
await disposeStarted
expect(settled).toBe(false)
expect(stdout).not.toContain("Demo session")
releaseDispose()
await task
expect(stdout).toContain("Demo session")
expect(stdout).toContain("opencode -s dummy")
expect(clipboard.dispose).toBe(1)
expect(clipboard.hostDispose).toBe(1)
} finally {
process.stdout.write = originalWrite
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
mock.restore()
restoreOpenTui()
}
})
test("direct renderer destruction disposes the clipboard once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const clipboard = await mockOpenTuiClipboard(setup.renderer)
const calls = createFetch()
try {
const { task } = await launch(calls)
const staleCopy = setup.renderer.console.onCopySelection
setup.renderer.destroy()
await task
expect(clipboard.dispose).toBe(1)
expect(clipboard.hostDispose).toBe(1)
await staleCopy?.("stale")
expect(clipboard.hostWrite).toBe(0)
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
restoreOpenTui()
}
})
test("clipboard construction failure releases the renderer", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const failure = new Error("clipboard construction failed")
await mockOpenTuiClipboard(setup.renderer, { constructionError: failure })
const calls = createFetch()
try {
const { run } = await import("../src/app")
await expect(
Effect.runPromise(
run({
url: "http://test",
directory,
config: createTuiResolvedConfig({ plugin_enabled: {} }),
fetch: calls.fetch,
events: createEventSource().source,
args: {},
pluginHost: { async start() {}, async dispose() {} },
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node))),
),
).rejects.toThrow("clipboard construction failed")
expect(setup.renderer.isDestroyed).toBe(true)
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
restoreOpenTui()
}
})
test("clipboard disposal failure is logged without failing remaining cleanup", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const clipboard = await mockOpenTuiClipboard(setup.renderer, {
dispose: async () => {
throw new Error("clipboard disposal failed")
},
})
const calls = createFetch()
const titles: string[] = []
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
setup.renderer.setTerminalTitle = (title) => {
titles.push(title)
setTitle(title)
}
let pluginDisposes = 0
const logs: unknown[] = []
try {
const { task } = await launch(calls, { logs, onDispose: () => void pluginDisposes++ })
setup.renderer.destroy()
await task
expect(clipboard.dispose).toBe(1)
expect(clipboard.hostDispose).toBe(1)
expect(pluginDisposes).toBe(1)
expect(titles.at(-1)).toBe("")
expect(setup.renderer.isDestroyed).toBe(true)
expect(
logs.some((message) =>
(Array.isArray(message) ? message : [message]).some((value) => value === "Failed to dispose TUI clipboard"),
),
).toBe(true)
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
restoreOpenTui()
}
})

View file

@ -1,19 +1,269 @@
import { expect, test } from "bun:test"
import { copyCommand } from "../src/clipboard"
import {
createClipboard,
type ClipboardReadOptions,
type ClipboardReadResult,
type ClipboardService as CoreClipboardService,
type ClipboardWriteOptions,
type ClipboardWriteResult,
type HostClipboardService,
} from "@opentui/core"
import {
ClipboardWriteError,
classifyClipboardWriteResult,
createClipboardAdapter,
formatClipboardWriteNotification,
} from "../src/clipboard"
test("prefers Wayland clipboard when available", () => {
expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual(["wl-copy"])
type OpenTuiFixture = {
read?: ClipboardReadResult
remote?: boolean
onCoreRead?: (options: ClipboardReadOptions) => void
onCoreWrite?: (text: string, options: ClipboardWriteOptions) => void
onHostWrite?: () => void
onTerminalWrite?: () => void
}
function openTuiClipboard(options: OpenTuiFixture = {}) {
const host: HostClipboardService = {
maxWriteBytes: 8 * 1024 * 1024,
async read() {
return options.read ?? { status: "empty" }
},
async writeText() {
options.onHostWrite?.()
return { status: "written" }
},
async clear() {
return { status: "cleared" }
},
async dispose() {},
}
const clipboard = createClipboard({
host,
terminal: {
remote: options.remote ?? false,
writeText() {
options.onTerminalWrite?.()
return { status: "attempted", capability: "supported" }
},
clear() {
return { status: "attempted", capability: "supported" }
},
},
})
return {
read(input) {
options.onCoreRead?.(input)
return clipboard.read(input)
},
writeText(text, input) {
options.onCoreWrite?.(text, input)
return clipboard.writeText(text, input)
},
clear: clipboard.clear,
dispose: clipboard.dispose,
} satisfies CoreClipboardService
}
function writeResult(
host: ClipboardWriteResult["host"]["status"],
terminal: ClipboardWriteResult["terminal"]["status"],
error = new Error("host failed"),
): ClipboardWriteResult {
return {
host: host === "failed" ? { status: host, error } : { status: host },
terminal: { status: terminal, capability: "supported" },
}
}
function writeError(result: ClipboardWriteResult) {
try {
classifyClipboardWriteResult(result)
} catch (error) {
if (error instanceof ClipboardWriteError) return error
throw error
}
throw new Error("Expected clipboard classification to fail")
}
test("requests the standard clipboard with image-first preferences and adapts PNG bytes", async () => {
const requests: ClipboardReadOptions[] = []
const clipboard = createClipboardAdapter(
openTuiClipboard({
read: { status: "read", representation: { mimeType: "image/png", bytes: new Uint8Array([0, 1, 2, 255]) } },
onCoreRead: (input) => requests.push(input),
}),
)
expect(await clipboard.read()).toEqual({ data: "AAEC/w==", mime: "image/png" })
expect(requests).toEqual([{ preferredTypes: ["image/png", "text/plain"], selection: "clipboard" }])
})
test("uses osascript on macOS", () => {
expect(copyCommand("darwin", false, (name) => name === "osascript")).toEqual(["osascript"])
test("decodes text through OpenTUI without normalizing its contents", async () => {
const text = "line 1\r\n\t\u001b[31m世界"
const bytes = new TextEncoder().encode(text)
const clipboard = createClipboardAdapter(
openTuiClipboard({ read: { status: "read", representation: { mimeType: "text/plain", bytes } } }),
)
expect(await clipboard.read()).toEqual({ data: text, mime: "text/plain" })
})
test("falls back through X11 clipboard commands", () => {
expect(copyCommand("linux", true, (name) => name === "xclip")).toEqual(["xclip", "-selection", "clipboard"])
expect(copyCommand("linux", false, (name) => name === "xsel")).toEqual(["xsel", "--clipboard", "--input"])
test("maps empty host results and zero-byte text to no content", async () => {
await Promise.all(
(["empty", "unsupported", "cancelled"] as const).map(async (status) => {
expect(await createClipboardAdapter(openTuiClipboard({ read: { status } })).read()).toBeUndefined()
}),
)
expect(
await createClipboardAdapter(
openTuiClipboard({
read: { status: "read", representation: { mimeType: "text/plain", bytes: new Uint8Array() } },
}),
).read(),
).toBeUndefined()
})
test("returns undefined when native clipboard is unavailable", () => {
expect(copyCommand("linux", false, () => false)).toBeUndefined()
test("preserves backend read failures and synthesizes operational errors", async () => {
const failure = new Error("read failed")
const failed = createClipboardAdapter(openTuiClipboard({ read: { status: "failed", error: failure } }))
expect(await failed.read().then(undefined, (error) => error)).toBe(failure)
const timedOut = createClipboardAdapter(openTuiClipboard({ read: { status: "timed-out" } }))
await expect(timedOut.read()).rejects.toThrow("Clipboard read timed out after 1000ms")
const limited = createClipboardAdapter(openTuiClipboard({ read: { status: "limit-exceeded" } }))
const limitError = await limited.read().then(undefined, (error) => error)
expect(limitError).toBeInstanceOf(RangeError)
if (!(limitError instanceof RangeError)) throw limitError
expect(limitError.message).toBe("Clipboard read exceeded the 8388608-byte limit")
const unexpected = createClipboardAdapter(
openTuiClipboard({
read: { status: "read", representation: { mimeType: "text/html", bytes: new Uint8Array([1]) } },
}),
)
await expect(unexpected.read()).rejects.toThrow("Unexpected clipboard MIME type: text/html")
})
test("uses OpenTUI all-available composition for local writes", async () => {
let hostWrites = 0
let terminalWrites = 0
const writes: [string, ClipboardWriteOptions][] = []
const clipboard = createClipboardAdapter(
openTuiClipboard({
onHostWrite: () => hostWrites++,
onTerminalWrite: () => terminalWrites++,
onCoreWrite: (text, options) => writes.push([text, options]),
}),
)
expect(await clipboard.write("hello")).toMatchObject({ delivery: "confirmed", partial: false })
expect(hostWrites).toBe(1)
expect(terminalWrites).toBe(1)
expect(writes).toEqual([["hello", { destination: "all-available", selection: "clipboard" }]])
})
test("does not authorize a process-host write for remote renderers", async () => {
let hostWrites = 0
let terminalWrites = 0
const clipboard = createClipboardAdapter(
openTuiClipboard({
remote: true,
onHostWrite: () => hostWrites++,
onTerminalWrite: () => terminalWrites++,
}),
)
expect(await clipboard.write("hello")).toMatchObject({
delivery: "attempted",
partial: false,
result: { host: { status: "not-attempted" }, terminal: { status: "attempted" } },
})
expect(hostWrites).toBe(0)
expect(terminalWrites).toBe(1)
})
test("classifies confirmed, attempted, and partial delivery", () => {
const scenarios = [
{ result: writeResult("written", "attempted"), delivery: "confirmed", partial: false },
{ result: writeResult("written", "not-attempted"), delivery: "confirmed", partial: false },
{ result: writeResult("unsupported", "attempted"), delivery: "attempted", partial: false },
{ result: writeResult("failed", "attempted"), delivery: "attempted", partial: true },
{ result: writeResult("timed-out", "attempted"), delivery: "attempted", partial: true },
{ result: writeResult("cancelled", "attempted"), delivery: "attempted", partial: true },
{ result: writeResult("written", "local-failure"), delivery: "confirmed", partial: true },
] as const
scenarios.forEach((scenario) => {
expect(classifyClipboardWriteResult(scenario.result)).toMatchObject({
delivery: scenario.delivery,
partial: scenario.partial,
result: scenario.result,
})
})
})
test("rejects every unavailable total-failure combination", () => {
const hosts = ["failed", "unsupported", "cancelled", "timed-out", "not-attempted"] as const
const terminals = ["local-failure", "not-attempted"] as const
hosts.flatMap((host) => terminals.map((terminal) => [host, terminal] as const)).forEach(([host, terminal]) => {
const failure = new Error("native write failed")
const result = writeResult(host, terminal, failure)
const error = writeError(result)
expect(error.message).toBe(`Clipboard write failed (host: ${host}, terminal: ${terminal})`)
expect(error.result).toBe(result)
expect(error.cause).toBe(host === "failed" ? failure : undefined)
})
})
test("formats truthful notifications for every delivered outcome", () => {
const confirmed = { message: "Copied value", variant: "success" as const }
expect(
formatClipboardWriteNotification(
{ delivery: "confirmed", partial: false, result: writeResult("written", "attempted") },
confirmed,
),
).toBe(confirmed)
expect(
formatClipboardWriteNotification(
{ delivery: "attempted", partial: false, result: writeResult("unsupported", "attempted") },
confirmed,
),
).toEqual({ message: "Sent to terminal clipboard (acceptance unconfirmed)", variant: "info" })
expect(
formatClipboardWriteNotification(
{ delivery: "confirmed", partial: true, result: writeResult("written", "local-failure") },
confirmed,
),
).toEqual({ message: "Copied to host clipboard; terminal clipboard dispatch failed", variant: "warning" })
expect(
formatClipboardWriteNotification(
{ delivery: "attempted", partial: true, result: writeResult("failed", "attempted") },
confirmed,
),
).toEqual({ message: "Sent to terminal clipboard; host clipboard write failed", variant: "warning" })
})
test("retains OpenTUI text validation and the configured 8 MiB UTF-8 bound", async () => {
let hostWrites = 0
let terminalWrites = 0
const clipboard = createClipboardAdapter(
openTuiClipboard({
onHostWrite: () => hostWrites++,
onTerminalWrite: () => terminalWrites++,
}),
)
const boundary = "é".repeat(4 * 1024 * 1024)
await expect(clipboard.write(boundary)).resolves.toMatchObject({ delivery: "confirmed" })
await expect(clipboard.write("")).rejects.toThrow("writeText requires non-empty text")
await expect(clipboard.write("before\0after")).rejects.toThrow("writeText does not support NUL characters")
await expect(clipboard.write(boundary + "a")).rejects.toThrow(
"writeText exceeds the configured 8388608 byte limit",
)
expect(hostWrites).toBe(1)
expect(terminalWrites).toBe(1)
})

View file

@ -0,0 +1,126 @@
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { ErrorComponent } from "../../src/component/error-component"
import { ClipboardProvider, type ClipboardService, type ClipboardWriteOutcome } from "../../src/context/clipboard"
import { ExitProvider } from "../../src/context/exit"
const outcomes = [
{
label: "Copied",
absent: "Copied (terminal failed)",
outcome: {
delivery: "confirmed",
partial: false,
result: {
host: { status: "written" },
terminal: { status: "attempted", capability: "supported" },
},
},
},
{
label: "Copied (terminal failed)",
absent: undefined,
outcome: {
delivery: "confirmed",
partial: true,
result: {
host: { status: "written" },
terminal: { status: "local-failure", capability: "supported" },
},
},
},
{
label: "Sent",
absent: "Sent (host failed)",
outcome: {
delivery: "attempted",
partial: false,
result: {
host: { status: "unsupported" },
terminal: { status: "attempted", capability: "supported" },
},
},
},
{
label: "Sent (host failed)",
absent: undefined,
outcome: {
delivery: "attempted",
partial: true,
result: {
host: { status: "failed", error: new Error("host failed") },
terminal: { status: "attempted", capability: "supported" },
},
},
},
] as const satisfies readonly { label: string; absent: string | undefined; outcome: ClipboardWriteOutcome }[]
function clipboard(write: () => Promise<ClipboardWriteOutcome>): ClipboardService {
return {
async read() {
return undefined
},
write,
}
}
async function waitForFrame(
app: { renderOnce(): Promise<void>; captureCharFrame(): string },
match: (frame: string) => boolean,
) {
for (let attempts = 0; attempts < 20; attempts++) {
await app.renderOnce()
const frame = app.captureCharFrame()
if (match(frame)) return frame
await Bun.sleep(1)
}
throw new Error("Timed out waiting for clipboard state")
}
test("crash report distinguishes every delivered clipboard state", async () => {
for (const scenario of outcomes) {
const app = await testRender(
() => (
<ExitProvider exit={() => {}}>
<ClipboardProvider value={clipboard(async () => scenario.outcome)}>
<ErrorComponent error={new Error("boom")} reset={() => {}} />
</ClipboardProvider>
</ExitProvider>
),
{ width: 100, height: 24 },
)
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Copy report")
app.mockInput.pressKey("c")
const frame = await waitForFrame(app, (frame) => frame.includes(scenario.label))
if (scenario.absent) expect(frame).not.toContain(scenario.absent)
} finally {
app.renderer.destroy()
}
}
})
test("crash report catches clipboard rejection", async () => {
const app = await testRender(
() => (
<ExitProvider exit={() => {}}>
<ClipboardProvider
value={clipboard(async () => {
throw new Error("copy failed")
})}
>
<ErrorComponent error={new Error("boom")} reset={() => {}} />
</ClipboardProvider>
</ExitProvider>
),
{ width: 100, height: 24 },
)
try {
await app.renderOnce()
app.mockInput.pressKey("c")
expect(await waitForFrame(app, (frame) => frame.includes("Copy failed"))).toContain("Copy failed")
} finally {
app.renderer.destroy()
}
})

View file

@ -0,0 +1,50 @@
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { ErrorBoundary, type JSX } from "solid-js"
import { ClipboardProvider, type ClipboardService, useClipboard } from "../../src/context/clipboard"
const clipboard: ClipboardService = {
async read() {
return { data: "text", mime: "text/plain" }
},
async write() {
return {
delivery: "confirmed",
partial: false,
result: {
host: { status: "written" },
terminal: { status: "not-attempted", capability: "unknown" },
},
}
},
}
test("requires explicit provider injection", () => {
expect(() => useClipboard()).toThrow("useClipboard must be used within a ClipboardProvider")
})
test("keeps clipboard access available to an error boundary fallback", async () => {
let value: ClipboardService | undefined
function Crash(): JSX.Element {
throw new Error("crash")
}
function Fallback() {
value = useClipboard()
return <text>fallback</text>
}
const app = await testRender(() => (
<ClipboardProvider value={clipboard}>
<ErrorBoundary fallback={() => <Fallback />}>
<Crash />
</ErrorBoundary>
</ClipboardProvider>
))
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("fallback")
expect(value).toBe(clipboard)
} finally {
app.renderer.destroy()
}
})

View file

@ -6,12 +6,30 @@ import {
type TuiPaths,
} from "../../src/context/runtime"
import type { ParentProps } from "solid-js"
import { ClipboardProvider, type ClipboardService } from "../../src/context/clipboard"
const clipboard: ClipboardService = {
async read() {
return undefined
},
async write() {
return {
delivery: "confirmed",
partial: false,
result: {
host: { status: "written" },
terminal: { status: "not-attempted", capability: "unknown" },
},
}
},
}
export function TestTuiContexts(
props: ParentProps<{
cwd?: string
directory?: string
paths?: Partial<TuiPaths>
clipboard?: ClipboardService
}>,
) {
return (
@ -25,7 +43,9 @@ export function TestTuiContexts(
}}
>
<TuiTerminalEnvironmentProvider value={{ platform: "linux" }}>
<TuiStartupProvider value={{ skipInitialLoading: false }}>{props.children}</TuiStartupProvider>
<TuiStartupProvider value={{ skipInitialLoading: false }}>
<ClipboardProvider value={props.clipboard ?? clipboard}>{props.children}</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiPathsProvider>
)

View file

@ -0,0 +1,104 @@
import { expect, test } from "bun:test"
import type { ClipboardService } from "../../src/context/clipboard"
import { Selection } from "../../src/util/selection"
test("copies semantic selection text, reports attempted partial delivery, and clears immediately", async () => {
const notifications: { message: string; variant: "info" | "success" | "warning" | "error" }[] = []
const errors: unknown[] = []
const writes: string[] = []
const events: string[] = []
let notify!: () => void
const notified = new Promise<void>((resolve) => {
notify = resolve
})
const focus = {
hasSelection: () => true,
getClipboardText: (text: string) => text.replace("[Pasted ~2 lines]", "first\nsecond"),
}
const renderer = {
getSelection: () => ({
getSelectedText: () => "before [Pasted ~2 lines] after",
selectedRenderables: [focus],
}),
clearSelection: () => events.push("clear"),
currentFocusedRenderable: focus,
}
const clipboard: ClipboardService = {
async read() {
return undefined
},
async write(text) {
events.push("write")
writes.push(text)
return {
delivery: "attempted",
partial: true,
result: {
host: { status: "failed", error: new Error("host failed") },
terminal: { status: "attempted", capability: "supported" },
},
}
},
}
expect(
Selection.copy(
renderer,
{
show: (notification) => {
notifications.push(notification)
notify()
},
error: (error) => errors.push(error),
},
clipboard,
),
).toBe(true)
expect(writes).toEqual(["before first\nsecond after"])
expect(events).toEqual(["write", "clear"])
await notified
expect(notifications).toEqual([
{ message: "Sent to terminal clipboard; host clipboard write failed", variant: "warning" },
])
expect(errors).toEqual([])
})
test("reports total selection-copy failure without delaying selection clearing", async () => {
const failure = new Error("copy failed")
const errors: unknown[] = []
const notifications: unknown[] = []
let clears = 0
let report!: () => void
const reported = new Promise<void>((resolve) => {
report = resolve
})
const clipboard: ClipboardService = {
async read() {
return undefined
},
async write() {
throw failure
},
}
expect(
Selection.copy(
{
getSelection: () => ({ getSelectedText: () => "text", selectedRenderables: [] }),
clearSelection: () => void clears++,
},
{
show: (notification) => notifications.push(notification),
error: (error) => {
errors.push(error)
report()
},
},
clipboard,
),
).toBe(true)
expect(clears).toBe(1)
await reported
expect(notifications).toEqual([])
expect(errors).toEqual([failure])
})