diff --git a/bun.lock b/bun.lock
index 3f5343d3151..9c57102d753 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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=="],
diff --git a/packages/tui/package.json b/packages/tui/package.json
index 8523c2fd684..8fd0b97af3e 100644
--- a/packages/tui/package.json
+++ b/packages/tui/package.json
@@ -55,7 +55,6 @@
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
- "clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx
index 57f372ef709..e469f846000 100644
--- a/packages/tui/src/app.tsx
+++ b/packages/tui/src/app.tsx
@@ -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) {
}}
>
(exit.epilogue = value)}>
- }>
-
-
+ }>
+
-
-
+
@@ -340,11 +352,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
-
-
-
-
-
+
+
+
+
+
)
@@ -438,8 +450,12 @@ function App(props: { onSnapshot?: () => Promise; 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; 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()
},
diff --git a/packages/tui/src/clipboard.ts b/packages/tui/src/clipboard.ts
index 08f86f9f7a9..bbfe496868a 100644
--- a/packages/tui/src/clipboard.ts
+++ b/packages/tui/src/clipboard.ts
@@ -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((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 }>
- 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> | 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`
}
diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx
index 0fd51e3c1c7..73d6efd063e 100644
--- a/packages/tui/src/component/dialog-provider.tsx
+++ b/packages/tui/src/component/dialog-provider.tsx
@@ -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 = {
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)
},
},
diff --git a/packages/tui/src/component/error-component.tsx b/packages/tui/src/component/error-component.tsx
index 1141da83822..b128ba5661c 100644
--- a/packages/tui/src/component/error-component.tsx
+++ b/packages/tui/src/component/error-component.tsx
@@ -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("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?:
{(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 (
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?:
>
{action.label()}
@@ -188,9 +212,14 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
- {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()]}
opencode {InstallationVersion}
diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx
index 115c10296ef..f9cbfb2db79 100644
--- a/packages/tui/src/component/prompt/index.tsx
+++ b/packages/tui/src/component/prompt/index.tsx
@@ -372,17 +372,21 @@ export function Prompt(props: PromptProps) {
run: async (ctx: CommandContext) => {
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)
}
},
},
diff --git a/packages/tui/src/context/clipboard.tsx b/packages/tui/src/context/clipboard.tsx
index 6e0ac5370e1..5b721003a0e 100644
--- a/packages/tui/src/context/clipboard.tsx
+++ b/packages/tui/src/context/clipboard.tsx
@@ -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
- write?(text: string): Promise
+export type ClipboardWriteOutcome = Readonly<{
+ delivery: "confirmed" | "attempted"
+ partial: boolean
+ result: ClipboardWriteResult
+}>
+export type ClipboardService = Readonly<{
+ read(): Promise
+ write(text: string): Promise
}>
-const clipboard = { read, write }
-const ClipboardContext = createContext(clipboard)
-export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
- return {props.children}
+const ClipboardContext = createContext()
+
+export function ClipboardProvider(props: { value: ClipboardService; children: JSX.Element }) {
+ return {props.children}
}
export function useClipboard() {
- return useContext(ClipboardContext)
+ const value = useContext(ClipboardContext)
+ if (!value) throw new Error("useClipboard must be used within a ClipboardProvider")
+ return value
}
diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx
index b7d01842060..e88dd1278de 100644
--- a/packages/tui/src/routes/session/dialog-message.tsx
+++ b/packages/tui/src/routes/session/dialog-message.tsx
@@ -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 (
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" })
}
diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx
index 50281630bc5..33333e80a15 100644
--- a/packages/tui/src/ui/dialog.tsx
+++ b/packages/tui/src/ui/dialog.tsx
@@ -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()
diff --git a/packages/tui/src/util/selection.ts b/packages/tui/src/util/selection.ts
index d9158ba4076..ec50e343ba6 100644
--- a/packages/tui/src/util/selection.ts
+++ b/packages/tui/src/util/selection.ts
@@ -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()
diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx
index 57066342471..5279815665e 100644
--- a/packages/tui/test/app-lifecycle.test.tsx
+++ b/packages/tui/test/app-lifecycle.test.tsx
@@ -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
+ 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,
+ options: {
+ args?: TuiInput["args"]
+ onStart?: (api: TuiPluginApi) => void
+ onDispose?: () => void | Promise
+ logs?: unknown[]
+ } = {},
+) {
+ const { run } = await import("../src/app")
+ let started!: () => void
+ const ready = new Promise((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((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((resolve) => {
+ startDispose = resolve
+ })
+ const disposeReady = new Promise((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((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()
}
})
diff --git a/packages/tui/test/clipboard.test.ts b/packages/tui/test/clipboard.test.ts
index f2d4994c7e2..c773647672e 100644
--- a/packages/tui/test/clipboard.test.ts
+++ b/packages/tui/test/clipboard.test.ts
@@ -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)
})
diff --git a/packages/tui/test/component/error-component.test.tsx b/packages/tui/test/component/error-component.test.tsx
new file mode 100644
index 00000000000..21ddaac3211
--- /dev/null
+++ b/packages/tui/test/component/error-component.test.tsx
@@ -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): ClipboardService {
+ return {
+ async read() {
+ return undefined
+ },
+ write,
+ }
+}
+
+async function waitForFrame(
+ app: { renderOnce(): Promise; 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(
+ () => (
+ {}}>
+ scenario.outcome)}>
+ {}} />
+
+
+ ),
+ { 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(
+ () => (
+ {}}>
+ {
+ throw new Error("copy failed")
+ })}
+ >
+ {}} />
+
+
+ ),
+ { 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()
+ }
+})
diff --git a/packages/tui/test/context/clipboard.test.tsx b/packages/tui/test/context/clipboard.test.tsx
new file mode 100644
index 00000000000..ff562ca72a6
--- /dev/null
+++ b/packages/tui/test/context/clipboard.test.tsx
@@ -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 fallback
+ }
+
+ const app = await testRender(() => (
+
+ }>
+
+
+
+ ))
+ try {
+ await app.renderOnce()
+ expect(app.captureCharFrame()).toContain("fallback")
+ expect(value).toBe(clipboard)
+ } finally {
+ app.renderer.destroy()
+ }
+})
diff --git a/packages/tui/test/fixture/tui-environment.tsx b/packages/tui/test/fixture/tui-environment.tsx
index 543332ba182..d180ffbaad3 100644
--- a/packages/tui/test/fixture/tui-environment.tsx
+++ b/packages/tui/test/fixture/tui-environment.tsx
@@ -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
+ clipboard?: ClipboardService
}>,
) {
return (
@@ -25,7 +43,9 @@ export function TestTuiContexts(
}}
>
- {props.children}
+
+ {props.children}
+
)
diff --git a/packages/tui/test/util/selection.test.ts b/packages/tui/test/util/selection.test.ts
new file mode 100644
index 00000000000..c2c007d969b
--- /dev/null
+++ b/packages/tui/test/util/selection.test.ts
@@ -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((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((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])
+})