Compare commits

..

7 Commits

Author SHA1 Message Date
Simon Klee f3bb96ef8a chore(tui): upgrade integrated OpenTUI snapshot 2026-07-31 18:36:45 +02:00
Simon Klee 8f9cc9cdf0 chore(tui): upgrade integrated OpenTUI snapshot 2026-07-31 18:36:23 +02:00
Simon Klee 8fa9eb08d5 chore(tui): upgrade integrated OpenTUI snapshot 2026-07-31 18:36:23 +02:00
Simon Klee 7f916c68d0 tui: preserve clipboard paste semantics 2026-07-31 18:36:23 +02:00
Simon Klee 94f8a38869 chore(tui): upgrade OpenTUI snapshot 2026-07-31 18:36:23 +02:00
Simon Klee c1aa9940e1 tui: report clipboard copy delivery status 2026-07-31 18:36:23 +02:00
Simon Klee da6c867884 tui: use OpenTUI clipboard service 2026-07-31 18:36:23 +02:00
29 changed files with 1418 additions and 380 deletions
+31 -56
View File
@@ -621,9 +621,9 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"@opentui/core": "0.0.0-20260731-8e554196",
"@opentui/keymap": "0.0.0-20260731-8e554196",
"@opentui/solid": "0.0.0-20260731-8e554196",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -914,7 +914,6 @@
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
@@ -1101,9 +1100,9 @@
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
},
"overrides": {
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@opentui/core": "0.0.0-20260731-8e554196",
"@opentui/keymap": "0.0.0-20260731-8e554196",
"@opentui/solid": "0.0.0-20260731-8e554196",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:",
@@ -1121,9 +1120,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.4.5",
"@opentui/keymap": "0.4.5",
"@opentui/solid": "0.4.5",
"@opentui/core": "0.0.0-20260731-8e554196",
"@opentui/keymap": "0.0.0-20260731-8e554196",
"@opentui/solid": "0.0.0-20260731-8e554196",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -2162,27 +2161,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="],
"@opentui/core": ["@opentui/core@0.0.0-20260731-8e554196", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.0.0-20260731-8e554196", "@opentui/core-darwin-x64": "0.0.0-20260731-8e554196", "@opentui/core-linux-arm64": "0.0.0-20260731-8e554196", "@opentui/core-linux-arm64-musl": "0.0.0-20260731-8e554196", "@opentui/core-linux-x64": "0.0.0-20260731-8e554196", "@opentui/core-linux-x64-musl": "0.0.0-20260731-8e554196", "@opentui/core-win32-arm64": "0.0.0-20260731-8e554196", "@opentui/core-win32-x64": "0.0.0-20260731-8e554196" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-xXX9wN3NkXwrzNw6yfnbpRkoA5AAhgO8N8p1SNwdXdMxJajyMi5oXB8As/xHkubawEJmPRGF/0LFcRjz0p6riA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20260731-8e554196", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9+Yeyh1rXHjYP0TY+1rmmYZ3gn89to16jVO11Yn9M9ggiC8htHSOT/tg2RWwXhl2Jm4nSpIk9wKF3TdKtU6BFQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20260731-8e554196", "", { "os": "darwin", "cpu": "x64" }, "sha512-DQ9fseLrrygk4WC9FI/8/tx/wVy+6yU8TbhP37wn80HZsuSNvzIwsmifEFwL3fe4l+odkEJ6YCyr8UvRswdbQw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20260731-8e554196", "", { "os": "linux", "cpu": "arm64" }, "sha512-uS7Ej6HdNPB7YGbXtf3JFfjG9dTWEi/YuD+ejGIxLho/zNQUdhnfT9SsrPOIeDWqT0uO5BjXFffcUKD7fG+GdA=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.0.0-20260731-8e554196", "", { "os": "linux", "cpu": "arm64" }, "sha512-b/q4Etu8AK0+0oHcmE4XkpfZCFWIYht7C5bIDeYCa6yQNCVMZeemDx4WEHH7DQDSsev208l01ly+CB0MlhnnOw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20260731-8e554196", "", { "os": "linux", "cpu": "x64" }, "sha512-YB1WokWhHdFm7ytBZkSHyMIuUdLbXeguz9fdt8N1TkI1FMWeVPRANVhk7XrH9zlI/IRqLLvTKUgWKyhbYGuP7A=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.0.0-20260731-8e554196", "", { "os": "linux", "cpu": "x64" }, "sha512-U84Pu0yD3Iuhwmj1SJQMQID69J+v8fykYb/H3acrmKKmCWBf9XBQ0PXP4T2X/GeyXCZdI+I+3rUtlsa/QIyqDg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20260731-8e554196", "", { "os": "win32", "cpu": "arm64" }, "sha512-Kw9jMghKVArQ13bohhgvvkrvEFLWwtAMLrrZLNH1WUH+j5QF4mpnGb+p1K/S/6Gri44HZBANB2gKDCtk0b3g8A=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20260731-8e554196", "", { "os": "win32", "cpu": "x64" }, "sha512-qNbF9a+Om1XmYt6xpKrZmKftYovX+QFwpH3ORT2AfguESkSfzj/fqLivOm0lEZDuDTnXQvM/pwqJOk/UNwCMug=="],
"@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="],
"@opentui/keymap": ["@opentui/keymap@0.0.0-20260731-8e554196", "", { "dependencies": { "@opentui/core": "0.0.0-20260731-8e554196" }, "peerDependencies": { "@opentui/react": "0.0.0-20260731-8e554196", "@opentui/solid": "0.0.0-20260731-8e554196", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-lOD/FqN5y/ydobbzlD9llBK3/mfckfZcK7RRoBuelyxtWCvyASVrj93j1DdiQp4T8hh5Gy0gocZ94egxaVHrNA=="],
"@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="],
"@opentui/solid": ["@opentui/solid@0.0.0-20260731-8e554196", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20260731-8e554196", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-xk3FYM5+wASJ6vdHpTKjgt9gVghLKujzfAGszbdpXOMmmVYwxN/BcV/JtmGwORg4EW2Xk+zQqziI4fmeAaz57g=="],
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
@@ -3530,7 +3529,7 @@
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
"bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
@@ -3610,8 +3609,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=="],
@@ -4028,7 +4025,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=="],
@@ -4310,7 +4307,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=="],
@@ -4454,8 +4451,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=="],
@@ -4794,7 +4789,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=="],
@@ -4914,7 +4909,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=="],
@@ -4940,7 +4935,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=="],
@@ -5540,7 +5535,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=="],
@@ -5572,8 +5567,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=="],
@@ -6514,8 +6507,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.1", "", { "dependencies": { "@vercel/cli-config": "0.2.1", "@vercel/cli-exec": "1.0.0", "jose": "^5.9.6" } }, "sha512-ufdalm2MWOYksyj8KVpWjoOFPJO6zoYpuyvIggIQ2bB0CFCjTCiTkGXHqAKwG77GVRjOaN3/8S5ITlZpXWmqOw=="],
"@vercel/nft/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
@@ -6698,9 +6689,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=="],
@@ -6774,8 +6767,6 @@
"node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
"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=="],
@@ -7550,20 +7541,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=="],
@@ -8618,8 +8595,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=="],
+6 -6
View File
@@ -45,9 +45,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.4.5",
"@opentui/keymap": "0.4.5",
"@opentui/solid": "0.4.5",
"@opentui/core": "0.0.0-20260731-8e554196",
"@opentui/keymap": "0.0.0-20260731-8e554196",
"@opentui/solid": "0.0.0-20260731-8e554196",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
@@ -148,9 +148,9 @@
"electron"
],
"overrides": {
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@opentui/core": "0.0.0-20260731-8e554196",
"@opentui/keymap": "0.0.0-20260731-8e554196",
"@opentui/solid": "0.0.0-20260731-8e554196",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:"
+3 -3
View File
@@ -31,9 +31,9 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"@opentui/core": "0.0.0-20260731-8e554196",
"@opentui/keymap": "0.0.0-20260731-8e554196",
"@opentui/solid": "0.0.0-20260731-8e554196",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
-15
View File
@@ -26,27 +26,12 @@ import type { JSX } from "@opentui/solid"
import type { Store } from "solid-js/store"
export interface Storage {
/**
* Durable JSON state: persisted to disk, survives hot reloads and TUI
* restarts, and stays live-synced across running TUI instances.
*/
store<Value extends object>(
key: string,
options: {
readonly initial: Value
},
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
/**
* Ephemeral in-memory state: survives plugin hot reloads (old and new
* generations share the same live store) and is gone when the TUI exits.
* Updates are synchronous and values need not be JSON-serializable.
*/
memory<Value extends object>(
key: string,
options: {
readonly initial: Value
},
): readonly [Store<Value>, (mutation: (draft: Value) => void) => void]
}
interface LocationCollection<Value> {
-1
View File
@@ -92,7 +92,6 @@
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
+62 -52
View File
@@ -92,6 +92,7 @@ import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
import { StorageProvider } from "./context/storage"
import { createTuiClipboard, formatClipboardWriteNotification } from "./clipboard"
registerOpencodeSpinner()
@@ -258,6 +259,13 @@ 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<void>>()
yield* Effect.addFinalizer(() =>
@@ -293,57 +301,57 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
>
<EpilogueProvider set={(value) => (exit.epilogue = value)}>
<TuiAppProvider value={input.app}>
<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",
}}
<ClipboardProvider value={clipboard}>
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
>
<StorageProvider>
<TuiLifecycleProvider
value={{
add(finalizer) {
finalizers.add(finalizer)
return () => finalizers.delete(finalizer)
},
}}
>
<TuiTerminalEnvironmentProvider
<TuiPathsProvider
value={{
cwd: process.cwd(),
home: global.home,
state: global.state,
worktree: global.data + "/worktree",
}}
>
<StorageProvider>
<TuiLifecycleProvider
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,
add(finalizer) {
finalizers.add(finalizer)
return () => finalizers.delete(finalizer)
},
}}
>
<TuiStartupProvider
<TuiTerminalEnvironmentProvider
value={{
initialRoute: process.env.OPENCODE_STORY
? {
type: "plugin",
id: "opencode.storybook",
name: "storybook",
// OPENCODE_STORY=1 opens the index; any other value opens that story.
data:
process.env.OPENCODE_STORY === "1"
? undefined
: { story: process.env.OPENCODE_STORY },
}
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
: undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<ClipboardProvider>
<TuiStartupProvider
value={{
initialRoute: process.env.OPENCODE_STORY
? {
type: "plugin",
id: "opencode.storybook",
name: "storybook",
// OPENCODE_STORY=1 opens the index; any other value opens that story.
data:
process.env.OPENCODE_STORY === "1"
? undefined
: { story: process.env.OPENCODE_STORY },
}
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
: undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<ArgsProvider {...input.args}>
<ConfigProvider
config={config}
@@ -408,13 +416,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
</Keymap.Provider>
</ConfigProvider>
</ArgsProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiLifecycleProvider>
</StorageProvider>
</TuiPathsProvider>
</ErrorBoundary>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiLifecycleProvider>
</StorageProvider>
</TuiPathsProvider>
</ErrorBoundary>
</ClipboardProvider>
</TuiAppProvider>
</EpilogueProvider>
</ExitProvider>
@@ -509,8 +517,10 @@ 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()
+121 -109
View File
@@ -1,124 +1,136 @@
import { execFile, spawn } from "node:child_process"
import { readFile, rm } from "node:fs/promises"
import { platform, release, tmpdir } from "node:os"
import path from "node:path"
import { promisify } from "node:util"
import {
createClipboard,
createHostClipboard,
createRendererClipboardAdapter,
decodePasteBytes,
type ClipboardService as CoreClipboardService,
type ClipboardWriteResult,
type RendererClipboardBoundary,
} from "@opentui/core"
import type { ClipboardService, ClipboardWriteOutcome } from "./context/clipboard"
const exec = promisify(execFile)
const timeoutMs = 1_000
const maxReadBytes = 8 * 1024 * 1024
function command(command: string, args: string[] = [], input?: string) {
return new Promise<Buffer>((resolve, reject) => {
const child = spawn(command, args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"] })
const output: Buffer[] = []
child.on("error", reject)
child.stdout?.on("data", (chunk: Buffer) => output.push(chunk))
child.on("close", (code) => {
if (code === 0) return resolve(Buffer.concat(output))
reject(new Error(`${command} exited with code ${code}`))
})
if (input !== undefined) child.stdin?.end(input)
})
}
export type ClipboardNotification = Readonly<{
message: string
variant: "info" | "success" | "warning"
}>
function writeOsc52(text: string) {
if (!process.stdout.isTTY) return
const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`
process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${sequence}\x1b\\` : sequence)
}
export type ClipboardCopyState =
| "idle"
| "confirmed"
| "confirmed-partial"
| "attempted"
| "attempted-partial"
| "failed"
export async function read() {
if (platform() === "darwin") {
const file = path.join(tmpdir(), "opencode-clipboard.png")
try {
await exec("osascript", [
"-e",
'set imageData to the clipboard as "PNGf"',
"-e",
`set fileRef to open for access POSIX file "${file}" with write permission`,
"-e",
"set eof fileRef to 0",
"-e",
"write imageData to fileRef",
"-e",
"close access fileRef",
])
return { data: (await readFile(file)).toString("base64"), mime: "image/png" }
} catch {
// Fall through to text clipboard.
} finally {
await rm(file, { force: true }).catch(() => {})
}
}
export type OwnedClipboardService = ClipboardService & Readonly<{ dispose(): Promise<void> }>
if (platform() === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const image = await command("powershell.exe", ["-NonInteractive", "-NoProfile", "-command", script]).catch(() =>
Buffer.alloc(0),
export class ClipboardWriteError extends Error {
readonly result: ClipboardWriteResult
constructor(result: ClipboardWriteResult) {
super(
`Clipboard write failed (host: ${result.host.status}, terminal: ${result.terminal.status})`,
result.host.status === "failed" ? { cause: result.host.error } : undefined,
)
if (image.length) return { data: image.toString().trim(), mime: "image/png" }
}
if (platform() === "linux") {
const wayland = await command("wl-paste", ["-t", "image/png"]).catch(() => Buffer.alloc(0))
if (wayland.length) return { data: wayland.toString("base64"), mime: "image/png" }
const x11 = await command("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]).catch(() =>
Buffer.alloc(0),
)
if (x11.length) return { data: x11.toString("base64"), mime: "image/png" }
}
const { default: clipboardy } = await import("clipboardy")
const text = await clipboardy.read().catch(() => undefined)
if (text) return { data: text, mime: "text/plain" }
}
export function copyCommand(
os: NodeJS.Platform,
wayland: boolean,
has: (name: string) => boolean,
): string[] | undefined {
if (os === "darwin" && has("osascript")) return ["osascript"]
if (os === "linux" && wayland && has("wl-copy")) return ["wl-copy"]
if (os === "linux" && has("xclip")) return ["xclip", "-selection", "clipboard"]
if (os === "linux" && has("xsel")) return ["xsel", "--clipboard", "--input"]
if (os === "win32" && has("powershell.exe")) {
return [
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
]
this.name = "ClipboardWriteError"
this.result = result
}
}
let copyMethod: Promise<(text: string) => Promise<void>> | undefined
export function createTuiClipboard(renderer: RendererClipboardBoundary): OwnedClipboardService {
return createClipboardAdapter(
createClipboard({
host: createHostClipboard({
timeoutMs,
maxReadBytes,
maxWriteBytes: 8 * 1024 * 1024,
maxImagePixels: 64 * 1024 * 1024,
maxConversionBytes: 512 * 1024 * 1024,
maxConcurrentOperations: 16,
maxProviderTransfers: 16,
maxWorkUnitsPerDrain: 64,
}),
terminal: createRendererClipboardAdapter(renderer),
}),
)
}
function getCopyMethod() {
return (copyMethod ??= (async () => {
const { which } = await import("@opencode-ai/core/util/which")
const native = copyCommand(platform(), Boolean(process.env.WAYLAND_DISPLAY), (name) => Boolean(which(name)))
if (native?.[0] === "osascript") {
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await command("osascript", ["-e", `set the clipboard to "${escaped}"`]).catch(() => undefined)
export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedClipboardService {
return {
async read() {
const result = await clipboard.read({
preferredTypes: ["image/png", "text/plain"],
selection: "clipboard",
})
if (result.status !== "read") {
if (result.status === "empty" || result.status === "unsupported" || result.status === "cancelled") return
if (result.status === "failed") throw result.error
if (result.status === "timed-out") throw new Error(`Clipboard read timed out after ${timeoutMs}ms`)
if (result.status === "limit-exceeded") {
throw new RangeError("Clipboard content exceeded configured read or image conversion limits")
}
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 -5
View File
@@ -9,6 +9,7 @@ import { useClipboard } from "../context/clipboard"
import { useToast } from "../ui/toast"
import { describeOS, describeTerminal } from "../util/system"
import { useTuiApp } from "../context/runtime"
import { formatClipboardWriteNotification } from "../clipboard"
export function DialogDebug() {
const theme = useTheme()
@@ -38,13 +39,22 @@ export function DialogDebug() {
const text = entries()
.map((entry) => `${entry.label}: ${entry.value}`)
.join("\n")
setCopied(false)
void clipboard
.write?.(text)
.then(() => {
setCopied(true)
toast.show({ message: "Debug info copied to clipboard", variant: "info" })
.write(text)
.then((outcome) => {
setCopied(outcome.delivery === "confirmed")
toast.show(
formatClipboardWriteNotification(outcome, {
message: "Debug info copied to clipboard",
variant: "info",
}),
)
})
.catch((error) => {
setCopied(false)
toast.error(error)
})
.catch(toast.error)
}
Keymap.createLayer(() => ({
@@ -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)
},
},
+7 -2
View File
@@ -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)
}
+38 -9
View File
@@ -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<ClipboardCopyState>("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?:
<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}
@@ -149,7 +173,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>
@@ -189,9 +213,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 {app.version}</text>
</box>
+15 -12
View File
@@ -382,16 +382,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)
}
},
},
@@ -1393,11 +1397,10 @@ export function Prompt(props: PromptProps) {
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
// Replace CRLF first, then any remaining CR
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
// Windows Terminal <1.25 can surface image-only clipboard as an
// empty bracketed paste. Windows Terminal 1.25+ does not.
if (!pastedContent) {
if (event.bytes.byteLength === 0) {
keymap.dispatch("prompt.paste")
return
}
+16 -9
View File
@@ -1,18 +1,25 @@
import type { ClipboardWriteResult } from "@opentui/core"
import { createContext, type JSX, useContext } from "solid-js"
import { read, write } from "../clipboard"
export type ClipboardContent = Readonly<{ data: string; mime: string }>
export type ClipboardService = Readonly<{
read?(): Promise<ClipboardContent | undefined>
write?(text: string): Promise<void>
export type ClipboardWriteOutcome = Readonly<{
delivery: "confirmed" | "attempted"
partial: boolean
result: ClipboardWriteResult
}>
export type ClipboardService = Readonly<{
read(): Promise<ClipboardContent | undefined>
write(text: string): Promise<ClipboardWriteOutcome>
}>
const clipboard = { read, write }
const ClipboardContext = createContext<ClipboardService>(clipboard)
export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
return <ClipboardContext.Provider value={props.value ?? clipboard}>{props.children}</ClipboardContext.Provider>
const ClipboardContext = createContext<ClipboardService>()
export function ClipboardProvider(props: { value: ClipboardService; children: JSX.Element }) {
return <ClipboardContext.Provider value={props.value}>{props.children}</ClipboardContext.Provider>
}
export function useClipboard() {
return useContext(ClipboardContext)
const value = useContext(ClipboardContext)
if (!value) throw new Error("useClipboard must be used within a ClipboardProvider")
return value
}
+1 -18
View File
@@ -1,5 +1,5 @@
import { batch, createContext, onCleanup, useContext, type ParentProps } from "solid-js"
import { createStore, produce, reconcile, type Store } from "solid-js/store"
import { createStore, reconcile, type Store } from "solid-js/store"
import path from "path"
import { mkdirSync, readFileSync, watch } from "fs"
import { Flock } from "@opencode-ai/util/flock"
@@ -13,20 +13,12 @@ type Options<Value extends object> = {
}
type Entry<Value extends object> = readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
type MemoryEntry<Value extends object> = readonly [Store<Value>, (mutation: (draft: Value) => void) => void]
export interface Storage {
store<Value extends object>(
key: string,
options: Options<Value>,
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
/**
* Ephemeral in-process state. Entries are memoized here, above consumer
* lifecycles, so the same live store survives plugin hot reloads; it is
* gone when the TUI exits. Updates are synchronous and values need not be
* JSON-serializable.
*/
memory<Value extends object>(key: string, options: { readonly initial: Value }): MemoryEntry<Value>
}
function clone<Value extends object>(value: Value) {
@@ -45,7 +37,6 @@ function segment(value: string) {
function createStorage(root: string, channel: string) {
const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>()
const memories = new Map<string, MemoryEntry<object>>()
const directory = path.join(root, segment(channel), "tui")
const locks = path.join(root, segment(channel), "locks")
mkdirSync(directory, { recursive: true })
@@ -82,14 +73,6 @@ function createStorage(root: string, channel: string) {
entries.set(file, { value: entry as Entry<object>, reload })
return entry
},
memory<Value extends object>(key: string, options: { readonly initial: Value }) {
const existing = memories.get(key)
if (existing) return existing as MemoryEntry<Value>
const [store, setStore] = createStore(options.initial)
const entry = [store, (mutation: (draft: Value) => void) => setStore(produce(mutation))] as const
memories.set(key, entry as MemoryEntry<object>)
return entry
},
}
const watcher = watch(directory, () => entries.forEach((entry) => entry.reload()))
-1
View File
@@ -240,7 +240,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
},
storage: {
store: (key, options) => storage.store(`plugin.${item.plugin.id}.${key}`, options),
memory: (key, options) => storage.memory(`plugin.${item.plugin.id}.${key}`, options),
},
ui: {
dialog: dialogApi,
@@ -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)
}
},
},
{
+7 -4
View File
@@ -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"
@@ -364,12 +365,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)
}
@@ -992,7 +995,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
enter <span style={{ fg: theme.text.subdued }}>{actionLabel()}</span>
</text>
<Show when={externalField() && clipboard.write}>
<Show when={externalField()}>
<text fg={theme.text.default} onMouseUp={copyExternal}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
</text>
+21 -6
View File
@@ -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"
@@ -769,8 +770,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()
},
@@ -787,8 +795,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" })
}
@@ -830,9 +843,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
}
+4 -2
View File
@@ -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 type DialogSize = "medium" | "large" | "xlarge"
@@ -204,9 +205,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()
+5 -2
View File
@@ -1,4 +1,5 @@
import type { ClipboardService } from "../context/clipboard"
import { formatClipboardWriteNotification } from "../clipboard"
type Toast = {
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
@@ -35,8 +36,10 @@ export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardServi
focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text
clipboard
?.write?.(clipboardText)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.write(clipboardText)
.then((outcome) =>
toast.show(formatClipboardWriteNotification(outcome, { message: "Copied to clipboard", variant: "info" })),
)
.catch(toast.error)
renderer.clearSelection()
+258 -8
View File
@@ -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<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
}
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<void>((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<void>((resolve) => {
startDispose = resolve
})
const disposeReady = new Promise<void>((resolve) => {
releaseDispose = resolve
})
const clipboard = await mockOpenTuiClipboard(setup.renderer, {
dispose: async () => {
startDispose()
await disposeReady
},
})
let initialTitle!: () => void
const initialTitleSet = new Promise<void>((resolve) => {
initialTitle = resolve
@@ -124,17 +227,164 @@ 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).toBe("")
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<void>((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({
app: { name: "test", version: "test", channel: "test" },
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({
app: { name: "test", version: "test", channel: "test" },
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<void>((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({
app: { name: "test", version: "test", channel: "test" },
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()
}
})
+11 -1
View File
@@ -60,9 +60,19 @@ async function mountForm(root: string, width = 80) {
>
<ClipboardProvider
value={{
async read() {
return undefined
},
write(text) {
copied.push(text)
return Promise.resolve()
return Promise.resolve({
delivery: "confirmed" as const,
partial: false,
result: {
host: { status: "written" as const },
terminal: { status: "not-attempted" as const, capability: "unknown" as const },
},
})
},
}}
>
+262 -10
View File
@@ -1,19 +1,271 @@
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 bytes = new Uint8Array([0, 1, 2, 255])
const clipboard = createClipboardAdapter(
openTuiClipboard({
read: { status: "read", representation: { mimeType: "image/png", bytes } },
onCoreRead: (input) => requests.push(input),
}),
)
expect(await clipboard.read()).toEqual({ data: "AAEC/w==", mime: "image/png" })
expect(bytes).toEqual(new Uint8Array([0, 1, 2, 255]))
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 content exceeded configured read or image conversion limits")
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)
})
@@ -0,0 +1,131 @@
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"
import { TuiAppProvider } from "../../src/context/runtime"
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(
() => (
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<ExitProvider exit={() => {}}>
<ClipboardProvider value={clipboard(async () => scenario.outcome)}>
<ErrorComponent error={new Error("boom")} reset={() => {}} />
</ClipboardProvider>
</ExitProvider>
</TuiAppProvider>
),
{ 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(
() => (
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<ExitProvider exit={() => {}}>
<ClipboardProvider
value={clipboard(async () => {
throw new Error("copy failed")
})}
>
<ErrorComponent error={new Error("boom")} reset={() => {}} />
</ClipboardProvider>
</ExitProvider>
</TuiAppProvider>
),
{ 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()
}
})
@@ -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()
}
})
@@ -1,44 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { mkdtempSync } from "fs"
import { tmpdir } from "os"
import path from "path"
import { TuiAppProvider } from "../../src/context/runtime"
import { StorageProvider, useStorage, type Storage } from "../../src/context/storage"
import { TestTuiContexts } from "../fixture/tui-environment"
test("memory storage is synchronous, keyed, and stable across lookups", async () => {
let storage!: Storage
function Probe() {
storage = useStorage()
return <box />
}
await testRender(() => (
<TestTuiContexts paths={{ state: mkdtempSync(path.join(tmpdir(), "opencode-storage-test-")) }}>
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<StorageProvider>
<Probe />
</StorageProvider>
</TuiAppProvider>
</TestTuiContexts>
))
const [state, update] = storage.memory("tick", { initial: { count: 0, at: undefined as Date | undefined } })
// Synchronous update, no JSON round-trip: a Date survives as-is.
const now = new Date()
update((draft) => {
draft.count += 1
draft.at = now
})
expect(state.count).toBe(1)
expect(state.at).toBe(now)
// Same key returns the same live store (what hot-reload survival relies
// on); a different key is isolated.
const [again] = storage.memory("tick", { initial: { count: 99, at: undefined as Date | undefined } })
expect(again).toBe(state)
expect(again.count).toBe(1)
const [other] = storage.memory("other", { initial: { count: 0 } })
expect(other.count).toBe(0)
})
+21 -1
View File
@@ -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<TuiPaths>
log?: LogSink
clipboard?: ClipboardService
}>,
) {
return (
@@ -28,7 +46,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>
</LogProvider>
+208
View File
@@ -0,0 +1,208 @@
import { afterAll, expect, mock, test } from "bun:test"
import { TextareaRenderable, type ClipboardReadResult, type HostClipboardService } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem } from "effect"
import { createComponent } from "solid-js"
import { Prompt, type PromptRef } from "../../src/component/prompt"
import { createEventStream, createFetch } from "../fixture/tui-client"
const openTui = { ...(await import("@opentui/core")) }
let activeSetup: Awaited<ReturnType<typeof createTestRenderer>> | undefined
let activeHost: HostClipboardService | undefined
let activePromptRef: PromptRef | undefined
await mock.module("@opentui/core", () => ({
...openTui,
createCliRenderer: async () => {
if (!activeSetup) throw new Error("Prompt renderer is not mounted")
return activeSetup.renderer
},
createHostClipboard: () => {
if (!activeHost) throw new Error("Prompt clipboard is not mounted")
return activeHost
},
}))
await mock.module("../../src/routes/home", () => ({
Home: () =>
createComponent(Prompt, {
ref: (value) => (activePromptRef = value),
showPlaceholder: false,
}),
}))
const { run } = await import("../../src/app")
afterAll(() => mock.restore())
async function mountPrompt(read: () => Promise<ClipboardReadResult>) {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
let reads = 0
let ready!: () => void
const mounted = new Promise<void>((resolve) => (ready = resolve))
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
setup.renderer.setTerminalTitle = (title) => {
if (title === "OpenCode") ready()
setTitle(title)
}
const host: HostClipboardService = {
maxWriteBytes: 8 * 1024 * 1024,
async read() {
reads++
return read()
},
async writeText() {
return { status: "written" }
},
async clear() {
return { status: "cleared" }
},
async dispose() {},
}
activeSetup = setup
activeHost = host
activePromptRef = undefined
const events = createEventStream()
const calls = createFetch(undefined, events)
let preloaded!: () => void
const preload = new Promise<void>((resolve) => (preloaded = resolve))
const server = Bun.serve({
port: 0,
fetch: async (request) => {
const response = await calls.fetch(request)
const url = new URL(request.url)
if (url.pathname === "/api/session" && url.searchParams.has("project")) preloaded()
return response
},
})
let task: Promise<unknown> | undefined
try {
task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ prompt: { paste: "full" as const } }), update: async () => ({}) },
packages: { resolve: async () => undefined },
args: {},
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await mounted
await setup.waitFor(() => activePromptRef?.focused === true)
await preload
await Bun.sleep(0)
} catch (error) {
setup.renderer.destroy()
await task?.catch(() => undefined)
await server.stop()
activeSetup = undefined
activeHost = undefined
activePromptRef = undefined
throw error
}
return {
setup,
get input() {
const input = setup.renderer.currentFocusedEditor
if (!(input instanceof TextareaRenderable)) throw new Error("Prompt textarea is not focused")
return input
},
get reads() {
return reads
},
get prompt() {
if (!activePromptRef) throw new Error("Prompt ref is not mounted")
return activePromptRef
},
async dispose() {
activePromptRef?.reset()
setup.renderer.destroy()
await task
await server.stop()
activeSetup = undefined
activeHost = undefined
activePromptRef = undefined
},
}
}
test("inserts nonempty whitespace-only terminal paste without reading the host clipboard", async () => {
const prompt = await mountPrompt(async () => ({ status: "empty" }))
try {
await prompt.setup.mockInput.pasteBracketedText(" \t\n")
await prompt.setup.waitFor(() => prompt.input.plainText === " \t\n")
expect(prompt.input.plainText).toBe(" \t\n")
expect(prompt.reads).toBe(0)
} finally {
await prompt.dispose()
}
})
test("uses one host clipboard read for a zero-byte terminal paste", async () => {
const prompt = await mountPrompt(async () => ({ status: "empty" }))
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.reads === 1)
expect(prompt.input.plainText).toBe("")
expect(prompt.reads).toBe(1)
} finally {
await prompt.dispose()
}
})
test("normalizes host clipboard text once before inserting it", async () => {
const bytes = new TextEncoder().encode("first\r\nsecond\rthird")
const prompt = await mountPrompt(async () => ({ status: "read", representation: { mimeType: "text/plain", bytes } }))
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.input.plainText === "first\nsecond\nthird")
expect(prompt.input.plainText).toBe("first\nsecond\nthird")
expect(prompt.reads).toBe(1)
} finally {
await prompt.dispose()
}
})
test("creates one image mention from PNG clipboard bytes", async () => {
const prompt = await mountPrompt(async () => ({
status: "read",
representation: { mimeType: "image/png", bytes: new Uint8Array([137, 80, 78, 71]) },
}))
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.input.plainText === "[Image 1] ")
expect(prompt.input.plainText).toBe("[Image 1] ")
expect(prompt.input.extmarks.getVirtual()).toHaveLength(1)
expect(prompt.prompt.current.files).toEqual([
{
uri: "data:image/png;base64,iVBORw==",
name: "clipboard",
mention: { start: 0, end: 9, text: "[Image 1]" },
},
])
expect(prompt.reads).toBe(1)
} finally {
await prompt.dispose()
}
})
test("shows clipboard read failures without changing prompt state", async () => {
const prompt = await mountPrompt(async () => ({ status: "failed", error: new Error("clipboard read failed") }))
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitForFrame((frame) => frame.includes("clipboard read failed"))
expect(prompt.input.plainText).toBe("")
expect(prompt.input.extmarks.getAll()).toHaveLength(0)
expect(prompt.reads).toBe(1)
} finally {
await prompt.dispose()
}
})
+104
View File
@@ -0,0 +1,104 @@
import { expect, test } from "bun:test"
import type { ClipboardService } from "../../src/context/clipboard"
import { Selection } from "../../src/util/selection"
test("copies semantic selection text, reports attempted partial delivery, and clears immediately", async () => {
const notifications: { message: string; variant: "info" | "success" | "warning" | "error" }[] = []
const errors: unknown[] = []
const writes: string[] = []
const events: string[] = []
let notify!: () => void
const notified = new Promise<void>((resolve) => {
notify = resolve
})
const focus = {
hasSelection: () => true,
getClipboardText: (text: string) => text.replace("[Pasted ~2 lines]", "first\nsecond"),
}
const renderer = {
getSelection: () => ({
getSelectedText: () => "before [Pasted ~2 lines] after",
selectedRenderables: [focus],
}),
clearSelection: () => events.push("clear"),
currentFocusedRenderable: focus,
}
const clipboard: ClipboardService = {
async read() {
return undefined
},
async write(text) {
events.push("write")
writes.push(text)
return {
delivery: "attempted",
partial: true,
result: {
host: { status: "failed", error: new Error("host failed") },
terminal: { status: "attempted", capability: "supported" },
},
}
},
}
expect(
Selection.copy(
renderer,
{
show: (notification) => {
notifications.push(notification)
notify()
},
error: (error) => errors.push(error),
},
clipboard,
),
).toBe(true)
expect(writes).toEqual(["before first\nsecond after"])
expect(events).toEqual(["write", "clear"])
await notified
expect(notifications).toEqual([
{ message: "Sent to terminal clipboard; host clipboard write failed", variant: "warning" },
])
expect(errors).toEqual([])
})
test("reports total selection-copy failure without delaying selection clearing", async () => {
const failure = new Error("copy failed")
const errors: unknown[] = []
const notifications: unknown[] = []
let clears = 0
let report!: () => void
const reported = new Promise<void>((resolve) => {
report = resolve
})
const clipboard: ClipboardService = {
async read() {
return undefined
},
async write() {
throw failure
},
}
expect(
Selection.copy(
{
getSelection: () => ({ getSelectedText: () => "text", selectedRenderables: [] }),
clearSelection: () => void clears++,
},
{
show: (notification) => notifications.push(notification),
error: (error) => {
errors.push(error)
report()
},
},
clipboard,
),
).toBe(true)
expect(clears).toBe(1)
await reported
expect(notifications).toEqual([])
expect(errors).toEqual([failure])
})