mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-19 07:16:26 +00:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
547afa509e | ||
|
|
dd71cdbd84 | ||
|
|
1c90e8f01c | ||
|
|
9843d13c29 | ||
|
|
871db0025e | ||
|
|
7b7a67080e | ||
|
|
3361953fe1 | ||
|
|
e9201ce933 | ||
|
|
b38d72e174 | ||
|
|
e1e2efe8ec | ||
|
|
c2b171d7c4 | ||
|
|
c3bf8864b9 | ||
|
|
3693f56374 | ||
|
|
971f4e558e | ||
|
|
dd95e3f2ed | ||
|
|
dfde4628e3 | ||
|
|
e755fce13d | ||
|
|
5b565e8923 | ||
|
|
ec8d95f1f2 | ||
|
|
ea52d18ec5 | ||
|
|
0f9504d6c5 | ||
|
|
f6353a48b5 | ||
|
|
4facc03371 | ||
|
|
485a027c1a | ||
|
|
988a66ca4f | ||
|
|
98c6b41573 | ||
|
|
4ee9bf0213 | ||
|
|
681338c533 | ||
|
|
5464aa4a74 | ||
|
|
36083c9520 | ||
|
|
168aba6183 | ||
|
|
ec19dc09c7 | ||
|
|
fca4701688 | ||
|
|
c720ca20ed | ||
|
|
dae1e8abbd | ||
|
|
ffe4d1522c | ||
|
|
b1860465cd | ||
|
|
cab11795e5 | ||
|
|
810e79a6d4 |
@@ -87,7 +87,6 @@
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"luxon": "catalog:",
|
||||
"qr-scanner": "1.4.2",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
@@ -100,7 +99,6 @@
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"diff": "catalog:",
|
||||
@@ -414,9 +412,7 @@
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
"electron-log": "^5",
|
||||
"electron-store": "11.0.2",
|
||||
"electron-updater": "6.8.9",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"lighthouse": "13.4.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -3343,8 +3339,6 @@
|
||||
|
||||
"ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"ajv-i18n": ["ajv-i18n@4.2.0", "", { "peerDependencies": { "ajv": "^8.0.0-beta.0" } }, "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg=="],
|
||||
|
||||
"am-i-vibing": ["am-i-vibing@0.4.0", "", { "dependencies": { "process-ancestry": "^0.1.0" }, "bin": { "am-i-vibing": "dist/cli.mjs" } }, "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg=="],
|
||||
@@ -3639,8 +3633,6 @@
|
||||
|
||||
"condense-newlines": ["condense-newlines@0.2.1", "", { "dependencies": { "extend-shallow": "^2.0.1", "is-whitespace": "^0.3.0", "kind-of": "^3.0.2" } }, "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg=="],
|
||||
|
||||
"conf": ["conf@15.1.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og=="],
|
||||
|
||||
"config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="],
|
||||
|
||||
"configstore": ["configstore@7.1.0", "", { "dependencies": { "atomically": "^2.0.3", "dot-prop": "^9.0.0", "graceful-fs": "^4.2.11", "xdg-basedir": "^5.1.0" } }, "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg=="],
|
||||
@@ -3779,8 +3771,6 @@
|
||||
|
||||
"db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="],
|
||||
|
||||
"debounce-fn": ["debounce-fn@6.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
@@ -3907,16 +3897,12 @@
|
||||
|
||||
"electron-publish": ["electron-publish@26.15.3", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q=="],
|
||||
|
||||
"electron-store": ["electron-store@11.0.2", "", { "dependencies": { "conf": "^15.0.2", "type-fest": "^5.0.1" } }, "sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.411", "", {}, "sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg=="],
|
||||
|
||||
"electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="],
|
||||
|
||||
"electron-vite": ["electron-vite@6.0.0-beta.1", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-arrow-functions": "^7.27.1", "cac": "^7.0.0", "esbuild": "^0.25.11", "magic-string": "^0.30.21", "picocolors": "^1.1.1" }, "peerDependencies": { "@swc/core": "^1.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@swc/core"], "bin": { "electron-vite": "bin/electron-vite.js" } }, "sha512-jltST77AwNxIeTTDtYhnIEA8ZM0RW9jmSJcqS8x/dQcgeeLlxlcJoYNNJ1tv7/Swor0AbXMYteBGdezoAOt+Nw=="],
|
||||
|
||||
"electron-window-state": ["electron-window-state@5.0.3", "", { "dependencies": { "jsonfile": "^4.0.0", "mkdirp": "^0.5.1" } }, "sha512-1mNTwCfkolXl3kMf50yW3vE2lZj0y92P/HYWFBrb+v2S/pCka5mdwN3cagKm458A7NjndSwijynXgcLWRodsVg=="],
|
||||
|
||||
"electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="],
|
||||
|
||||
"emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="],
|
||||
@@ -4461,8 +4447,6 @@
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"json-stringify-nice": ["json-stringify-nice@1.1.4", "", {}, "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw=="],
|
||||
|
||||
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
|
||||
@@ -4473,7 +4457,7 @@
|
||||
|
||||
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
|
||||
|
||||
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="],
|
||||
|
||||
@@ -4735,8 +4719,6 @@
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||
|
||||
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||
|
||||
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
|
||||
@@ -5491,8 +5473,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=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
@@ -5617,8 +5597,6 @@
|
||||
|
||||
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
|
||||
|
||||
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
|
||||
|
||||
"ulid": ["ulid@3.0.1", "", { "bin": { "ulid": "dist/cli.js" } }, "sha512-dPJyqPzx8preQhqq24bBG1YNkvigm87K8kVEHCD+ruZg24t6IFEFv00xMWfxcC4djmFtiTLdFuADn4+DOz6R7Q=="],
|
||||
|
||||
"ultrahtml": ["ultrahtml@1.7.0", "", {}, "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g=="],
|
||||
@@ -6445,10 +6423,6 @@
|
||||
|
||||
"condense-newlines/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="],
|
||||
|
||||
"conf/dot-prop": ["dot-prop@10.2.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw=="],
|
||||
|
||||
"conf/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
|
||||
"config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
|
||||
|
||||
"configstore/dot-prop": ["dot-prop@9.0.0", "", { "dependencies": { "type-fest": "^4.18.2" } }, "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ=="],
|
||||
@@ -6489,8 +6463,6 @@
|
||||
|
||||
"electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
|
||||
|
||||
"electron-store/type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
|
||||
|
||||
"electron-updater/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
|
||||
|
||||
"electron-updater/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
@@ -6521,8 +6493,6 @@
|
||||
|
||||
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
|
||||
@@ -6925,18 +6895,12 @@
|
||||
|
||||
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="],
|
||||
|
||||
"@electron/fuses/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
"@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||
|
||||
"@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
"@electron/notarize/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"@electron/universal/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"@electron/universal/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
|
||||
|
||||
"@electron/windows-sign/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"@expressive-code/plugin-shiki/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="],
|
||||
|
||||
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="],
|
||||
@@ -7013,8 +6977,6 @@
|
||||
|
||||
"@jsx-email/doiuse-email/htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="],
|
||||
@@ -7393,8 +7355,6 @@
|
||||
|
||||
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"conf/dot-prop/type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
|
||||
|
||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
|
||||
@@ -7419,6 +7379,8 @@
|
||||
|
||||
"electron-updater/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"electron-winstaller/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||
|
||||
"electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
"electron/@electron/get/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
@@ -7567,8 +7529,6 @@
|
||||
|
||||
"tw-to-css/tailwindcss/postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
|
||||
|
||||
"unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="],
|
||||
@@ -7577,8 +7537,6 @@
|
||||
|
||||
"vscode-languageserver/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
|
||||
|
||||
"workbox-build/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-TlCPtuIvTNF01fLI0yHCLzVkx53yF9xxvAObDMGeo8M=",
|
||||
"aarch64-linux": "sha256-aEjyPIXHeA5w1iZXbPKJAOIJQBfXg+LAx/y7C5uFz4g=",
|
||||
"aarch64-darwin": "sha256-lthMTio2qNy+H2tL9cAPmqC41O+wdSADI61cnW4dIb0=",
|
||||
"x86_64-darwin": "sha256-ONFbRQnwzOYHo0Pyh327QfJG2uzqzNEn/SS2vbYYMdM="
|
||||
"x86_64-linux": "sha256-TRfKunG6/UE8rQFyRkx/pX+pe7GT542IJWLEKcFFfAY=",
|
||||
"aarch64-linux": "sha256-JxCYBQoHeJVuEMEe4Ef5f6UxxUCAuhPo96jqMT047Fc=",
|
||||
"aarch64-darwin": "sha256-UEgjeQMivNC7JVsLEDlNbuqp9LJH84f9nHgLHTZ2zDI=",
|
||||
"x86_64-darwin": "sha256-jEs/Oadjf2LVAinY0xyFna0V+NTwoCKXiXmQ83OchF8="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many.
|
||||
|
||||
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
|
||||
- Prefer forward compatibility for provider-defined options that OpenCode only passes through. For pass-through string enums, expose known values for autocomplete while accepting future values with `Known | (string & {})`, and accept any string at runtime. Closed literals are appropriate when OpenCode branches on a value, transforms its associated structure, or otherwise cannot correctly handle an unknown variant. New options whose shape or behavior requires implementation remain unsupported until they are handled; do not blindly forward unknown structures.
|
||||
- Order reasoning-effort values from lowest to highest: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Provider-specific subsets follow the same relative order in types, schemas, option lists, and tests.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -83,7 +83,7 @@ const AnthropicCacheControl = Schema.Struct({
|
||||
type: Schema.tag("ephemeral"),
|
||||
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
|
||||
})
|
||||
const AnthropicServiceTier = Schema.Literals(["auto", "standard_only"])
|
||||
const AnthropicServiceTier = knownString<"auto" | "standard_only">()
|
||||
|
||||
const AnthropicTextBlock = Schema.Struct({
|
||||
type: Schema.tag("text"),
|
||||
@@ -276,7 +276,7 @@ const AnthropicThinkingBlockBinding = Schema.Struct({
|
||||
})
|
||||
|
||||
const AnthropicThinkingFields = {
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
display: Schema.optional(knownString<"summarized" | "omitted">()),
|
||||
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
|
||||
}
|
||||
const AnthropicThinkingEnabled = Schema.Struct({
|
||||
|
||||
@@ -4,6 +4,22 @@ import { Anthropic } from "../../src/providers.js"
|
||||
const model = Anthropic.provider.model("claude-sonnet-4-5")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { thinking: { type: "adaptive" } } })
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: {
|
||||
serviceTier: "future-tier",
|
||||
thinking: { type: "adaptive", display: "future-display" },
|
||||
},
|
||||
})
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: {
|
||||
// @ts-expect-error Anthropic cache TTL values are protocol constraints.
|
||||
cacheControl: { type: "ephemeral", ttl: "future-ttl" },
|
||||
},
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
|
||||
@@ -215,13 +215,31 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forwards unknown values for pass-through string enums", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: {
|
||||
service_tier: "future-tier",
|
||||
thinking: { type: "adaptive", display: "future-display" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
service_tier: "future-tier",
|
||||
thinking: { type: "adaptive", display: "future-display" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores unknown provider options and rejects malformed known ones", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { unknownOption: true } }))
|
||||
const malformed = [
|
||||
{ service_tier: "fast" },
|
||||
{ service_tier: 1 },
|
||||
{ metadata: { user_id: 42 } },
|
||||
{ cache_control: { type: "ephemeral", ttl: "2h" } },
|
||||
{ cache_control: { type: "ephemeral", ttl: "future-ttl" } },
|
||||
{ output_config: { format: { type: "text" } } },
|
||||
{ thinking: { type: "automatic" } },
|
||||
]
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"diff": "catalog:",
|
||||
@@ -88,7 +87,6 @@
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"luxon": "catalog:",
|
||||
"qr-scanner": "1.4.2",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import { prependHistoryEntry, type PromptHistoryComment } from "./entry"
|
||||
import { prependHistoryEntry, removeHistoryEntry, type PromptHistoryComment } from "./entry"
|
||||
import { Schema } from "effect"
|
||||
import { PromptHistoryState } from "../schema"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
@@ -36,6 +36,24 @@ describe("Composer history", () => {
|
||||
expect(dedupedComments).toBe(commentsOnly)
|
||||
})
|
||||
|
||||
test("removeHistoryEntry drops the entry recorded for a prompt and leaves others alone", () => {
|
||||
const image: Prompt = [
|
||||
{ type: "text", content: "look", start: 0, end: 4 },
|
||||
{ type: "image", id: "img", filename: "big.png", mime: "image/png", blob: { id: "hash", url: "" } },
|
||||
]
|
||||
const entries = prependHistoryEntry(prependHistoryEntry([], text("earlier")), image, [comment("c1")])
|
||||
expect(entries).toHaveLength(2)
|
||||
|
||||
const untouched = removeHistoryEntry(entries, text("never sent"))
|
||||
expect(untouched).toBe(entries)
|
||||
|
||||
const withoutComments = removeHistoryEntry(entries, image)
|
||||
expect(withoutComments).toBe(entries)
|
||||
|
||||
const removed = removeHistoryEntry(entries, image, [comment("c1")])
|
||||
expect(removed).toEqual(prependHistoryEntry([], text("earlier")))
|
||||
})
|
||||
|
||||
test("insertion isolates canonical entries from source mutations", () => {
|
||||
const prompt: Prompt = [
|
||||
{
|
||||
|
||||
@@ -48,6 +48,18 @@ export function prependHistoryEntry(
|
||||
return [entry, ...entries].slice(0, max)
|
||||
}
|
||||
|
||||
// A send that failed puts its prompt back in the composer, so the entry recorded for it would
|
||||
// only duplicate the draft and keep its attachments referenced for as long as history holds it.
|
||||
export function removeHistoryEntry(
|
||||
entries: PromptHistoryStoredEntry[],
|
||||
prompt: Prompt,
|
||||
comments: PromptHistoryComment[] = [],
|
||||
) {
|
||||
const entry = { prompt, comments } satisfies PromptHistoryEntry
|
||||
const next = entries.filter((item) => !isPromptEqual(item, entry))
|
||||
return next.length === entries.length ? entries : next
|
||||
}
|
||||
|
||||
function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryComment) {
|
||||
return (
|
||||
commentA.path === commentB.path &&
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import {
|
||||
clonePromptHistoryComments,
|
||||
prependHistoryEntry,
|
||||
removeHistoryEntry,
|
||||
type PromptHistoryComment,
|
||||
type PromptHistoryStoredEntry,
|
||||
} from "./entry"
|
||||
@@ -13,6 +14,7 @@ import { PromptHistoryState } from "../schema"
|
||||
export type ComposerHistoryStore = {
|
||||
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
||||
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||
remove: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||
}
|
||||
|
||||
type PromptHistoryState = typeof PromptHistoryState.Type
|
||||
@@ -32,6 +34,13 @@ function createComposerHistoryStore(
|
||||
if (next === current.entries) return
|
||||
setCurrent("entries", next)
|
||||
},
|
||||
remove(prompt, mode, comments) {
|
||||
const current = mode === "shell" ? shell : normal
|
||||
const setCurrent = mode === "shell" ? setShell : setNormal
|
||||
const next = removeHistoryEntry(current.entries, prompt, comments)
|
||||
if (next === current.entries) return
|
||||
setCurrent("entries", next)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,5 +65,12 @@ export function createComposerHistory() {
|
||||
const metadata = clonePromptHistoryComments(comments)
|
||||
void ready.then(() => history.add(saved, mode, metadata))
|
||||
},
|
||||
remove(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
|
||||
const ready = mode === "shell" ? shellInit : normalInit
|
||||
if (!(ready instanceof Promise)) return history.remove(prompt, mode, comments)
|
||||
const saved = clonePrompt(prompt)
|
||||
const metadata = clonePromptHistoryComments(comments)
|
||||
void ready.then(() => history.remove(saved, mode, metadata))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +264,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
editor: () => editor,
|
||||
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
|
||||
addToHistory: (value, mode) => controller.addHistory(value, mode),
|
||||
removeFromHistory: (value, mode, comments) => history.remove(value, mode, mode === "shell" ? [] : comments),
|
||||
resetHistory: () => controller.resetHistory(),
|
||||
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
|
||||
closePopover: () => controller.dispatch({ type: "popover.close" }),
|
||||
|
||||
@@ -53,6 +53,7 @@ function submitInput(
|
||||
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
|
||||
mode: "normal" | "shell" = "normal",
|
||||
commands: () => readonly { name: string }[] | undefined = () => [],
|
||||
history: string[] = [],
|
||||
) {
|
||||
return createComposerSubmit({
|
||||
adapter,
|
||||
@@ -60,7 +61,9 @@ function submitInput(
|
||||
commands,
|
||||
editor: () => undefined,
|
||||
queueScroll() {},
|
||||
addToHistory() {},
|
||||
addToHistory: (prompt) => history.push(`add:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
|
||||
removeFromHistory: (prompt) =>
|
||||
history.push(`remove:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
|
||||
resetHistory() {},
|
||||
setMode() {},
|
||||
closePopover() {},
|
||||
@@ -533,12 +536,8 @@ describe("Composer submission", () => {
|
||||
missingSelection() {},
|
||||
failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()),
|
||||
}
|
||||
const submission = submitInput(
|
||||
adapter,
|
||||
notify,
|
||||
"normal",
|
||||
() => [],
|
||||
)
|
||||
const history: string[] = []
|
||||
const submission = submitInput(adapter, notify, "normal", () => [], history)
|
||||
|
||||
await submission.submit(new Event("submit"))
|
||||
await first.promise
|
||||
@@ -549,6 +548,8 @@ describe("Composer submission", () => {
|
||||
expect(new Set(attempts).size).toBe(1)
|
||||
expect(statuses).toEqual(["running", "idle", "running", "idle"])
|
||||
expect(state.current()).toMatchObject([{ type: "text", content: text }])
|
||||
// The restored prompt is the draft again, so history does not also keep it (and its attachments).
|
||||
expect(history).toEqual([`add:${text}`, `remove:${text}`, `add:${text}`, `remove:${text}`])
|
||||
})
|
||||
|
||||
test("forwards structured mentions to custom commands", async () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ type ComposerSubmitInput = {
|
||||
editor: () => HTMLDivElement | undefined
|
||||
queueScroll: () => void
|
||||
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
|
||||
removeFromHistory: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||
resetHistory: () => void
|
||||
setMode: (mode: "normal" | "shell") => void
|
||||
closePopover: () => void
|
||||
@@ -250,6 +251,8 @@ function restoreSubmission(
|
||||
) {
|
||||
const restored = submission.restore()
|
||||
if (!restored) return false
|
||||
// The prompt is back in the composer; its history entry would only keep attachments referenced.
|
||||
input.removeFromHistory(value.prompt, value.mode, comments)
|
||||
restored.target.set(restored.prompt, promptLength(restored.prompt))
|
||||
restored.target.mode.set(value.mode)
|
||||
restored.target.context.replaceComments(
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode/ui/dialog"
|
||||
import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { DateTime } from "luxon"
|
||||
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
|
||||
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
@@ -327,19 +326,19 @@ export function homeSessionSearchKey(record: HomeSessionRecord) {
|
||||
return `${pathKey(record.session.location.directory)}:${record.session.id}`
|
||||
}
|
||||
|
||||
// Calendar day in the local time zone, comparable as a number.
|
||||
function localDay(date: Date) {
|
||||
return date.getFullYear() * 10_000 + date.getMonth() * 100 + date.getDate()
|
||||
}
|
||||
|
||||
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
|
||||
const now = DateTime.local()
|
||||
const yesterday = now.minus({ days: 1 })
|
||||
const todaySessions = records.filter((record) =>
|
||||
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"),
|
||||
)
|
||||
const yesterdaySessions = records.filter((record) =>
|
||||
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"),
|
||||
)
|
||||
const olderSessions = records.filter((record) => {
|
||||
const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created)
|
||||
return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day")
|
||||
})
|
||||
const now = new Date()
|
||||
const today = localDay(now)
|
||||
const yesterday = localDay(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
|
||||
const day = (record: HomeSessionRecord) => localDay(new Date(record.session.time.updated ?? record.session.time.created))
|
||||
const todaySessions = records.filter((record) => day(record) === today)
|
||||
const yesterdaySessions = records.filter((record) => day(record) === yesterday)
|
||||
const olderSessions = records.filter((record) => day(record) !== today && day(record) !== yesterday)
|
||||
const olderTitle =
|
||||
todaySessions.length === 0 && yesterdaySessions.length === 0
|
||||
? language.t("sidebar.project.recentSessions")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import { DateTime } from "luxon"
|
||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
@@ -9,6 +8,8 @@ export type ModelKey = { providerID: string; modelID: string }
|
||||
|
||||
type Visibility = "show" | "hide"
|
||||
const RECENT_LIMIT = 5
|
||||
// luxon's diffNow().as("months") used an average month; keep the same window.
|
||||
const sixMonths = 6 * 30.436875 * 24 * 60 * 60 * 1000
|
||||
|
||||
function modelKey(model: ModelKey) {
|
||||
return `${model.providerID}:${model.modelID}`
|
||||
@@ -29,27 +30,23 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
),
|
||||
)
|
||||
|
||||
// Release dates as epoch ms; an unparseable date is NaN and never counts as recent.
|
||||
const release = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
available().map((model) => {
|
||||
const parsed = DateTime.fromISO(model.release_date)
|
||||
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
|
||||
}),
|
||||
available().map(
|
||||
(model) => [modelKey({ providerID: model.provider.id, modelID: model.id }), Date.parse(model.release_date)] as const,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const latest = createMemo(() =>
|
||||
pipe(
|
||||
available(),
|
||||
filter(
|
||||
(x) =>
|
||||
Math.abs(
|
||||
(release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
|
||||
.diffNow()
|
||||
.as("months"),
|
||||
) < 6,
|
||||
),
|
||||
filter((x) => {
|
||||
const released = release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? NaN
|
||||
return Math.abs(Date.now() - released) < sixMonths
|
||||
}),
|
||||
groupBy((x) => x.provider.id),
|
||||
mapValues((models) =>
|
||||
pipe(
|
||||
@@ -101,9 +98,8 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
if (state === "hide") return false
|
||||
if (state === "show") return true
|
||||
if (latestSet().has(key)) return true
|
||||
const date = release().get(key)
|
||||
if (!date?.isValid) return true
|
||||
return false
|
||||
// Models without a parseable release date stay visible.
|
||||
return !Number.isFinite(release().get(key) ?? NaN)
|
||||
}
|
||||
|
||||
const setVisibility = (model: ModelKey, state: boolean) => {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { DateTime } from "luxon"
|
||||
|
||||
export function createSessionContextFormatter(locale: string) {
|
||||
// The fields luxon's DATETIME_MED preset passed to Intl; output is identical.
|
||||
const dateTime = new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})
|
||||
return {
|
||||
number(value: number | null | undefined) {
|
||||
if (value === undefined) return "—"
|
||||
@@ -14,7 +20,7 @@ export function createSessionContextFormatter(locale: string) {
|
||||
},
|
||||
time(value: number | undefined) {
|
||||
if (!value) return "—"
|
||||
return DateTime.fromMillis(value).setLocale(locale).toLocaleString(DateTime.DATETIME_MED)
|
||||
return dateTime.format(value)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,17 +36,17 @@ export function ErrorOverlay(props: { component: string; error: unknown; onClose
|
||||
<Dialog centered onClose={props.onClose}>
|
||||
<box maxHeight={Math.max(1, dimensions().height - 3)} paddingX={2} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
|
||||
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.text.feedback.error.base} attributes={TextAttributes.BOLD}>
|
||||
Error while hot reloading
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={props.onClose}>
|
||||
<text fg={theme.text.muted} onMouseUp={props.onClose}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.default}>
|
||||
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.base}>
|
||||
{props.error instanceof Error ? props.error.message : String(props.error)}
|
||||
</text>
|
||||
<text flexShrink={0} fg={theme.text.subdued}>
|
||||
<text flexShrink={0} fg={theme.text.muted}>
|
||||
{props.component} · Fix the component and save to retry.
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
await $`rm -rf dist`
|
||||
await $`bun tsc -p tsconfig.build.json`
|
||||
const root = path.resolve("src")
|
||||
const result = await Bun.build({
|
||||
entrypoints: await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: root, absolute: true })).then((files) =>
|
||||
files.filter((file) => !file.endsWith(".d.ts")),
|
||||
),
|
||||
root,
|
||||
outdir: "dist",
|
||||
target: "node",
|
||||
format: "esm",
|
||||
packages: "external",
|
||||
splitting: true,
|
||||
naming: {
|
||||
entry: "[dir]/[name].[ext]",
|
||||
chunk: "chunks/[name]-[hash].[ext]",
|
||||
},
|
||||
})
|
||||
if (!result.success) throw new AggregateError(result.logs, "Failed to build Client")
|
||||
|
||||
@@ -131,7 +131,9 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
}).pipe(
|
||||
Effect.repeat({
|
||||
until: Option.isSome,
|
||||
schedule: Schedule.max([Schedule.spaced(timing.pollInterval), Schedule.recurs(timing.attempts)]),
|
||||
// Probes run sequentially, so a slow probe stretches each iteration; bound the loop by wall clock
|
||||
// like the Promise variant rather than by attempt count.
|
||||
schedule: Schedule.spaced(timing.pollInterval).pipe(Schedule.upTo({ duration: timing.promiseTimeout })),
|
||||
}),
|
||||
Effect.ensuring(Effect.sync(() => contenders.forEach((contender) => contender.release()))),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export type EnsureTiming = {
|
||||
readonly pollInterval: number
|
||||
readonly attempts: number
|
||||
readonly requestTimeout: number
|
||||
readonly spawnDelay: number
|
||||
readonly maxSpawnDelay: number
|
||||
@@ -11,9 +10,11 @@ export type EnsureTiming = {
|
||||
|
||||
const timings = new WeakMap<object, EnsureTiming>()
|
||||
|
||||
// A freshly spawned service registers in ~250 ms, so the poll cadence is a large share of the
|
||||
// time a client waits for it. Probes are sequential, so the wait between them only matters while
|
||||
// the connection is refused; both variants give up after promiseTimeout of wall-clock time.
|
||||
export const defaultEnsureTiming: EnsureTiming = {
|
||||
pollInterval: 100,
|
||||
attempts: 1_200,
|
||||
pollInterval: 25,
|
||||
requestTimeout: 2_000,
|
||||
spawnDelay: 5_000,
|
||||
maxSpawnDelay: 30_000,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { withEnsureTiming } from "../../src/service-timing"
|
||||
|
||||
const timing = {
|
||||
pollInterval: 20,
|
||||
attempts: 120,
|
||||
requestTimeout: 100,
|
||||
spawnDelay: 200,
|
||||
maxSpawnDelay: 1_200,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as Watcher from "./watcher.js"
|
||||
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import { createWrapper } from "@parcel/watcher/wrapper.js"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { FileSystem } from "@opencode/schema/filesystem"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
|
||||
@@ -453,6 +453,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
tool: {
|
||||
transform: tools.transform,
|
||||
reload: tools.reload,
|
||||
list: tools.list,
|
||||
hook: (name, callback) => hooks.register("tool", name, callback),
|
||||
},
|
||||
vcs: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { IntegrationConnection } from "../../integration/connection.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { WebSearch } from "../../websearch.js"
|
||||
import { ConfigProvider } from "@opencode/schema/config/provider"
|
||||
@@ -131,7 +132,13 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
const config = credential
|
||||
? yield* fetchConfig(http, credential).pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
|
||||
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(
|
||||
Effect.as(
|
||||
IntegrationConnection.key(snapshot.connection) === IntegrationConnection.key(connection)
|
||||
? snapshot.config
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as ToolInputRepairPlugin from "./tool-input-repair.js"
|
||||
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import type { ToolEditor } from "@opencode/plugin/effect/tool"
|
||||
import { Effect, JsonSchema, Option, Predicate, Schema } from "effect"
|
||||
import { definition } from "../tool/runtime.js"
|
||||
|
||||
@@ -21,25 +20,19 @@ const maxDepth = 6
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.tool.input.repair",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
let get: ToolEditor["get"] = () => undefined
|
||||
yield* ctx.tool.transform((draft) => {
|
||||
// The draft sees later tool transforms too; reload replaces this lookup.
|
||||
get = draft.get
|
||||
})
|
||||
yield* ctx.tool.hook("execute.before", (event) =>
|
||||
Effect.sync(() => {
|
||||
effect: (ctx) =>
|
||||
ctx.tool.hook("execute.before", (event) =>
|
||||
Effect.gen(function* () {
|
||||
// The outer Code Mode tool is built per snapshot rather than registered, so it cannot be
|
||||
// looked up here. Its `{ code }` input is trivial; the tools it calls are repaired normally.
|
||||
if (event.tool === "execute") return
|
||||
const tool = get(event.tool)
|
||||
const tool = (yield* ctx.tool.list()).find((tool) => tool.id === event.tool)
|
||||
if (!tool) return
|
||||
const schema = definition(tool).inputSchema
|
||||
if (schema.type !== "object") return
|
||||
event.input = repair(event.input, schema, schema, 0)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
function repair(value: unknown, schema: JsonSchema.JsonSchema, root: JsonSchema.JsonSchema, depth: number): unknown {
|
||||
|
||||
@@ -26,6 +26,7 @@ const render = (skills: ReadonlyArray<Summary>) =>
|
||||
[
|
||||
"Skills provide specialized instructions and workflows for specific tasks.",
|
||||
"Use the skill tool to load a skill when a task matches its description.",
|
||||
"The user may also invoke a skill directly. When that happens, its instructions appear in the conversation as a <skill_content> block, the same shape the skill tool returns. A skill that is already present this way does not need to be invoked again.",
|
||||
...(skills.length === 0
|
||||
? ["No skills are currently available."]
|
||||
: ["<available_skills>", ...entries(skills), "</available_skills>"]),
|
||||
|
||||
@@ -39,6 +39,7 @@ type Data = {
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Editor> {
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Tool.Info & { readonly id: string }>>
|
||||
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
|
||||
}
|
||||
|
||||
@@ -220,6 +221,7 @@ const layer = Layer.effect(
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
list: () => Effect.sync(() => Array.from(state.get().tools.values())),
|
||||
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
|
||||
Effect.sync(() => {
|
||||
const data = state.get()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Ignore } from "@opencode/core/filesystem/ignore"
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import { createWrapper } from "@parcel/watcher/wrapper.js"
|
||||
|
||||
test("parcel patterns ignore built-in folders at any depth", async () => {
|
||||
let ignoreGlobs: string[] = []
|
||||
|
||||
@@ -72,6 +72,7 @@ export const registerToolPlugin = <R>(
|
||||
tool: {
|
||||
transform: tools.transform,
|
||||
reload: tools.reload,
|
||||
list: tools.list,
|
||||
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -133,6 +133,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
tool: overrides.tool ?? {
|
||||
transform: () => Effect.die("unused tool.transform"),
|
||||
reload: () => Effect.die("unused tool.reload"),
|
||||
list: () => Effect.die("unused tool.list"),
|
||||
hook: () => Effect.die("unused tool.hook"),
|
||||
},
|
||||
vcs: overrides.vcs ?? {
|
||||
|
||||
@@ -73,6 +73,7 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
|
||||
tool: {
|
||||
transform: () => Effect.die("unused tool.transform"),
|
||||
reload: () => Effect.die("unused tool.reload"),
|
||||
list: () => Effect.die("unused tool.list"),
|
||||
hook: (name, callback) => {
|
||||
if (name === "execute.after") {
|
||||
// Hook names and callbacks are correlated, but TypeScript does not narrow this generic registration API.
|
||||
|
||||
@@ -550,14 +550,15 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("refreshes hosted search with Console config and skips unchanged snapshots", () =>
|
||||
it.effect("refreshes hosted search with Console config, retains it on failure, and skips unchanged snapshots", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const state = { advertised: false, requests: 0 }
|
||||
const state = { advertised: false, failing: false, requests: 0 }
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => {
|
||||
state.requests++
|
||||
if (state.failing) return new Response("Unavailable", { status: 502 })
|
||||
return Response.json({
|
||||
providers: {},
|
||||
...(state.advertised ? { websearch: { providerID: "opencode" } } : {}),
|
||||
@@ -607,10 +608,18 @@ describe("OpencodePlugin", () => {
|
||||
expect(rebuilds).toEqual({ provider: initial.provider + 1, websearch: initial.websearch + 1 })
|
||||
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
|
||||
|
||||
state.advertised = false
|
||||
state.failing = true
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
expect(state.requests).toBe(4)
|
||||
expect(rebuilds).toEqual({ provider: initial.provider + 1, websearch: initial.websearch + 1 })
|
||||
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
|
||||
|
||||
state.failing = false
|
||||
state.advertised = false
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
expect(state.requests).toBe(5)
|
||||
expect(rebuilds).toEqual({ provider: initial.provider + 2, websearch: initial.websearch + 2 })
|
||||
expect(yield* websearch.providers()).toEqual([])
|
||||
expect(yield* websearch.default()).toBeUndefined()
|
||||
|
||||
@@ -22,30 +22,19 @@ function run(input: unknown, inputSchema: JsonSchema.JsonSchema) {
|
||||
"execute.before": event,
|
||||
"execute.after": { ...event, status: "error", error: new Tool.Error({ message: "unused" }) },
|
||||
}
|
||||
const tool = {
|
||||
id: "test",
|
||||
name: "test",
|
||||
description: "Test repair",
|
||||
input: inputSchema,
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
}
|
||||
const base = host()
|
||||
return ToolInputRepairPlugin.Plugin.effect(
|
||||
host({
|
||||
tool: {
|
||||
...base.tool,
|
||||
transform: (callback) =>
|
||||
Effect.sync(() => {
|
||||
const tool = {
|
||||
id: "test",
|
||||
name: "test",
|
||||
description: "Test repair",
|
||||
input: inputSchema,
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
}
|
||||
callback({
|
||||
list: () => [tool],
|
||||
get: (id) => (id === tool.id ? tool : undefined),
|
||||
add: () => {},
|
||||
namespace: () => {},
|
||||
update: () => {},
|
||||
remove: () => {},
|
||||
})
|
||||
return { dispose: Effect.void }
|
||||
}),
|
||||
list: () => Effect.succeed([tool]),
|
||||
hook: (name, callback) => callback(events[name]).pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -59,6 +59,7 @@ describe("SkillInstructions", () => {
|
||||
[
|
||||
"Skills provide specialized instructions and workflows for specific tasks.",
|
||||
"Use the skill tool to load a skill when a task matches its description.",
|
||||
"The user may also invoke a skill directly. When that happens, its instructions appear in the conversation as a <skill_content> block, the same shape the skill tool returns. A skill that is already present this way does not need to be invoked again.",
|
||||
"<available_skills>",
|
||||
" <skill>",
|
||||
" <id>effect</id>",
|
||||
|
||||
@@ -855,6 +855,21 @@ describe("Tool", () => {
|
||||
])
|
||||
}),
|
||||
)
|
||||
it.effect("lists registered tools by effective name", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* transform(service, { echo: make() }, { codemode: false })
|
||||
yield* transform(service, { count: { ...constant("1"), name: "count" } }, { namespace: "acme" })
|
||||
|
||||
expect((yield* service.list()).map((tool) => [tool.id, tool.name])).toEqual([
|
||||
["echo", "echo"],
|
||||
["acme_count", "count"],
|
||||
])
|
||||
|
||||
yield* service.transform((editor) => editor.remove("echo"))
|
||||
expect((yield* service.list()).map((tool) => tool.id)).toEqual(["acme_count"])
|
||||
}),
|
||||
)
|
||||
;[
|
||||
{ name: "string", content: "hooked", text: "hooked" },
|
||||
{ name: "empty string", content: "", text: "" },
|
||||
|
||||
@@ -39,3 +39,39 @@ CLI preparation uses these channel rules:
|
||||
`bun dev` is separate from packaging: it uses local renderer/server mode, the dev app identity, and the CLI source by
|
||||
default. `bun dev --download-server <version>` instead downloads that CLI version for local development. Neither path
|
||||
requires `OPENCODE_CLI_DIST` or runs the production prebuild.
|
||||
|
||||
## Startup benchmark
|
||||
|
||||
`bun run bench:startup` measures a **packaged** build from process spawn to the restored tab being ready and the
|
||||
renderer going idle, so dev-server and bundling costs are not part of the numbers.
|
||||
|
||||
```bash
|
||||
OPENCODE_CHANNEL=dev bun run build && bunx electron-builder --win --dir --config electron-builder.config.ts
|
||||
bun run bench:startup -- --runs 5 # warm service (started once, reused by every launch)
|
||||
bun run bench:startup -- --runs 5 --compare dist/other/OpenCode\ Dev.exe # A/B: alternate launches of two builds
|
||||
bun run bench:startup -- --service cold # each launch spawns the service
|
||||
bun run bench:startup -- --fresh # first launch after an install (profile wiped each time)
|
||||
bun run bench:startup -- --profile-main --profile-renderer --trace # CPU profiles and a Chromium startup trace
|
||||
bun run bench:startup -- --seed "%APPDATA%\ai.opencode.desktop.dev" # restore tabs and drafts from an existing profile
|
||||
```
|
||||
|
||||
The app runs in an isolated home (`%TEMP%\opencode-bench-startup`): its own `%APPDATA%`, XDG directories, OpenCode
|
||||
database, config and service registration, with the developer's `OPENCODE_*` and `OTEL_*` environment stripped
|
||||
(an inherited OTLP endpoint alone adds a network round trip to every CLI exit). It never attaches to or restarts the
|
||||
developer's live service, and only ever kills the process tree it spawned. `--service cold` stops the service before
|
||||
each launch so the desktop has to spawn it; the isolated config directory gives that service a private port. One
|
||||
`--warmup` launch per build is discarded by default because the first launch of a new binary pays the antivirus scan.
|
||||
`--compare` alternates two builds so machine drift affects both equally; they must bundle the same CLI or the desktop
|
||||
restarts the service on the version mismatch.
|
||||
|
||||
Milestones (ms since spawn) come from the main log, the renderer's performance timeline and DOM readiness polled over
|
||||
CDP. Node's bootstrap timing is read from the main process after each run over `--inspect` (nothing attaches until the
|
||||
run is over): `processCreated` → `nodeStart` is Electron's native init, `nodeStart` → `nodeBootstrapped` is Node
|
||||
itself, and `nodeBootstrapped` → `appStarting` is Electron's JavaScript init plus our main bundle up to its first
|
||||
log line. Renderer idle is the start of the first 500 ms window with under 10 % main-thread task time that stays quiet
|
||||
for `--settle-ms`; `rendererTaskMs` is the renderer's total main-thread task time until then. Raw samples are
|
||||
written to `dist/bench-startup`.
|
||||
|
||||
A packaged beta or prod build registers itself as the `opencode://` handler when it starts, even from the bench; the
|
||||
installed app takes the registration back on its next launch. Those channels run with `HTTPS_PROXY` pointed at a
|
||||
closed port (`--offline` forces it for dev) so the updater's first check fails fast instead of reaching GitHub.
|
||||
@@ -91,6 +91,11 @@ for (const channel of channels) {
|
||||
"js-yaml/dist/js-yaml.min.js",
|
||||
"js-yaml/dist/js-yaml.mjs.map",
|
||||
"js-yaml/bin/js-yaml.js",
|
||||
"unrelated/dist/index.js.map",
|
||||
"unrelated/dist/index.cjs.map",
|
||||
"unrelated/dist/index.d.ts",
|
||||
"unrelated/dist/index.d.cts",
|
||||
"unrelated/dist/index.d.ts.map",
|
||||
]) {
|
||||
expect(filter(path.join(import.meta.dirname, prefix, file), statSync(import.meta.filename))).toBe(false)
|
||||
}
|
||||
@@ -112,7 +117,10 @@ for (const channel of channels) {
|
||||
"js-yaml/lib/loader.js",
|
||||
"js-yaml/dist/js-yaml.mjs",
|
||||
"debug/src/index.js",
|
||||
"unrelated/dist/index.js.map",
|
||||
"unrelated/dist/index.js",
|
||||
"unrelated/dist/index.cjs",
|
||||
"unrelated/dist/data.json",
|
||||
"unrelated/src/index.ts",
|
||||
...["@zip.js/zip.js", "electron-updater", "builder-util-runtime", "ajv", "ajv-formats", "js-yaml"].flatMap(
|
||||
(name) => [`${name}/package.json`, `${name}/LICENSE`],
|
||||
),
|
||||
@@ -202,7 +210,7 @@ for (const channel of ["dev", "beta"] as const) {
|
||||
{
|
||||
from: "resources/",
|
||||
to: "",
|
||||
filter: ["opencode-cli", "opencode-cli.exe"],
|
||||
filter: ["opencode-cli", "opencode-cli.exe", "opencode-cli.version"],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -77,11 +77,14 @@ const getBase = (appId: string): Configuration => ({
|
||||
// Log export imports Zip.js as ESM. Keep index.js and lib, including its inline worker.
|
||||
"!**/node_modules/@zip.js/zip.js/dist{,/**/*}",
|
||||
"!**/node_modules/@zip.js/zip.js/{index.cjs,index.min.js,index-fflate.js,deno.json,eslint.config.mjs}",
|
||||
// These packages execute compiled JavaScript, not their sources or source maps.
|
||||
"!**/node_modules/{electron-updater,builder-util-runtime,lazy-val}/out/**/*.js.map",
|
||||
// Nothing executes type declarations or source maps, and every entry costs startup time: the
|
||||
// main process parses the whole asar header before it runs any JavaScript.
|
||||
"!**/node_modules/**/*.d.{ts,cts,mts}",
|
||||
"!**/node_modules/**/*.d.{ts,cts,mts}.map",
|
||||
"!**/node_modules/**/*.{js,cjs,mjs}.map",
|
||||
// These packages execute compiled JavaScript, not their sources.
|
||||
"!**/node_modules/ajv/lib{,/**/*}",
|
||||
"!**/node_modules/ajv-formats/src{,/**/*}",
|
||||
"!**/node_modules/{ajv,ajv-formats}/dist/**/*.js.map",
|
||||
// Keep js-yaml's CommonJS sources and dist/js-yaml.mjs ESM entry, not browser bundles or its CLI.
|
||||
"!**/node_modules/js-yaml/dist/{js-yaml.js,js-yaml.min.js,*.map}",
|
||||
"!**/node_modules/js-yaml/bin{,/**/*}",
|
||||
@@ -90,7 +93,7 @@ const getBase = (appId: string): Configuration => ({
|
||||
{
|
||||
from: "resources/",
|
||||
to: "",
|
||||
filter: ["opencode-cli", "opencode-cli.exe"],
|
||||
filter: ["opencode-cli", "opencode-cli.exe", "opencode-cli.version"],
|
||||
},
|
||||
],
|
||||
afterPack: async (context) => {
|
||||
@@ -100,6 +103,8 @@ const getBase = (appId: string): Configuration => ({
|
||||
)
|
||||
const file = await stat(cli)
|
||||
if (!file.isFile() || file.size === 0) throw new Error(`Bundled CLI must be a non-empty file: ${cli}`)
|
||||
const version = path.join(path.dirname(cli), "opencode-cli.version")
|
||||
if ((await stat(version)).size === 0) throw new Error(`Bundled CLI version must be a non-empty file: ${version}`)
|
||||
},
|
||||
mac: {
|
||||
category: "public.app-category.developer-tools",
|
||||
|
||||
@@ -20,16 +20,15 @@
|
||||
"package": "electron-builder --config electron-builder.config.ts",
|
||||
"package:mac": "electron-builder --mac --config electron-builder.config.ts",
|
||||
"package:win": "electron-builder --win --config electron-builder.config.ts",
|
||||
"package:linux": "electron-builder --linux --config electron-builder.config.ts"
|
||||
"package:linux": "electron-builder --linux --config electron-builder.config.ts",
|
||||
"bench:startup": "bun ./scripts/bench-startup.ts"
|
||||
},
|
||||
"main": "./out/main/index.js",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
"electron-log": "^5",
|
||||
"electron-store": "11.0.2",
|
||||
"electron-updater": "6.8.9",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"lighthouse": "13.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
#!/usr/bin/env bun
|
||||
// Cold-start benchmark for a packaged desktop build.
|
||||
//
|
||||
// bun run bench:startup -- [--exe <path>] [--compare <path>] [--runs 5] [--warmup 1] [--service warm|cold]
|
||||
// [--fresh] [--offline] [--seed <userData dir>] [--profile-main] [--profile-renderer]
|
||||
// [--trace] [--out <dir>] [--home <dir>]
|
||||
//
|
||||
// The app runs in an isolated home directory (its own %APPDATA%, XDG dirs, OpenCode DB, config and
|
||||
// service registration) with the developer's OPENCODE_* / OTEL_* environment stripped, so it never
|
||||
// attaches to, restarts or reads the developer's live service or state and does not inherit their
|
||||
// telemetry configuration. `warm` starts one service from the bundled CLI before the runs and lets
|
||||
// every launch reuse it; `cold` stops it before each run so the desktop has to spawn it. `--fresh`
|
||||
// wipes the profile before each launch to measure the first launch after an install. `--compare`
|
||||
// alternates launches of a second build so machine drift affects both equally, and `--warmup`
|
||||
// launches are discarded (the first launch of a new binary pays the antivirus scan).
|
||||
//
|
||||
// Milestones come from the main log, the renderer's performance timeline and DOM readiness polled
|
||||
// over CDP, plus Node's own bootstrap timing read from the main process afterwards over --inspect
|
||||
// (nothing attaches until the run is over), which splits the time before the first log line into
|
||||
// Electron/Chromium native init, Node bootstrap and our main bundle. `--profile-main` records a
|
||||
// main-process CPU profile from the first statement (via --inspect-brk) on the first run,
|
||||
// `--profile-renderer` records the renderer main thread from the moment its debug target appears,
|
||||
// and `--trace` records Chromium's startup trace on the last run. Raw samples are written as JSON.
|
||||
import { spawn } from "node:child_process"
|
||||
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
|
||||
import { createServer } from "node:net"
|
||||
import { tmpdir } from "node:os"
|
||||
import { basename, dirname, join, relative, resolve } from "node:path"
|
||||
import { parseArgs } from "node:util"
|
||||
|
||||
const args = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
options: {
|
||||
exe: { type: "string" },
|
||||
compare: { type: "string" },
|
||||
runs: { type: "string", default: "5" },
|
||||
warmup: { type: "string", default: "1" },
|
||||
service: { type: "string", default: "warm" },
|
||||
fresh: { type: "boolean", default: false },
|
||||
offline: { type: "boolean", default: false },
|
||||
seed: { type: "string" },
|
||||
home: { type: "string" },
|
||||
out: { type: "string" },
|
||||
"profile-main": { type: "boolean", default: false },
|
||||
"profile-renderer": { type: "boolean", default: false },
|
||||
trace: { type: "boolean", default: false },
|
||||
"settle-ms": { type: "string", default: "1500" },
|
||||
},
|
||||
allowPositionals: true,
|
||||
})
|
||||
const packageDir = resolve(import.meta.dirname, "..")
|
||||
const builds = [
|
||||
{ label: args.values.compare ? "A" : "", exe: resolve(args.values.exe ?? defaultExe()) },
|
||||
...(args.values.compare ? [{ label: "B", exe: resolve(args.values.compare) }] : []),
|
||||
]
|
||||
const runs = Number(args.values.runs)
|
||||
const warmup = Number(args.values.warmup)
|
||||
const service = args.values.service === "cold" ? "cold" : "warm"
|
||||
const settleMs = Number(args.values["settle-ms"])
|
||||
const outDir = resolve(args.values.out ?? join(packageDir, "dist", "bench-startup"))
|
||||
const home = resolve(args.values.home ?? join(tmpdir(), "opencode-bench-startup"))
|
||||
for (const build of builds) {
|
||||
if (!existsSync(build.exe))
|
||||
throw new Error(`Packaged executable not found: ${build.exe}. Run 'bun run build && bun run package:win' (or pass --exe).`)
|
||||
}
|
||||
if (!Number.isSafeInteger(runs) || runs < 1) throw new Error("--runs must be a positive integer")
|
||||
if (!Number.isSafeInteger(warmup) || warmup < 0) throw new Error("--warmup must be a non-negative integer")
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
|
||||
const appId = appIdFor(builds[0].exe)
|
||||
if (builds.some((build) => appIdFor(build.exe) !== appId)) throw new Error("Compared builds must be the same channel")
|
||||
const userData = join(home, "AppData", "Roaming", appId)
|
||||
const paths = {
|
||||
home,
|
||||
appData: join(home, "AppData", "Roaming"),
|
||||
localAppData: join(home, "AppData", "Local"),
|
||||
temp: join(home, "AppData", "Local", "Temp"),
|
||||
db: join(home, ".local", "share", "opencode", "opencode.db"),
|
||||
config: join(home, ".config", "opencode"),
|
||||
registration: join(home, ".local", "state", "opencode", "service.json"),
|
||||
logs: join(userData, "logs"),
|
||||
}
|
||||
prepareHome()
|
||||
// The desktop deletes XDG_STATE_HOME on Windows, so isolation goes through the home directory.
|
||||
// OPENCODE_* and OTEL_* from the developer's shell would otherwise leak into the measured app and
|
||||
// its service (an OTLP endpoint alone adds a network round trip to every CLI exit).
|
||||
const env = {
|
||||
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCODE_|OTEL_|SENTRY_)/.test(key))),
|
||||
USERPROFILE: home,
|
||||
HOME: home,
|
||||
APPDATA: paths.appData,
|
||||
LOCALAPPDATA: paths.localAppData,
|
||||
TEMP: paths.temp,
|
||||
TMP: paths.temp,
|
||||
XDG_DATA_HOME: join(home, ".local", "share"),
|
||||
XDG_CONFIG_HOME: join(home, ".config"),
|
||||
XDG_CACHE_HOME: join(home, ".cache"),
|
||||
OPENCODE_DB: paths.db,
|
||||
OPENCODE_CONFIG_DIR: paths.config,
|
||||
// Beta and prod builds check for updates on start; a closed proxy port fails that fast and offline.
|
||||
...(args.values.offline || appId !== "ai.opencode.desktop.dev" ? { HTTPS_PROXY: "http://127.0.0.1:9" } : {}),
|
||||
}
|
||||
const cdpPort = await freePort()
|
||||
// A private service port keeps a cold launch's own service away from the developer's service.
|
||||
const servicePort = await freePort()
|
||||
writeFileSync(join(paths.config, "service.json"), JSON.stringify({ port: servicePort }))
|
||||
const inspectPort = await freePort()
|
||||
let appPid: number | undefined
|
||||
let serviceProcess: ReturnType<typeof spawn> | undefined
|
||||
// Renderer readiness, read over CDP: paint timing plus the DOM states the user actually waits for.
|
||||
const probe = `(() => ({
|
||||
origin: performance.timeOrigin,
|
||||
firstPaint: performance.getEntriesByType('paint').find((e) => e.name === 'first-paint')?.startTime,
|
||||
domInteractive: performance.getEntriesByType('navigation')[0]?.domInteractive,
|
||||
shell: !!document.querySelector('[data-titlebar-tab-link], [data-action="vertical-tabs-home"]'),
|
||||
editor: !!document.querySelector('[data-component="composer-editor"][contenteditable="true"]'),
|
||||
rows: document.querySelectorAll('[data-timeline-row]').length,
|
||||
home: !!document.querySelector('[data-action="home-new-session"], [data-action="home-add-project-row"]'),
|
||||
url: location.pathname + location.search,
|
||||
}))()`
|
||||
// Main-process bootstrap timing, read after the run: when the process was created, when Node
|
||||
// started inside it, and when Node's own bootstrap finished and handed control to the entry module.
|
||||
const mainTiming = `JSON.stringify({ created: process.getCreationTime(), origin: performance.timeOrigin, ...performance.nodeTiming.toJSON(), cpu: process.cpuUsage(), rss: process.memoryUsage().rss })`
|
||||
// Renderer navigation and resource timing plus any marks the app emitted, read once the run is settled.
|
||||
const rendererTimeline = `(() => {
|
||||
const nav = performance.getEntriesByType('navigation')[0]
|
||||
const resources = performance.getEntriesByType('resource')
|
||||
const scripts = resources.filter((r) => r.initiatorType === 'script' || r.name.endsWith('.js'))
|
||||
return {
|
||||
navigation: nav && { fetchStart: nav.fetchStart, responseEnd: nav.responseEnd, domInteractive: nav.domInteractive, domContentLoaded: nav.domContentLoadedEventEnd, load: nav.loadEventEnd },
|
||||
resources: { count: resources.length, scripts: scripts.length, firstStart: Math.min(...resources.map((r) => r.startTime)), lastEnd: Math.max(...resources.map((r) => r.responseEnd)), bytes: resources.reduce((n, r) => n + (r.encodedBodySize || 0), 0) },
|
||||
paint: Object.fromEntries(performance.getEntriesByType('paint').map((e) => [e.name, e.startTime])),
|
||||
marks: performance.getEntriesByType('mark').map((e) => [e.name, Math.round(e.startTime)]),
|
||||
measures: performance.getEntriesByType('measure').map((e) => [e.name, Math.round(e.startTime), Math.round(e.duration)]),
|
||||
}
|
||||
})()`
|
||||
|
||||
for (const build of builds) console.log(`bench${build.label ? ` ${build.label}` : ""}: ${build.exe}`)
|
||||
console.log(`home: ${home}`)
|
||||
console.log(`service: ${service}, runs: ${runs} (+${warmup} warm-up), cdp ${cdpPort}, inspect ${inspectPort}${args.values.fresh ? ", fresh profile per launch" : ""}`)
|
||||
|
||||
if (service === "warm") await warmService()
|
||||
|
||||
const samples: Sample[] = []
|
||||
// A launch that never produces a renderer would otherwise leave an instance behind that every later
|
||||
// launch hands off to through the single-instance lock.
|
||||
process.on("uncaughtException", async (error) => {
|
||||
console.error(error)
|
||||
await killApp()
|
||||
await stopService()
|
||||
process.exit(1)
|
||||
})
|
||||
for (let run = 1 - warmup; run <= runs; run++) {
|
||||
for (const build of builds) {
|
||||
const sample = await launch(build, run)
|
||||
if (run < 1) {
|
||||
console.log(`warm-up${build.label ? ` ${build.label}` : ""}: shell ${sample.msSinceSpawn.shellVisible} ms`)
|
||||
continue
|
||||
}
|
||||
samples.push(sample)
|
||||
console.log(JSON.stringify(sample))
|
||||
}
|
||||
}
|
||||
await killApp()
|
||||
if (service === "warm") await stopService()
|
||||
|
||||
const summaries = Object.fromEntries(
|
||||
builds.map((build) => {
|
||||
const own = samples.filter((s) => s.build === build.label)
|
||||
const timed = own.filter((s) => !s.profiled && !s.traced)
|
||||
return [build.label || "A", summarize(timed.length ? timed : own)]
|
||||
}),
|
||||
)
|
||||
// Durations between consecutive checkpoints, so "where did the time go" needs no subtraction.
|
||||
const phaseOrder = [
|
||||
["electron native init", "processCreated", "nodeStart"],
|
||||
["node bootstrap", "nodeStart", "nodeBootstrapped"],
|
||||
["electron js init → entry", "nodeBootstrapped", "entryStart"],
|
||||
["entry → chromium ready", "entryStart", "electronReady"],
|
||||
["ready → window shown", "electronReady", "windowVisible"],
|
||||
["main bundle load + evaluate", "windowVisible", "bundleEvaluated"],
|
||||
["layers → first log line", "bundleEvaluated", "appStarting"],
|
||||
["layers → renderer process", "appStarting", "rendererProcess"],
|
||||
["renderer boot → first paint", "rendererProcess", "firstPaint"],
|
||||
["first paint → shell", "firstPaint", "shellVisible"],
|
||||
["shell → idle", "shellVisible", "rendererIdle"],
|
||||
] as const
|
||||
const phases = Object.fromEntries(
|
||||
builds.map((build) => {
|
||||
const own = samples.filter((s) => s.build === build.label && !s.profiled && !s.traced)
|
||||
const out: Record<string, number> = {}
|
||||
for (const [name, from, to] of phaseOrder) {
|
||||
const deltas = own
|
||||
.map((s) => (s.msSinceSpawn[to] ?? NaN) - (s.msSinceSpawn[from] ?? NaN))
|
||||
.filter(Number.isFinite)
|
||||
.sort((a, b) => a - b)
|
||||
if (deltas.length) out[name] = deltas[Math.floor(deltas.length / 2)]
|
||||
}
|
||||
return [build.label || "A", out]
|
||||
}),
|
||||
)
|
||||
const report = { builds, service, runs, warmup, fresh: args.values.fresh, home, summaries, phases, samples }
|
||||
const reportPath = join(outDir, `startup-${Date.now()}.json`)
|
||||
writeFileSync(reportPath, JSON.stringify(report, null, 2))
|
||||
const labels = Object.keys(summaries)
|
||||
const header = `${"".padEnd(32)}${labels.map((label) => (labels.length > 1 ? label : "").padStart(6).padEnd(22)).join("")}`
|
||||
console.log(`\n${service} service${args.values.fresh ? ", fresh profile" : ""} — median (min…max) ms since spawn over ${runs} runs`)
|
||||
console.log(header)
|
||||
for (const key of [...new Set(labels.flatMap((label) => Object.keys(summaries[label])))]) {
|
||||
const cells = labels.map((label) => {
|
||||
const v = summaries[label][key]
|
||||
return v ? `${String(v.median).padStart(6)} (${v.min}…${v.max})`.padEnd(22) : "".padEnd(22)
|
||||
})
|
||||
console.log(`${key.padEnd(32)}${cells.join("")}`)
|
||||
}
|
||||
console.log(`\nphases — median ms`)
|
||||
console.log(header)
|
||||
for (const [name] of phaseOrder) {
|
||||
const cells = labels.map((label) => String(phases[label][name] ?? "").padStart(6).padEnd(22))
|
||||
console.log(`${name.padEnd(32)}${cells.join("")}`)
|
||||
}
|
||||
const idle = (label: string) => samples.filter((s) => s.build === label && !s.profiled && !s.traced).at(-1)
|
||||
console.log(`\nmemory once idle (last run) — working set MB per process, main CPU ms`)
|
||||
for (const label of labels) {
|
||||
const s = idle(label === "A" && labels.length === 1 ? "" : label)
|
||||
if (!s) continue
|
||||
const total = s.processes.reduce((n, p) => n + p.rssMB, 0)
|
||||
console.log(`${(labels.length > 1 ? label : "").padEnd(4)}total ${total} MB · main cpu ${s.mainCpuMs ?? "?"} ms · renderer heap ${s.renderer.jsHeapMB} MB, ${s.renderer.domNodes} nodes, layout ${s.renderer.layoutMs} ms/${s.renderer.layouts}×, style ${s.renderer.styleMs} ms/${s.renderer.styleRecalcs}×`)
|
||||
console.log(` ${s.processes.map((p) => `${p.name} ${p.rssMB}`).join(" · ")}`)
|
||||
}
|
||||
console.log(`\nreport: ${reportPath}`)
|
||||
process.exit(0)
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
type Probe = {
|
||||
origin: number
|
||||
firstPaint?: number
|
||||
domInteractive?: number
|
||||
shell: boolean
|
||||
editor: boolean
|
||||
rows: number
|
||||
home: boolean
|
||||
url: string
|
||||
}
|
||||
type Sample = {
|
||||
build: string
|
||||
run: number
|
||||
profiled: boolean
|
||||
traced?: string
|
||||
mainProfile?: string
|
||||
rendererProfile?: string
|
||||
msSinceSpawn: Record<string, number | undefined>
|
||||
final: { url?: string; timelineRows?: number }
|
||||
rendererCpu: { taskMs: number; scriptMs: number }
|
||||
// Renderer main-thread breakdown from Performance.getMetrics at the end of the run.
|
||||
renderer: Record<string, number>
|
||||
rendererTimeline: unknown
|
||||
// Working set per process in the app's tree (main, renderer, GPU, utility) once idle, in MB, and
|
||||
// the main process's CPU time.
|
||||
processes: { name: string; pid: number; rssMB: number }[]
|
||||
mainCpuMs?: number
|
||||
// Every timestamped line from the run's log directory (main, crash, onboarding, window, …) as
|
||||
// [ms since spawn, file, message].
|
||||
timeline: [number, string, string][]
|
||||
}
|
||||
|
||||
async function launch(build: { label: string; exe: string }, run: number): Promise<Sample> {
|
||||
await killApp()
|
||||
if (service === "cold") await stopService()
|
||||
if (args.values.fresh) rmSync(userData, { recursive: true, force: true })
|
||||
const profile = args.values["profile-main"] && run === 1
|
||||
const trace = args.values.trace && run === runs
|
||||
const tracePath = join(outDir, `startup-trace-${Date.now()}.json`)
|
||||
const launchArgs = [
|
||||
`--remote-debugging-port=${cdpPort}`,
|
||||
profile ? `--inspect-brk=${inspectPort}` : `--inspect=${inspectPort}`,
|
||||
...(trace
|
||||
? [
|
||||
"--trace-startup=*,disabled-by-default-v8.cpu_profiler",
|
||||
"--trace-startup-duration=6",
|
||||
"--trace-startup-format=json",
|
||||
`--trace-startup-file=${tracePath}`,
|
||||
]
|
||||
: []),
|
||||
]
|
||||
const spawnAt = Date.now()
|
||||
const child = spawn(build.exe, launchArgs, { env, detached: true, stdio: "ignore" })
|
||||
child.unref()
|
||||
appPid = child.pid
|
||||
|
||||
let mainProfile: Promise<unknown> | undefined
|
||||
if (profile) mainProfile = profileMain(spawnAt)
|
||||
|
||||
const page = await waitFor(
|
||||
() => targets(cdpPort).then((list) => list.find((t) => t.type === "page" && t.url.startsWith("oc://"))),
|
||||
60_000,
|
||||
)
|
||||
const cdp = await connect(page.webSocketDebuggerUrl)
|
||||
await cdp.send("Runtime.enable")
|
||||
await cdp.send("Performance.enable")
|
||||
const rendererProfile = args.values["profile-renderer"] && run === 1
|
||||
if (rendererProfile) {
|
||||
await cdp.send("Profiler.enable")
|
||||
await cdp.send("Profiler.setSamplingInterval", { interval: 100 })
|
||||
await cdp.send("Profiler.start")
|
||||
}
|
||||
// Poll DOM readiness and the renderer's cumulative main-thread task time together. The run ends
|
||||
// when the shell is up and the main thread has spent under 10 % of any 500 ms window in tasks for `settleMs`.
|
||||
const seen: Record<string, number> = {}
|
||||
let last: Probe | undefined
|
||||
let quietSince: number | undefined
|
||||
let taskMs = 0
|
||||
let scriptMs = 0
|
||||
const window: { at: number; task: number }[] = []
|
||||
const deadline = Date.now() + 60_000
|
||||
while (Date.now() < deadline) {
|
||||
const result = await cdp.send("Runtime.evaluate", { expression: probe, returnByValue: true })
|
||||
last = result.result?.result?.value as Probe | undefined
|
||||
const t = Date.now() - spawnAt
|
||||
if (last?.shell && !seen.shellVisible) seen.shellVisible = t
|
||||
if (last?.editor && !seen.composerEditable) seen.composerEditable = t
|
||||
if (last?.rows && !seen.timelineRows) seen.timelineRows = t
|
||||
if (last?.home && !seen.homeReady) seen.homeReady = t
|
||||
const metrics = (await cdp.send("Performance.getMetrics")).result?.metrics as { name: string; value: number }[]
|
||||
const task = (metrics.find((m) => m.name === "TaskDuration")?.value ?? 0) * 1000
|
||||
scriptMs = (metrics.find((m) => m.name === "ScriptDuration")?.value ?? 0) * 1000
|
||||
if (process.env.BENCH_DEBUG && task - taskMs > 5) console.log(`busy +${Date.now() - spawnAt} ${Math.round(task - taskMs)} ms`)
|
||||
taskMs = task
|
||||
window.push({ at: Date.now(), task })
|
||||
while (window.length > 1 && window[1].at <= Date.now() - 500) window.shift()
|
||||
if (task - window[0].task > 50) quietSince = undefined
|
||||
else quietSince ??= window[0].at
|
||||
if (seen.shellVisible && quietSince && Date.now() - quietSince >= settleMs) break
|
||||
await sleep(50)
|
||||
}
|
||||
const rendererIdleMs = quietSince ? quietSince - spawnAt : undefined
|
||||
const rendererProfilePath = rendererProfile ? join(outDir, `renderer-${Date.now()}.cpuprofile`) : undefined
|
||||
if (rendererProfilePath) {
|
||||
const stopped = await cdp.send("Profiler.stop")
|
||||
writeFileSync(rendererProfilePath, JSON.stringify(stopped.result.profile))
|
||||
console.log("renderer profile:", rendererProfilePath)
|
||||
}
|
||||
const finalMetrics = (await cdp.send("Performance.getMetrics")).result?.metrics as { name: string; value: number }[]
|
||||
const metric = (name: string) => finalMetrics.find((m) => m.name === name)?.value ?? 0
|
||||
const renderer = {
|
||||
taskMs: Math.round(metric("TaskDuration") * 1000),
|
||||
scriptMs: Math.round(metric("ScriptDuration") * 1000),
|
||||
layoutMs: Math.round(metric("LayoutDuration") * 1000),
|
||||
styleMs: Math.round(metric("RecalcStyleDuration") * 1000),
|
||||
layouts: metric("LayoutCount"),
|
||||
styleRecalcs: metric("RecalcStyleCount"),
|
||||
domNodes: metric("Nodes"),
|
||||
jsHeapMB: Math.round(metric("JSHeapUsedSize") / 1048576),
|
||||
}
|
||||
const timelineResult = await cdp.send("Runtime.evaluate", { expression: rendererTimeline, returnByValue: true })
|
||||
cdp.close()
|
||||
const processes = appPid ? await processTree(appPid) : []
|
||||
const boot = profile ? undefined : await mainBootTiming()
|
||||
await sleep(300)
|
||||
// Chromium writes the startup trace when --trace-startup-duration elapses; keep the app alive until then.
|
||||
if (trace) await waitFor(async () => (existsSync(tracePath) && statSync(tracePath).size > 0 ? true : undefined), 20_000)
|
||||
const main = mainLog()
|
||||
const origin = last?.origin ? Math.round(last.origin - spawnAt) : undefined
|
||||
const sample: Sample = {
|
||||
build: build.label,
|
||||
run,
|
||||
profiled: profile || !!rendererProfile,
|
||||
traced: trace ? tracePath : undefined,
|
||||
rendererProfile: rendererProfilePath,
|
||||
msSinceSpawn: {
|
||||
processCreated: boot && Math.round(boot.created - spawnAt),
|
||||
nodeStart: boot && Math.round(boot.origin + boot.nodeStart - spawnAt),
|
||||
nodeBootstrapped: boot && Math.round(boot.origin + boot.bootstrapComplete - spawnAt),
|
||||
entryStart: main.marks.entry && main.marks.entry - spawnAt,
|
||||
electronReady: main.marks.ready && main.marks.ready - spawnAt,
|
||||
bundleEvaluated: main.marks.bundle && main.marks.bundle - spawnAt,
|
||||
appStarting: main.appStarting && main.appStarting - spawnAt,
|
||||
cliVersionStart: main.versionStart && main.versionStart - spawnAt,
|
||||
cliVersionDone: main.versionDone && main.versionDone - spawnAt,
|
||||
serviceStarting: main.serviceStarting && main.serviceStarting - spawnAt,
|
||||
serviceReady: main.serviceReady && main.serviceReady - spawnAt,
|
||||
rendererProcess: origin,
|
||||
windowVisible: main.windowVisible && main.windowVisible - spawnAt,
|
||||
domInteractive:
|
||||
last?.domInteractive !== undefined && origin !== undefined ? Math.round(origin + last.domInteractive) : undefined,
|
||||
firstPaint: last?.firstPaint !== undefined && origin !== undefined ? Math.round(origin + last.firstPaint) : undefined,
|
||||
...seen,
|
||||
rendererIdle: rendererIdleMs,
|
||||
},
|
||||
final: { url: last?.url, timelineRows: last?.rows },
|
||||
rendererCpu: { taskMs: Math.round(taskMs), scriptMs: Math.round(scriptMs) },
|
||||
renderer,
|
||||
rendererTimeline: timelineResult.result?.result?.value,
|
||||
processes,
|
||||
mainCpuMs: boot && Math.round((boot.cpu.user + boot.cpu.system) / 1000),
|
||||
timeline: main.timeline.map(([at, file, message]) => [at - spawnAt, file, message]),
|
||||
}
|
||||
if (mainProfile) {
|
||||
const profilePath = join(outDir, `main-${Date.now()}.cpuprofile`)
|
||||
writeFileSync(profilePath, JSON.stringify(await mainProfile))
|
||||
sample.mainProfile = profilePath
|
||||
console.log("main profile:", profilePath)
|
||||
}
|
||||
return sample
|
||||
}
|
||||
|
||||
// Read after the run, so the inspector attach cannot influence what was measured.
|
||||
async function mainBootTiming() {
|
||||
const target = (await targets(inspectPort)).find((t) => t.type === "node")
|
||||
if (!target) return undefined
|
||||
const cdp = await connect(target.webSocketDebuggerUrl)
|
||||
const result = await cdp.send("Runtime.evaluate", { expression: mainTiming, returnByValue: true })
|
||||
cdp.close()
|
||||
const value = result.result?.result?.value
|
||||
if (typeof value !== "string") return undefined
|
||||
return JSON.parse(value) as {
|
||||
created: number
|
||||
origin: number
|
||||
nodeStart: number
|
||||
bootstrapComplete: number
|
||||
cpu: { user: number; system: number }
|
||||
rss: number
|
||||
}
|
||||
}
|
||||
|
||||
function defaultExe() {
|
||||
const unpacked = join(packageDir, "dist", process.platform === "win32" ? "win-unpacked" : process.platform === "darwin" ? "mac" : "linux-unpacked")
|
||||
if (!existsSync(unpacked)) return join(unpacked, "OpenCode Dev.exe")
|
||||
const candidate = readdirSync(unpacked).find((f) => (process.platform === "win32" ? f.endsWith(".exe") : f.endsWith(".app") || !f.includes(".")))
|
||||
return join(unpacked, candidate ?? "OpenCode Dev.exe")
|
||||
}
|
||||
|
||||
function appIdFor(executable: string) {
|
||||
const name = basename(executable, ".exe")
|
||||
if (/beta/i.test(name)) return "ai.opencode.desktop.beta"
|
||||
if (/dev/i.test(name)) return "ai.opencode.desktop.dev"
|
||||
return "ai.opencode.desktop"
|
||||
}
|
||||
|
||||
function prepareHome() {
|
||||
for (const dir of [paths.appData, paths.temp, dirname(paths.db), paths.config, dirname(paths.registration), join(home, ".cache")]) mkdirSync(dir, { recursive: true })
|
||||
if (args.values.seed && !existsSync(userData)) {
|
||||
// Seed only the app's own state (tabs, drafts, settings, window placement); Chromium profile
|
||||
// data, caches, logs and the staged CLI are recreated by the app.
|
||||
const seed = resolve(args.values.seed)
|
||||
const keep = /^(drafts\.sqlite(-wal|-shm)?|opencode\.[a-z]+|\.?window-state.*\.json|opencode)$/
|
||||
cpSync(seed, userData, {
|
||||
recursive: true,
|
||||
filter: (source) => source === seed || keep.test(relative(seed, source).split(/[\\/]/)[0] ?? ""),
|
||||
})
|
||||
}
|
||||
mkdirSync(paths.logs, { recursive: true })
|
||||
}
|
||||
|
||||
async function freePort() {
|
||||
return new Promise<number>((resolvePort, reject) => {
|
||||
const server = createServer()
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address()
|
||||
server.close(() => (typeof address === "object" && address ? resolvePort(address.port) : reject(new Error("no port"))))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function targets(port: number) {
|
||||
return fetch(`http://127.0.0.1:${port}/json`)
|
||||
.then((r) => r.json() as Promise<{ type: string; url: string; webSocketDebuggerUrl: string }[]>)
|
||||
.catch(() => [] as { type: string; url: string; webSocketDebuggerUrl: string }[])
|
||||
}
|
||||
|
||||
async function waitFor<T>(fn: () => Promise<T | undefined>, timeout: number) {
|
||||
const deadline = Date.now() + timeout
|
||||
while (Date.now() < deadline) {
|
||||
const value = await fn()
|
||||
if (value) return value
|
||||
await sleep(25)
|
||||
}
|
||||
throw new Error("Timed out waiting for the renderer debug target")
|
||||
}
|
||||
|
||||
async function connect(url: string) {
|
||||
const ws = new WebSocket(url)
|
||||
await new Promise((r) => (ws.onopen = r))
|
||||
let id = 0
|
||||
const pending = new Map<number, (v: any) => void>()
|
||||
const events: any[] = []
|
||||
ws.onmessage = (ev) => {
|
||||
const msg = JSON.parse(String(ev.data))
|
||||
if (msg.method) events.push(msg)
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
pending.get(msg.id)!(msg)
|
||||
pending.delete(msg.id)
|
||||
}
|
||||
}
|
||||
return {
|
||||
events,
|
||||
send: (method: string, params: Record<string, unknown> = {}) =>
|
||||
new Promise<any>((resolvePromise) => {
|
||||
const n = ++id
|
||||
pending.set(n, resolvePromise)
|
||||
ws.send(JSON.stringify({ id: n, method, params }))
|
||||
}),
|
||||
close: () => ws.close(),
|
||||
}
|
||||
}
|
||||
|
||||
// Attach to the main process while it is paused on its first statement, start sampling, release it.
|
||||
async function profileMain(spawnAt: number) {
|
||||
const target = await waitFor(() => targets(inspectPort).then((list) => list.find((t) => t.type === "node")), 30_000)
|
||||
const cdp = await connect(target.webSocketDebuggerUrl)
|
||||
await cdp.send("Runtime.enable")
|
||||
await cdp.send("Debugger.enable")
|
||||
await cdp.send("Profiler.enable")
|
||||
await cdp.send("Profiler.setSamplingInterval", { interval: 200 })
|
||||
await cdp.send("Profiler.start")
|
||||
await cdp.send("Runtime.runIfWaitingForDebugger")
|
||||
const until = Date.now() + 3000
|
||||
while (!cdp.events.some((e) => e.method === "Debugger.paused") && Date.now() < until) await sleep(10)
|
||||
await cdp.send("Debugger.resume")
|
||||
console.log(`debugger released at +${Date.now() - spawnAt} ms`)
|
||||
await sleep(8000)
|
||||
const result = await cdp.send("Profiler.stop")
|
||||
cdp.close()
|
||||
return result.result.profile
|
||||
}
|
||||
|
||||
// Every timestamped line of the run's log directory, merged across the main log and the scoped logs
|
||||
// (crash, onboarding, window, …) that electron-log writes next to it.
|
||||
function mainLog() {
|
||||
const dirs = existsSync(paths.logs) ? readdirSync(paths.logs).sort().reverse() : []
|
||||
const dir = dirs.map((d) => join(paths.logs, d)).find((d) => existsSync(join(d, "main.log")))
|
||||
const timeline: [number, string, string][] = []
|
||||
let windowShownAt: number | undefined
|
||||
// Epoch marks the entry module recorded before any logger existed, reported with "app starting".
|
||||
const marks: Record<string, number> = {}
|
||||
for (const name of dir ? readdirSync(dir).filter((f) => f.endsWith(".log")) : []) {
|
||||
const text = readFileSync(join(dir!, name), "utf8")
|
||||
// electron-log wraps long objects onto continuation lines; read them as part of the entry.
|
||||
for (const entry of text.split(/\r?\n(?=\[\d{4}-)/)) {
|
||||
const line = entry.split(/\r?\n/)[0]
|
||||
const m = line.match(/^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{3})\]\s+\[\w+\]\s+(?:\([\w-]+\)\s+)?(.*)$/)
|
||||
if (!m) continue
|
||||
const message = m[2].replace(/\s*\{.*$/, "").trim()
|
||||
// A window shown before the logger existed reports when it was shown; the line itself is later.
|
||||
const shown = /main window visible/.test(message) ? entry.match(/shownAt: (\d+)/)?.[1] : undefined
|
||||
if (shown) windowShownAt = Number(shown)
|
||||
if (/app starting/.test(message))
|
||||
for (const [, key, value] of entry.matchAll(/\b(entry|ready|window|bundle): (\d{10,})/g)) marks[key] = Number(value)
|
||||
timeline.push([new Date(m[1].replace(" ", "T")).getTime(), name.replace(/\.log$/, ""), message])
|
||||
}
|
||||
}
|
||||
timeline.sort((a, b) => a[0] - b[0])
|
||||
const at = (pattern: RegExp) => timeline.find(([, , message]) => pattern.test(message))?.[0]
|
||||
return {
|
||||
timeline,
|
||||
appStarting: at(/app starting/),
|
||||
versionStart: at(/v2 CLI command started/),
|
||||
versionDone: at(/v2 CLI command completed/),
|
||||
serviceStarting: at(/v2 CLI background service starting/),
|
||||
serviceReady: at(/background service ready/),
|
||||
windowVisible: windowShownAt ?? marks.window ?? at(/main window visible/),
|
||||
marks,
|
||||
}
|
||||
}
|
||||
|
||||
// Working set of every process in the launched app's tree once it is idle.
|
||||
async function processTree(root: number) {
|
||||
const script =
|
||||
process.platform === "win32"
|
||||
? [
|
||||
"powershell",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
`$all = Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, Name, WorkingSetSize, CommandLine; $ids = @(${root}); do { $before = $ids.Count; $ids = @($ids + ($all | Where-Object { $ids -contains $_.ParentProcessId } | ForEach-Object ProcessId) | Sort-Object -Unique) } while ($ids.Count -ne $before); $all | Where-Object { $ids -contains $_.ProcessId } | ForEach-Object { '{0}|{1}|{2}|{3}' -f $_.ProcessId, $_.Name, $_.WorkingSetSize, (($_.CommandLine -split ' ' | Where-Object { $_ -like '--type=*' }) -join '') } `,
|
||||
],
|
||||
]
|
||||
: ["sh", ["-c", `ps -eo pid=,ppid=,rss=,comm= | awk -v r=${root} 'BEGIN{ids[r]=1} {p[$1]=$2; rss[$1]=$3; c[$1]=$4} END{for(k=0;k<8;k++) for(i in p) if(p[i] in ids) ids[i]=1; for(i in ids) if(i in rss) print i "|" c[i] "|" rss[i]*1024 "|"}'`]]
|
||||
const out = await new Promise<string>((done) => {
|
||||
const child = spawn(script[0] as string, script[1] as string[], { stdio: ["ignore", "pipe", "ignore"] })
|
||||
let text = ""
|
||||
child.stdout?.on("data", (chunk) => (text += chunk))
|
||||
child.on("close", () => done(text))
|
||||
})
|
||||
return out
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [pid, name, rss, type] = line.split("|")
|
||||
return { pid: Number(pid), name: `${name}${type ? ` ${type.replace("--type=", "")}` : ""}`, rssMB: Math.round(Number(rss) / 1048576) }
|
||||
})
|
||||
}
|
||||
|
||||
function summarize(list: Sample[]) {
|
||||
const values = (s: Sample): Record<string, number | undefined> => ({
|
||||
...s.msSinceSpawn,
|
||||
rendererTaskMs: s.rendererCpu.taskMs,
|
||||
rendererScriptMs: s.rendererCpu.scriptMs,
|
||||
})
|
||||
const keys = [...new Set(list.flatMap((s) => Object.keys(values(s))))]
|
||||
const out: Record<string, { median: number; min: number; max: number }> = {}
|
||||
for (const key of keys) {
|
||||
const sorted = list.map((s) => values(s)[key]).filter((v): v is number => Number.isFinite(v)).sort((a, b) => a - b)
|
||||
if (!sorted.length) continue
|
||||
out[key] = { median: sorted[Math.floor(sorted.length / 2)], min: sorted[0], max: sorted[sorted.length - 1] }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The service must come from the CLI bundled with the executable: the desktop restarts a service
|
||||
// whose version differs from its bundled CLI, which would turn a warm run into a cold one.
|
||||
function bundledCli(exe: string) {
|
||||
const resources = process.platform === "darwin" ? join(dirname(exe), "..", "Resources") : join(dirname(exe), "resources")
|
||||
return join(resources, process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli")
|
||||
}
|
||||
|
||||
async function warmService() {
|
||||
await stopService()
|
||||
const clis = builds.map((build) => bundledCli(build.exe))
|
||||
const identity = (cli: string) => {
|
||||
const version = join(dirname(cli), "opencode-cli.version")
|
||||
return existsSync(version) ? readFileSync(version, "utf8").trim() : String(statSync(cli).size)
|
||||
}
|
||||
if (new Set(clis.map(identity)).size > 1)
|
||||
throw new Error("The compared builds bundle different CLIs; the desktop would restart the service on the mismatch")
|
||||
serviceProcess = spawn(clis[0], ["serve", "--service"], { env, detached: true, stdio: "ignore" })
|
||||
serviceProcess.unref()
|
||||
const deadline = Date.now() + 60_000
|
||||
while (Date.now() < deadline) {
|
||||
if (existsSync(paths.registration)) {
|
||||
const registration = JSON.parse(readFileSync(paths.registration, "utf8")) as { url?: string }
|
||||
if (registration.url && (await fetch(`${registration.url}/api/info`).then((r) => r.status < 500).catch(() => false))) {
|
||||
console.log(`service warm at ${registration.url}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
await sleep(200)
|
||||
}
|
||||
throw new Error("The bench service did not become ready")
|
||||
}
|
||||
|
||||
async function stopService() {
|
||||
if (existsSync(paths.registration)) {
|
||||
const registration = JSON.parse(readFileSync(paths.registration, "utf8")) as { pid?: number }
|
||||
if (registration.pid) {
|
||||
try {
|
||||
process.kill(registration.pid)
|
||||
} catch {}
|
||||
}
|
||||
rmSync(paths.registration, { force: true })
|
||||
}
|
||||
if (serviceProcess?.pid) {
|
||||
try {
|
||||
process.kill(serviceProcess.pid)
|
||||
} catch {}
|
||||
serviceProcess = undefined
|
||||
}
|
||||
await sleep(500)
|
||||
}
|
||||
|
||||
// Only ever touches the process this run spawned (a prod-channel build shares its executable name
|
||||
// with the developer's installed app). Ask it to quit first so it exits the way a user's session
|
||||
// ends (Node and Chromium flush their caches on a normal exit); force-kill the tree if it lingers.
|
||||
async function killApp() {
|
||||
const pid = appPid
|
||||
if (!pid) return
|
||||
appPid = undefined
|
||||
const running = () =>
|
||||
new Promise<boolean>((done) => {
|
||||
const check =
|
||||
process.platform === "win32"
|
||||
? spawn("tasklist", ["/FI", `PID eq ${pid}`, "/NH"], { stdio: ["ignore", "pipe", "ignore"] })
|
||||
: spawn("kill", ["-0", String(pid)], { stdio: "ignore" })
|
||||
let out = ""
|
||||
check.stdout?.on("data", (chunk) => (out += chunk))
|
||||
check.on("close", (code) => done(process.platform === "win32" ? out.includes(String(pid)) : code === 0))
|
||||
})
|
||||
if (!(await running())) return
|
||||
if (process.platform === "win32") spawn("taskkill", ["/PID", String(pid)], { stdio: "ignore" })
|
||||
else process.kill(pid, "SIGTERM")
|
||||
const deadline = Date.now() + 5000
|
||||
while (Date.now() < deadline && (await running())) await sleep(100)
|
||||
if (await running()) {
|
||||
if (process.platform === "win32") spawn("taskkill", ["/PID", String(pid), "/F", "/T"], { stdio: "ignore" })
|
||||
else process.kill(pid, "SIGKILL")
|
||||
await sleep(1000)
|
||||
}
|
||||
await sleep(500)
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((r) => setTimeout(r, ms))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { $ } from "bun"
|
||||
import { chmod, copyFile, mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { dirname, join } from "node:path"
|
||||
|
||||
const CLI_VERSION = "dev"
|
||||
|
||||
@@ -75,10 +75,7 @@ export async function downloadCliToResources(version = CLI_VERSION, dest = windo
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
|
||||
try {
|
||||
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${version}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
|
||||
await copyCliToResources(
|
||||
join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode.exe" : "opencode"),
|
||||
dest,
|
||||
)
|
||||
await copyCliToResources(join(directory, "node_modules", cli.package), dest)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
@@ -89,12 +86,23 @@ export async function downloadCliToResources(version = CLI_VERSION, dest = windo
|
||||
export async function copyBuiltCliToResources(root: string, dest = windowsify("resources/opencode-cli")) {
|
||||
const cli = getCurrentCli()
|
||||
const directory = cli.package.replace("@opencode/", "")
|
||||
await copyCliToResources(join(root, directory, "bin", cli.os === "win32" ? "opencode.exe" : "opencode"), dest)
|
||||
await copyCliToResources(join(root, directory), dest)
|
||||
}
|
||||
|
||||
async function copyCliToResources(source: string, dest: string) {
|
||||
await copyFile(source, dest)
|
||||
// The package directory is an npm package: its package.json version is the string the executable
|
||||
// prints for --version. Writing it next to the executable spares the desktop a ~400 ms spawn of the
|
||||
// 200 MB binary on first launch.
|
||||
async function copyCliToResources(pkg: string, dest: string) {
|
||||
const cli = getCurrentCli()
|
||||
await copyFile(join(pkg, "bin", cli.os === "win32" ? "opencode.exe" : "opencode"), dest)
|
||||
await prepareCli(dest)
|
||||
const manifest = (await Bun.file(join(pkg, "package.json")).json()) as { version?: string }
|
||||
if (!manifest.version) throw new Error(`Bundled CLI package has no version: ${pkg}`)
|
||||
await Bun.write(versionFile(dest), manifest.version)
|
||||
}
|
||||
|
||||
export function versionFile(cli: string) {
|
||||
return join(dirname(cli), "opencode-cli.version")
|
||||
}
|
||||
|
||||
async function prepareCli(dest: string) {
|
||||
|
||||
@@ -6,3 +6,17 @@ export const CHANNEL: Channel = raw === "local" || raw === "dev" || raw === "bet
|
||||
export const VERSION = app.isPackaged ? app.getVersion() : (process.env.OPENCODE_VERSION ?? app.getVersion())
|
||||
|
||||
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
|
||||
|
||||
const appNames: Record<string, string> = {
|
||||
dev: "OpenCode Dev",
|
||||
beta: "OpenCode Beta",
|
||||
prod: "OpenCode",
|
||||
}
|
||||
const appIDs: Record<string, string> = {
|
||||
dev: "ai.opencode.desktop.dev",
|
||||
beta: "ai.opencode.desktop.beta",
|
||||
prod: "ai.opencode.desktop",
|
||||
}
|
||||
// Local renderer/server mode keeps the dev application identity.
|
||||
export const APP_NAME = app.isPackaged ? appNames[CHANNEL] : "OpenCode Dev"
|
||||
export const APP_ID = app.isPackaged ? appIDs[CHANNEL] : "ai.opencode.desktop.dev"
|
||||
|
||||
@@ -3,15 +3,21 @@ import { app } from "electron"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Ipc } from "./ipc"
|
||||
import { DesktopInitialization } from "./lifecycle/desktop-initialization"
|
||||
import { installContextMenu } from "./lifecycle/environment"
|
||||
import { ApplicationLifecycle } from "./lifecycle"
|
||||
import { BackgroundService } from "./service/background-service"
|
||||
import { DesktopCli } from "./service/desktop-cli"
|
||||
import { UpdaterLive } from "./updater/live"
|
||||
import { marks } from "./lifecycle/marks"
|
||||
|
||||
// Everything above has been loaded and evaluated by now; the layers start below.
|
||||
marks.bundle = Date.now()
|
||||
|
||||
const runIpc = Effect.fn("Desktop.runIpc")(function* () {
|
||||
const lifecycle = yield* ApplicationLifecycle.Service
|
||||
const ipc = yield* Ipc.registerIpcHandlers
|
||||
if (lifecycle.restoreWindows().length) ipc.installMenu()
|
||||
yield* installContextMenu
|
||||
yield* Effect.callback<void>((resume) => {
|
||||
const quit = () => resume(Effect.void)
|
||||
app.once("will-quit", quit)
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
export {}
|
||||
// Imported first so its evaluation stamps the moment Electron handed control to this module.
|
||||
import { marks } from "./lifecycle/marks"
|
||||
import { app } from "electron"
|
||||
import { acquireApplicationLock, configureApplication } from "./lifecycle/configure"
|
||||
import { createEarlyWindow } from "./windows/early"
|
||||
import { registerRendererScheme } from "./windows/scheme"
|
||||
|
||||
await import("./desktop")
|
||||
// This module stays small on purpose. Electron holds the ready event until the entry module has
|
||||
// finished, and the first window should be on screen before the rest of the main process — a few
|
||||
// hundred milliseconds of module evaluation and layers — loads. Configuration and the scheme must
|
||||
// precede ready; the window is created the moment ready fires; everything else is imported after.
|
||||
configureApplication()
|
||||
if (acquireApplicationLock()) {
|
||||
registerRendererScheme()
|
||||
// Window first, then the bundle: starting the import before ready delays ready itself, because the
|
||||
// module graph evaluates on the same thread Chromium needs to finish initialising.
|
||||
void app.whenReady().then(() => {
|
||||
marks.ready = Date.now()
|
||||
createEarlyWindow()
|
||||
marks.window = Date.now()
|
||||
return import("./desktop")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BrowserWindow } from "electron"
|
||||
import { Effect } from "effect"
|
||||
import { EventRpcs } from "../../shared/ipc-rpc"
|
||||
import { createBrowserPane } from "../browser-pane"
|
||||
import { ipcEventStream } from "../ipc-events"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { Shutdown } from "../lifecycle/shutdown"
|
||||
@@ -14,8 +13,16 @@ export const eventHandlers = EventRpcs.toLayer(
|
||||
const handoff = yield* IpcPortHandoff
|
||||
const shutdown = yield* Shutdown.Service
|
||||
const storage = yield* DesktopStorage.Service
|
||||
const browser = createBrowserPane(storage.state)
|
||||
const stop = Effect.promise(() => browser.dispose())
|
||||
// The browser pane brings the CDP driver and the full RPC client with every protocol schema;
|
||||
// load it when a renderer first opens a pane instead of at startup.
|
||||
const load = async () => {
|
||||
const { createBrowserPane } = await import("../browser-pane")
|
||||
return createBrowserPane(storage.state)
|
||||
}
|
||||
let browser: ReturnType<typeof load> | undefined
|
||||
const stop = Effect.promise(async () => {
|
||||
if (browser) await (await browser).dispose()
|
||||
})
|
||||
const remove = yield* shutdown.add(stop)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(remove).pipe(Effect.andThen(stop)))
|
||||
return EventRpcs.of({
|
||||
@@ -27,10 +34,12 @@ export const eventHandlers = EventRpcs.toLayer(
|
||||
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL())) {
|
||||
throw new Error("browser.pane.owner.invalid")
|
||||
}
|
||||
if (request.type === "register") return browser.register(win, request.bindingID, request.target)
|
||||
if (request.type === "layout") return browser.layout(win, request.bindingID, request.layout)
|
||||
if (request.type === "command") return browser.command(win, request.bindingID, request.command)
|
||||
return browser.close(win, request.bindingID)
|
||||
browser ??= load()
|
||||
const pane = await browser
|
||||
if (request.type === "register") return pane.register(win, request.bindingID, request.target)
|
||||
if (request.type === "layout") return pane.layout(win, request.bindingID, request.layout)
|
||||
if (request.type === "command") return pane.command(win, request.bindingID, request.command)
|
||||
return pane.close(win, request.bindingID)
|
||||
}).pipe(Effect.orDie),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,14 @@ import { BrowserWindow } from "electron"
|
||||
import { Effect } from "effect"
|
||||
import { WindowRpcs } from "../../shared/ipc-rpc"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, setWindowThemeReady, updateTitlebar } from "../windows"
|
||||
import {
|
||||
getPinchZoomEnabled,
|
||||
saveWindowPrepaint,
|
||||
setPinchZoomEnabled,
|
||||
setTitlebar,
|
||||
setWindowThemeReady,
|
||||
updateTitlebar,
|
||||
} from "../windows"
|
||||
import { sender } from "./context"
|
||||
|
||||
export const windowHandlers = WindowRpcs.toLayer(
|
||||
@@ -38,6 +45,11 @@ export const windowHandlers = WindowRpcs.toLayer(
|
||||
const win = BrowserWindow.fromWebContents(sender(handoff, context))
|
||||
if (win) setTitlebar(win, theme)
|
||||
}),
|
||||
WindowSavePrepaint: ({ html }, context) =>
|
||||
Effect.promise(() => {
|
||||
const win = BrowserWindow.fromWebContents(sender(handoff, context))
|
||||
return win ? saveWindowPrepaint(win, html) : Promise.resolve()
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export * as Ipc from "./ipc"
|
||||
|
||||
import { app, BrowserWindow, MessageChannelMain } from "electron"
|
||||
import { app, BrowserWindow, ipcMain, MessageChannelMain } from "electron"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { RpcServer } from "effect/unstable/rpc"
|
||||
import { DesktopRpcs } from "../shared/ipc-rpc"
|
||||
import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport"
|
||||
import { DragCancelEvent, IpcTransportPort, IpcTransportPortRequest } from "../shared/ipc-transport"
|
||||
import { DesktopFiles, openExternalURL } from "./files"
|
||||
import { appHandlers } from "./ipc-handlers/app"
|
||||
import { eventHandlers } from "./ipc-handlers/events"
|
||||
@@ -23,6 +23,7 @@ import { createMenu, sendMenuCommand } from "./native/menu"
|
||||
import { DesktopCli } from "./service/desktop-cli"
|
||||
import { Updater } from "./updater"
|
||||
import { getLastFocusedWindow } from "./windows"
|
||||
import { isRendererUrl } from "./windows/protocol"
|
||||
import { Wsl } from "./wsl/start"
|
||||
|
||||
const services = Layer.mergeAll(DesktopFiles.layer, Wsl.layer, Ssh.layer)
|
||||
@@ -65,18 +66,27 @@ export const registerIpcHandlers = Effect.gen(function* () {
|
||||
if (input.type !== "keyDown" || input.key !== "Escape") return
|
||||
win.webContents.send(DragCancelEvent)
|
||||
})
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) return
|
||||
const channel = new MessageChannelMain()
|
||||
handoff.bind(win.webContents, channel.port1)
|
||||
win.webContents.postMessage(IpcTransportPort, null, [channel.port2])
|
||||
})
|
||||
}
|
||||
// Each renderer document asks for its own port once its client is listening; see ipc-client.ts.
|
||||
const handPort = (event: Electron.IpcMainEvent) => {
|
||||
const contents = event.sender
|
||||
if (contents.isDestroyed() || !isRendererUrl(contents.getURL())) return
|
||||
const channel = new MessageChannelMain()
|
||||
handoff.bind(contents, channel.port1)
|
||||
contents.postMessage(IpcTransportPort, null, [channel.port2])
|
||||
}
|
||||
yield* Effect.sync(() => {
|
||||
app.on("browser-window-created", wire)
|
||||
BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win))
|
||||
ipcMain.on(IpcTransportPortRequest, handPort)
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => app.off("browser-window-created", wire)))
|
||||
yield* Effect.addFinalizer(
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
app.off("browser-window-created", wire)
|
||||
ipcMain.off(IpcTransportPortRequest, handPort)
|
||||
}),
|
||||
)
|
||||
return {
|
||||
installMenu: () => createMenu(menu),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdirSync, rmSync } from "node:fs"
|
||||
import { homedir, tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { app } from "electron"
|
||||
import { APP_ID, APP_NAME } from "../constants"
|
||||
|
||||
const testOnboarding = process.env.OPENCODE_TEST_ONBOARDING === "1"
|
||||
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
|
||||
|
||||
// Runs synchronously from the entry module, before Chromium is ready: command-line switches only
|
||||
// take effect before ready, the single-instance lock is scoped to userData, and the early window
|
||||
// needs userData to find the persisted window list and state.
|
||||
export function configureApplication() {
|
||||
try {
|
||||
process.chdir(homedir())
|
||||
} catch {}
|
||||
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
|
||||
|
||||
app.setName(APP_NAME)
|
||||
app.setAppUserModelId(APP_ID)
|
||||
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
|
||||
const features = app.commandLine.getSwitchValue("enable-features")
|
||||
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
|
||||
if (!app.isPackaged)
|
||||
app.commandLine.appendSwitch("remote-debugging-port", process.env.OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT ?? "9222")
|
||||
|
||||
const testRoot = createTestRoot()
|
||||
app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), APP_ID))
|
||||
if (testRoot) {
|
||||
app.setPath("sessionData", path.join(testRoot, "session"))
|
||||
if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents"))
|
||||
}
|
||||
}
|
||||
|
||||
export function acquireApplicationLock() {
|
||||
if (app.requestSingleInstanceLock()) return true
|
||||
app.quit()
|
||||
return false
|
||||
}
|
||||
|
||||
function createTestRoot() {
|
||||
const root = testOnboarding
|
||||
? path.join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
|
||||
: app.isPackaged
|
||||
? undefined
|
||||
: process.env.OPENCODE_DESKTOP_TEST_ROOT
|
||||
if (!root) return undefined
|
||||
if (testOnboarding) rmSync(root, { recursive: true, force: true })
|
||||
for (const dir of ["data", "config", "cache", "state", "desktop", "session", "documents"])
|
||||
mkdirSync(path.join(root, dir), { recursive: true })
|
||||
if (testOnboarding) process.env.OPENCODE_DB = ":memory:"
|
||||
process.env.XDG_DATA_HOME = path.join(root, "data")
|
||||
process.env.XDG_CONFIG_HOME = path.join(root, "config")
|
||||
process.env.XDG_CACHE_HOME = path.join(root, "cache")
|
||||
process.env.XDG_STATE_HOME = path.join(root, "state")
|
||||
return root
|
||||
}
|
||||
@@ -1,59 +1,18 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import http from "node:http"
|
||||
import { homedir, tmpdir } from "node:os"
|
||||
import { getCACertificates, setDefaultCACertificates } from "node:tls"
|
||||
import { app } from "electron"
|
||||
import contextMenu from "electron-context-menu"
|
||||
import { Effect, FileSystem, Path } from "effect"
|
||||
import { CHANNEL } from "../constants"
|
||||
import { Effect, Path } from "effect"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { getUserShell, loadShellEnv } from "../service/shell-env"
|
||||
import { registerRendererProtocol, setDockIcon } from "../windows"
|
||||
|
||||
const appNames: Record<string, string> = {
|
||||
dev: "OpenCode Dev",
|
||||
beta: "OpenCode Beta",
|
||||
prod: "OpenCode",
|
||||
}
|
||||
const appIDs: Record<string, string> = {
|
||||
dev: "ai.opencode.desktop.dev",
|
||||
beta: "ai.opencode.desktop.beta",
|
||||
prod: "ai.opencode.desktop",
|
||||
}
|
||||
const testOnboarding = process.env.OPENCODE_TEST_ONBOARDING === "1"
|
||||
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
|
||||
|
||||
export const configureApplication = Effect.fn("Application.configure")(function* () {
|
||||
const path = yield* Path.Path
|
||||
// electron-context-menu attaches to every existing and future window, so it can load once the first
|
||||
// window is up instead of holding up startup with its dependency tree.
|
||||
export const installContextMenu = Effect.gen(function* () {
|
||||
const { default: contextMenu } = yield* Effect.promise(() => import("electron-context-menu"))
|
||||
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
|
||||
try {
|
||||
process.chdir(homedir())
|
||||
} catch {}
|
||||
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
|
||||
|
||||
const appID = app.isPackaged ? appIDs[CHANNEL] : "ai.opencode.desktop.dev"
|
||||
app.setName(app.isPackaged ? appNames[CHANNEL] : "OpenCode Dev")
|
||||
app.setAppUserModelId(appID)
|
||||
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
|
||||
const features = app.commandLine.getSwitchValue("enable-features")
|
||||
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
|
||||
if (!app.isPackaged)
|
||||
app.commandLine.appendSwitch("remote-debugging-port", process.env.OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT ?? "9222")
|
||||
|
||||
const testRoot = yield* createTestRoot()
|
||||
app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), appID))
|
||||
if (testRoot) {
|
||||
app.setPath("sessionData", path.join(testRoot, "session"))
|
||||
if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents"))
|
||||
}
|
||||
})
|
||||
|
||||
export function acquireApplicationLock() {
|
||||
if (app.requestSingleInstanceLock()) return true
|
||||
app.quit()
|
||||
return false
|
||||
}
|
||||
|
||||
export const prepareApplicationEnvironment = Effect.gen(function* () {
|
||||
yield* loadSystemCertificates
|
||||
yield* loadProxyEnvironment
|
||||
@@ -91,29 +50,6 @@ export const loadProxyEnvironment = Effect.gen(function* () {
|
||||
}).pipe(Effect.catch((error) => Effect.logWarning("failed to load proxy environment", { error })))
|
||||
})
|
||||
|
||||
const createTestRoot = Effect.fn("Application.createTestRoot")(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const root = testOnboarding
|
||||
? path.join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
|
||||
: app.isPackaged
|
||||
? undefined
|
||||
: process.env.OPENCODE_DESKTOP_TEST_ROOT
|
||||
if (!root) return undefined
|
||||
if (testOnboarding) yield* fs.remove(root, { recursive: true, force: true })
|
||||
yield* Effect.forEach(
|
||||
["data", "config", "cache", "state", "desktop", "session", "documents"],
|
||||
(dir) => fs.makeDirectory(path.join(root, dir), { recursive: true }),
|
||||
{ discard: true },
|
||||
)
|
||||
if (testOnboarding) process.env.OPENCODE_DB = ":memory:"
|
||||
process.env.XDG_DATA_HOME = path.join(root, "data")
|
||||
process.env.XDG_CONFIG_HOME = path.join(root, "config")
|
||||
process.env.XDG_CACHE_HOME = path.join(root, "cache")
|
||||
process.env.XDG_STATE_HOME = path.join(root, "state")
|
||||
return root
|
||||
})
|
||||
|
||||
const loadSystemCertificates = Effect.try({
|
||||
try: () => {
|
||||
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
|
||||
|
||||
@@ -9,7 +9,6 @@ import { DesktopLogging, scoped } from "../native/logging"
|
||||
import { DesktopStorage } from "../storage"
|
||||
import { safeWebContentsURL } from "../windows/state"
|
||||
import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
|
||||
import { acquireApplicationLock, configureApplication } from "./environment"
|
||||
import { initializeFirstLaunchOnboarding } from "./onboarding"
|
||||
import { Shutdown } from "./shutdown"
|
||||
|
||||
@@ -145,7 +144,7 @@ const runtime = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
// Storage opens after configureApplication has set userData and before windows exist, so window
|
||||
// Storage opens after the entry module has set userData and before the renderer loads, so window
|
||||
// teardown can clear a window's persisted state and every renderer request finds it ready.
|
||||
const platform = Layer.mergeAll(
|
||||
DesktopLogging.layer,
|
||||
@@ -155,9 +154,6 @@ const platform = Layer.mergeAll(
|
||||
|
||||
export const layer = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
// Electron scopes the single-instance lock to userData.
|
||||
yield* configureApplication()
|
||||
if (!acquireApplicationLock()) return yield* Effect.interrupt
|
||||
// Decide first-launch state before the storage layer creates drafts.sqlite, which would
|
||||
// otherwise read as evidence of an earlier launch on a fresh install.
|
||||
yield* initializeFirstLaunchOnboarding(app.getPath("userData"))
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Startup marks, epoch ms. The entry module records them before any logger exists; the logging
|
||||
// layer reports them with "app starting" so the startup benchmark can split the time before the
|
||||
// first log line into Electron's own initialisation, our entry, and the main bundle.
|
||||
export const marks: { entry: number; ready?: number; window?: number; bundle?: number } = { entry: Date.now() }
|
||||
@@ -5,6 +5,7 @@ import { app, crashReporter, netLog, shell } from "electron"
|
||||
import { Context, Effect, FileSystem, Layer, Logger, Option, Path, References, Stream } from "effect"
|
||||
import { homedir } from "node:os"
|
||||
import { VERSION } from "../constants"
|
||||
import { marks } from "../lifecycle/marks"
|
||||
|
||||
const MAX_LOG_AGE_DAYS = 7
|
||||
const TAIL_LINES = 1000
|
||||
@@ -34,6 +35,7 @@ const serviceLayer = Layer.effect(
|
||||
version: VERSION,
|
||||
packaged: app.isPackaged,
|
||||
onboardingTest: process.env.OPENCODE_TEST_ONBOARDING === "1",
|
||||
marks,
|
||||
})
|
||||
const exportDebug = exportDebugLogsEffect(fs, path).pipe(Effect.orDie)
|
||||
return Service.of({
|
||||
|
||||
@@ -1,15 +1,57 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
import { BackgroundServiceState } from "./background-service-state"
|
||||
|
||||
test("new consumers receive the latest reconnected service", async () => {
|
||||
const initial = { url: "http://127.0.0.1:4100", password: "first" }
|
||||
const replacement = { url: "http://127.0.0.1:4200", password: "second" }
|
||||
const service = await Effect.runPromise(
|
||||
BackgroundServiceState.make({ initial: Effect.succeed(initial), reconnect: Effect.succeed(replacement) }),
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* BackgroundServiceState.make({
|
||||
initial: Effect.succeed(initial),
|
||||
reconnect: Effect.succeed(replacement),
|
||||
})
|
||||
expect(yield* state.connection).toEqual(initial)
|
||||
expect(yield* state.reconnect).toEqual(replacement)
|
||||
return yield* state.connection
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(service).toEqual(replacement)
|
||||
})
|
||||
|
||||
expect(await Effect.runPromise(service.connection)).toEqual(initial)
|
||||
expect(await Effect.runPromise(service.reconnect)).toEqual(replacement)
|
||||
expect(await Effect.runPromise(service.connection)).toEqual(replacement)
|
||||
test("the state is ready before the initial connect finishes and consumers wait for it", async () => {
|
||||
const initial = { url: "http://127.0.0.1:4100", password: "first" }
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const gate = yield* Deferred.make<void>()
|
||||
const state = yield* BackgroundServiceState.make({
|
||||
initial: Deferred.await(gate).pipe(Effect.as(initial)),
|
||||
reconnect: Effect.succeed(initial),
|
||||
})
|
||||
const consumer = yield* Effect.forkChild(state.connection)
|
||||
yield* Effect.sleep("10 millis")
|
||||
expect(consumer.pollUnsafe()).toBeUndefined()
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
expect(yield* Fiber.join(consumer)).toEqual(initial)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("a failed initial connect fails consumers instead of hanging them", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* BackgroundServiceState.make({
|
||||
initial: Effect.fail(new Error("service unavailable")),
|
||||
reconnect: Effect.succeed({ url: "http://127.0.0.1:4100", password: "later" }),
|
||||
})
|
||||
return yield* state.connection.pipe(Effect.exit)
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(result._tag).toBe("Failure")
|
||||
})
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
export * as BackgroundServiceState from "./background-service-state"
|
||||
|
||||
import { Effect, Exit, Ref } from "effect"
|
||||
import { Deferred, Effect, Exit, Ref } from "effect"
|
||||
import type { SidecarCredentials } from "./sidecar-credentials"
|
||||
|
||||
// The initial connect runs in the background: windows are created as soon as the main process
|
||||
// is up, and the renderer loads its bundle while the CLI service is being resolved. Consumers
|
||||
// await the first outcome through `connection`, so nothing observes the service before it exists.
|
||||
export const make = Effect.fn("BackgroundServiceState.make")(function* (options: {
|
||||
readonly initial: Effect.Effect<SidecarCredentials.Data, unknown>
|
||||
readonly reconnect: Effect.Effect<SidecarCredentials.Data>
|
||||
}) {
|
||||
// Every Exit is an Effect, so the latest resolution replays directly for each consumer.
|
||||
const current = yield* Ref.make<Exit.Exit<SidecarCredentials.Data, unknown>>(yield* options.initial.pipe(Effect.exit))
|
||||
const current = yield* Ref.make<Exit.Exit<SidecarCredentials.Data, unknown> | undefined>(undefined)
|
||||
const first = yield* Deferred.make<void>()
|
||||
yield* options.initial.pipe(
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => Ref.set(current, exit)),
|
||||
Effect.ensuring(Deferred.succeed(first, undefined)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return {
|
||||
connection: Ref.get(current).pipe(Effect.flatten, Effect.orDie),
|
||||
connection: Deferred.await(first).pipe(
|
||||
Effect.flatMap(() => Ref.get(current)),
|
||||
Effect.flatMap((exit) => exit ?? Exit.die(new Error("background service connect did not resolve"))),
|
||||
Effect.orDie,
|
||||
),
|
||||
reconnect: options.reconnect.pipe(Effect.tap((next) => Ref.set(current, Exit.succeed(next)))),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,9 +3,11 @@ export * as DesktopCli from "./desktop-cli"
|
||||
import { execFile, spawn } from "node:child_process"
|
||||
import { promisify } from "node:util"
|
||||
import { app } from "electron"
|
||||
import { Context, Effect, FileSystem, Layer, Path } from "effect"
|
||||
import { Context, Effect, FileSystem, Layer, Option, Path } from "effect"
|
||||
import installer from "../../../../../install?raw"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { BUNDLED_CLI_VERSION_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
import { parseCliVersion } from "./cli-version"
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
@@ -79,11 +81,46 @@ const resolveBundledCli = Effect.fn("DesktopCli.resolveBundled")(function* (isol
|
||||
? path.join(process.resourcesPath, executableName())
|
||||
: path.join(paths.developmentResourcesRoot, isolated ? developmentExecutableName() : executableName())
|
||||
yield* Effect.logInfo("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
|
||||
const version = parseCliVersion(yield* run(bundled, ["--version"]))
|
||||
const version = yield* bundledVersion(bundled)
|
||||
const binary = app.isPackaged || isolated ? yield* installCli(bundled, version) : bundled
|
||||
return { version, binary, command: [binary] }
|
||||
})
|
||||
|
||||
// Spawning the bundled executable for `--version` costs ~400 ms of startup on a 200 MB binary (and
|
||||
// several seconds on the first launch after an update, while the antivirus scans it). The build
|
||||
// writes the version next to the executable, so a packaged app never spawns; the per-identity cache
|
||||
// covers executables that arrived without that file.
|
||||
const bundledVersion = Effect.fn("DesktopCli.bundledVersion")(function* (bundled: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const shipped = yield* fs
|
||||
.readFileString(path.join(path.dirname(bundled), "opencode-cli.version"))
|
||||
.pipe(Effect.map((text) => text.trim()), Effect.orElseSucceed(() => ""))
|
||||
if (shipped) {
|
||||
yield* Effect.logInfo("v2 CLI version bundled", { version: shipped })
|
||||
return shipped
|
||||
}
|
||||
const stat = yield* fs.stat(bundled).pipe(Effect.orElseSucceed(() => undefined))
|
||||
const identity = stat ? `${stat.size}:${Option.getOrUndefined(stat.mtime)?.getTime() ?? ""}` : undefined
|
||||
const store = getStore()
|
||||
const cached = store.get(BUNDLED_CLI_VERSION_KEY)
|
||||
if (identity && isVersionCache(cached) && cached.path === bundled && cached.identity === identity) {
|
||||
yield* Effect.logInfo("v2 CLI version reused", { version: cached.version })
|
||||
return cached.version
|
||||
}
|
||||
const version = parseCliVersion(yield* run(bundled, ["--version"]))
|
||||
if (identity) store.set(BUNDLED_CLI_VERSION_KEY, { path: bundled, identity, version } satisfies VersionCache)
|
||||
return version
|
||||
})
|
||||
|
||||
type VersionCache = { path: string; identity: string; version: string }
|
||||
|
||||
function isVersionCache(value: unknown): value is VersionCache {
|
||||
if (!value || typeof value !== "object") return false
|
||||
const cache = value as Record<string, unknown>
|
||||
return typeof cache.path === "string" && typeof cache.identity === "string" && typeof cache.version === "string"
|
||||
}
|
||||
|
||||
export const cleanStages = Effect.fn("DesktopCli.cleanStages")(function* (binary: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
|
||||
@@ -17,17 +17,29 @@ describe("draft store", () => {
|
||||
expect(database.db.all(sql`SELECT key FROM document`)).toEqual([])
|
||||
})
|
||||
|
||||
test("stores blobs by content hash and collects unreferenced ones on open", () => {
|
||||
test("stores blobs by content hash and collects unreferenced ones shortly after open", async () => {
|
||||
const database = openDatabase(":memory:")
|
||||
const first = createDraftStore(database.db, { delay: 1_000 })
|
||||
let clock = 0
|
||||
const first = createDraftStore(database.db, { delay: 1_000, now: () => clock })
|
||||
const used = first.putBlob(new Uint8Array([1, 2, 3]))
|
||||
const unused = first.putBlob(new Uint8Array([4, 5, 6]))
|
||||
expect(first.putBlob(new Uint8Array([1, 2, 3]))).toBe(used)
|
||||
first.set("doc", JSON.stringify({ parts: [{ blob: { id: used } }] }))
|
||||
first.flush()
|
||||
const second = createDraftStore(database.db, { delay: 1_000 })
|
||||
expect(second.getBlob(used)).toEqual(new Uint8Array([1, 2, 3]))
|
||||
expect(second.getBlob(unused)).toBeNull()
|
||||
first.close()
|
||||
// The startup collection runs off the window's critical path and keeps the usual grace, so a
|
||||
// blob from a session that ended within the grace period is still there...
|
||||
const second = createDraftStore(database.db, { delay: 1_000, now: () => clock, collectDelay: 0 })
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
expect(second.getBlob(unused)).toEqual(new Uint8Array([4, 5, 6]))
|
||||
second.close()
|
||||
// ...and gone once the grace has passed.
|
||||
clock = blobGrace + 1
|
||||
const third = createDraftStore(database.db, { delay: 1_000, now: () => clock, collectDelay: 0 })
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
expect(third.getBlob(used)).toEqual(new Uint8Array([1, 2, 3]))
|
||||
expect(third.getBlob(unused)).toBeNull()
|
||||
third.close()
|
||||
})
|
||||
|
||||
test("collects a retired chunk only once it is unreferenced and past the grace period", () => {
|
||||
|
||||
@@ -27,11 +27,14 @@ const referenced = (value: unknown) => sql`
|
||||
|
||||
export function createDraftStore(
|
||||
db: Database,
|
||||
input: { delay?: number; onError?: (error: unknown) => void; now?: () => number } = {},
|
||||
input: { delay?: number; collectDelay?: number; onError?: (error: unknown) => void; now?: () => number } = {},
|
||||
) {
|
||||
const now = input.now ?? Date.now
|
||||
// Nothing outside this process can hold a blob id at startup, so no grace applies.
|
||||
collectBlobs(db, Infinity)
|
||||
// Orphans left by an earlier session are collected once the window is up rather than before it:
|
||||
// the scan walks every stored document, and the usual grace keeps anything a renderer has
|
||||
// uploaded in the meantime.
|
||||
const startup = setTimeout(() => collectBlobs(db, now() - blobGrace), input.collectDelay ?? 10_000)
|
||||
startup.unref()
|
||||
let collected = now()
|
||||
let orphans = false
|
||||
const byKey = eq(document.key, sql.placeholder("key"))
|
||||
@@ -104,7 +107,10 @@ export function createDraftStore(
|
||||
return data ? (data as Uint8Array<ArrayBuffer>) : null
|
||||
},
|
||||
flush: writer.flush,
|
||||
close: writer.close,
|
||||
close() {
|
||||
clearTimeout(startup)
|
||||
writer.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,3 +5,4 @@ export const WSL_SERVERS_KEY = "wslServers"
|
||||
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
|
||||
export const BACKGROUND_COLOR_KEY = "backgroundColor"
|
||||
export const WINDOW_IDS_KEY = "windowIds"
|
||||
export const BUNDLED_CLI_VERSION_KEY = "bundledCliVersion"
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { createSettingsStore } from "./store"
|
||||
|
||||
const dir = () => mkdtempSync(path.join(tmpdir(), "opencode-settings-"))
|
||||
|
||||
describe("settings store", () => {
|
||||
test("reads the tab-indented file electron-store wrote and rewrites it in the same shape", () => {
|
||||
const file = path.join(dir(), "opencode.settings")
|
||||
writeFileSync(file, JSON.stringify({ windowIds: ["a"], backgroundColor: "#121212" }, null, "\t"))
|
||||
const store = createSettingsStore(file)
|
||||
expect(store.get("windowIds")).toEqual(["a"])
|
||||
store.set("backgroundColor", "#ffffff")
|
||||
expect(readFileSync(file, "utf8")).toBe(
|
||||
JSON.stringify({ windowIds: ["a"], backgroundColor: "#ffffff" }, null, "\t"),
|
||||
)
|
||||
expect(createSettingsStore(file).get("backgroundColor")).toBe("#ffffff")
|
||||
})
|
||||
|
||||
test("starts empty without a file and creates it on the first write", () => {
|
||||
const file = path.join(dir(), "nested", "opencode.updater")
|
||||
const store = createSettingsStore(file)
|
||||
expect(store.get("ready")).toBeUndefined()
|
||||
expect(existsSync(file)).toBe(false)
|
||||
store.set("ready", { version: "1.0.0" })
|
||||
expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ ready: { version: "1.0.0" } })
|
||||
store.delete("ready")
|
||||
expect(store.get("ready")).toBeUndefined()
|
||||
expect(readFileSync(file, "utf8")).toBe("{}")
|
||||
})
|
||||
|
||||
test("sets an unreadable file aside instead of failing", () => {
|
||||
const file = path.join(dir(), "opencode.settings")
|
||||
writeFileSync(file, "{ not json")
|
||||
const store = createSettingsStore(file)
|
||||
expect(store.get("anything")).toBeUndefined()
|
||||
expect(existsSync(`${file}.corrupt`)).toBe(true)
|
||||
store.set("firstLaunchOnboardingComplete", true)
|
||||
expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ firstLaunchOnboardingComplete: true })
|
||||
})
|
||||
|
||||
test("rejects undefined like electron-store did", () => {
|
||||
const store = createSettingsStore(path.join(dir(), "opencode.settings"))
|
||||
expect(() => store.set("key", undefined)).toThrow(TypeError)
|
||||
})
|
||||
})
|
||||
@@ -1,28 +1,62 @@
|
||||
import Store from "electron-store"
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import electron from "electron"
|
||||
|
||||
import { SETTINGS_STORE } from "./keys"
|
||||
|
||||
const cache = new Map<string, Store>()
|
||||
const cache = new Map<string, SettingsStore>()
|
||||
|
||||
export type SettingsStore = ReturnType<typeof createSettingsStore>
|
||||
|
||||
// Main-process settings only (onboarding, default server, window list, appearance, updater).
|
||||
// These are read synchronously before the storage layer exists and written on user action, so
|
||||
// electron-store's synchronous file write is acceptable here. Renderer state goes through
|
||||
// DesktopStorage instead.
|
||||
// These are read synchronously before the storage layer exists and written on user action, so a
|
||||
// synchronous whole-file write is acceptable here. Renderer state goes through DesktopStorage.
|
||||
//
|
||||
// We cannot instantiate the electron-store at module load time because
|
||||
// module import hoisting causes this to run before app.setPath("userData", ...)
|
||||
// in index.ts has executed, which would result in files being written to the default directory
|
||||
// (e.g. bad: %APPDATA%\@opencode\desktop\opencode.settings vs good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings).
|
||||
// The file is the tab-indented JSON object electron-store used to write, so existing profiles
|
||||
// (and older builds) read the same data.
|
||||
//
|
||||
// We cannot instantiate the store at module load time because module import hoisting causes this
|
||||
// to run before app.setPath("userData", ...) has executed, which would result in files being
|
||||
// written to the default directory (e.g. bad: %APPDATA%\@opencode\desktop\opencode.settings vs
|
||||
// good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings).
|
||||
export function getStore(name = SETTINGS_STORE) {
|
||||
const cached = cache.get(name)
|
||||
if (cached) return cached
|
||||
const next = new Store({
|
||||
name,
|
||||
cwd: electron.app.getPath("userData"),
|
||||
fileExtension: "",
|
||||
accessPropertiesByDotNotation: false,
|
||||
})
|
||||
const next = createSettingsStore(path.join(electron.app.getPath("userData"), name))
|
||||
cache.set(name, next)
|
||||
return next
|
||||
}
|
||||
|
||||
export function createSettingsStore(file: string) {
|
||||
let data = read(file)
|
||||
const write = () => {
|
||||
mkdirSync(path.dirname(file), { recursive: true })
|
||||
writeFileSync(`${file}.tmp`, JSON.stringify(data, null, "\t"))
|
||||
renameSync(`${file}.tmp`, file)
|
||||
}
|
||||
return {
|
||||
path: file,
|
||||
get: (key: string): unknown => data[key],
|
||||
set: (key: string, value: unknown) => {
|
||||
if (value === undefined) throw new TypeError(`Use \`delete()\` to clear values: ${key}`)
|
||||
data = { ...data, [key]: value }
|
||||
write()
|
||||
},
|
||||
delete: (key: string) => {
|
||||
data = Object.fromEntries(Object.entries(data).filter(([item]) => item !== key))
|
||||
write()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// A missing file is an empty store. An unreadable one is set aside instead of blocking startup.
|
||||
function read(file: string): Record<string, unknown> {
|
||||
if (!existsSync(file)) return {}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(file, "utf8"))
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {}
|
||||
} catch {
|
||||
renameSync(file, `${file}.corrupt`)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { resolveThemeVariant } from "@opencode/ui/theme/resolve"
|
||||
import type { DesktopTheme } from "@opencode/ui/theme/types"
|
||||
import oc2ThemeJson from "../../../../ui/src/theme/themes/oc-2.json"
|
||||
import { app, BrowserWindow, nativeImage, nativeTheme } from "electron"
|
||||
import type { Path } from "effect"
|
||||
import { type TitlebarTheme } from "../../shared/ipc-contract"
|
||||
@@ -9,16 +6,10 @@ import { emitIpcEvent } from "../ipc-events"
|
||||
import type { DesktopPaths } from "../paths"
|
||||
import { BACKGROUND_COLOR_KEY, PINCH_ZOOM_ENABLED_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
import { storedBackgroundColor, titlebarOverlay, tone } from "./defaults"
|
||||
|
||||
const oc2Theme = oc2ThemeJson as DesktopTheme
|
||||
const oc2Background = {
|
||||
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
|
||||
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
|
||||
}
|
||||
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
|
||||
const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
|
||||
// Match the renderer's 36px titlebar plus its former 8px content inset.
|
||||
const titlebarHeight = 44
|
||||
const maxZoomLevel = 10
|
||||
const minZoomLevel = 0.2
|
||||
let backgroundColor: string | undefined
|
||||
@@ -28,7 +19,7 @@ export function windowAppearance(path: Path.Path, paths: DesktopPaths.Resolved)
|
||||
return {
|
||||
title: "OpenCode",
|
||||
icon: iconPath(path, paths),
|
||||
backgroundColor: getBackgroundColor() ?? oc2Background[mode],
|
||||
backgroundColor: backgroundColor ?? storedBackgroundColor(),
|
||||
...(process.platform === "darwin"
|
||||
? {
|
||||
titleBarStyle: "hidden" as const,
|
||||
@@ -58,8 +49,10 @@ export function setDockIcon(path: Path.Path, paths: DesktopPaths.Resolved) {
|
||||
}
|
||||
|
||||
export function setBackgroundColor(color: string) {
|
||||
// The renderer reports its theme background on every boot; electron-store rewrites and fsyncs the
|
||||
// settings file on each set, so only persist a change.
|
||||
if (getBackgroundColor() !== color) getStore().set(BACKGROUND_COLOR_KEY, color)
|
||||
backgroundColor = color
|
||||
getStore().set(BACKGROUND_COLOR_KEY, color)
|
||||
BrowserWindow.getAllWindows().forEach((win) => {
|
||||
win.setBackgroundColor(color)
|
||||
if (process.platform === "darwin") win.invalidateShadow()
|
||||
@@ -106,7 +99,9 @@ export function setZoomFactor(win: BrowserWindow, factor: number) {
|
||||
|
||||
export function wireZoom(win: BrowserWindow) {
|
||||
pinchZoomEnabled.set(win, getPinchZoomEnabled())
|
||||
win.webContents.setZoomFactor(1)
|
||||
// Setting the factor forces a visual-properties round trip with the renderer, so leave it alone
|
||||
// when it is already 1: the first window has a document on screen by now.
|
||||
if (win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
|
||||
win.webContents.on("zoom-changed", (event, direction) => {
|
||||
event.preventDefault()
|
||||
if (pinchZoomEnabled.get(win)) {
|
||||
@@ -137,17 +132,8 @@ function iconPath(path: Path.Path, paths: DesktopPaths.Resolved) {
|
||||
return path.join(iconsDir(path, paths), `icon.${process.platform === "win32" ? "ico" : "png"}`)
|
||||
}
|
||||
|
||||
function tone() {
|
||||
return nativeTheme.shouldUseDarkColors ? "dark" : "light"
|
||||
}
|
||||
|
||||
function overlay(theme: Partial<TitlebarTheme> = {}, zoom = 1) {
|
||||
const mode = theme.mode ?? tone()
|
||||
return {
|
||||
color: "#00000000",
|
||||
symbolColor: mode === "dark" ? "white" : "black",
|
||||
height: Math.max(titlebarHeight, Math.round(titlebarHeight * zoom)),
|
||||
}
|
||||
return titlebarOverlay(theme.mode ?? tone(), zoom)
|
||||
}
|
||||
|
||||
function clampZoom(value: number) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { resolveThemeVariant } from "@opencode/ui/theme/resolve"
|
||||
import type { DesktopTheme } from "@opencode/ui/theme/types"
|
||||
import { nativeTheme } from "electron"
|
||||
import oc2ThemeJson from "../../../../ui/src/theme/themes/oc-2.json"
|
||||
import { BACKGROUND_COLOR_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
|
||||
// Frame defaults shared by the early window (created on ready, before the renderer exists) and the
|
||||
// full window setup in appearance.ts, so both draw the same frame.
|
||||
|
||||
const oc2Theme = oc2ThemeJson as DesktopTheme
|
||||
const oc2Background = {
|
||||
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
|
||||
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
|
||||
}
|
||||
// Match the renderer's 36px titlebar plus its former 8px content inset.
|
||||
export const titlebarHeight = 44
|
||||
|
||||
export function tone() {
|
||||
return nativeTheme.shouldUseDarkColors ? "dark" : "light"
|
||||
}
|
||||
|
||||
// The colour the renderer reported on its last run, or the default theme's for the system tone, so
|
||||
// a window shown before the renderer paints already has the right background.
|
||||
export function storedBackgroundColor() {
|
||||
const stored = getStore().get(BACKGROUND_COLOR_KEY)
|
||||
return typeof stored === "string" ? stored : oc2Background[tone()]
|
||||
}
|
||||
|
||||
export function titlebarOverlay(mode: "light" | "dark" = tone(), zoom = 1) {
|
||||
return {
|
||||
color: "#00000000",
|
||||
symbolColor: mode === "dark" ? "white" : "black",
|
||||
height: Math.max(titlebarHeight, Math.round(titlebarHeight * zoom)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { app, BrowserWindow, screen } from "electron"
|
||||
import { windowIDArgument } from "../../shared/window-bootstrap"
|
||||
import { WINDOW_IDS_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
import { storedBackgroundColor, titlebarOverlay } from "./defaults"
|
||||
import { rendererHost, rendererProtocol } from "./scheme"
|
||||
import { earlyQuery, serveRenderer } from "./serve"
|
||||
import { manageWindowState, readWindowState, resolveWindowState, windowStateFile, type WindowState } from "./window-state"
|
||||
|
||||
export type EarlyWindow = { id: string; win: BrowserWindow; state: WindowState; shownAt: number; loaded: boolean }
|
||||
|
||||
let pending: EarlyWindow | undefined
|
||||
|
||||
const displays = {
|
||||
all: () => screen.getAllDisplays().map((display) => display.bounds),
|
||||
primary: () => screen.getPrimaryDisplay().bounds,
|
||||
matching: (bounds: Electron.Rectangle) => screen.getDisplayMatching(bounds).bounds,
|
||||
}
|
||||
|
||||
// Creates and shows the first restored window the moment Electron is ready, before the rest of the
|
||||
// main process has loaded. The frame options mirror windowAppearance(); the persisted background
|
||||
// colour stands in for the theme until the renderer applies it, so the window is on screen while the
|
||||
// bundle, the layers and the renderer boot. restoreWindows() adopts it through takeEarlyWindow().
|
||||
export function createEarlyWindow() {
|
||||
const ids = getStore().get(WINDOW_IDS_KEY)
|
||||
const id = Array.isArray(ids) && typeof ids[0] === "string" ? ids[0] : randomUUID()
|
||||
const root = path.dirname(fileURLToPath(import.meta.url))
|
||||
const file = path.join(app.getPath("userData"), windowStateFile(id))
|
||||
const state = resolveWindowState(readWindowState(file), { width: 1280, height: 800 }, displays)
|
||||
const icons = app.isPackaged ? path.join(process.resourcesPath, "icons") : path.join(root, "../../resources/icons")
|
||||
const win = new BrowserWindow({
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
title: "OpenCode",
|
||||
icon: path.join(icons, `icon.${process.platform === "win32" ? "ico" : "png"}`),
|
||||
backgroundColor: storedBackgroundColor(),
|
||||
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const, trafficLightPosition: { x: 14, y: 14 } } : {}),
|
||||
...(process.platform === "win32" ? { frame: false, titleBarStyle: "hidden" as const, titleBarOverlay: titlebarOverlay() } : {}),
|
||||
webPreferences: {
|
||||
preload: path.join(root, "../preload/index.cjs"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
additionalArguments: [windowIDArgument(id)],
|
||||
},
|
||||
})
|
||||
manageWindowState(win, file, state, displays)
|
||||
// Closing the only window before the rest of the app has adopted it is a quit.
|
||||
win.once("closed", () => {
|
||||
if (pending?.win !== win) return
|
||||
pending = undefined
|
||||
app.quit()
|
||||
})
|
||||
// In production the window starts its document now - the same index.html without its scripts,
|
||||
// carrying the shell snapshot from the previous run - so the renderer process and the stylesheet
|
||||
// are ready, and the user sees their UI, while the bundle and the layers load. The scripts are
|
||||
// added when restoreWindows() adopts the window. Development keeps loading from the dev server.
|
||||
const loaded = !process.env.ELECTRON_RENDERER_URL
|
||||
if (loaded) {
|
||||
serveRenderer(path.join(root, "../renderer"))
|
||||
void win.loadURL(`${rendererProtocol}://${rendererHost}/index.html?${earlyQuery}=${encodeURIComponent(id)}`)
|
||||
}
|
||||
pending = { id, win, state, shownAt: Date.now(), loaded }
|
||||
}
|
||||
|
||||
export function takeEarlyWindow() {
|
||||
const taken = pending
|
||||
pending = undefined
|
||||
return taken
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import windowState from "electron-window-state"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { app, BrowserWindow } from "electron"
|
||||
import { app, BrowserWindow, screen } from "electron"
|
||||
import { Effect, FileSystem, Path } from "effect"
|
||||
import { openExternalURL } from "../files"
|
||||
import { scoped } from "../native/logging"
|
||||
@@ -23,11 +22,21 @@ import {
|
||||
wireZoom,
|
||||
} from "./appearance"
|
||||
import { loadWindow, registerRendererProtocol } from "./protocol"
|
||||
import { removePrepaint, writePrepaint } from "./prepaint"
|
||||
import { releaseRenderer } from "./serve"
|
||||
import { createWindowRegistry } from "./registry"
|
||||
import { makeWindowRecovery } from "./recovery"
|
||||
import { takeEarlyWindow, type EarlyWindow } from "./early"
|
||||
import { manageWindowState, readWindowState, resolveWindowState, windowStateFile } from "./window-state"
|
||||
import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security"
|
||||
|
||||
const themeReady = new WeakMap<BrowserWindow, () => void>()
|
||||
const windowIDs = new WeakMap<BrowserWindow, string>()
|
||||
const displays = {
|
||||
all: () => screen.getAllDisplays().map((display) => display.bounds),
|
||||
primary: () => screen.getPrimaryDisplay().bounds,
|
||||
matching: (bounds: Electron.Rectangle) => screen.getDisplayMatching(bounds).bounds,
|
||||
}
|
||||
const registry = createWindowRegistry<BrowserWindow>({
|
||||
read: () => getStore().get(WINDOW_IDS_KEY),
|
||||
write: (ids) => getStore().set(WINDOW_IDS_KEY, ids),
|
||||
@@ -74,6 +83,12 @@ export function setWindowThemeReady(win: BrowserWindow) {
|
||||
themeReady.get(win)?.()
|
||||
}
|
||||
|
||||
export function saveWindowPrepaint(win: BrowserWindow, html: string) {
|
||||
const id = windowIDs.get(win)
|
||||
if (!id) return Promise.resolve()
|
||||
return writePrepaint(id, html)
|
||||
}
|
||||
|
||||
export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
@@ -83,47 +98,68 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
const wireWindowRecovery = yield* makeWindowRecovery
|
||||
|
||||
const restore = () => {
|
||||
// The entry module created and showed the first restored window on ready; it is adopted here,
|
||||
// before any renderer loads, so the user never waited for the layers to see a window.
|
||||
const early = takeEarlyWindow()
|
||||
const usable = early && !early.win.isDestroyed() ? early : undefined
|
||||
const ids = registry.persisted()
|
||||
return (ids.length ? ids : [randomUUID()]).map((id) => create(id))
|
||||
const list = ids.length ? ids : [usable?.id ?? randomUUID()]
|
||||
if (usable && !list.includes(usable.id)) usable.win.destroy()
|
||||
return list.map((id) => create(id, usable?.id === id ? usable : undefined))
|
||||
}
|
||||
|
||||
const create = (id: string = randomUUID()) => {
|
||||
const state = windowState({ file: windowStateFile(id), defaultWidth: 1280, defaultHeight: 800 })
|
||||
const create = (id: string = randomUUID(), early?: EarlyWindow) => {
|
||||
const stateFile = path.join(app.getPath("userData"), windowStateFile(id))
|
||||
const state = early?.state ?? resolveWindowState(readWindowState(stateFile), { width: 1280, height: 800 }, displays)
|
||||
const appearance = windowAppearance(path, paths)
|
||||
const win = new BrowserWindow({
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
...appearance,
|
||||
webPreferences: {
|
||||
...appearance.webPreferences,
|
||||
additionalArguments: [windowIDArgument(id)],
|
||||
},
|
||||
})
|
||||
const win =
|
||||
early?.win ??
|
||||
new BrowserWindow({
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
...appearance,
|
||||
webPreferences: {
|
||||
...appearance.webPreferences,
|
||||
additionalArguments: [windowIDArgument(id)],
|
||||
},
|
||||
})
|
||||
|
||||
allowRendererPermissions(win)
|
||||
wireWindowRecovery(win, id, () => relaunchHandler())
|
||||
wireNavigationPolicy(win, (url) => runFork(openExternalURL(url)))
|
||||
wireRendererHeaders(win)
|
||||
state.manage(win)
|
||||
if (!early) manageWindowState(win, stateFile, state, displays)
|
||||
register(win, id)
|
||||
wireFullscreen(win)
|
||||
loadWindow(win, "index.html")
|
||||
if (early?.loaded) {
|
||||
runFork(
|
||||
Effect.tryPromise(() => releaseRenderer(win, paths.rendererRoot)).pipe(
|
||||
Effect.catch((error) => scoped("window", Effect.logError("failed to release early renderer", { id, error }))),
|
||||
),
|
||||
)
|
||||
} else loadWindow(win, "index.html")
|
||||
wireZoom(win)
|
||||
let contentReady = false
|
||||
let appliedTheme = false
|
||||
let revealed = false
|
||||
let revealed = !!early
|
||||
const focusForTests = () => {
|
||||
if (app.isPackaged || process.env.OPENCODE_TEST_ONBOARDING !== "1") return
|
||||
if (process.platform === "darwin") app.focus({ steal: true })
|
||||
win.focus()
|
||||
}
|
||||
if (early) {
|
||||
focusForTests()
|
||||
runFork(Effect.logInfo("main window visible", { window: id, shownAt: early.shownAt }))
|
||||
}
|
||||
const reveal = () => {
|
||||
if (!contentReady || !appliedTheme || revealed || win.isDestroyed()) return
|
||||
revealed = true
|
||||
win.show()
|
||||
if (!app.isPackaged && process.env.OPENCODE_TEST_ONBOARDING === "1") {
|
||||
if (process.platform === "darwin") app.focus({ steal: true })
|
||||
win.focus()
|
||||
}
|
||||
focusForTests()
|
||||
runFork(Effect.logInfo("main window visible", { window: id }))
|
||||
}
|
||||
const ready = () => {
|
||||
@@ -142,6 +178,7 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
|
||||
const register = (win: BrowserWindow, id: string) => {
|
||||
registry.register(id, win)
|
||||
windowIDs.set(win, id)
|
||||
win.on("focus", () => registry.focused(id))
|
||||
// Windows emits session-end, but not before-quit, during shutdown and logoff.
|
||||
win.on("session-end", () => registry.setQuitting())
|
||||
@@ -151,6 +188,7 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.try(() => storage.state.clear(windowDataFile(id)))
|
||||
yield* fs.remove(path.join(app.getPath("userData"), windowStateFile(id)), { force: true })
|
||||
yield* Effect.promise(() => removePrepaint(id))
|
||||
}).pipe(
|
||||
Effect.catch((error) => scoped("window", Effect.logError("failed to clean window state", { id, error }))),
|
||||
),
|
||||
@@ -161,10 +199,6 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
return { create, restore }
|
||||
})
|
||||
|
||||
function windowStateFile(id: string) {
|
||||
return `window-state-${safeWindowID(id)}.json`
|
||||
}
|
||||
|
||||
// Mirrors windowStorage() in packages/app/src/runtime/persistence/storage.ts; it is the state
|
||||
// namespace the renderer persists this window's tabs under.
|
||||
function windowDataFile(id: string) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { mkdir, rename, rm, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { app } from "electron"
|
||||
|
||||
// The shell snapshot a window shows before its renderer has booted. It is a file next to the
|
||||
// window-state JSON rather than a row in the desktop database because the entry module serves it
|
||||
// before the storage layers exist; a snapshot from another app version is ignored, since its class
|
||||
// names may no longer match the bundled stylesheet.
|
||||
|
||||
export function prepaintFile(id: string) {
|
||||
return `prepaint-${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.html`
|
||||
}
|
||||
|
||||
export function prepaintMarker(version: string) {
|
||||
return `<!-- opencode ${version} -->`
|
||||
}
|
||||
|
||||
export async function writePrepaint(id: string, html: string) {
|
||||
const file = path.join(app.getPath("userData"), prepaintFile(id))
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await writeFile(`${file}.tmp`, `${prepaintMarker(app.getVersion())}\n${html}`)
|
||||
await rename(`${file}.tmp`, file)
|
||||
}
|
||||
|
||||
export function removePrepaint(id: string) {
|
||||
return rm(path.join(app.getPath("userData"), prepaintFile(id)), { force: true })
|
||||
}
|
||||
@@ -1,68 +1,19 @@
|
||||
import { net, protocol } from "electron"
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { Effect, Path } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { scoped } from "../native/logging"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
|
||||
|
||||
const rendererProtocol = "oc"
|
||||
const rendererHost = "renderer"
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: rendererProtocol,
|
||||
privileges: {
|
||||
secure: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
])
|
||||
import { rendererHost, rendererProtocol } from "./scheme"
|
||||
import { serveRenderer, setRendererProtocolLogger } from "./serve"
|
||||
|
||||
// The entry module normally registers the handler before the bundle loads; this only wires its
|
||||
// logging, and registers it when the entry module did not (development, or a window created later).
|
||||
export const registerRendererProtocol = Effect.fn("Window.registerRendererProtocol")(function* () {
|
||||
const path = yield* Path.Path
|
||||
const paths = yield* DesktopPaths.resolve
|
||||
const runFork = Effect.runForkWith(yield* Effect.context<never>())
|
||||
if (protocol.isProtocolHandled(rendererProtocol)) return
|
||||
|
||||
protocol.handle(rendererProtocol, async (request) => {
|
||||
const url = new URL(request.url)
|
||||
if (url.host !== rendererHost) {
|
||||
runFork(scoped("protocol", Effect.logWarning("rejected host", { url: request.url })))
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
const file = path.resolve(paths.rendererRoot, `.${decodeURIComponent(url.pathname)}`)
|
||||
const rel = path.relative(paths.rendererRoot, file)
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
runFork(scoped("protocol", Effect.logWarning("rejected path", { url: request.url, file })))
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const range = request.headers.get("range")
|
||||
const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined })
|
||||
if (response.status >= 400) {
|
||||
runFork(
|
||||
scoped(
|
||||
"protocol",
|
||||
Effect.logError("fetch failed", {
|
||||
url: request.url,
|
||||
file,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
return addDocumentPolicy(response, file)
|
||||
} catch (error) {
|
||||
runFork(scoped("protocol", Effect.logError("fetch error", { url: request.url, file, error })))
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
})
|
||||
setRendererProtocolLogger((level, message, data) =>
|
||||
runFork(scoped("protocol", level === "error" ? Effect.logError(message, data) : Effect.logWarning(message, data))),
|
||||
)
|
||||
serveRenderer(paths.rendererRoot)
|
||||
})
|
||||
|
||||
export function loadWindow(win: BrowserWindow, html: string) {
|
||||
@@ -82,11 +33,4 @@ export function isRendererUrl(value?: string, html = false) {
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (!devUrl || !URL.canParse(devUrl)) return false
|
||||
return url.origin === new URL(devUrl).origin
|
||||
}
|
||||
|
||||
function addDocumentPolicy(response: Response, file: string) {
|
||||
if (!file.toLowerCase().endsWith(".html")) return response
|
||||
const headers = new Headers(response.headers)
|
||||
headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy)
|
||||
return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { protocol } from "electron"
|
||||
|
||||
export const rendererProtocol = "oc"
|
||||
export const rendererHost = "renderer"
|
||||
|
||||
// Scheme privileges can only be granted before the app is ready, so the entry module calls this
|
||||
// before it loads anything else.
|
||||
export function registerRendererScheme() {
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: rendererProtocol,
|
||||
privileges: {
|
||||
secure: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
stream: true,
|
||||
// Let Chromium keep V8 bytecode for the renderer bundle between launches.
|
||||
codeCache: true,
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { app, net, protocol } from "electron"
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
|
||||
import { prepaintFile, prepaintMarker } from "./prepaint"
|
||||
import { rendererHost, rendererProtocol } from "./scheme"
|
||||
|
||||
// Serves the renderer bundle over oc://renderer. This module has no Effect dependency because the
|
||||
// entry module registers it right after Electron is ready, before the main bundle has loaded, so
|
||||
// the first window can start its document while the bundle and the layers evaluate.
|
||||
|
||||
export const earlyQuery = "early"
|
||||
|
||||
type Log = (level: "warning" | "error", message: string, data: Record<string, unknown>) => void
|
||||
let log: Log = () => {}
|
||||
|
||||
export function setRendererProtocolLogger(logger: Log) {
|
||||
log = logger
|
||||
}
|
||||
|
||||
export function serveRenderer(rendererRoot: string) {
|
||||
if (protocol.isProtocolHandled(rendererProtocol)) return
|
||||
protocol.handle(rendererProtocol, async (request) => {
|
||||
const url = new URL(request.url)
|
||||
if (url.host !== rendererHost) {
|
||||
log("warning", "rejected host", { url: request.url })
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
const file = path.resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
|
||||
const rel = path.relative(rendererRoot, file)
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
log("warning", "rejected path", { url: request.url, file })
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
const early = url.pathname === "/index.html" ? url.searchParams.get(earlyQuery) : null
|
||||
if (early !== null) return earlyDocument(file, early)
|
||||
|
||||
try {
|
||||
const range = request.headers.get("range")
|
||||
const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined })
|
||||
if (response.status >= 400) {
|
||||
log("error", "fetch failed", {
|
||||
url: request.url,
|
||||
file,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
})
|
||||
}
|
||||
return addDocumentPolicy(response, file)
|
||||
} catch (error) {
|
||||
log("error", "fetch error", { url: request.url, file, error })
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The early window loads index.html?early=<window id>: the same document without its module
|
||||
// scripts, plus the shell snapshot the window captured last time, so the user sees their own UI
|
||||
// while the main process is still loading. releaseRenderer() adds the scripts back once the window
|
||||
// has been adopted.
|
||||
async function earlyDocument(file: string, id: string) {
|
||||
const [html, prepaint] = await Promise.all([
|
||||
readFile(file, "utf8"),
|
||||
readFile(path.join(app.getPath("userData"), prepaintFile(id)), "utf8").catch(() => undefined),
|
||||
])
|
||||
const body = prepaint?.startsWith(prepaintMarker(app.getVersion()))
|
||||
? `<div id="oc-prepaint" inert aria-hidden="true" style="position:fixed;inset:0;z-index:100;pointer-events:none;display:flex;flex-direction:column;background-color:var(--background-base)">${prepaint.slice(prepaint.indexOf("\n") + 1)}</div></body>`
|
||||
: "</body>"
|
||||
const text = html.replace(moduleScript, "").replace("</body>", body)
|
||||
return new Response(text, {
|
||||
headers: {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
[documentPolicyHeader]: jsCallStacksDocumentPolicy,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const moduleScript = /<script type="module"[^>]*><\/script>\s*/g
|
||||
|
||||
export async function releaseRenderer(win: BrowserWindow, rendererRoot: string) {
|
||||
const html = await readFile(path.join(rendererRoot, "index.html"), "utf8")
|
||||
const srcs = [...html.matchAll(moduleScript)].flatMap((match) => {
|
||||
const src = /\bsrc="([^"]+)"/.exec(match[0])
|
||||
return src ? [src[1]] : []
|
||||
})
|
||||
if (win.isDestroyed()) return
|
||||
// async = false keeps the runtime chunk ahead of the entry, as the static tags did. Dropping the
|
||||
// query afterwards makes a reload load the full document.
|
||||
await win.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
for (const src of ${JSON.stringify(srcs)}) {
|
||||
const script = document.createElement("script")
|
||||
script.type = "module"
|
||||
script.async = false
|
||||
script.crossOrigin = ""
|
||||
script.src = src
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
history.replaceState(history.state, "", "/index.html")
|
||||
})()`,
|
||||
)
|
||||
}
|
||||
|
||||
function addDocumentPolicy(response: Response, file: string) {
|
||||
if (!file.toLowerCase().endsWith(".html")) return response
|
||||
const headers = new Headers(response.headers)
|
||||
headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy)
|
||||
return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { mkdtempSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { readWindowState, resolveWindowState } from "./window-state"
|
||||
|
||||
const defaults = { width: 1280, height: 800 }
|
||||
const primary = { x: 0, y: 0, width: 1920, height: 1080 }
|
||||
const displays = {
|
||||
all: () => [primary, { x: 1920, y: 0, width: 2560, height: 1440 }],
|
||||
primary: () => primary,
|
||||
matching: () => primary,
|
||||
}
|
||||
|
||||
describe("window state", () => {
|
||||
test("keeps bounds that still fit on a display", () => {
|
||||
const saved = { x: 2000, y: 100, width: 1000, height: 700, displayBounds: { x: 1920, y: 0, width: 2560, height: 1440 } }
|
||||
expect(resolveWindowState(saved, defaults, displays)).toEqual(saved)
|
||||
})
|
||||
|
||||
test("falls back to the default size on the primary display when the saved display is gone", () => {
|
||||
const saved = { x: 5000, y: 100, width: 1000, height: 700, displayBounds: { x: 4480, y: 0, width: 1920, height: 1080 } }
|
||||
expect(resolveWindowState(saved, defaults, displays)).toEqual({ ...defaults, x: 0, y: 0, displayBounds: primary })
|
||||
})
|
||||
|
||||
test("uses defaults without a position when nothing usable was saved", () => {
|
||||
expect(resolveWindowState(undefined, defaults, displays)).toEqual(defaults)
|
||||
expect(resolveWindowState({ width: 0, height: -1 }, defaults, displays)).toEqual(defaults)
|
||||
expect(resolveWindowState("junk", defaults, displays)).toEqual(defaults)
|
||||
})
|
||||
|
||||
test("keeps a maximized flag even without bounds", () => {
|
||||
expect(resolveWindowState({ isMaximized: true }, defaults, displays)).toEqual({ ...defaults, isMaximized: true })
|
||||
})
|
||||
|
||||
test("reads the file electron-window-state wrote and ignores an unreadable one", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "opencode-window-state-"))
|
||||
const file = path.join(dir, "window-state-a.json")
|
||||
writeFileSync(
|
||||
file,
|
||||
'{"width":1280,"height":800,"x":0,"y":0,"displayBounds":{"x":0,"y":0,"width":3440,"height":1440},"isMaximized":false,"isFullScreen":false}',
|
||||
)
|
||||
expect(resolveWindowState(readWindowState(file), defaults, displays)).toEqual({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
x: 0,
|
||||
y: 0,
|
||||
displayBounds: { x: 0, y: 0, width: 3440, height: 1440 },
|
||||
isMaximized: false,
|
||||
isFullScreen: false,
|
||||
})
|
||||
writeFileSync(file, "{oops")
|
||||
expect(readWindowState(file)).toBeUndefined()
|
||||
expect(readWindowState(path.join(dir, "missing.json"))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import type { BrowserWindow, Rectangle } from "electron"
|
||||
|
||||
// Remembers a window's bounds and maximized / fullscreen flags across launches in the same JSON
|
||||
// file electron-window-state wrote, so existing profiles restore where they were.
|
||||
|
||||
export type WindowState = {
|
||||
x?: number
|
||||
y?: number
|
||||
width: number
|
||||
height: number
|
||||
displayBounds?: Rectangle
|
||||
isMaximized?: boolean
|
||||
isFullScreen?: boolean
|
||||
}
|
||||
|
||||
export type Displays = {
|
||||
all: () => Rectangle[]
|
||||
primary: () => Rectangle
|
||||
matching: (bounds: Rectangle) => Rectangle
|
||||
}
|
||||
|
||||
// Bounds are only trusted when they still fit on one of the current displays; otherwise the window
|
||||
// falls back to the default size on the primary display. A saved maximized or fullscreen state
|
||||
// without bounds is kept so manage() can restore it.
|
||||
export function resolveWindowState(saved: unknown, defaults: { width: number; height: number }, displays: Displays) {
|
||||
const state = isState(saved) ? saved : undefined
|
||||
if (!state) return { width: defaults.width, height: defaults.height } satisfies WindowState
|
||||
if (!hasBounds(state)) {
|
||||
return state.isMaximized || state.isFullScreen
|
||||
? ({ ...state, width: defaults.width, height: defaults.height } satisfies WindowState)
|
||||
: ({ width: defaults.width, height: defaults.height } satisfies WindowState)
|
||||
}
|
||||
if (!state.displayBounds) return state
|
||||
const visible = displays.all().some((bounds) => within(state, bounds))
|
||||
if (visible) return state
|
||||
return {
|
||||
width: defaults.width,
|
||||
height: defaults.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
displayBounds: displays.primary(),
|
||||
} satisfies WindowState
|
||||
}
|
||||
|
||||
export function windowStateFile(id: string) {
|
||||
return `window-state-${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
|
||||
}
|
||||
|
||||
export function readWindowState(file: string): unknown {
|
||||
if (!existsSync(file)) return undefined
|
||||
try {
|
||||
return JSON.parse(readFileSync(file, "utf8"))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Tracks the window until it closes and writes the final state then. Bounds are recorded while
|
||||
// the window is in its normal state so a maximized window restores to its previous size.
|
||||
export function manageWindowState(win: BrowserWindow, file: string, initial: WindowState, displays: Displays) {
|
||||
const state = { ...initial }
|
||||
if (state.isMaximized) win.maximize()
|
||||
if (state.isFullScreen) win.setFullScreen(true)
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const update = () => {
|
||||
if (win.isDestroyed()) return
|
||||
const bounds = win.getBounds()
|
||||
if (!win.isMaximized() && !win.isMinimized() && !win.isFullScreen()) {
|
||||
state.x = bounds.x
|
||||
state.y = bounds.y
|
||||
state.width = bounds.width
|
||||
state.height = bounds.height
|
||||
}
|
||||
state.isMaximized = win.isMaximized()
|
||||
state.isFullScreen = win.isFullScreen()
|
||||
state.displayBounds = displays.matching(bounds)
|
||||
}
|
||||
const changed = () => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(update, 100)
|
||||
}
|
||||
const closed = () => {
|
||||
clearTimeout(timer)
|
||||
win.off("resize", changed)
|
||||
win.off("move", changed)
|
||||
win.off("close", update)
|
||||
win.off("closed", closed)
|
||||
mkdirSync(path.dirname(file), { recursive: true })
|
||||
writeFileSync(`${file}.tmp`, JSON.stringify(state))
|
||||
renameSync(`${file}.tmp`, file)
|
||||
}
|
||||
win.on("resize", changed)
|
||||
win.on("move", changed)
|
||||
win.on("close", update)
|
||||
win.on("closed", closed)
|
||||
}
|
||||
|
||||
function isState(value: unknown): value is WindowState {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function hasBounds(state: WindowState): state is WindowState & Required<Pick<WindowState, "x" | "y">> {
|
||||
return (
|
||||
Number.isInteger(state.x) &&
|
||||
Number.isInteger(state.y) &&
|
||||
Number.isInteger(state.width) &&
|
||||
state.width > 0 &&
|
||||
Number.isInteger(state.height) &&
|
||||
state.height > 0
|
||||
)
|
||||
}
|
||||
|
||||
function within(state: WindowState & { x: number; y: number }, bounds: Rectangle) {
|
||||
return (
|
||||
state.x >= bounds.x &&
|
||||
state.y >= bounds.y &&
|
||||
state.x + state.width <= bounds.x + bounds.width &&
|
||||
state.y + state.height <= bounds.y + bounds.height
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import * as pty from "@lydell/node-pty"
|
||||
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "@opencode/app/wsl/types"
|
||||
import { Effect, FileSystem, Path } from "effect"
|
||||
import { nativeT } from "../native/translations"
|
||||
@@ -117,7 +116,14 @@ function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
function runInteractiveCommand(command: string, args: string[], opts: RunWslOptions = {}, defaultTimeoutMs: number) {
|
||||
async function runInteractiveCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
opts: RunWslOptions = {},
|
||||
defaultTimeoutMs: number,
|
||||
) {
|
||||
// The native addon is only needed for interactive installs; loading it here keeps it out of startup.
|
||||
const pty = await import("@lydell/node-pty")
|
||||
return new Promise<WslCommandResult>((resolve, reject) => {
|
||||
const child = pty.spawn(command, args, {
|
||||
name: "xterm-color",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { contextBridge, ipcRenderer, webUtils } from "electron"
|
||||
import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport"
|
||||
import { DragCancelEvent, IpcTransportPort, IpcTransportPortRequest } from "../shared/ipc-transport"
|
||||
import { windowIDFromArguments } from "../shared/window-bootstrap"
|
||||
|
||||
ipcRenderer.on(IpcTransportPort, (event) => {
|
||||
@@ -11,5 +11,6 @@ ipcRenderer.on(DragCancelEvent, () => window.dispatchEvent(new Event(DragCancelE
|
||||
|
||||
contextBridge.exposeInMainWorld("electron", {
|
||||
windowID: windowIDFromArguments(process.argv),
|
||||
requestRpcPort: () => ipcRenderer.send(IpcTransportPortRequest),
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type ElectronNative = {
|
||||
windowID: string
|
||||
requestRpcPort(): void
|
||||
getPathForFile(file: File): string
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ export type ElectronAPI = {
|
||||
draftBlobGet(id: string): Promise<ArrayBuffer | null>
|
||||
getWindowID(): string
|
||||
themeReady(): Promise<void>
|
||||
// Persists the sanitized shell markup the next launch shows before the renderer boots.
|
||||
savePrepaint(html: string): Promise<void>
|
||||
onMenuCommand(cb: (id: string) => void): () => void
|
||||
onDeepLink(cb: (urls: string[]) => void): () => void
|
||||
openDirectoryPicker(opts?: DirectoryPickerOptions): Promise<string | string[] | null>
|
||||
|
||||
@@ -112,6 +112,7 @@ export const api: ElectronAPI = {
|
||||
|
||||
getWindowID: () => window.electron.windowID,
|
||||
themeReady: () => invoke("WindowThemeReady"),
|
||||
savePrepaint: (html) => invoke("WindowSavePrepaint", { html }),
|
||||
onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.id)),
|
||||
onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb(mutable(event.urls))),
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "@opencode/app/desktop"
|
||||
import { useTheme } from "@opencode/ui/theme/context"
|
||||
import type { BaseRouterProps } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, lazy, Show, Suspense } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, lazy, on, Show, Suspense } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { ElectronAPI } from "./api-types"
|
||||
import { DesktopFirstLaunchOnboarding } from "./onboarding"
|
||||
@@ -27,6 +27,7 @@ import { createDesktopPlatform } from "./platform"
|
||||
import { bindDesktopMenu } from "./platform/menu"
|
||||
import { createSidecarResolver, initializationData, sidecarHttp } from "./startup/initialization"
|
||||
import { preloadStoredLocale } from "./startup/locale"
|
||||
import { hasPrepaint, removePrepaint, schedulePrepaintCapture } from "./startup/prepaint"
|
||||
import { LoadingSplash } from "./startup/splash"
|
||||
import { getLastActiveUrl } from "./window/route-storage"
|
||||
import { DesktopMemoryRouter } from "./window/router"
|
||||
@@ -40,9 +41,12 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
|
||||
const initialUrl = getLastActiveUrl(windowState.id)
|
||||
const url = new URL(initialUrl, "http://localhost")
|
||||
const route = currentRoute(url.pathname, url.search)
|
||||
// With a shell snapshot on screen the splash is not needed; the snapshot is removed when the
|
||||
// interface would otherwise be revealed.
|
||||
const prepaint = hasPrepaint()
|
||||
const [startup, setStartup] = createStore({
|
||||
ready: false,
|
||||
visible: true,
|
||||
visible: !prepaint,
|
||||
themeReady: false,
|
||||
onboardingReady: false,
|
||||
drawingReady: false,
|
||||
@@ -72,6 +76,25 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
|
||||
if (!startup.themeReady || firstLaunch.loading) return
|
||||
void props.api.themeReady()
|
||||
})
|
||||
createEffect(() => {
|
||||
if (!prepaint || firstLaunch() !== true) return
|
||||
removePrepaint()
|
||||
setStartup("visible", true)
|
||||
})
|
||||
createEffect(() => {
|
||||
if (!readyToReveal()) return
|
||||
removePrepaint()
|
||||
schedulePrepaintCapture(props.api.savePrepaint)
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => startup.route,
|
||||
() => {
|
||||
if (startup.ready) schedulePrepaintCapture(props.api.savePrepaint, 3000)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
function ReadyApp() {
|
||||
const wslServers = useWslServers()
|
||||
|
||||
@@ -13,6 +13,8 @@ type InvokeResult<Tag extends InvokeTag> =
|
||||
ReturnType<DesktopRpcClient[Tag]> extends Effect.Effect<infer Value, unknown> ? Value : never
|
||||
type EventValue<Tag extends EventTag> = Extract<DesktopEvent, { readonly _tag: Tag }>
|
||||
|
||||
// The renderer asks for its port rather than receiving one on load: the first window's document
|
||||
// is on screen before its scripts, and before the main process has its IPC layer, exist.
|
||||
const port = new Promise<MessagePort>((resolve) => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (event.source !== window || event.data !== IpcTransportPort) return
|
||||
@@ -22,6 +24,7 @@ const port = new Promise<MessagePort>((resolve) => {
|
||||
resolve(value)
|
||||
}
|
||||
window.addEventListener("message", onMessage)
|
||||
window.electron.requestRpcPort()
|
||||
})
|
||||
|
||||
const ClientProtocolLive = Layer.unwrap(Effect.promise(() => port).pipe(Effect.map((value) => clientProtocol(value))))
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// The early document carries a static copy of this window's shell from the previous run, served by
|
||||
// the main process as #oc-prepaint, so the user sees their UI while the renderer boots. It is
|
||||
// removed when the real interface is ready to be revealed, and re-captured from the live DOM so the
|
||||
// next launch shows the current state.
|
||||
|
||||
const prepaintID = "oc-prepaint"
|
||||
const maxBytes = 1_000_000
|
||||
|
||||
export function hasPrepaint() {
|
||||
return document.getElementById(prepaintID) !== null
|
||||
}
|
||||
|
||||
export function removePrepaint() {
|
||||
document.getElementById(prepaintID)?.remove()
|
||||
}
|
||||
|
||||
let timer: number | undefined
|
||||
|
||||
// Captures once the renderer is idle; `delay` coalesces bursts of route changes into one capture.
|
||||
export function schedulePrepaintCapture(save: (html: string) => Promise<void>, delay = 0) {
|
||||
clearTimeout(timer)
|
||||
timer = window.setTimeout(() => {
|
||||
requestIdleCallback(
|
||||
() => {
|
||||
const root = document.getElementById("root")
|
||||
const html = root && capturePrepaint(root)
|
||||
if (html) void save(html).catch(() => undefined)
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
// Elements that hold live or transient state and would look wrong, or leak, in a static copy.
|
||||
const dropped =
|
||||
"script, style, link, iframe, object, embed, canvas, video, audio, dialog, [popover], [role='dialog'], [role='menu'], [role='listbox'], [role='tooltip'], [data-component='startup-overlay']"
|
||||
// Attributes that could act, be targeted, or collide with the live document once it mounts.
|
||||
const stripped = new Set(["id", "href", "tabindex", "contenteditable", "autofocus", "for", "name", "action", "formaction"])
|
||||
|
||||
export function capturePrepaint(root: HTMLElement) {
|
||||
const clone = root.cloneNode(true) as HTMLElement
|
||||
clone.querySelectorAll(dropped).forEach((element) => element.remove())
|
||||
const symbols = new Set<string>()
|
||||
for (const element of clone.querySelectorAll("*")) {
|
||||
if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) element.removeAttribute("value")
|
||||
if (element.hasAttribute("contenteditable")) element.replaceChildren()
|
||||
for (const attribute of Array.from(element.attributes)) {
|
||||
const name = attribute.name
|
||||
if (name.startsWith("on")) element.removeAttribute(name)
|
||||
// SVG keeps its ids and hrefs: gradients, clip paths and <use> references are local visuals.
|
||||
if (element instanceof SVGElement) {
|
||||
if (name === "href" && attribute.value.startsWith("#")) symbols.add(attribute.value.slice(1))
|
||||
continue
|
||||
}
|
||||
if (stripped.has(name)) element.removeAttribute(name)
|
||||
if (name === "src" && !/^(\.\/|\/|oc:|data:)/.test(attribute.value)) element.removeAttribute(name)
|
||||
}
|
||||
}
|
||||
const root_ = document.documentElement
|
||||
const html = `<div class="${document.body.className} flex flex-col h-dvh" lang="${root_.lang}" dir="${root_.dir}">${sprite(symbols)}${clone.innerHTML}</div>`
|
||||
return html.length <= maxBytes ? html : undefined
|
||||
}
|
||||
|
||||
// Icons are <use href="#symbol"> into a sprite outside #root; copy only the symbols the shell uses.
|
||||
function sprite(symbols: Set<string>) {
|
||||
const defs = [...symbols]
|
||||
.map((id) => document.getElementById(id))
|
||||
.filter((element) => element instanceof SVGSymbolElement)
|
||||
.map((element) => element.outerHTML)
|
||||
if (!defs.length) return ""
|
||||
return `<svg aria-hidden="true" width="0" height="0" style="position:absolute;overflow:hidden">${defs.join("")}</svg>`
|
||||
}
|
||||
@@ -24,6 +24,9 @@ export const WindowSetTitlebar = Rpc.make("WindowSetTitlebar", {
|
||||
}),
|
||||
},
|
||||
})
|
||||
export const WindowSavePrepaint = Rpc.make("WindowSavePrepaint", {
|
||||
payload: { html: Schema.String },
|
||||
})
|
||||
export const WindowRpcs = RpcGroup.make(
|
||||
WindowThemeReady,
|
||||
WindowGetFocused,
|
||||
@@ -35,4 +38,5 @@ export const WindowRpcs = RpcGroup.make(
|
||||
WindowGetPinchZoomEnabled,
|
||||
WindowSetPinchZoomEnabled,
|
||||
WindowSetTitlebar,
|
||||
WindowSavePrepaint,
|
||||
)
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export const IpcTransportPort = "desktop-rpc-port"
|
||||
export const IpcTransportPortRequest = "desktop-rpc-port-request"
|
||||
export const DragCancelEvent = "opencode:drag-cancel"
|
||||
|
||||
@@ -5,8 +5,8 @@ export default Plugin.define({
|
||||
id: "opencode.latex",
|
||||
setup(context) {
|
||||
const render = createLatexCodeBlockRenderer(context.renderer, () => ({
|
||||
text: context.theme.text.default,
|
||||
subdued: context.theme.text.subdued,
|
||||
text: context.theme.text.base,
|
||||
subdued: context.theme.text.muted,
|
||||
}))
|
||||
context.markdown.registerCodeBlockRenderer("latex", render)
|
||||
context.markdown.registerCodeBlockRenderer("math", render)
|
||||
|
||||
@@ -83,15 +83,15 @@ describe("OpenCode diagram palette", () => {
|
||||
}
|
||||
const theme = {
|
||||
text: {
|
||||
default: rgb([230, 232, 240]),
|
||||
subdued: rgb([114, 120, 138]),
|
||||
base: rgb([230, 232, 240]),
|
||||
muted: rgb([114, 120, 138]),
|
||||
feedback: {
|
||||
info: { default: rgb([40, 120, 220]) },
|
||||
success: { default: rgb([80, 180, 120]) },
|
||||
warning: { default: rgb([220, 160, 80]) },
|
||||
info: { base: rgb([40, 120, 220]) },
|
||||
success: { base: rgb([80, 180, 120]) },
|
||||
warning: { base: rgb([220, 160, 80]) },
|
||||
},
|
||||
},
|
||||
background: { default: rgb([250, 250, 250]) },
|
||||
background: { base: rgb([250, 250, 250]) },
|
||||
categorical: [accent],
|
||||
}
|
||||
const palette = resolveOpenCodeDiagramPalette(theme, mode)
|
||||
|
||||
@@ -46,27 +46,27 @@ export function createOpenCodeDiagramPalette(input: OpenCodeDiagramPaletteInput)
|
||||
export function resolveOpenCodeDiagramPalette(
|
||||
theme: {
|
||||
readonly text: {
|
||||
readonly default: RGBA
|
||||
readonly subdued: RGBA
|
||||
readonly base: RGBA
|
||||
readonly muted: RGBA
|
||||
readonly feedback: {
|
||||
readonly info: { readonly default: RGBA }
|
||||
readonly success: { readonly default: RGBA }
|
||||
readonly warning: { readonly default: RGBA }
|
||||
readonly info: { readonly base: RGBA }
|
||||
readonly success: { readonly base: RGBA }
|
||||
readonly warning: { readonly base: RGBA }
|
||||
}
|
||||
}
|
||||
readonly background: { readonly default: RGBA }
|
||||
readonly background: { readonly base: RGBA }
|
||||
readonly categorical: readonly Readonly<Record<200 | 300 | 700 | 800, RGBA>>[]
|
||||
},
|
||||
mode: "dark" | "light",
|
||||
) {
|
||||
const accent = theme.categorical[3] ?? theme.categorical[0]!
|
||||
return createOpenCodeDiagramPalette({
|
||||
text: theme.text.default,
|
||||
subdued: theme.text.subdued,
|
||||
info: theme.text.feedback.info.default,
|
||||
success: theme.text.feedback.success.default,
|
||||
warning: theme.text.feedback.warning.default,
|
||||
background: theme.background.default,
|
||||
text: theme.text.base,
|
||||
subdued: theme.text.muted,
|
||||
info: theme.text.feedback.info.base,
|
||||
success: theme.text.feedback.success.base,
|
||||
warning: theme.text.feedback.warning.base,
|
||||
background: theme.background.base,
|
||||
accent: {
|
||||
soft: accent[mode === "dark" ? 300 : 700],
|
||||
clear: accent[mode === "dark" ? 200 : 800],
|
||||
|
||||
@@ -159,8 +159,14 @@ export const Operations = [
|
||||
),
|
||||
operation(
|
||||
"tabs.open",
|
||||
"Open a browser tab. Defaults to about:blank and focused. Website traffic uses the connected server's network; localhost reaches that server.",
|
||||
{ url: optional(short), focus: optional(Schema.Boolean) },
|
||||
"Open a browser tab, show it in the Review pane, and select it. Defaults to about:blank. Website traffic uses the connected server's network; localhost reaches that server.",
|
||||
{
|
||||
url: optional(short),
|
||||
focus: optional(Schema.Boolean).annotate({
|
||||
description:
|
||||
"Default true: open the Review pane and select the new tab so the user sees it. Pass false only when the user asked for the tab to stay in the background.",
|
||||
}),
|
||||
},
|
||||
Tab,
|
||||
),
|
||||
operation(
|
||||
|
||||
@@ -54,5 +54,7 @@ export interface ToolFailures extends Record<keyof ToolHooks, unknown> {
|
||||
export interface ToolDomain {
|
||||
readonly transform: Transform<ToolEditor>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
/** Currently registered tools, after every transform, keyed by effective name. */
|
||||
readonly list: () => Effect.Effect<readonly (Tool.Info & { readonly id: string })[]>
|
||||
readonly hook: Hooks<ToolHooks, ToolFailures>
|
||||
}
|
||||
|
||||
@@ -462,6 +462,10 @@ export function fromPromise(plugin: Plugin) {
|
||||
},
|
||||
tool: {
|
||||
reload: () => run(host.tool.reload()),
|
||||
list: () =>
|
||||
run(host.tool.list()).then((tools) =>
|
||||
tools.map((tool) => ({ ...tool, execute: promiseExecutor(tool.execute) })),
|
||||
),
|
||||
transform: (callback) =>
|
||||
register(
|
||||
host.tool.transform((editor) =>
|
||||
|
||||
@@ -65,5 +65,7 @@ interface ToolHooks {
|
||||
export interface ToolDomain {
|
||||
readonly transform: Transform<ToolEditor>
|
||||
readonly reload: () => Promise<void>
|
||||
/** Currently registered tools, after every transform, keyed by effective name. */
|
||||
readonly list: () => Promise<readonly (Info & { readonly id: string })[]>
|
||||
readonly hook: Hooks<ToolHooks>
|
||||
}
|
||||
|
||||
@@ -340,6 +340,17 @@ export namespace Frontend {
|
||||
export const Color = Schema.Tuple([Schema.Number, Schema.Number, Schema.Number, Schema.Number])
|
||||
export type Color = Schema.Schema.Type<typeof Color>
|
||||
|
||||
export const CapturedImage = Schema.Struct({
|
||||
x: Schema.Number,
|
||||
y: Schema.Number,
|
||||
width: Schema.Number,
|
||||
height: Schema.Number,
|
||||
pixelWidth: Schema.Number,
|
||||
pixelHeight: Schema.Number,
|
||||
rgba: Schema.String.check(Schema.isBase64()),
|
||||
})
|
||||
export interface CapturedImage extends Schema.Schema.Type<typeof CapturedImage> {}
|
||||
|
||||
export const CapturedFrame = Schema.Struct({
|
||||
cols: Schema.Number,
|
||||
rows: Schema.Number,
|
||||
@@ -357,6 +368,7 @@ export namespace Frontend {
|
||||
),
|
||||
}),
|
||||
),
|
||||
images: Schema.optionalKey(Schema.Array(CapturedImage)),
|
||||
})
|
||||
export interface CapturedFrame extends Schema.Schema.Type<typeof CapturedFrame> {}
|
||||
|
||||
|
||||
@@ -2,21 +2,27 @@
|
||||
|
||||
import { $ } from "bun"
|
||||
import { rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
await rm("dist", { recursive: true, force: true })
|
||||
await $`bun tsc -p tsconfig.build.json`
|
||||
const files = await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: "src" })).then((items) =>
|
||||
items.map((item) => `src/${item}`),
|
||||
)
|
||||
const transpiler = new Bun.Transpiler({ loader: "ts", target: "bun" })
|
||||
await Promise.all(
|
||||
files.map(async (file) =>
|
||||
Bun.write(
|
||||
file.replace(/^src\//, "dist/").replace(/\.ts$/, ".js"),
|
||||
await transpiler.transform(await Bun.file(file).text()),
|
||||
),
|
||||
const root = path.resolve("src")
|
||||
const result = await Bun.build({
|
||||
entrypoints: await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: root, absolute: true })).then((files) =>
|
||||
files.filter((file) => !file.endsWith(".d.ts")),
|
||||
),
|
||||
)
|
||||
root,
|
||||
outdir: "dist",
|
||||
target: "node",
|
||||
format: "esm",
|
||||
packages: "external",
|
||||
splitting: true,
|
||||
naming: {
|
||||
entry: "[dir]/[name].[ext]",
|
||||
chunk: "chunks/[name]-[hash].[ext]",
|
||||
},
|
||||
})
|
||||
if (!result.success) throw new AggregateError(result.logs, "Failed to build SDK")
|
||||
|
||||
@@ -213,6 +213,7 @@ for (const module of modules) {
|
||||
throw new Error(`Packed SDK consumer resolved multiple Effect runtimes:\n${runtimes.join("\n")}`)
|
||||
}
|
||||
await $`bun imports.mjs`.cwd(consumer)
|
||||
await $`node imports.mjs`.cwd(consumer)
|
||||
await $`bun --conditions=workerd imports.mjs`.cwd(consumer)
|
||||
await $`node_modules/.bin/wrangler deploy --dry-run --config wrangler.jsonc --outdir dist`.cwd(consumer)
|
||||
|
||||
|
||||
@@ -2,21 +2,27 @@
|
||||
|
||||
import { $ } from "bun"
|
||||
import { rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
await rm("dist", { recursive: true, force: true })
|
||||
await $`bun tsc -p tsconfig.build.json`
|
||||
const files = await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: "src" })).then((items) =>
|
||||
items.map((item) => `src/${item}`),
|
||||
)
|
||||
const transpiler = new Bun.Transpiler({ loader: "ts", target: "bun" })
|
||||
await Promise.all(
|
||||
files.map(async (file) =>
|
||||
Bun.write(
|
||||
file.replace(/^src\//, "dist/").replace(/\.ts$/, ".js"),
|
||||
await transpiler.transform(await Bun.file(file).text()),
|
||||
),
|
||||
const root = path.resolve("src")
|
||||
const result = await Bun.build({
|
||||
entrypoints: await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: root, absolute: true })).then((files) =>
|
||||
files.filter((file) => !file.endsWith(".d.ts")),
|
||||
),
|
||||
)
|
||||
root,
|
||||
outdir: "dist",
|
||||
target: "node",
|
||||
format: "esm",
|
||||
packages: "external",
|
||||
splitting: true,
|
||||
naming: {
|
||||
entry: "[dir]/[name].[ext]",
|
||||
chunk: "chunks/[name]-[hash].[ext]",
|
||||
},
|
||||
})
|
||||
if (!result.success) throw new AggregateError(result.logs, "Failed to build Server")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CliRenderer, Renderable } from "@opentui/core"
|
||||
import { ImageRenderable, type CliRenderer, type Renderable } from "@opentui/core"
|
||||
import {
|
||||
createMockKeys,
|
||||
createMockMouse,
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type MockInput,
|
||||
type MockMouse,
|
||||
} from "@opentui/core/testing"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Encoding, Schema } from "effect"
|
||||
import { SimulationProtocol } from "../protocol"
|
||||
import { SimulationRenderer } from "./renderer"
|
||||
import { SimulationSemantics } from "./semantics"
|
||||
@@ -165,9 +165,55 @@ export const capture = Effect.fn("SimulationActions.capture")(function* (harness
|
||||
width: span.width,
|
||||
})),
|
||||
})),
|
||||
images: captureImages(harness.renderer),
|
||||
} satisfies SimulationProtocol.Frontend.CapturedFrame
|
||||
})
|
||||
|
||||
function captureImages(renderer: CliRenderer): SimulationProtocol.Frontend.CapturedImage[] {
|
||||
return all(renderer.root).flatMap((renderable) => {
|
||||
if (!(renderable instanceof ImageRenderable) || !renderable.visible || renderable.isDestroyed || !renderable.image)
|
||||
return []
|
||||
const fitted = renderable.fit === "cover"
|
||||
? { width: renderable.width, height: renderable.height }
|
||||
: renderable.getFittedSize(renderable.width, renderable.height)
|
||||
if (fitted.width <= 0 || fitted.height <= 0) return []
|
||||
const x = renderable.screenX + Math.floor((renderable.width - fitted.width) / 2)
|
||||
const y = renderable.screenY + Math.floor((renderable.height - fitted.height) / 2)
|
||||
let source = renderable.image
|
||||
let extracted: typeof source | undefined
|
||||
if (renderable.fit === "cover") {
|
||||
const targetAspect = renderable.width / (renderable.height * renderable.cellAspectRatio)
|
||||
const sourceAspect = source.width / source.height
|
||||
const width = sourceAspect > targetAspect ? Math.max(1, Math.round(source.height * targetAspect)) : source.width
|
||||
const height = sourceAspect > targetAspect ? source.height : Math.max(1, Math.round(source.width / targetAspect))
|
||||
extracted = source.extract({
|
||||
left: Math.floor((source.width - width) / 2),
|
||||
top: Math.floor((source.height - height) / 2),
|
||||
width,
|
||||
height,
|
||||
})
|
||||
source = extracted
|
||||
}
|
||||
try {
|
||||
const raw = source.raw("rgba8")
|
||||
const rgba = new Uint8Array(raw.width * raw.height * 4)
|
||||
for (let row = 0; row < raw.height; row++)
|
||||
rgba.set(raw.data.subarray(row * raw.stride, row * raw.stride + raw.width * 4), row * raw.width * 4)
|
||||
return [{
|
||||
x,
|
||||
y,
|
||||
width: fitted.width,
|
||||
height: fitted.height,
|
||||
pixelWidth: raw.width,
|
||||
pixelHeight: raw.height,
|
||||
rgba: Encoding.encodeBase64(rgba),
|
||||
}]
|
||||
} finally {
|
||||
extracted?.dispose()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const execute = Effect.fn("SimulationActions.execute")(function* (harness: Harness, action: Action) {
|
||||
switch (action.type) {
|
||||
case "ui.type":
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { BoxRenderable, TextRenderable } from "@opentui/core"
|
||||
import { BoxRenderable, ImageRenderable, NativeImage, TextRenderable } from "@opentui/core"
|
||||
import { Effect } from "effect"
|
||||
import { createHarness, execute, type Harness, matches, snapshot, state } from "../src/frontend/actions"
|
||||
import { capture, createHarness, execute, type Harness, matches, snapshot, state } from "../src/frontend/actions"
|
||||
import { SimulationRenderer } from "../src/frontend/renderer"
|
||||
import { SimulationSemantics } from "../src/frontend/semantics"
|
||||
|
||||
@@ -74,6 +74,42 @@ test("headless input mirrors the configured kitty keyboard protocol", async () =
|
||||
)
|
||||
})
|
||||
|
||||
test("captures image pixels and their terminal placement", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const renderer = yield* SimulationRenderer.create({ width: 20, height: 10 })
|
||||
const image = NativeImage.fromRgba(
|
||||
Uint8Array.from([255, 0, 0, 255, 0, 0, 255, 255]),
|
||||
2,
|
||||
1,
|
||||
)
|
||||
renderer.root.add(new ImageRenderable(renderer, {
|
||||
source: image,
|
||||
position: "absolute",
|
||||
left: 2,
|
||||
top: 1,
|
||||
width: 2,
|
||||
height: 1,
|
||||
fit: "fill",
|
||||
}))
|
||||
|
||||
const frame = yield* capture(createHarness(renderer))
|
||||
|
||||
expect(frame.images).toEqual([{
|
||||
x: 2,
|
||||
y: 1,
|
||||
width: 2,
|
||||
height: 1,
|
||||
pixelWidth: 2,
|
||||
pixelHeight: 1,
|
||||
rgba: "/wAA/wAA//8=",
|
||||
}])
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("clicks a target at relative coordinates through descendant text", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { HueName } from "./schema.js"
|
||||
|
||||
export const DEFAULT_CATEGORICAL = ["blue", "purple", "green", "orange", "red", "cyan"] as const satisfies readonly HueName[]
|
||||
@@ -1,444 +0,0 @@
|
||||
import type { HueName, ThemeDocument } from "./schema.js"
|
||||
|
||||
export const DEFAULT_CATEGORICAL = [
|
||||
"blue",
|
||||
"purple",
|
||||
"green",
|
||||
"orange",
|
||||
"red",
|
||||
"cyan",
|
||||
] as const satisfies readonly HueName[]
|
||||
|
||||
export const DEFAULT_THEME = {
|
||||
version: 2,
|
||||
light: {
|
||||
hue: {
|
||||
gray: {
|
||||
100: "#f3f4f6",
|
||||
200: "#e5e7eb",
|
||||
300: "#d1d5db",
|
||||
400: "#9ca3af",
|
||||
500: "#6b7280",
|
||||
600: "#4b5563",
|
||||
700: "#374151",
|
||||
800: "#1f2937",
|
||||
900: "#111827",
|
||||
},
|
||||
red: {
|
||||
100: "#fee2e2",
|
||||
200: "#fecaca",
|
||||
300: "#fca5a5",
|
||||
400: "#f87171",
|
||||
500: "#ef4444",
|
||||
600: "#dc2626",
|
||||
700: "#b91c1c",
|
||||
800: "#991b1b",
|
||||
900: "#7f1d1d",
|
||||
},
|
||||
orange: {
|
||||
100: "#ffedd5",
|
||||
200: "#fed7aa",
|
||||
300: "#fdba74",
|
||||
400: "#fb923c",
|
||||
500: "#f97316",
|
||||
600: "#ea580c",
|
||||
700: "#c2410c",
|
||||
800: "#9a3412",
|
||||
900: "#7c2d12",
|
||||
},
|
||||
yellow: {
|
||||
100: "#fef9c3",
|
||||
200: "#fef08a",
|
||||
300: "#fde047",
|
||||
400: "#facc15",
|
||||
500: "#eab308",
|
||||
600: "#ca8a04",
|
||||
700: "#a16207",
|
||||
800: "#854d0e",
|
||||
900: "#713f12",
|
||||
},
|
||||
green: {
|
||||
100: "#dcfce7",
|
||||
200: "#bbf7d0",
|
||||
300: "#86efac",
|
||||
400: "#4ade80",
|
||||
500: "#22c55e",
|
||||
600: "#16a34a",
|
||||
700: "#15803d",
|
||||
800: "#166534",
|
||||
900: "#14532d",
|
||||
},
|
||||
cyan: {
|
||||
100: "#cffafe",
|
||||
200: "#a5f3fc",
|
||||
300: "#67e8f9",
|
||||
400: "#22d3ee",
|
||||
500: "#06b6d4",
|
||||
600: "#0891b2",
|
||||
700: "#0e7490",
|
||||
800: "#155e75",
|
||||
900: "#164e63",
|
||||
},
|
||||
blue: {
|
||||
100: "#dbeafe",
|
||||
200: "#bfdbfe",
|
||||
300: "#93c5fd",
|
||||
400: "#60a5fa",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
800: "#1e40af",
|
||||
900: "#1e3a8a",
|
||||
},
|
||||
purple: {
|
||||
100: "#f3e8ff",
|
||||
200: "#e9d5ff",
|
||||
300: "#d8b4fe",
|
||||
400: "#c084fc",
|
||||
500: "#a855f7",
|
||||
600: "#9333ea",
|
||||
700: "#7e22ce",
|
||||
800: "#6b21a8",
|
||||
900: "#581c87",
|
||||
},
|
||||
accent: "$hue.blue",
|
||||
interactive: "$hue.blue",
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
categorical: DEFAULT_CATEGORICAL,
|
||||
text: {
|
||||
default: "$hue.neutral.800",
|
||||
subdued: "$hue.neutral.600",
|
||||
action: {
|
||||
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
|
||||
secondary: { default: "$text.subdued", $hovered: "$text.default" },
|
||||
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
|
||||
},
|
||||
formfield: {
|
||||
default: "$hue.neutral.800",
|
||||
$focused: "$text.action.primary.default",
|
||||
$pressed: "$hue.neutral.200",
|
||||
$disabled: "$hue.neutral.500",
|
||||
$selected: "$hue.interactive.700",
|
||||
},
|
||||
status: {
|
||||
running: "$hue.interactive.800",
|
||||
question: "$text.status.unread",
|
||||
permission: "$text.status.unread",
|
||||
unread: "$hue.accent.800",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
|
||||
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
|
||||
success: { default: "$hue.green.700", subdued: "$hue.green.600" },
|
||||
info: { default: "$hue.cyan.700", subdued: "$hue.cyan.600" },
|
||||
},
|
||||
},
|
||||
background: {
|
||||
default: "$hue.neutral.200",
|
||||
raised: {
|
||||
base: "$hue.neutral.300",
|
||||
high: "$hue.neutral.400",
|
||||
max: "$hue.neutral.500",
|
||||
},
|
||||
action: {
|
||||
primary: {
|
||||
default: "$hue.interactive.600",
|
||||
$hovered: "$hue.interactive.700",
|
||||
$focused: "$hue.interactive.700",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$selected: "$hue.interactive.700",
|
||||
$disabled: "$hue.neutral.300",
|
||||
},
|
||||
secondary: { default: "transparent" },
|
||||
destructive: {
|
||||
default: "$hue.red.600",
|
||||
$hovered: "$hue.red.700",
|
||||
$focused: "$hue.red.700",
|
||||
$pressed: "$hue.red.800",
|
||||
$selected: "$hue.red.700",
|
||||
$disabled: "$hue.neutral.300",
|
||||
},
|
||||
},
|
||||
formfield: {
|
||||
default: "$background.default",
|
||||
$hovered: "$background.raised.base",
|
||||
$focused: "$background.action.primary.default",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$disabled: "$background.default",
|
||||
$selected: "$background.formfield.default",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$background.default" },
|
||||
warning: { default: "$background.default" },
|
||||
success: { default: "$background.default" },
|
||||
info: { default: "$background.default" },
|
||||
},
|
||||
},
|
||||
border: { default: "$hue.neutral.300" },
|
||||
scrollbar: { default: "$hue.neutral.400" },
|
||||
diff: {
|
||||
text: {
|
||||
added: "$hue.green.700",
|
||||
removed: "$hue.red.700",
|
||||
context: "$hue.neutral.900",
|
||||
hunkHeader: "$hue.purple.600",
|
||||
},
|
||||
background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" },
|
||||
highlight: { added: "$hue.green.600", removed: "$hue.red.600" },
|
||||
lineNumber: {
|
||||
text: "$hue.neutral.600",
|
||||
background: { added: "$hue.green.200", removed: "$hue.red.200" },
|
||||
},
|
||||
},
|
||||
syntax: {
|
||||
comment: "$hue.neutral.600",
|
||||
keyword: "$hue.purple.600",
|
||||
function: "$hue.accent.600",
|
||||
variable: "$hue.neutral.900",
|
||||
string: "$hue.green.700",
|
||||
number: "$hue.yellow.800",
|
||||
type: "$hue.yellow.500",
|
||||
operator: "$hue.cyan.600",
|
||||
punctuation: "$hue.neutral.900",
|
||||
},
|
||||
markdown: {
|
||||
text: "$hue.neutral.900",
|
||||
heading: "$hue.purple.600",
|
||||
link: "$hue.accent.600",
|
||||
linkText: "$hue.cyan.600",
|
||||
code: "$hue.green.700",
|
||||
blockQuote: "$hue.neutral.600",
|
||||
emphasis: "$hue.yellow.500",
|
||||
strong: "$hue.neutral.900",
|
||||
horizontalRule: "$hue.neutral.300",
|
||||
listItem: "$hue.accent.600",
|
||||
listEnumeration: "$hue.cyan.600",
|
||||
image: "$hue.accent.600",
|
||||
imageText: "$hue.cyan.600",
|
||||
codeBlock: "$hue.neutral.900",
|
||||
},
|
||||
"@dialog": {
|
||||
text: { action: { primary: { default: "$hue.neutral.100" } } },
|
||||
background: {
|
||||
default: "$background.raised.base",
|
||||
action: { primary: { default: "$hue.interactive.500", $hovered: "$background.raised.high" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
hue: {
|
||||
gray: {
|
||||
100: "#f3f4f6",
|
||||
200: "#e5e7eb",
|
||||
300: "#d1d5db",
|
||||
400: "#9ca3af",
|
||||
500: "#6b7280",
|
||||
600: "#4b5563",
|
||||
700: "#374151",
|
||||
800: "#1f2937",
|
||||
900: "#111827",
|
||||
},
|
||||
red: {
|
||||
100: "#fee2e2",
|
||||
200: "#fecaca",
|
||||
300: "#fca5a5",
|
||||
400: "#f87171",
|
||||
500: "#ef4444",
|
||||
600: "#dc2626",
|
||||
700: "#b91c1c",
|
||||
800: "#991b1b",
|
||||
900: "#7f1d1d",
|
||||
},
|
||||
orange: {
|
||||
100: "#ffedd5",
|
||||
200: "#fed7aa",
|
||||
300: "#fdba74",
|
||||
400: "#fb923c",
|
||||
500: "#f97316",
|
||||
600: "#ea580c",
|
||||
700: "#c2410c",
|
||||
800: "#9a3412",
|
||||
900: "#7c2d12",
|
||||
},
|
||||
yellow: {
|
||||
100: "#fef9c3",
|
||||
200: "#fef08a",
|
||||
300: "#fde047",
|
||||
400: "#facc15",
|
||||
500: "#eab308",
|
||||
600: "#ca8a04",
|
||||
700: "#a16207",
|
||||
800: "#854d0e",
|
||||
900: "#713f12",
|
||||
},
|
||||
green: {
|
||||
100: "#dcfce7",
|
||||
200: "#bbf7d0",
|
||||
300: "#86efac",
|
||||
400: "#4ade80",
|
||||
500: "#22c55e",
|
||||
600: "#16a34a",
|
||||
700: "#15803d",
|
||||
800: "#166534",
|
||||
900: "#14532d",
|
||||
},
|
||||
cyan: {
|
||||
100: "#cffafe",
|
||||
200: "#a5f3fc",
|
||||
300: "#67e8f9",
|
||||
400: "#22d3ee",
|
||||
500: "#06b6d4",
|
||||
600: "#0891b2",
|
||||
700: "#0e7490",
|
||||
800: "#155e75",
|
||||
900: "#164e63",
|
||||
},
|
||||
blue: {
|
||||
100: "#dbeafe",
|
||||
200: "#bfdbfe",
|
||||
300: "#93c5fd",
|
||||
400: "#60a5fa",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
800: "#1e40af",
|
||||
900: "#1e3a8a",
|
||||
},
|
||||
purple: {
|
||||
100: "#f3e8ff",
|
||||
200: "#e9d5ff",
|
||||
300: "#d8b4fe",
|
||||
400: "#c084fc",
|
||||
500: "#a855f7",
|
||||
600: "#9333ea",
|
||||
700: "#7e22ce",
|
||||
800: "#6b21a8",
|
||||
900: "#581c87",
|
||||
},
|
||||
accent: "$hue.blue",
|
||||
interactive: "$hue.blue",
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
categorical: DEFAULT_CATEGORICAL,
|
||||
text: {
|
||||
default: "$hue.neutral.200",
|
||||
subdued: "$hue.neutral.400",
|
||||
action: {
|
||||
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
|
||||
secondary: { default: "$text.subdued", $hovered: "$text.default" },
|
||||
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
|
||||
},
|
||||
formfield: {
|
||||
default: "$hue.neutral.200",
|
||||
$focused: "$text.action.primary.default",
|
||||
$pressed: "$hue.neutral.200",
|
||||
$disabled: "$hue.neutral.500",
|
||||
$selected: "$hue.interactive.500",
|
||||
},
|
||||
status: {
|
||||
running: "$hue.interactive.200",
|
||||
question: "$text.status.unread",
|
||||
permission: "$text.status.unread",
|
||||
unread: "$hue.accent.200",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
|
||||
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
|
||||
success: { default: "$hue.green.300", subdued: "$hue.green.400" },
|
||||
info: { default: "$hue.cyan.300", subdued: "$hue.cyan.400" },
|
||||
},
|
||||
},
|
||||
background: {
|
||||
default: "$hue.neutral.800",
|
||||
raised: {
|
||||
base: "$hue.neutral.700",
|
||||
high: "$hue.neutral.600",
|
||||
max: "$hue.neutral.500",
|
||||
},
|
||||
action: {
|
||||
primary: {
|
||||
default: "$hue.interactive.500",
|
||||
$hovered: "$hue.interactive.600",
|
||||
$focused: "$hue.interactive.600",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$selected: "$hue.interactive.600",
|
||||
$disabled: "$hue.neutral.800",
|
||||
},
|
||||
secondary: { default: "transparent" },
|
||||
destructive: {
|
||||
default: "$hue.red.600",
|
||||
$hovered: "$hue.red.700",
|
||||
$focused: "$hue.red.700",
|
||||
$pressed: "$hue.red.800",
|
||||
$selected: "$hue.red.700",
|
||||
$disabled: "$hue.neutral.800",
|
||||
},
|
||||
},
|
||||
formfield: {
|
||||
default: "$background.default",
|
||||
$hovered: "$background.raised.base",
|
||||
$focused: "$background.action.primary.default",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$disabled: "$background.default",
|
||||
$selected: "$background.formfield.default",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$background.default" },
|
||||
warning: { default: "$background.default" },
|
||||
success: { default: "$background.default" },
|
||||
info: { default: "$background.default" },
|
||||
},
|
||||
},
|
||||
border: { default: "$hue.neutral.700" },
|
||||
scrollbar: { default: "$hue.neutral.600" },
|
||||
diff: {
|
||||
text: {
|
||||
added: "$hue.green.300",
|
||||
removed: "$hue.red.300",
|
||||
context: "$hue.neutral.100",
|
||||
hunkHeader: "$hue.purple.400",
|
||||
},
|
||||
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
|
||||
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
|
||||
lineNumber: {
|
||||
text: "$hue.neutral.400",
|
||||
background: { added: "$hue.green.800", removed: "$hue.red.800" },
|
||||
},
|
||||
},
|
||||
syntax: {
|
||||
comment: "$hue.neutral.400",
|
||||
keyword: "$hue.purple.400",
|
||||
function: "$hue.accent.400",
|
||||
variable: "$hue.neutral.100",
|
||||
string: "$hue.green.300",
|
||||
number: "$hue.yellow.200",
|
||||
type: "$hue.yellow.500",
|
||||
operator: "$hue.cyan.400",
|
||||
punctuation: "$hue.neutral.100",
|
||||
},
|
||||
markdown: {
|
||||
text: "$hue.neutral.100",
|
||||
heading: "$hue.purple.400",
|
||||
link: "$hue.accent.400",
|
||||
linkText: "$hue.cyan.400",
|
||||
code: "$hue.green.300",
|
||||
blockQuote: "$hue.neutral.400",
|
||||
emphasis: "$hue.yellow.500",
|
||||
strong: "$hue.neutral.100",
|
||||
horizontalRule: "$hue.neutral.700",
|
||||
listItem: "$hue.accent.400",
|
||||
listEnumeration: "$hue.cyan.400",
|
||||
image: "$hue.accent.400",
|
||||
imageText: "$hue.cyan.400",
|
||||
codeBlock: "$hue.neutral.100",
|
||||
},
|
||||
"@dialog": {
|
||||
text: { action: { primary: { default: "$hue.neutral.200" } } },
|
||||
background: {
|
||||
default: "$background.raised.base",
|
||||
action: { primary: { default: "$hue.interactive.400", $hovered: "$background.raised.high" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies ThemeDocument
|
||||
@@ -27,7 +27,7 @@ export function mergeTheme(...values: unknown[]): Record<string, unknown> {
|
||||
return values.reduce<Record<string, unknown>>((result, value) => {
|
||||
if (!isRecord(value)) return result
|
||||
return Object.entries(value).reduce<Record<string, unknown>>((next, [key, item]) => {
|
||||
if (item === undefined || key === "mergeMode") return next
|
||||
if (item === undefined) return next
|
||||
return {
|
||||
...next,
|
||||
[key]: isRecord(item) ? mergeTheme(next[key], item) : item,
|
||||
@@ -40,7 +40,7 @@ function expandText(definition: TextDefinition | undefined): TextDefinition | un
|
||||
if (!definition) return
|
||||
return {
|
||||
...definition,
|
||||
subdued: definition.subdued ?? (definition.default ? "$text.default" : undefined),
|
||||
muted: definition.muted ?? (definition.base ? "$text.base" : undefined),
|
||||
action: expandActions(definition.action, "text.action"),
|
||||
formfield: expandFormfield(definition.formfield, "text.formfield"),
|
||||
feedback: definition.feedback
|
||||
@@ -50,7 +50,7 @@ function expandText(definition: TextDefinition | undefined): TextDefinition | un
|
||||
kind,
|
||||
{
|
||||
...feedback,
|
||||
subdued: feedback.subdued ?? (feedback.default ? `$text.feedback.${kind}.default` : undefined),
|
||||
muted: feedback.muted ?? (feedback.base ? `$text.feedback.${kind}.base` : undefined),
|
||||
},
|
||||
]
|
||||
}),
|
||||
@@ -69,11 +69,11 @@ function expandBackground(definition: BackgroundDefinition | undefined): Backgro
|
||||
}
|
||||
|
||||
function expandFormfield(definition: StatefulColorDefinition | undefined, path: string) {
|
||||
if (!definition?.default) return definition
|
||||
if (!definition?.base) return definition
|
||||
return {
|
||||
...definition,
|
||||
...Object.fromEntries(
|
||||
ActionState.literals.map((state) => [`$${state}`, definition[`$${state}`] ?? `$${path}.default`]),
|
||||
ActionState.literals.map((state) => [`$${state}`, definition[`$${state}`] ?? `$${path}.base`]),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -85,13 +85,13 @@ function expandActions<Definition extends Partial<Record<string, StatefulColorDe
|
||||
if (!definition) return
|
||||
return Object.fromEntries(
|
||||
Object.entries(definition).map(([variant, value]) => {
|
||||
if (!value?.default) return [variant, value]
|
||||
if (!value?.base) return [variant, value]
|
||||
return [
|
||||
variant,
|
||||
{
|
||||
...value,
|
||||
...Object.fromEntries(
|
||||
ActionState.literals.map((state) => [`$${state}`, value[`$${state}`] ?? `$${path}.${variant}.default`]),
|
||||
ActionState.literals.map((state) => [`$${state}`, value[`$${state}`] ?? `$${path}.${variant}.base`]),
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { Mode, ThemeTokensDefinition } from "./index.js"
|
||||
import { DEFAULT_THEME } from "./defaults.js"
|
||||
import { ActionVariant, FeedbackKind } from "./schema.js"
|
||||
|
||||
export function fallback(mode: Mode): ThemeTokensDefinition {
|
||||
const red = "#ff0000"
|
||||
|
||||
return {
|
||||
text: {
|
||||
default: red,
|
||||
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
|
||||
formfield: { default: red },
|
||||
status: DEFAULT_THEME[mode].text.status,
|
||||
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
|
||||
},
|
||||
background: {
|
||||
default: red,
|
||||
raised: { base: red, high: red, max: red },
|
||||
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
|
||||
formfield: { default: red },
|
||||
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
|
||||
},
|
||||
border: { default: red },
|
||||
scrollbar: { default: red },
|
||||
diff: {
|
||||
text: { added: red, removed: red, context: red, hunkHeader: red },
|
||||
background: { added: red, removed: red, context: red },
|
||||
highlight: { added: red, removed: red },
|
||||
lineNumber: { text: red, background: { added: red, removed: red } },
|
||||
},
|
||||
syntax: {
|
||||
comment: red,
|
||||
keyword: red,
|
||||
function: red,
|
||||
variable: red,
|
||||
string: red,
|
||||
number: red,
|
||||
type: red,
|
||||
operator: red,
|
||||
punctuation: red,
|
||||
},
|
||||
markdown: {
|
||||
text: red,
|
||||
heading: red,
|
||||
link: red,
|
||||
linkText: red,
|
||||
code: red,
|
||||
blockQuote: red,
|
||||
emphasis: red,
|
||||
strong: red,
|
||||
horizontalRule: red,
|
||||
listItem: red,
|
||||
listEnumeration: red,
|
||||
image: red,
|
||||
imageText: red,
|
||||
codeBlock: red,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export {
|
||||
type ActionStateKey,
|
||||
ActionVariant,
|
||||
BaseHue,
|
||||
BaseThemeDefinition,
|
||||
CategoricalDefinition,
|
||||
FeedbackKind,
|
||||
FormfieldState,
|
||||
@@ -20,11 +21,8 @@ export {
|
||||
ThemeDocument,
|
||||
type BackgroundDefinition,
|
||||
type DiffDefinition,
|
||||
type FileThemeDefinition,
|
||||
type FormfieldColorDefinition,
|
||||
type HueDefinition,
|
||||
type HueOverrideDefinition,
|
||||
type MergeModeDefinition,
|
||||
type Mode,
|
||||
type StatefulColorDefinition,
|
||||
type TextDefinition,
|
||||
@@ -44,7 +42,7 @@ export type {
|
||||
ResolvedThemeTokens,
|
||||
StatefulColor,
|
||||
} from "./types.js"
|
||||
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
|
||||
export { DEFAULT_CATEGORICAL } from "./categorical.js"
|
||||
export { expandTheme } from "./expand.js"
|
||||
export { migrateV1 } from "./v1-migrate.js"
|
||||
export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { Schema } from "effect"
|
||||
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
|
||||
import { expandTheme, expandTokens, mergeTheme } from "./expand.js"
|
||||
import { fallback } from "./fallback.js"
|
||||
import { expandTheme, mergeTheme } from "./expand.js"
|
||||
import {
|
||||
ActionState,
|
||||
ActionVariant,
|
||||
@@ -26,7 +24,7 @@ import type {
|
||||
StatefulColorDefinition,
|
||||
ThemeTokensDefinition,
|
||||
} from "./index.js"
|
||||
import { selectTheme, selectThemeMode } from "./select.js"
|
||||
import { selectThemeMode } from "./select.js"
|
||||
|
||||
const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition, { reportInput: true })
|
||||
|
||||
@@ -46,34 +44,22 @@ export function themeDecodeError(error: unknown, name: string) {
|
||||
|
||||
export function resolveThemeDocument(document: ThemeDocument, mode?: Mode) {
|
||||
const selected = selectThemeMode(document, mode)
|
||||
const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
|
||||
const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
|
||||
const core = expandTokens(fallback(selected.mode))
|
||||
const merged = document.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition)
|
||||
if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
|
||||
return resolveExpandedTheme(
|
||||
{
|
||||
...merged,
|
||||
categorical: merged["categorical"] ?? DEFAULT_CATEGORICAL,
|
||||
} as ThemeDefinition,
|
||||
selected.mode,
|
||||
)
|
||||
const definition = expandTheme(selected.theme)
|
||||
return resolveExpandedTheme(definition)
|
||||
}
|
||||
|
||||
export function resolveTheme(definition: ThemeDefinition, mode: Mode): ResolvedTheme {
|
||||
return resolveExpandedTheme(expandTheme(decodeThemeDefinition(definition)), mode)
|
||||
export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
return resolveExpandedTheme(expandTheme(decodeThemeDefinition(definition)))
|
||||
}
|
||||
|
||||
function resolveExpandedTheme(definition: ThemeDefinition, mode: Mode): ResolvedTheme {
|
||||
function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
|
||||
const hue = resolveHue(definition.hue)
|
||||
const categorical = (definition.categorical ?? DEFAULT_CATEGORICAL).map((name) => hue[name])
|
||||
const categorical = definition.categorical.map((name) => hue[name])
|
||||
const hueSteps = compileHueSteps(hue)
|
||||
const raise = (color: RGBA) => (mode === "light" ? hueSteps.increase(color) : hueSteps.decrease(color))
|
||||
const base = tokens(definition)
|
||||
const views = {} as Record<SurfaceName, ResolvedTheme>
|
||||
const view = (tokens: ThemeTokensDefinition): ResolvedTheme => ({
|
||||
...resolveView(tokens, hue, categorical, hueSteps),
|
||||
raise,
|
||||
surface: (name) => views[name],
|
||||
})
|
||||
views.dialog = definition["@dialog"] ? view(contextualize(base, definition["@dialog"])) : view(base)
|
||||
@@ -114,14 +100,14 @@ function contextualActions(
|
||||
return [
|
||||
variant,
|
||||
Object.fromEntries(
|
||||
(["default", ...ActionState.literals] as readonly ResolvedActionState[]).map((state) => {
|
||||
const key = state === "default" ? undefined : (`$${state}` as ActionStateKey)
|
||||
(["base", ...ActionState.literals] as readonly ResolvedActionState[]).map((state) => {
|
||||
const key = state === "base" ? undefined : (`$${state}` as ActionStateKey)
|
||||
return [
|
||||
key ?? "default",
|
||||
key ?? "base",
|
||||
(key ? surfaceVariant?.[key] : undefined) ??
|
||||
surfaceVariant?.default ??
|
||||
surfaceVariant?.base ??
|
||||
(key ? baseVariant?.[key] : undefined) ??
|
||||
baseVariant?.default,
|
||||
baseVariant?.base,
|
||||
]
|
||||
}),
|
||||
),
|
||||
@@ -165,7 +151,7 @@ function statefulActions(actions: Readonly<Record<ActionVariant, StatefulColor>>
|
||||
function statefulColor(color: StatefulColor): StatefulColor {
|
||||
return {
|
||||
...color,
|
||||
state: (states: ActionStates) => color[ActionState.literals.find((state) => states[state]) ?? "default"],
|
||||
state: (states: ActionStates) => color[ActionState.literals.find((state) => states[state]) ?? "base"],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,23 +61,8 @@ const HueDefinition = Schema.Struct({
|
||||
})
|
||||
export type HueDefinition = Schema.Schema.Type<typeof HueDefinition>
|
||||
|
||||
const HueOverrideDefinition = Schema.Struct({
|
||||
gray: Schema.optional(HueValueDefinition),
|
||||
red: Schema.optional(HueValueDefinition),
|
||||
orange: Schema.optional(HueValueDefinition),
|
||||
yellow: Schema.optional(HueValueDefinition),
|
||||
green: Schema.optional(HueValueDefinition),
|
||||
cyan: Schema.optional(HueValueDefinition),
|
||||
blue: Schema.optional(HueValueDefinition),
|
||||
purple: Schema.optional(HueValueDefinition),
|
||||
accent: Schema.optional(HueValueDefinition),
|
||||
interactive: Schema.optional(HueValueDefinition),
|
||||
neutral: Schema.optional(HueValueDefinition),
|
||||
})
|
||||
export type HueOverrideDefinition = Schema.Schema.Type<typeof HueOverrideDefinition>
|
||||
|
||||
const StatefulColorDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
base: Schema.optional(ColorValue),
|
||||
$hovered: Schema.optional(ColorValue),
|
||||
$focused: Schema.optional(ColorValue),
|
||||
$pressed: Schema.optional(ColorValue),
|
||||
@@ -95,27 +80,19 @@ const ActionColorDefinition = Schema.Struct({
|
||||
})
|
||||
|
||||
const TextFeedbackDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
subdued: Schema.optional(ColorValue),
|
||||
base: Schema.optional(ColorValue),
|
||||
muted: Schema.optional(ColorValue),
|
||||
})
|
||||
|
||||
const BackgroundFeedbackDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
base: Schema.optional(ColorValue),
|
||||
})
|
||||
|
||||
const TextDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
subdued: Schema.optional(ColorValue),
|
||||
base: Schema.optional(ColorValue),
|
||||
muted: Schema.optional(ColorValue),
|
||||
action: Schema.optional(ActionColorDefinition),
|
||||
formfield: Schema.optional(StatefulColorDefinition),
|
||||
status: Schema.optional(
|
||||
Schema.Struct({
|
||||
running: Schema.optional(ColorValue),
|
||||
question: Schema.optional(ColorValue),
|
||||
permission: Schema.optional(ColorValue),
|
||||
unread: Schema.optional(ColorValue),
|
||||
}),
|
||||
),
|
||||
feedback: Schema.optional(
|
||||
Schema.Struct({
|
||||
error: Schema.optional(TextFeedbackDefinition),
|
||||
@@ -128,7 +105,7 @@ const TextDefinition = Schema.Struct({
|
||||
export type TextDefinition = Schema.Schema.Type<typeof TextDefinition>
|
||||
|
||||
const BackgroundDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
base: Schema.optional(ColorValue),
|
||||
raised: Schema.optional(
|
||||
Schema.Struct({
|
||||
base: Schema.optional(ColorValue),
|
||||
@@ -217,49 +194,111 @@ export type DiffDefinition = Schema.Schema.Type<typeof DiffDefinition>
|
||||
const ThemeTokensDefinition = Schema.Struct({
|
||||
text: Schema.optional(TextDefinition),
|
||||
background: Schema.optional(BackgroundDefinition),
|
||||
border: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
|
||||
scrollbar: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
|
||||
border: Schema.optional(Schema.Struct({ base: Schema.optional(ColorValue) })),
|
||||
scrollbar: Schema.optional(Schema.Struct({ base: Schema.optional(ColorValue) })),
|
||||
diff: Schema.optional(DiffDefinition),
|
||||
syntax: Schema.optional(SyntaxDefinition),
|
||||
markdown: Schema.optional(MarkdownDefinition),
|
||||
})
|
||||
export type ThemeTokensDefinition = Schema.Schema.Type<typeof ThemeTokensDefinition>
|
||||
|
||||
const CompleteStatefulColorDefinition = Schema.Struct({
|
||||
base: ColorValue,
|
||||
$hovered: Schema.optional(ColorValue),
|
||||
$focused: Schema.optional(ColorValue),
|
||||
$pressed: Schema.optional(ColorValue),
|
||||
$selected: Schema.optional(ColorValue),
|
||||
$disabled: Schema.optional(ColorValue),
|
||||
})
|
||||
|
||||
const CompleteActionColorDefinition = Schema.Struct({
|
||||
primary: CompleteStatefulColorDefinition,
|
||||
secondary: CompleteStatefulColorDefinition,
|
||||
destructive: CompleteStatefulColorDefinition,
|
||||
})
|
||||
|
||||
const CompleteTextFeedbackDefinition = Schema.Struct({ base: ColorValue, muted: Schema.optional(ColorValue) })
|
||||
const CompleteBackgroundFeedbackDefinition = Schema.Struct({ base: ColorValue })
|
||||
|
||||
const CompleteThemeTokensDefinition = Schema.Struct({
|
||||
text: Schema.Struct({
|
||||
base: ColorValue,
|
||||
muted: ColorValue,
|
||||
action: CompleteActionColorDefinition,
|
||||
formfield: CompleteStatefulColorDefinition,
|
||||
feedback: Schema.Struct({
|
||||
error: CompleteTextFeedbackDefinition,
|
||||
warning: CompleteTextFeedbackDefinition,
|
||||
success: CompleteTextFeedbackDefinition,
|
||||
info: CompleteTextFeedbackDefinition,
|
||||
}),
|
||||
}),
|
||||
background: Schema.Struct({
|
||||
base: ColorValue,
|
||||
raised: Schema.Struct({ base: ColorValue, high: ColorValue, max: ColorValue }),
|
||||
action: CompleteActionColorDefinition,
|
||||
formfield: CompleteStatefulColorDefinition,
|
||||
feedback: Schema.Struct({
|
||||
error: CompleteBackgroundFeedbackDefinition,
|
||||
warning: CompleteBackgroundFeedbackDefinition,
|
||||
success: CompleteBackgroundFeedbackDefinition,
|
||||
info: CompleteBackgroundFeedbackDefinition,
|
||||
}),
|
||||
}),
|
||||
border: Schema.Struct({ base: ColorValue }),
|
||||
scrollbar: Schema.Struct({ base: ColorValue }),
|
||||
diff: Schema.Struct({
|
||||
text: Schema.Struct({ added: ColorValue, removed: ColorValue, context: ColorValue, hunkHeader: ColorValue }),
|
||||
background: Schema.Struct({ added: ColorValue, removed: ColorValue, context: ColorValue }),
|
||||
highlight: Schema.Struct({ added: ColorValue, removed: ColorValue }),
|
||||
lineNumber: Schema.Struct({
|
||||
text: ColorValue,
|
||||
background: Schema.Struct({ added: ColorValue, removed: ColorValue }),
|
||||
}),
|
||||
}),
|
||||
syntax: Schema.Record(SyntaxToken, HueColorValue),
|
||||
markdown: Schema.Record(MarkdownToken, HueColorValue),
|
||||
})
|
||||
|
||||
const ThemeDefinitionFields = Schema.Struct({
|
||||
hue: HueDefinition,
|
||||
categorical: Schema.optional(CategoricalDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
categorical: CategoricalDefinition,
|
||||
...CompleteThemeTokensDefinition.fields,
|
||||
"@dialog": Schema.optional(ThemeTokensDefinition),
|
||||
})
|
||||
export const ThemeDefinition = ThemeDefinitionFields
|
||||
export type ThemeDefinition = Schema.Schema.Type<typeof ThemeDefinition>
|
||||
|
||||
const FileThemeDefinition = Schema.Struct({
|
||||
hue: Schema.optional(HueOverrideDefinition),
|
||||
categorical: Schema.optional(CategoricalDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
export const BaseThemeDefinition = Schema.Struct({
|
||||
categorical: CategoricalDefinition,
|
||||
...CompleteThemeTokensDefinition.fields,
|
||||
"@dialog": Schema.optional(ThemeTokensDefinition),
|
||||
})
|
||||
export type FileThemeDefinition = Schema.Schema.Type<typeof FileThemeDefinition>
|
||||
export type BaseThemeDefinition = Schema.Schema.Type<typeof BaseThemeDefinition>
|
||||
|
||||
const MergeModeDefinition = Schema.Struct({
|
||||
mergeMode: Schema.Literal(true),
|
||||
hue: Schema.optional(HueOverrideDefinition),
|
||||
export const ModeDefinition = Schema.Struct({
|
||||
hue: HueDefinition,
|
||||
categorical: Schema.optional(CategoricalDefinition),
|
||||
...ThemeTokensDefinition.fields,
|
||||
"@dialog": Schema.optional(ThemeTokensDefinition),
|
||||
})
|
||||
export type MergeModeDefinition = Schema.Schema.Type<typeof MergeModeDefinition>
|
||||
export const ModeDefinition = Schema.Union([MergeModeDefinition, FileThemeDefinition])
|
||||
export type ModeDefinition = Schema.Schema.Type<typeof ModeDefinition>
|
||||
|
||||
const FileMetadata = {
|
||||
$schema: Schema.optional(Schema.String),
|
||||
version: Schema.Literal(2),
|
||||
standalone: Schema.optional(Schema.Boolean),
|
||||
}
|
||||
export const ThemeDocument = Schema.Union([
|
||||
Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: Schema.optional(ModeDefinition) }),
|
||||
Schema.Struct({ ...FileMetadata, light: Schema.optional(ModeDefinition), dark: ModeDefinition }),
|
||||
Schema.Struct({
|
||||
...FileMetadata,
|
||||
base: BaseThemeDefinition,
|
||||
light: ModeDefinition,
|
||||
dark: Schema.optional(ModeDefinition),
|
||||
}),
|
||||
Schema.Struct({
|
||||
...FileMetadata,
|
||||
base: BaseThemeDefinition,
|
||||
light: Schema.optional(ModeDefinition),
|
||||
dark: ModeDefinition,
|
||||
}),
|
||||
])
|
||||
export type ThemeDocument = Schema.Schema.Type<typeof ThemeDocument>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user