From 4514ed67b72ffa0b8b42f2dd98db0730f9dded87 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Sun, 5 Jul 2026 20:56:06 +0200 Subject: [PATCH] tui: use OpenTUI clipboard service --- bun.lock | 27 -- packages/tui/package.json | 1 - packages/tui/src/app.tsx | 80 +++-- packages/tui/src/clipboard.ts | 230 +++++++------ .../tui/src/component/dialog-provider.tsx | 9 +- .../tui/src/component/error-component.tsx | 47 ++- packages/tui/src/component/prompt/index.tsx | 26 +- packages/tui/src/context/clipboard.tsx | 25 +- .../tui/src/routes/session/dialog-message.tsx | 18 +- packages/tui/src/routes/session/index.tsx | 21 +- packages/tui/src/ui/dialog.tsx | 6 +- packages/tui/src/util/selection.ts | 7 +- packages/tui/test/app-lifecycle.test.tsx | 310 ++++++++++++++---- packages/tui/test/clipboard.test.ts | 270 ++++++++++++++- .../test/component/error-component.test.tsx | 126 +++++++ packages/tui/test/context/clipboard.test.tsx | 50 +++ packages/tui/test/fixture/tui-environment.tsx | 22 +- packages/tui/test/util/selection.test.ts | 104 ++++++ 18 files changed, 1102 insertions(+), 277 deletions(-) create mode 100644 packages/tui/test/component/error-component.test.tsx create mode 100644 packages/tui/test/context/clipboard.test.tsx create mode 100644 packages/tui/test/util/selection.test.ts 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]) +})