mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 16:53:23 +00:00
tui: use OpenTUI clipboard service
This commit is contained in:
parent
c8a454197f
commit
4514ed67b7
18 changed files with 1102 additions and 277 deletions
27
bun.lock
27
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=="],
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@
|
|||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"clipboardy": "4.0.0",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ import * as TuiAudio from "./audio"
|
|||
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
|
||||
import { destroyRenderer } from "./util/renderer"
|
||||
import { cliErrorMessage, errorFormat } from "./util/error"
|
||||
import { createTuiClipboard, formatClipboardWriteNotification } from "./clipboard"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
|
|
@ -211,6 +212,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
destroyRenderer(renderer)
|
||||
}),
|
||||
)
|
||||
const clipboard = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => createTuiClipboard(renderer)),
|
||||
(clipboard) =>
|
||||
Effect.tryPromise(() => clipboard.dispose()).pipe(
|
||||
Effect.catchCause((cause) => Effect.logError("Failed to dispose TUI clipboard", { cause })),
|
||||
),
|
||||
)
|
||||
win32DisableProcessedInput()
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
yield* Effect.acquireRelease(
|
||||
|
|
@ -252,33 +260,37 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
}}
|
||||
>
|
||||
<EpilogueProvider set={(value) => (exit.epilogue = value)}>
|
||||
<ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}>
|
||||
<TuiPathsProvider
|
||||
value={{
|
||||
cwd: process.cwd(),
|
||||
home: global.home,
|
||||
state: global.state,
|
||||
worktree: global.data + "/worktree",
|
||||
}}
|
||||
>
|
||||
<TuiTerminalEnvironmentProvider
|
||||
<ClipboardProvider value={clipboard}>
|
||||
<ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}>
|
||||
<TuiPathsProvider
|
||||
value={{
|
||||
platform: process.platform,
|
||||
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
|
||||
displayServer: process.env.WAYLAND_DISPLAY
|
||||
? "wayland"
|
||||
: process.env.DISPLAY
|
||||
? "x11"
|
||||
: undefined,
|
||||
cwd: process.cwd(),
|
||||
home: global.home,
|
||||
state: global.state,
|
||||
worktree: global.data + "/worktree",
|
||||
}}
|
||||
>
|
||||
<TuiStartupProvider
|
||||
<TuiTerminalEnvironmentProvider
|
||||
value={{
|
||||
initialRoute: process.env.OPENCODE_ROUTE ? JSON.parse(process.env.OPENCODE_ROUTE) : undefined,
|
||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||
platform: process.platform,
|
||||
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
|
||||
displayServer: process.env.WAYLAND_DISPLAY
|
||||
? "wayland"
|
||||
: process.env.DISPLAY
|
||||
? "x11"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<ClipboardProvider>
|
||||
<TuiStartupProvider
|
||||
value={{
|
||||
initialRoute: process.env.OPENCODE_SCRAP
|
||||
? { type: "plugin", id: "scrap" }
|
||||
: process.env.OPENCODE_ROUTE
|
||||
? JSON.parse(process.env.OPENCODE_ROUTE)
|
||||
: undefined,
|
||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||
}}
|
||||
>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<ArgsProvider {...input.args}>
|
||||
<KVProvider>
|
||||
|
|
@ -340,11 +352,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
</KVProvider>
|
||||
</ArgsProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</ClipboardProvider>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiPathsProvider>
|
||||
</ErrorBoundary>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiPathsProvider>
|
||||
</ErrorBoundary>
|
||||
</ClipboardProvider>
|
||||
</EpilogueProvider>
|
||||
</ExitProvider>
|
||||
)
|
||||
|
|
@ -438,8 +450,12 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
if (!text || text.length === 0) return
|
||||
|
||||
await clipboard
|
||||
.write?.(text)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.write(text)
|
||||
.then((outcome) =>
|
||||
toast.show(
|
||||
formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" }),
|
||||
),
|
||||
)
|
||||
.catch(toast.error)
|
||||
|
||||
renderer.clearSelection()
|
||||
|
|
@ -601,8 +617,12 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||
const workspace = currentWorktreeWorkspace()
|
||||
if (!workspace?.directory) return
|
||||
await clipboard
|
||||
.write?.(workspace.directory)
|
||||
.then(() => toast.show({ message: "Copied worktree path", variant: "info" }))
|
||||
.write(workspace.directory)
|
||||
.then((outcome) =>
|
||||
toast.show(
|
||||
formatClipboardWriteNotification(outcome, { message: "Copied worktree path", variant: "info" }),
|
||||
),
|
||||
)
|
||||
.catch(toast.error)
|
||||
dialog.clear()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,124 +1,136 @@
|
|||
import { execFile, spawn } from "node:child_process"
|
||||
import { readFile, rm } from "node:fs/promises"
|
||||
import { platform, release, tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { promisify } from "node:util"
|
||||
import {
|
||||
createClipboard,
|
||||
createHostClipboard,
|
||||
createRendererClipboardAdapter,
|
||||
decodePasteBytes,
|
||||
type ClipboardService as CoreClipboardService,
|
||||
type ClipboardWriteResult,
|
||||
type RendererClipboardBoundary,
|
||||
} from "@opentui/core"
|
||||
import type { ClipboardService, ClipboardWriteOutcome } from "./context/clipboard"
|
||||
|
||||
const exec = promisify(execFile)
|
||||
const timeoutMs = 1_000
|
||||
const maxReadBytes = 8 * 1024 * 1024
|
||||
|
||||
function command(command: string, args: string[] = [], input?: string) {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const child = spawn(command, args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"] })
|
||||
const output: Buffer[] = []
|
||||
child.on("error", reject)
|
||||
child.stdout?.on("data", (chunk: Buffer) => output.push(chunk))
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) return resolve(Buffer.concat(output))
|
||||
reject(new Error(`${command} exited with code ${code}`))
|
||||
})
|
||||
if (input !== undefined) child.stdin?.end(input)
|
||||
})
|
||||
}
|
||||
export type ClipboardNotification = Readonly<{
|
||||
message: string
|
||||
variant: "info" | "success" | "warning"
|
||||
}>
|
||||
|
||||
function writeOsc52(text: string) {
|
||||
if (!process.stdout.isTTY) return
|
||||
const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`
|
||||
process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${sequence}\x1b\\` : sequence)
|
||||
}
|
||||
export type ClipboardCopyState =
|
||||
| "idle"
|
||||
| "confirmed"
|
||||
| "confirmed-partial"
|
||||
| "attempted"
|
||||
| "attempted-partial"
|
||||
| "failed"
|
||||
|
||||
export async function read() {
|
||||
if (platform() === "darwin") {
|
||||
const file = path.join(tmpdir(), "opencode-clipboard.png")
|
||||
try {
|
||||
await exec("osascript", [
|
||||
"-e",
|
||||
'set imageData to the clipboard as "PNGf"',
|
||||
"-e",
|
||||
`set fileRef to open for access POSIX file "${file}" with write permission`,
|
||||
"-e",
|
||||
"set eof fileRef to 0",
|
||||
"-e",
|
||||
"write imageData to fileRef",
|
||||
"-e",
|
||||
"close access fileRef",
|
||||
])
|
||||
return { data: (await readFile(file)).toString("base64"), mime: "image/png" }
|
||||
} catch {
|
||||
// Fall through to text clipboard.
|
||||
} finally {
|
||||
await rm(file, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
export type OwnedClipboardService = ClipboardService & Readonly<{ dispose(): Promise<void> }>
|
||||
|
||||
if (platform() === "win32" || release().includes("WSL")) {
|
||||
const script =
|
||||
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
|
||||
const image = await command("powershell.exe", ["-NonInteractive", "-NoProfile", "-command", script]).catch(() =>
|
||||
Buffer.alloc(0),
|
||||
export class ClipboardWriteError extends Error {
|
||||
readonly result: ClipboardWriteResult
|
||||
|
||||
constructor(result: ClipboardWriteResult) {
|
||||
super(
|
||||
`Clipboard write failed (host: ${result.host.status}, terminal: ${result.terminal.status})`,
|
||||
result.host.status === "failed" ? { cause: result.host.error } : undefined,
|
||||
)
|
||||
if (image.length) return { data: image.toString().trim(), mime: "image/png" }
|
||||
}
|
||||
|
||||
if (platform() === "linux") {
|
||||
const wayland = await command("wl-paste", ["-t", "image/png"]).catch(() => Buffer.alloc(0))
|
||||
if (wayland.length) return { data: wayland.toString("base64"), mime: "image/png" }
|
||||
const x11 = await command("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]).catch(() =>
|
||||
Buffer.alloc(0),
|
||||
)
|
||||
if (x11.length) return { data: x11.toString("base64"), mime: "image/png" }
|
||||
}
|
||||
|
||||
const { default: clipboardy } = await import("clipboardy")
|
||||
const text = await clipboardy.read().catch(() => undefined)
|
||||
if (text) return { data: text, mime: "text/plain" }
|
||||
}
|
||||
|
||||
export function copyCommand(
|
||||
os: NodeJS.Platform,
|
||||
wayland: boolean,
|
||||
has: (name: string) => boolean,
|
||||
): string[] | undefined {
|
||||
if (os === "darwin" && has("osascript")) return ["osascript"]
|
||||
if (os === "linux" && wayland && has("wl-copy")) return ["wl-copy"]
|
||||
if (os === "linux" && has("xclip")) return ["xclip", "-selection", "clipboard"]
|
||||
if (os === "linux" && has("xsel")) return ["xsel", "--clipboard", "--input"]
|
||||
if (os === "win32" && has("powershell.exe")) {
|
||||
return [
|
||||
"powershell.exe",
|
||||
"-NonInteractive",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
|
||||
]
|
||||
this.name = "ClipboardWriteError"
|
||||
this.result = result
|
||||
}
|
||||
}
|
||||
|
||||
let copyMethod: Promise<(text: string) => Promise<void>> | undefined
|
||||
export function createTuiClipboard(renderer: RendererClipboardBoundary): OwnedClipboardService {
|
||||
return createClipboardAdapter(
|
||||
createClipboard({
|
||||
host: createHostClipboard({
|
||||
timeoutMs,
|
||||
maxReadBytes,
|
||||
maxWriteBytes: 8 * 1024 * 1024,
|
||||
maxImagePixels: 64 * 1024 * 1024,
|
||||
maxConversionBytes: 512 * 1024 * 1024,
|
||||
maxConcurrentOperations: 16,
|
||||
maxProviderTransfers: 16,
|
||||
maxWorkUnitsPerDrain: 64,
|
||||
}),
|
||||
terminal: createRendererClipboardAdapter(renderer),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function getCopyMethod() {
|
||||
return (copyMethod ??= (async () => {
|
||||
const { which } = await import("@opencode-ai/core/util/which")
|
||||
const native = copyCommand(platform(), Boolean(process.env.WAYLAND_DISPLAY), (name) => Boolean(which(name)))
|
||||
if (native?.[0] === "osascript") {
|
||||
return async (text: string) => {
|
||||
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
await command("osascript", ["-e", `set the clipboard to "${escaped}"`]).catch(() => undefined)
|
||||
export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedClipboardService {
|
||||
return {
|
||||
async read() {
|
||||
const result = await clipboard.read({
|
||||
preferredTypes: ["image/png", "text/plain"],
|
||||
selection: "clipboard",
|
||||
})
|
||||
if (result.status !== "read") {
|
||||
if (result.status === "empty" || result.status === "unsupported" || result.status === "cancelled") return
|
||||
if (result.status === "failed") throw result.error
|
||||
if (result.status === "timed-out") throw new Error(`Clipboard read timed out after ${timeoutMs}ms`)
|
||||
if (result.status === "limit-exceeded") {
|
||||
throw new RangeError(`Clipboard read exceeded the ${maxReadBytes}-byte limit`)
|
||||
}
|
||||
throw new Error(`Unexpected clipboard read status: ${result.status}`)
|
||||
}
|
||||
}
|
||||
if (native) {
|
||||
return async (text: string) => {
|
||||
await command(native[0], native.slice(1), text).catch(() => undefined)
|
||||
|
||||
if (result.representation.mimeType === "image/png") {
|
||||
return {
|
||||
data: Buffer.from(result.representation.bytes).toString("base64"),
|
||||
mime: result.representation.mimeType,
|
||||
}
|
||||
}
|
||||
}
|
||||
return async (text: string) => {
|
||||
const { default: clipboardy } = await import("clipboardy")
|
||||
await clipboardy.write(text).catch(() => undefined)
|
||||
}
|
||||
})())
|
||||
if (result.representation.mimeType === "text/plain") {
|
||||
if (result.representation.bytes.length === 0) return
|
||||
return {
|
||||
data: decodePasteBytes(result.representation.bytes),
|
||||
mime: result.representation.mimeType,
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected clipboard MIME type: ${result.representation.mimeType}`)
|
||||
},
|
||||
async write(text) {
|
||||
return classifyClipboardWriteResult(
|
||||
await clipboard.writeText(text, {
|
||||
destination: "all-available",
|
||||
selection: "clipboard",
|
||||
}),
|
||||
)
|
||||
},
|
||||
dispose() {
|
||||
return clipboard.dispose()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function write(text: string) {
|
||||
writeOsc52(text)
|
||||
const method = await getCopyMethod()
|
||||
await method(text)
|
||||
export function classifyClipboardWriteResult(result: ClipboardWriteResult): ClipboardWriteOutcome {
|
||||
const partial =
|
||||
result.host.status === "failed" ||
|
||||
result.host.status === "timed-out" ||
|
||||
result.host.status === "cancelled" ||
|
||||
result.terminal.status === "local-failure"
|
||||
|
||||
if (result.host.status === "written") return { delivery: "confirmed", partial, result }
|
||||
if (result.terminal.status === "attempted") return { delivery: "attempted", partial, result }
|
||||
throw new ClipboardWriteError(result)
|
||||
}
|
||||
|
||||
export function formatClipboardWriteNotification(
|
||||
outcome: ClipboardWriteOutcome,
|
||||
confirmed: ClipboardNotification,
|
||||
): ClipboardNotification {
|
||||
if (outcome.delivery === "confirmed" && !outcome.partial) return confirmed
|
||||
if (outcome.delivery === "attempted" && !outcome.partial) {
|
||||
return { message: "Sent to terminal clipboard (acceptance unconfirmed)", variant: "info" }
|
||||
}
|
||||
if (outcome.delivery === "confirmed") {
|
||||
return { message: "Copied to host clipboard; terminal clipboard dispatch failed", variant: "warning" }
|
||||
}
|
||||
return { message: "Sent to terminal clipboard; host clipboard write failed", variant: "warning" }
|
||||
}
|
||||
|
||||
export function clipboardCopyState(outcome: ClipboardWriteOutcome): ClipboardCopyState {
|
||||
if (!outcome.partial) return outcome.delivery
|
||||
return `${outcome.delivery}-partial`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { isConsoleManagedProvider } from "../util/provider-origin"
|
|||
import { useConnected } from "./use-connected"
|
||||
import { useBindings } from "../keymap"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { formatClipboardWriteNotification } from "../clipboard"
|
||||
|
||||
const PROVIDER_PRIORITY: Record<string, number> = {
|
||||
opencode: 0,
|
||||
|
|
@ -254,8 +255,12 @@ function AutoMethod(props: AutoMethodProps) {
|
|||
const code =
|
||||
props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
|
||||
clipboard
|
||||
.write?.(code)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.write(code)
|
||||
.then((outcome) =>
|
||||
toast.show(
|
||||
formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" }),
|
||||
),
|
||||
)
|
||||
.catch(toast.error)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@ import { useClipboard } from "../context/clipboard"
|
|||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { useExit } from "../context/exit"
|
||||
import { describeOS, describeTerminal } from "../util/system"
|
||||
import { clipboardCopyState, type ClipboardCopyState } from "../clipboard"
|
||||
|
||||
export function ErrorComponent(props: { error: Error; reset: () => void; mode?: "dark" | "light" }) {
|
||||
const term = useTerminalDimensions()
|
||||
const exit = useExit()
|
||||
const clipboard = useClipboard()
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const [copyState, setCopyState] = createSignal<ClipboardCopyState>("idle")
|
||||
|
||||
// Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the
|
||||
// theme context may be the thing that crashed.
|
||||
|
|
@ -27,6 +28,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
|||
onPrimary: "#ffffff",
|
||||
error: "#d1383d",
|
||||
success: "#3d9a57",
|
||||
warning: "#c18401",
|
||||
}
|
||||
: {
|
||||
bg: "#0a0a0a",
|
||||
|
|
@ -38,6 +40,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
|||
onPrimary: "#0a0a0a",
|
||||
error: "#e06c75",
|
||||
success: "#7fd88f",
|
||||
warning: "#e5c07b",
|
||||
}
|
||||
|
||||
const message = props.error.message || "An unknown error occurred."
|
||||
|
|
@ -45,11 +48,27 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
|||
const issueURL = buildIssueURL(message, stack)
|
||||
|
||||
const copyReport = () => {
|
||||
void clipboard.write?.(issueURL.toString()).then(() => setCopied(true))
|
||||
void clipboard
|
||||
.write(issueURL.toString())
|
||||
.then((outcome) => setCopyState(clipboardCopyState(outcome)))
|
||||
.catch(() => setCopyState("failed"))
|
||||
}
|
||||
|
||||
const actions = [
|
||||
{ key: "c", label: () => (copied() ? "✓ Copied" : "Copy report"), copy: true, onUse: copyReport },
|
||||
{
|
||||
key: "c",
|
||||
label: () =>
|
||||
({
|
||||
idle: "Copy report",
|
||||
confirmed: "Copied",
|
||||
"confirmed-partial": "Copied (terminal failed)",
|
||||
attempted: "Sent",
|
||||
"attempted-partial": "Sent (host failed)",
|
||||
failed: "Copy failed",
|
||||
})[copyState()],
|
||||
copy: true,
|
||||
onUse: copyReport,
|
||||
},
|
||||
{ key: "r", label: () => "Restart", onUse: props.reset },
|
||||
{ key: "q", label: () => "Quit", onUse: () => exit() },
|
||||
]
|
||||
|
|
@ -134,13 +153,18 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
|||
<For each={actions}>
|
||||
{(action, index) => {
|
||||
const isSelected = () => selected() === index()
|
||||
const isCopied = () => action.copy && copied()
|
||||
const copyColor = () => {
|
||||
if (!action.copy) return
|
||||
if (copyState() === "confirmed") return colors.success
|
||||
if (copyState() === "confirmed-partial" || copyState() === "attempted-partial") return colors.warning
|
||||
if (copyState() === "failed") return colors.error
|
||||
}
|
||||
return (
|
||||
<box flexDirection="column" alignItems="center" flexShrink={0}>
|
||||
<box
|
||||
onMouseDown={() => setSelected(index())}
|
||||
onMouseUp={() => action.onUse()}
|
||||
backgroundColor={isCopied() ? colors.success : isSelected() ? colors.primary : colors.element}
|
||||
backgroundColor={copyColor() ?? (isSelected() ? colors.primary : colors.element)}
|
||||
minWidth={15}
|
||||
alignItems="center"
|
||||
paddingLeft={2}
|
||||
|
|
@ -148,7 +172,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
|||
>
|
||||
<text
|
||||
attributes={TextAttributes.BOLD}
|
||||
fg={isCopied() || isSelected() ? colors.onPrimary : colors.text}
|
||||
fg={copyColor() || isSelected() ? colors.onPrimary : colors.text}
|
||||
>
|
||||
{action.label()}
|
||||
</text>
|
||||
|
|
@ -188,9 +212,14 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
|||
<Show when={showFooter()}>
|
||||
<box flexDirection="column" alignItems="center" flexShrink={0}>
|
||||
<text fg={colors.muted}>
|
||||
{copied()
|
||||
? "Report copied — paste it into a new GitHub issue."
|
||||
: "Copy the report and open a GitHub issue to help us fix this."}
|
||||
{{
|
||||
idle: "Copy the report and open a GitHub issue to help us fix this.",
|
||||
confirmed: "Report copied. Paste it into a new GitHub issue.",
|
||||
"confirmed-partial": "Report copied to host clipboard; terminal dispatch failed.",
|
||||
attempted: "Report sent to terminal clipboard; acceptance is unconfirmed.",
|
||||
"attempted-partial": "Report sent to terminal clipboard; host clipboard write failed.",
|
||||
failed: "Clipboard write failed. Try again or report the crash manually.",
|
||||
}[copyState()]}
|
||||
</text>
|
||||
<text fg={colors.muted}>opencode {InstallationVersion}</text>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -372,17 +372,21 @@ export function Prompt(props: PromptProps) {
|
|||
run: async (ctx: CommandContext<Renderable, KeyEvent>) => {
|
||||
ctx.event.preventDefault()
|
||||
ctx.event.stopPropagation()
|
||||
const content = await clipboard.read?.()
|
||||
if (content?.mime.startsWith("image/")) {
|
||||
await pasteAttachment({
|
||||
filename: "clipboard",
|
||||
mime: content.mime,
|
||||
content: content.data,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (content?.mime === "text/plain") {
|
||||
await pasteInputText(content.data)
|
||||
try {
|
||||
const content = await clipboard.read()
|
||||
if (content?.mime.startsWith("image/")) {
|
||||
await pasteAttachment({
|
||||
filename: "clipboard",
|
||||
mime: content.mime,
|
||||
content: content.data,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (content?.mime === "text/plain") {
|
||||
await pasteInputText(content.data)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,18 +1,25 @@
|
|||
import type { ClipboardWriteResult } from "@opentui/core"
|
||||
import { createContext, type JSX, useContext } from "solid-js"
|
||||
import { read, write } from "../clipboard"
|
||||
|
||||
export type ClipboardContent = Readonly<{ data: string; mime: string }>
|
||||
export type ClipboardService = Readonly<{
|
||||
read?(): Promise<ClipboardContent | undefined>
|
||||
write?(text: string): Promise<void>
|
||||
export type ClipboardWriteOutcome = Readonly<{
|
||||
delivery: "confirmed" | "attempted"
|
||||
partial: boolean
|
||||
result: ClipboardWriteResult
|
||||
}>
|
||||
export type ClipboardService = Readonly<{
|
||||
read(): Promise<ClipboardContent | undefined>
|
||||
write(text: string): Promise<ClipboardWriteOutcome>
|
||||
}>
|
||||
const clipboard = { read, write }
|
||||
const ClipboardContext = createContext<ClipboardService>(clipboard)
|
||||
|
||||
export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
|
||||
return <ClipboardContext.Provider value={props.value ?? clipboard}>{props.children}</ClipboardContext.Provider>
|
||||
const ClipboardContext = createContext<ClipboardService>()
|
||||
|
||||
export function ClipboardProvider(props: { value: ClipboardService; children: JSX.Element }) {
|
||||
return <ClipboardContext.Provider value={props.value}>{props.children}</ClipboardContext.Provider>
|
||||
}
|
||||
|
||||
export function useClipboard() {
|
||||
return useContext(ClipboardContext)
|
||||
const value = useContext(ClipboardContext)
|
||||
if (!value) throw new Error("useClipboard must be used within a ClipboardProvider")
|
||||
return value
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { useRoute } from "../../context/route"
|
|||
import { useClipboard } from "../../context/clipboard"
|
||||
import type { PromptInfo } from "../../component/prompt/history"
|
||||
import { stripPromptPartIDs as strip } from "../../prompt/part"
|
||||
import { formatClipboardWriteNotification } from "../../clipboard"
|
||||
import { useToast } from "../../ui/toast"
|
||||
|
||||
export function DialogMessage(props: {
|
||||
messageID: string
|
||||
|
|
@ -17,6 +19,7 @@ export function DialogMessage(props: {
|
|||
const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID))
|
||||
const route = useRoute()
|
||||
const clipboard = useClipboard()
|
||||
const toast = useToast()
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
|
|
@ -69,8 +72,19 @@ export function DialogMessage(props: {
|
|||
return agg
|
||||
}, "")
|
||||
|
||||
await clipboard.write?.(text)
|
||||
dialog.clear()
|
||||
if (!text) {
|
||||
toast.show({ message: "No text content found in message", variant: "error" })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const outcome = await clipboard.write(text)
|
||||
toast.show(
|
||||
formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" }),
|
||||
)
|
||||
dialog.clear()
|
||||
} catch (error) {
|
||||
toast.error(error)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ import { sessionEpilogue } from "../../util/presentation"
|
|||
import { setPreLayoutSiblingMargin } from "../../util/layout"
|
||||
import { useTuiConfig } from "../../config"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
import { formatClipboardWriteNotification } from "../../clipboard"
|
||||
import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { collapseToolOutput } from "../../util/collapse-tool-output"
|
||||
|
|
@ -904,8 +905,15 @@ export function Session() {
|
|||
}
|
||||
|
||||
clipboard
|
||||
.write?.(text)
|
||||
.then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
|
||||
.write(text)
|
||||
.then((outcome) =>
|
||||
toast.show(
|
||||
formatClipboardWriteNotification(outcome, {
|
||||
message: "Message copied to clipboard!",
|
||||
variant: "success",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
|
||||
dialog.clear()
|
||||
},
|
||||
|
|
@ -932,8 +940,13 @@ export function Session() {
|
|||
providers: sync.data.provider,
|
||||
},
|
||||
)
|
||||
await clipboard.write?.(transcript)
|
||||
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
|
||||
const outcome = await clipboard.write(transcript)
|
||||
toast.show(
|
||||
formatClipboardWriteNotification(outcome, {
|
||||
message: "Session transcript copied to clipboard!",
|
||||
variant: "success",
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
toast.show({ message: "Failed to copy session transcript", variant: "error" })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1,70 +1,191 @@
|
|||
import { expect, mock, test } from "bun:test"
|
||||
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { Effect } from "effect"
|
||||
import type {
|
||||
ClipboardOptions,
|
||||
ClipboardService,
|
||||
HostClipboardOptions,
|
||||
HostClipboardService,
|
||||
RendererClipboardBoundary,
|
||||
} from "@opentui/core"
|
||||
import { Effect, Logger } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
||||
import { createEventSource, createFetch, directory, json } from "./fixture/tui-sdk"
|
||||
import type { TuiInput } from "../src/app"
|
||||
|
||||
const openTui = { ...(await import("@opentui/core")) }
|
||||
|
||||
function restoreOpenTui() {
|
||||
mock.restore()
|
||||
mock.module("@opentui/core", () => openTui)
|
||||
}
|
||||
|
||||
async function mockOpenTuiClipboard(
|
||||
renderer: RendererClipboardBoundary,
|
||||
options: {
|
||||
dispose?: () => Promise<void>
|
||||
constructionError?: Error
|
||||
} = {},
|
||||
) {
|
||||
const calls = {
|
||||
host: [] as (HostClipboardOptions | undefined)[],
|
||||
adapter: [] as RendererClipboardBoundary[],
|
||||
service: [] as ClipboardOptions[],
|
||||
dispose: 0,
|
||||
hostDispose: 0,
|
||||
hostWrite: 0,
|
||||
}
|
||||
const host: HostClipboardService = {
|
||||
maxWriteBytes: 8 * 1024 * 1024,
|
||||
async read() {
|
||||
return { status: "empty" }
|
||||
},
|
||||
async writeText() {
|
||||
calls.hostWrite++
|
||||
return { status: "written" }
|
||||
},
|
||||
async clear() {
|
||||
return { status: "cleared" }
|
||||
},
|
||||
async dispose() {
|
||||
calls.hostDispose++
|
||||
await options.dispose?.()
|
||||
},
|
||||
}
|
||||
|
||||
mock.module("@opentui/core", () => ({
|
||||
...openTui,
|
||||
createCliRenderer: async () => renderer,
|
||||
createHostClipboard: (input?: HostClipboardOptions) => {
|
||||
if (options.constructionError) throw options.constructionError
|
||||
calls.host.push(input)
|
||||
return host
|
||||
},
|
||||
createRendererClipboardAdapter: (input: RendererClipboardBoundary) => {
|
||||
calls.adapter.push(input)
|
||||
return openTui.createRendererClipboardAdapter(input)
|
||||
},
|
||||
createClipboard: (input: ClipboardOptions) => {
|
||||
calls.service.push(input)
|
||||
const service = openTui.createClipboard(input)
|
||||
return {
|
||||
read: service.read,
|
||||
writeText: service.writeText,
|
||||
clear: service.clear,
|
||||
async dispose() {
|
||||
calls.dispose++
|
||||
await service.dispose()
|
||||
},
|
||||
} satisfies ClipboardService
|
||||
},
|
||||
}))
|
||||
return calls
|
||||
}
|
||||
|
||||
async function launch(
|
||||
calls: ReturnType<typeof createFetch>,
|
||||
options: {
|
||||
args?: TuiInput["args"]
|
||||
onStart?: (api: TuiPluginApi) => void
|
||||
onDispose?: () => void | Promise<void>
|
||||
logs?: unknown[]
|
||||
} = {},
|
||||
) {
|
||||
const { run } = await import("../src/app")
|
||||
let started!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
started = resolve
|
||||
})
|
||||
const effect = run({
|
||||
url: "http://test",
|
||||
directory,
|
||||
config: createTuiResolvedConfig({ plugin_enabled: {} }),
|
||||
fetch: calls.fetch,
|
||||
events: createEventSource().source,
|
||||
args: options.args ?? {},
|
||||
pluginHost: {
|
||||
async start(input) {
|
||||
options.onStart?.(input.api)
|
||||
started()
|
||||
},
|
||||
async dispose() {
|
||||
await options.onDispose?.()
|
||||
},
|
||||
},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
|
||||
const task = Effect.runPromise(
|
||||
options.logs
|
||||
? effect.pipe(
|
||||
Effect.provide(Logger.layer([Logger.make(({ message }) => void options.logs?.push(message))])),
|
||||
)
|
||||
: effect,
|
||||
)
|
||||
await ready
|
||||
return { task }
|
||||
}
|
||||
|
||||
test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const clipboard = await mockOpenTuiClipboard(setup.renderer)
|
||||
const titles: string[] = []
|
||||
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
|
||||
setup.renderer.setTerminalTitle = (title) => {
|
||||
titles.push(title)
|
||||
setTitle(title)
|
||||
}
|
||||
const listeners = new Set(process.listeners("SIGHUP"))
|
||||
const events = createEventSource()
|
||||
const listeners = process.listeners("SIGHUP")
|
||||
const calls = createFetch()
|
||||
let started!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
started = resolve
|
||||
})
|
||||
let disposes = 0
|
||||
|
||||
try {
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
url: "http://test",
|
||||
directory,
|
||||
config: createTuiResolvedConfig({ plugin_enabled: {} }),
|
||||
fetch: calls.fetch,
|
||||
events: events.source,
|
||||
args: {},
|
||||
pluginHost: {
|
||||
async start() {
|
||||
started()
|
||||
},
|
||||
async dispose() {
|
||||
disposes++
|
||||
},
|
||||
},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node))),
|
||||
)
|
||||
await ready
|
||||
const { task } = await launch(calls, { onDispose: () => void disposes++ })
|
||||
process.emit("SIGHUP")
|
||||
await task
|
||||
|
||||
expect(setup.renderer.isDestroyed).toBe(true)
|
||||
expect(titles.at(-1)).toBe("")
|
||||
expect(disposes).toBe(1)
|
||||
expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true)
|
||||
expect(clipboard.host).toEqual([
|
||||
{
|
||||
timeoutMs: 1_000,
|
||||
maxReadBytes: 8 * 1024 * 1024,
|
||||
maxWriteBytes: 8 * 1024 * 1024,
|
||||
maxImagePixels: 64 * 1024 * 1024,
|
||||
maxConversionBytes: 512 * 1024 * 1024,
|
||||
maxConcurrentOperations: 16,
|
||||
maxProviderTransfers: 16,
|
||||
maxWorkUnitsPerDrain: 64,
|
||||
},
|
||||
])
|
||||
expect(clipboard.adapter).toEqual([setup.renderer])
|
||||
expect(clipboard.service).toHaveLength(1)
|
||||
expect(clipboard.dispose).toBe(1)
|
||||
expect(clipboard.hostDispose).toBe(1)
|
||||
expect(process.listeners("SIGHUP").every((listener) => listeners.includes(listener))).toBe(true)
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
mock.restore()
|
||||
restoreOpenTui()
|
||||
}
|
||||
})
|
||||
|
||||
test("app.exit prints the session epilogue after scoped cleanup", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const events = createEventSource()
|
||||
let releaseDispose!: () => void
|
||||
let startDispose!: () => void
|
||||
const disposeStarted = new Promise<void>((resolve) => {
|
||||
startDispose = resolve
|
||||
})
|
||||
const disposeReady = new Promise<void>((resolve) => {
|
||||
releaseDispose = resolve
|
||||
})
|
||||
const clipboard = await mockOpenTuiClipboard(setup.renderer, {
|
||||
dispose: async () => {
|
||||
startDispose()
|
||||
await disposeReady
|
||||
},
|
||||
})
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/session")
|
||||
return json([
|
||||
|
|
@ -82,10 +203,6 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
|
|||
const originalWrite = process.stdout.write.bind(process.stdout)
|
||||
let stdout = ""
|
||||
let api: TuiPluginApi | undefined
|
||||
let started!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
started = resolve
|
||||
})
|
||||
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
stdout += String(chunk)
|
||||
|
|
@ -93,36 +210,113 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
|
|||
}) as typeof process.stdout.write
|
||||
|
||||
try {
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
url: "http://test",
|
||||
directory,
|
||||
config: createTuiResolvedConfig({ plugin_enabled: {} }),
|
||||
fetch: calls.fetch,
|
||||
events: events.source,
|
||||
args: { continue: true },
|
||||
pluginHost: {
|
||||
async start(input) {
|
||||
api = input.api
|
||||
started()
|
||||
},
|
||||
async dispose() {},
|
||||
},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node))),
|
||||
const { task } = await launch(calls, { args: { continue: true }, onStart: (value) => (api = value) })
|
||||
let settled = false
|
||||
void task.then(
|
||||
() => (settled = true),
|
||||
() => (settled = true),
|
||||
)
|
||||
|
||||
await ready
|
||||
await setup.renderOnce()
|
||||
await setup.renderOnce()
|
||||
api?.keymap.dispatchCommand("app.exit")
|
||||
await disposeStarted
|
||||
expect(settled).toBe(false)
|
||||
expect(stdout).not.toContain("Demo session")
|
||||
releaseDispose()
|
||||
await task
|
||||
|
||||
expect(stdout).toContain("Demo session")
|
||||
expect(stdout).toContain("opencode -s dummy")
|
||||
expect(clipboard.dispose).toBe(1)
|
||||
expect(clipboard.hostDispose).toBe(1)
|
||||
} finally {
|
||||
process.stdout.write = originalWrite
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
mock.restore()
|
||||
restoreOpenTui()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct renderer destruction disposes the clipboard once", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const clipboard = await mockOpenTuiClipboard(setup.renderer)
|
||||
const calls = createFetch()
|
||||
|
||||
try {
|
||||
const { task } = await launch(calls)
|
||||
const staleCopy = setup.renderer.console.onCopySelection
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
expect(clipboard.dispose).toBe(1)
|
||||
expect(clipboard.hostDispose).toBe(1)
|
||||
await staleCopy?.("stale")
|
||||
expect(clipboard.hostWrite).toBe(0)
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
restoreOpenTui()
|
||||
}
|
||||
})
|
||||
|
||||
test("clipboard construction failure releases the renderer", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const failure = new Error("clipboard construction failed")
|
||||
await mockOpenTuiClipboard(setup.renderer, { constructionError: failure })
|
||||
const calls = createFetch()
|
||||
|
||||
try {
|
||||
const { run } = await import("../src/app")
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
run({
|
||||
url: "http://test",
|
||||
directory,
|
||||
config: createTuiResolvedConfig({ plugin_enabled: {} }),
|
||||
fetch: calls.fetch,
|
||||
events: createEventSource().source,
|
||||
args: {},
|
||||
pluginHost: { async start() {}, async dispose() {} },
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node))),
|
||||
),
|
||||
).rejects.toThrow("clipboard construction failed")
|
||||
expect(setup.renderer.isDestroyed).toBe(true)
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
restoreOpenTui()
|
||||
}
|
||||
})
|
||||
|
||||
test("clipboard disposal failure is logged without failing remaining cleanup", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const clipboard = await mockOpenTuiClipboard(setup.renderer, {
|
||||
dispose: async () => {
|
||||
throw new Error("clipboard disposal failed")
|
||||
},
|
||||
})
|
||||
const calls = createFetch()
|
||||
const titles: string[] = []
|
||||
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
|
||||
setup.renderer.setTerminalTitle = (title) => {
|
||||
titles.push(title)
|
||||
setTitle(title)
|
||||
}
|
||||
let pluginDisposes = 0
|
||||
const logs: unknown[] = []
|
||||
|
||||
try {
|
||||
const { task } = await launch(calls, { logs, onDispose: () => void pluginDisposes++ })
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
expect(clipboard.dispose).toBe(1)
|
||||
expect(clipboard.hostDispose).toBe(1)
|
||||
expect(pluginDisposes).toBe(1)
|
||||
expect(titles.at(-1)).toBe("")
|
||||
expect(setup.renderer.isDestroyed).toBe(true)
|
||||
expect(
|
||||
logs.some((message) =>
|
||||
(Array.isArray(message) ? message : [message]).some((value) => value === "Failed to dispose TUI clipboard"),
|
||||
),
|
||||
).toBe(true)
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
restoreOpenTui()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
126
packages/tui/test/component/error-component.test.tsx
Normal file
126
packages/tui/test/component/error-component.test.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { ErrorComponent } from "../../src/component/error-component"
|
||||
import { ClipboardProvider, type ClipboardService, type ClipboardWriteOutcome } from "../../src/context/clipboard"
|
||||
import { ExitProvider } from "../../src/context/exit"
|
||||
|
||||
const outcomes = [
|
||||
{
|
||||
label: "Copied",
|
||||
absent: "Copied (terminal failed)",
|
||||
outcome: {
|
||||
delivery: "confirmed",
|
||||
partial: false,
|
||||
result: {
|
||||
host: { status: "written" },
|
||||
terminal: { status: "attempted", capability: "supported" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Copied (terminal failed)",
|
||||
absent: undefined,
|
||||
outcome: {
|
||||
delivery: "confirmed",
|
||||
partial: true,
|
||||
result: {
|
||||
host: { status: "written" },
|
||||
terminal: { status: "local-failure", capability: "supported" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Sent",
|
||||
absent: "Sent (host failed)",
|
||||
outcome: {
|
||||
delivery: "attempted",
|
||||
partial: false,
|
||||
result: {
|
||||
host: { status: "unsupported" },
|
||||
terminal: { status: "attempted", capability: "supported" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Sent (host failed)",
|
||||
absent: undefined,
|
||||
outcome: {
|
||||
delivery: "attempted",
|
||||
partial: true,
|
||||
result: {
|
||||
host: { status: "failed", error: new Error("host failed") },
|
||||
terminal: { status: "attempted", capability: "supported" },
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly { label: string; absent: string | undefined; outcome: ClipboardWriteOutcome }[]
|
||||
|
||||
function clipboard(write: () => Promise<ClipboardWriteOutcome>): ClipboardService {
|
||||
return {
|
||||
async read() {
|
||||
return undefined
|
||||
},
|
||||
write,
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForFrame(
|
||||
app: { renderOnce(): Promise<void>; captureCharFrame(): string },
|
||||
match: (frame: string) => boolean,
|
||||
) {
|
||||
for (let attempts = 0; attempts < 20; attempts++) {
|
||||
await app.renderOnce()
|
||||
const frame = app.captureCharFrame()
|
||||
if (match(frame)) return frame
|
||||
await Bun.sleep(1)
|
||||
}
|
||||
throw new Error("Timed out waiting for clipboard state")
|
||||
}
|
||||
|
||||
test("crash report distinguishes every delivered clipboard state", async () => {
|
||||
for (const scenario of outcomes) {
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<ExitProvider exit={() => {}}>
|
||||
<ClipboardProvider value={clipboard(async () => scenario.outcome)}>
|
||||
<ErrorComponent error={new Error("boom")} reset={() => {}} />
|
||||
</ClipboardProvider>
|
||||
</ExitProvider>
|
||||
),
|
||||
{ width: 100, height: 24 },
|
||||
)
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("Copy report")
|
||||
app.mockInput.pressKey("c")
|
||||
const frame = await waitForFrame(app, (frame) => frame.includes(scenario.label))
|
||||
if (scenario.absent) expect(frame).not.toContain(scenario.absent)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("crash report catches clipboard rejection", async () => {
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<ExitProvider exit={() => {}}>
|
||||
<ClipboardProvider
|
||||
value={clipboard(async () => {
|
||||
throw new Error("copy failed")
|
||||
})}
|
||||
>
|
||||
<ErrorComponent error={new Error("boom")} reset={() => {}} />
|
||||
</ClipboardProvider>
|
||||
</ExitProvider>
|
||||
),
|
||||
{ width: 100, height: 24 },
|
||||
)
|
||||
try {
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressKey("c")
|
||||
expect(await waitForFrame(app, (frame) => frame.includes("Copy failed"))).toContain("Copy failed")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
50
packages/tui/test/context/clipboard.test.tsx
Normal file
50
packages/tui/test/context/clipboard.test.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { ErrorBoundary, type JSX } from "solid-js"
|
||||
import { ClipboardProvider, type ClipboardService, useClipboard } from "../../src/context/clipboard"
|
||||
|
||||
const clipboard: ClipboardService = {
|
||||
async read() {
|
||||
return { data: "text", mime: "text/plain" }
|
||||
},
|
||||
async write() {
|
||||
return {
|
||||
delivery: "confirmed",
|
||||
partial: false,
|
||||
result: {
|
||||
host: { status: "written" },
|
||||
terminal: { status: "not-attempted", capability: "unknown" },
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
test("requires explicit provider injection", () => {
|
||||
expect(() => useClipboard()).toThrow("useClipboard must be used within a ClipboardProvider")
|
||||
})
|
||||
|
||||
test("keeps clipboard access available to an error boundary fallback", async () => {
|
||||
let value: ClipboardService | undefined
|
||||
function Crash(): JSX.Element {
|
||||
throw new Error("crash")
|
||||
}
|
||||
function Fallback() {
|
||||
value = useClipboard()
|
||||
return <text>fallback</text>
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<ClipboardProvider value={clipboard}>
|
||||
<ErrorBoundary fallback={() => <Fallback />}>
|
||||
<Crash />
|
||||
</ErrorBoundary>
|
||||
</ClipboardProvider>
|
||||
))
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("fallback")
|
||||
expect(value).toBe(clipboard)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
|
@ -6,12 +6,30 @@ import {
|
|||
type TuiPaths,
|
||||
} from "../../src/context/runtime"
|
||||
import type { ParentProps } from "solid-js"
|
||||
import { ClipboardProvider, type ClipboardService } from "../../src/context/clipboard"
|
||||
|
||||
const clipboard: ClipboardService = {
|
||||
async read() {
|
||||
return undefined
|
||||
},
|
||||
async write() {
|
||||
return {
|
||||
delivery: "confirmed",
|
||||
partial: false,
|
||||
result: {
|
||||
host: { status: "written" },
|
||||
terminal: { status: "not-attempted", capability: "unknown" },
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export function TestTuiContexts(
|
||||
props: ParentProps<{
|
||||
cwd?: string
|
||||
directory?: string
|
||||
paths?: Partial<TuiPaths>
|
||||
clipboard?: ClipboardService
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
|
|
@ -25,7 +43,9 @@ export function TestTuiContexts(
|
|||
}}
|
||||
>
|
||||
<TuiTerminalEnvironmentProvider value={{ platform: "linux" }}>
|
||||
<TuiStartupProvider value={{ skipInitialLoading: false }}>{props.children}</TuiStartupProvider>
|
||||
<TuiStartupProvider value={{ skipInitialLoading: false }}>
|
||||
<ClipboardProvider value={props.clipboard ?? clipboard}>{props.children}</ClipboardProvider>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiPathsProvider>
|
||||
)
|
||||
|
|
|
|||
104
packages/tui/test/util/selection.test.ts
Normal file
104
packages/tui/test/util/selection.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import type { ClipboardService } from "../../src/context/clipboard"
|
||||
import { Selection } from "../../src/util/selection"
|
||||
|
||||
test("copies semantic selection text, reports attempted partial delivery, and clears immediately", async () => {
|
||||
const notifications: { message: string; variant: "info" | "success" | "warning" | "error" }[] = []
|
||||
const errors: unknown[] = []
|
||||
const writes: string[] = []
|
||||
const events: string[] = []
|
||||
let notify!: () => void
|
||||
const notified = new Promise<void>((resolve) => {
|
||||
notify = resolve
|
||||
})
|
||||
const focus = {
|
||||
hasSelection: () => true,
|
||||
getClipboardText: (text: string) => text.replace("[Pasted ~2 lines]", "first\nsecond"),
|
||||
}
|
||||
const renderer = {
|
||||
getSelection: () => ({
|
||||
getSelectedText: () => "before [Pasted ~2 lines] after",
|
||||
selectedRenderables: [focus],
|
||||
}),
|
||||
clearSelection: () => events.push("clear"),
|
||||
currentFocusedRenderable: focus,
|
||||
}
|
||||
const clipboard: ClipboardService = {
|
||||
async read() {
|
||||
return undefined
|
||||
},
|
||||
async write(text) {
|
||||
events.push("write")
|
||||
writes.push(text)
|
||||
return {
|
||||
delivery: "attempted",
|
||||
partial: true,
|
||||
result: {
|
||||
host: { status: "failed", error: new Error("host failed") },
|
||||
terminal: { status: "attempted", capability: "supported" },
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
expect(
|
||||
Selection.copy(
|
||||
renderer,
|
||||
{
|
||||
show: (notification) => {
|
||||
notifications.push(notification)
|
||||
notify()
|
||||
},
|
||||
error: (error) => errors.push(error),
|
||||
},
|
||||
clipboard,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(writes).toEqual(["before first\nsecond after"])
|
||||
expect(events).toEqual(["write", "clear"])
|
||||
await notified
|
||||
expect(notifications).toEqual([
|
||||
{ message: "Sent to terminal clipboard; host clipboard write failed", variant: "warning" },
|
||||
])
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("reports total selection-copy failure without delaying selection clearing", async () => {
|
||||
const failure = new Error("copy failed")
|
||||
const errors: unknown[] = []
|
||||
const notifications: unknown[] = []
|
||||
let clears = 0
|
||||
let report!: () => void
|
||||
const reported = new Promise<void>((resolve) => {
|
||||
report = resolve
|
||||
})
|
||||
const clipboard: ClipboardService = {
|
||||
async read() {
|
||||
return undefined
|
||||
},
|
||||
async write() {
|
||||
throw failure
|
||||
},
|
||||
}
|
||||
|
||||
expect(
|
||||
Selection.copy(
|
||||
{
|
||||
getSelection: () => ({ getSelectedText: () => "text", selectedRenderables: [] }),
|
||||
clearSelection: () => void clears++,
|
||||
},
|
||||
{
|
||||
show: (notification) => notifications.push(notification),
|
||||
error: (error) => {
|
||||
errors.push(error)
|
||||
report()
|
||||
},
|
||||
},
|
||||
clipboard,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(clears).toBe(1)
|
||||
await reported
|
||||
expect(notifications).toEqual([])
|
||||
expect(errors).toEqual([failure])
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue