diff --git a/bun.lock b/bun.lock index a8ef37689e7..a0517fbdbd0 100644 --- a/bun.lock +++ b/bun.lock @@ -874,7 +874,6 @@ "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@solid-primitives/event-bus": "1.1.2", - "clipboardy": "4.0.0", "diff": "catalog:", "effect": "catalog:", "fuzzysort": "catalog:", @@ -3547,8 +3546,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@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], @@ -3965,7 +3962,7 @@ "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=="], + "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], @@ -4245,7 +4242,7 @@ "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=="], + "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], @@ -4389,8 +4386,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=="], @@ -4729,7 +4724,7 @@ "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-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], @@ -4849,7 +4844,7 @@ "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=="], + "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], @@ -4875,7 +4870,7 @@ "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=="], + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], @@ -5475,7 +5470,7 @@ "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-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], @@ -5507,8 +5502,6 @@ "svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="], - "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=="], @@ -6443,8 +6436,6 @@ "@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], - "@vercel/cli-exec/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], - "@vercel/functions/@vercel/oidc": ["@vercel/oidc@3.8.0", "", { "dependencies": { "@vercel/cli-config": "0.2.0", "@vercel/cli-exec": "1.0.0", "jose": "^5.9.6" } }, "sha512-r00laGW6Pv778RoR6M2NxX91ycSj+PBwVo+fOb9Bif+F0IyUKt25zrvBzfEzQpeAzbqOgPZyQibEWDdDFApd+A=="], "@vercel/nft/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], @@ -6633,9 +6624,11 @@ "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/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], - "execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], + "execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], @@ -6707,8 +6700,6 @@ "node-gyp/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="], - "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], - "openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], @@ -7503,20 +7494,6 @@ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], - "@vercel/cli-exec/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], - - "@vercel/cli-exec/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], - - "@vercel/cli-exec/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], - - "@vercel/cli-exec/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], - - "@vercel/cli-exec/execa/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - - "@vercel/cli-exec/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - - "@vercel/cli-exec/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], - "@vercel/functions/@vercel/oidc/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], "@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], @@ -8599,8 +8576,6 @@ "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], - "@vercel/cli-exec/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], diff --git a/packages/tui/package.json b/packages/tui/package.json index 0fb182f8d35..3800b5355f2 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -89,7 +89,6 @@ "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@solid-primitives/event-bus": "1.1.2", - "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 91b286e7986..97cc6c53ceb 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -88,6 +88,7 @@ import { DialogVariant } from "./component/dialog-variant" import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32" import { destroyRenderer } from "./util/renderer" import { cliErrorMessage, errorFormat } from "./util/error" +import { createTuiClipboard, formatClipboardWriteNotification } from "./clipboard" registerOpencodeSpinner() @@ -236,6 +237,15 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { (renderer) => Effect.sync(() => destroyRenderer(renderer)), ) }) + const clipboard = yield* Effect.acquireRelease( + Effect.sync(() => createTuiClipboard(renderer)), + (clipboard) => + Effect.tryPromise(() => clipboard.dispose()).pipe( + Effect.catch((error) => + Effect.sync(() => log("error", "Failed to dispose TUI clipboard", { error })), + ), + ), + ) win32DisableProcessedInput() const finalizers = new Set<() => Promise>() yield* Effect.addFinalizer(() => @@ -273,10 +283,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { > (exit.epilogue = value)}> - } - > - + } + > + - - + - - + - - + + + @@ -476,8 +486,12 @@ function App(props: { pair?: DialogPairCredentials }) { 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() 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-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 8a168b457d3..c909106685f 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -8,6 +8,7 @@ import type { } from "@opencode-ai/client" import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" import { useClipboard } from "../context/clipboard" +import { formatClipboardWriteNotification } from "../clipboard" import { useData } from "../context/data" import { useClient } from "../context/client" import { Keymap } from "../context/keymap" @@ -452,8 +453,12 @@ function OAuthAuto(props: { run: () => { const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url clipboard - .write?.(value) - .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) + .write(value) + .then((outcome) => + toast.show( + formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" }), + ), + ) .catch(toast.error) }, }, diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx index e1ecd011f49..f31a8d4b858 100644 --- a/packages/tui/src/component/dialog-mcp.tsx +++ b/packages/tui/src/component/dialog-mcp.tsx @@ -9,6 +9,7 @@ import { useTheme } from "../context/theme" import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core" import type { McpServer } from "@opencode-ai/client" import { useClipboard } from "../context/clipboard" +import { formatClipboardWriteNotification } from "../clipboard" import { useToast } from "../ui/toast" import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { useConfig } from "../config" @@ -146,10 +147,14 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) { onMount(() => dialog.setSize("large")) const copy = () => { - if (!clipboard.write) return void clipboard .write(error()) - .then(() => setCopied(true)) + .then((outcome) => { + setCopied(outcome.delivery === "confirmed") + 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 d500e36c0db..ad1d85ae8d6 100644 --- a/packages/tui/src/component/error-component.tsx +++ b/packages/tui/src/component/error-component.tsx @@ -6,13 +6,14 @@ import { useClipboard } from "../context/clipboard" import { useExit } from "../context/exit" import { useTuiApp } from "../context/runtime" 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 app = useTuiApp() - 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. @@ -28,6 +29,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?: onPrimary: "#ffffff", error: "#d1383d", success: "#3d9a57", + warning: "#c18401", } : { bg: "#0a0a0a", @@ -39,6 +41,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." @@ -46,11 +49,27 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?: const issueURL = buildIssueURL(message, stack, app.version) 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() }, ] @@ -135,13 +154,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} @@ -149,7 +173,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?: > {action.label()} @@ -189,9 +213,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 {app.version} diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index ff4b516a889..5d677a4703e 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -421,16 +421,20 @@ export function Prompt(props: PromptProps) { run: async (_input: string | undefined, event?: KeyEvent) => { event?.preventDefault() event?.stopPropagation() - const content = await clipboard.read?.() - if (content?.mime.startsWith("image/")) { - await pasteAttachment({ - filename: "clipboard", - uri: `data:${content.mime};base64,${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", + uri: `data:${content.mime};base64,${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 74e3aaab9b9..32da46f398a 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -8,6 +8,7 @@ import { errorMessage } from "../../util/error" import { DialogFork } from "./dialog-fork" import type { PromptInfo } from "../../prompt/history" import { projectedPromptInput } from "../../prompt/codec" +import { formatClipboardWriteNotification } from "../../clipboard" export function DialogMessage(props: { messageID: string @@ -66,8 +67,19 @@ export function DialogMessage(props: { : "text" in value ? value.text : "" - 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) + } }, }, { diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index 510bc7b6324..e9dff41c267 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -8,6 +8,7 @@ import type { FormField, FormValue } from "@opencode-ai/client" import type { FormWithLocation } from "../../context/data" import { useClient } from "../../context/client" import { useClipboard } from "../../context/clipboard" +import { formatClipboardWriteNotification } from "../../clipboard" import { SplitBorder } from "../../ui/border" import { useToast } from "../../ui/toast" import { Keymap } from "../../context/keymap" @@ -362,12 +363,14 @@ export function FormPrompt(props: { form: FormWithLocation }) { function copyExternal() { const current = externalField() - if (!current || !clipboard.write) return + if (!current) return void clipboard .write(current.url) - .then(() => { + .then((outcome) => { setStore("externalReady", { ...store.externalReady, [current.key]: true }) - toast.show({ message: "Copied URL to clipboard", variant: "info" }) + toast.show( + formatClipboardWriteNotification(outcome, { message: "Copied URL to clipboard", variant: "info" }), + ) }) .catch(toast.error) } @@ -1002,7 +1005,7 @@ export function FormPrompt(props: { form: FormWithLocation }) { > enter {actionLabel()} - + c copy diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 4b1717b4886..1438b16f755 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -73,6 +73,7 @@ import { DialogExportResult } from "../../ui/dialog-export-result" import { sessionEpilogue } from "../../util/presentation" import { useConfig } from "../../config" import { useClipboard } from "../../context/clipboard" +import { formatClipboardWriteNotification } from "../../clipboard" import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../context/thinking" import { getScrollAcceleration } from "../../util/scroll" import { collapseToolOutput } from "../../util/collapse-tool-output" @@ -725,8 +726,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() }, @@ -743,8 +751,13 @@ export function Session() { const sessionData = session() if (!sessionData) return const transcript = formatSessionTranscript(sessionData, messages(), showThinking()) - 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" }) } @@ -786,9 +799,11 @@ export function Session() { })() if (options.action === "copy") { - await clipboard.write?.(content) + const outcome = await clipboard.write(content) dialog.clear() - toast.show({ message: "Copied to clipboard", variant: "success" }) + toast.show( + formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "success" }), + ) return } diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index 8ab8a2269aa..70a7499fc3f 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -7,6 +7,7 @@ import { createStore } from "solid-js/store" import { useToast } from "./toast" import { useClipboard } from "../context/clipboard" import { useConfig } from "../config" +import { formatClipboardWriteNotification } from "../clipboard" export function Dialog( props: ParentProps<{ @@ -202,9 +203,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 a07e3e6b12b..708f994fd07 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -1,14 +1,89 @@ import { expect, mock, test } from "bun:test" import { createTestRenderer } from "@opentui/core/testing" +import type { + ClipboardOptions, + ClipboardService, + HostClipboardOptions, + HostClipboardService, + RendererClipboardBoundary, +} from "@opentui/core" import { Effect, FileSystem } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/util/global" import { createEventStream, createFetch, directory, json } from "./fixture/tui-client" +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 +} + 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[] = [] let started!: () => void const ready = new Promise((resolve) => { @@ -42,18 +117,46 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { expect(setup.renderer.isDestroyed).toBe(true) expect(titles.at(-1)).toBe("") + 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.has(listener))).toBe(true) } finally { if (!setup.renderer.isDestroyed) setup.renderer.destroy() await server.stop() - mock.restore() + restoreOpenTui() } }) test("session lifecycle updates the terminal title and prints the epilogue after 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 })) + 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 + }, + }) let initialTitle!: () => void const initialTitleSet = new Promise((resolve) => { initialTitle = resolve @@ -124,17 +227,162 @@ test("session lifecycle updates the terminal title and prints the epilogue after data: { sessionID: "dummy", title: "Renamed session" }, }) await renamedTitleSet - setup.renderer.destroy() + let settled = false + void task.then( + () => (settled = true), + () => (settled = true), + ) + events.emit({ + id: "evt_exit", + created: 2, + type: "tui.command.execute", + data: { command: "app.exit" }, + }) + await disposeStarted + expect(settled).toBe(false) + expect(stdout).not.toContain("Renamed session") + releaseDispose() await task expect(stdout).toContain("Renamed session") expect(stdout).toContain("opencode2 -s dummy") expect(promptRequests).toBe(0) + expect(clipboard.dispose).toBe(1) + expect(clipboard.hostDispose).toBe(1) } finally { + releaseDispose() process.stdout.write = originalWrite if (!setup.renderer.isDestroyed) setup.renderer.destroy() await server.stop() - 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) + let started!: () => void + const ready = new Promise((resolve) => { + started = resolve + }) + const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer) + setup.renderer.setTerminalTitle = (title) => { + if (title === "OpenCode") started() + setTitle(title) + } + const events = createEventStream() + const calls = createFetch(undefined, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + server: { endpoint: { url: server.url.toString() } }, + config: { get: async () => ({}), update: async () => ({}) }, + packages: { resolve: async () => undefined }, + args: {}, + log: () => {}, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), + ) + await ready + 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() + await server.stop() + 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 events = createEventStream() + const calls = createFetch(undefined, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + + try { + const { run } = await import("../src/app") + await expect( + Effect.runPromise( + run({ + server: { endpoint: { url: server.url.toString() } }, + config: { get: async () => ({}), update: async () => ({}) }, + packages: { resolve: async () => undefined }, + args: {}, + log: () => {}, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), + ), + ).rejects.toThrow("clipboard construction failed") + expect(setup.renderer.isDestroyed).toBe(true) + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await server.stop() + 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") + }, + }) + let started!: () => void + const ready = new Promise((resolve) => { + started = resolve + }) + const titles: string[] = [] + const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer) + setup.renderer.setTerminalTitle = (title) => { + titles.push(title) + if (title === "OpenCode") started() + setTitle(title) + } + const events = createEventStream() + const calls = createFetch(undefined, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + const logs: unknown[] = [] + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + server: { endpoint: { url: server.url.toString() } }, + config: { get: async () => ({}), update: async () => ({}) }, + packages: { resolve: async () => undefined }, + args: {}, + log: (_level, message) => void logs.push(message), + }).pipe( + Effect.provide(AppNodeBuilder.build(Global.node)), + Effect.provide(FileSystem.layerNoop({})), + ), + ) + await ready + setup.renderer.destroy() + await task + + expect(clipboard.dispose).toBe(1) + expect(clipboard.hostDispose).toBe(1) + expect(titles.at(-1)).toBe("") + 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() + await server.stop() + restoreOpenTui() } }) diff --git a/packages/tui/test/cli/tui/form.test.tsx b/packages/tui/test/cli/tui/form.test.tsx index 9aa82f7a896..55fe32bfb59 100644 --- a/packages/tui/test/cli/tui/form.test.tsx +++ b/packages/tui/test/cli/tui/form.test.tsx @@ -60,9 +60,19 @@ async function mountForm(root: string, width = 80) { > 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 e2ef9ba7745..b0072313a50 100644 --- a/packages/tui/test/fixture/tui-environment.tsx +++ b/packages/tui/test/fixture/tui-environment.tsx @@ -7,6 +7,23 @@ import { } from "../../src/context/runtime" import type { ParentProps } from "solid-js" import { LogProvider, type LogSink } from "../../src/context/log" +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<{ @@ -14,6 +31,7 @@ export function TestTuiContexts( directory?: string paths?: Partial log?: LogSink + clipboard?: ClipboardService }>, ) { return ( @@ -28,7 +46,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]) +})