mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-20 23:57:40 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2fc031d10 |
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -87,6 +87,7 @@
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"luxon": "catalog:",
|
||||
"qr-scanner": "1.4.2",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
@@ -99,6 +100,7 @@
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"diff": "catalog:",
|
||||
@@ -111,7 +113,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -175,7 +177,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -201,10 +203,11 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
@@ -214,7 +217,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -250,7 +253,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -277,7 +280,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -294,7 +297,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -318,7 +321,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -338,7 +341,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -406,12 +409,14 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"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": {
|
||||
@@ -455,7 +460,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -492,7 +497,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -508,7 +513,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -527,7 +532,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -540,7 +545,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -554,7 +559,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -569,7 +574,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -608,7 +613,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -638,7 +643,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -653,7 +658,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -677,7 +682,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -698,7 +703,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -720,7 +725,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -755,7 +760,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -775,7 +780,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -809,7 +814,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -828,7 +833,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -874,7 +879,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -888,7 +893,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -923,7 +928,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -958,7 +963,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -991,7 +996,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1032,7 +1037,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
@@ -3338,6 +3343,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -3632,6 +3639,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -3770,6 +3779,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -3896,12 +3907,16 @@
|
||||
|
||||
"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=="],
|
||||
@@ -4446,6 +4461,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -4456,7 +4473,7 @@
|
||||
|
||||
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
|
||||
|
||||
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||
|
||||
"jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="],
|
||||
|
||||
@@ -4718,6 +4735,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -5472,6 +5491,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -5596,6 +5617,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -6422,6 +6445,10 @@
|
||||
|
||||
"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=="],
|
||||
@@ -6462,6 +6489,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -6492,6 +6521,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -6894,12 +6925,18 @@
|
||||
|
||||
"@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/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||
"@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/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=="],
|
||||
@@ -6976,6 +7013,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -7354,6 +7393,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -7378,8 +7419,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -7528,6 +7567,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -7536,6 +7577,8 @@
|
||||
|
||||
"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-8mOzCscBAuogG4tm8CroqjTX5E5yzCvTobbhKvxQP0U=",
|
||||
"aarch64-linux": "sha256-drrRSpzxC8bfaTXBpOyaN0QAyBVV7WJzm0NstJ+8sAE=",
|
||||
"aarch64-darwin": "sha256-YY5A/zxLPONvgnI+DZlzcD2K5Q9PIw4F2YbDxKbT4UU=",
|
||||
"x86_64-darwin": "sha256-/c/Ew4onA+9+l6GRKXz3zWq3QFESM7j5RyGLCWRPQFw="
|
||||
"x86_64-linux": "sha256-TlCPtuIvTNF01fLI0yHCLzVkx53yF9xxvAObDMGeo8M=",
|
||||
"aarch64-linux": "sha256-aEjyPIXHeA5w1iZXbPKJAOIJQBfXg+LAx/y7C5uFz4g=",
|
||||
"aarch64-darwin": "sha256-lthMTio2qNy+H2tL9cAPmqC41O+wdSADI61cnW4dIb0=",
|
||||
"x86_64-darwin": "sha256-ONFbRQnwzOYHo0Pyh327QfJG2uzqzNEn/SS2vbYYMdM="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -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.
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -83,7 +83,7 @@ const AnthropicCacheControl = Schema.Struct({
|
||||
type: Schema.tag("ephemeral"),
|
||||
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
|
||||
})
|
||||
const AnthropicServiceTier = knownString<"auto" | "standard_only">()
|
||||
const AnthropicServiceTier = Schema.Literals(["auto", "standard_only"])
|
||||
|
||||
const AnthropicTextBlock = Schema.Struct({
|
||||
type: Schema.tag("text"),
|
||||
@@ -276,7 +276,7 @@ const AnthropicThinkingBlockBinding = Schema.Struct({
|
||||
})
|
||||
|
||||
const AnthropicThinkingFields = {
|
||||
display: Schema.optional(knownString<"summarized" | "omitted">()),
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
|
||||
}
|
||||
const AnthropicThinkingEnabled = Schema.Struct({
|
||||
|
||||
@@ -73,6 +73,11 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
)
|
||||
if (event.type === "error") {
|
||||
terminal = true
|
||||
yield* OpenResponses.decodeKnownErrorEvent(event).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(options.id, `${options.name} returned a malformed error event`, frame, cause),
|
||||
),
|
||||
)
|
||||
return {
|
||||
type: "provider-failure",
|
||||
error: OpenResponses.providerFailure(event, `${options.name} stream error`, frame),
|
||||
|
||||
@@ -108,7 +108,7 @@ const incremental = (
|
||||
return input.slice(baseline.length)
|
||||
}
|
||||
|
||||
const code = (event: OpenResponses.Event) => OpenResponses.errorDetail(event).code
|
||||
const code = (event: OpenResponses.Event) => event.code || event.error?.code || event.response?.error?.code || undefined
|
||||
|
||||
const rejected = (
|
||||
observation: Extract<ChannelObservation, { readonly type: "provider-failure" }>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Effect, Option, Schema, SchemaGetter } from "effect"
|
||||
import type { Content } from "@opencode/schema/tool"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
@@ -333,13 +333,53 @@ export const StreamItem = Schema.StructWithRest(
|
||||
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
|
||||
export type OutputItem = StreamItem & { readonly id: string }
|
||||
|
||||
// Responses-compatible providers put error details at the top level, under `error`, or under
|
||||
// `response.error`, and gateways reshape them freely: strings, numeric codes, extra fields. Those
|
||||
// fields decode as opaque values and `errorDetail` reads them defensively, so an error frame can
|
||||
// only fail on invalid JSON and otherwise always classifies with the raw body as the fallback.
|
||||
// Responses-compatible providers put streaming error details at the top level or
|
||||
// under `error`, and response failures under `response.error`. Accept all three shapes.
|
||||
// https://www.openresponses.org/specification
|
||||
const asText = (value: unknown) =>
|
||||
typeof value === "string" && value.length > 0 ? value : typeof value === "number" ? String(value) : undefined
|
||||
const OpenResponsesErrorObject = Schema.Struct({
|
||||
type: optionalNull(Schema.String),
|
||||
code: optionalNull(Schema.String),
|
||||
message: optionalNull(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
})
|
||||
const OpenResponsesErrorPayload = Schema.Union([Schema.String, OpenResponsesErrorObject]).pipe(
|
||||
Schema.decodeTo(OpenResponsesErrorObject, {
|
||||
decode: SchemaGetter.transform((error) => (typeof error === "string" ? { message: error } : error)),
|
||||
encode: SchemaGetter.passthrough(),
|
||||
}),
|
||||
)
|
||||
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
|
||||
|
||||
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
|
||||
export const WebSocketErrorEvent = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.tag("error"),
|
||||
status: Schema.optional(Schema.Number),
|
||||
status_code: Schema.optional(Schema.Number),
|
||||
code: optionalNull(Schema.String),
|
||||
message: Schema.optional(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
error: optionalNull(OpenResponsesErrorPayload),
|
||||
headers: Schema.optional(Schema.Record(Schema.String, WebSocketErrorHeader)),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const decodeWebSocketErrorEvent = Schema.decodeUnknownEffect(WebSocketErrorEvent)
|
||||
|
||||
export const decodeKnownErrorEvent = (event: Event) =>
|
||||
decodeWebSocketErrorEvent({
|
||||
...event,
|
||||
status: typeof event.status === "number" ? event.status : undefined,
|
||||
status_code: typeof event.status_code === "number" ? event.status_code : undefined,
|
||||
headers: ProviderShared.isRecord(event.headers)
|
||||
? Object.fromEntries(
|
||||
Object.entries(event.headers).filter(
|
||||
(entry): entry is [string, string | number | boolean] =>
|
||||
typeof entry[1] === "string" || typeof entry[1] === "number" || typeof entry[1] === "boolean",
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
})
|
||||
|
||||
export const Event = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
@@ -360,18 +400,31 @@ export const Event = Schema.StructWithRest(
|
||||
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
|
||||
output: Schema.optional(Schema.Array(StreamItem)),
|
||||
usage: optionalNull(OpenResponsesUsage),
|
||||
error: Schema.optional(Schema.Unknown),
|
||||
error: optionalNull(OpenResponsesErrorPayload),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
),
|
||||
code: Schema.optional(Schema.Unknown),
|
||||
message: Schema.optional(Schema.Unknown),
|
||||
error: Schema.optional(Schema.Unknown),
|
||||
code: optionalNull(Schema.String),
|
||||
message: Schema.optional(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
error: optionalNull(OpenResponsesErrorPayload),
|
||||
status: Schema.optional(Schema.Unknown),
|
||||
status_code: Schema.optional(Schema.Unknown),
|
||||
headers: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
).pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((event) => {
|
||||
if (event.type !== "error" || event.error != null) return event
|
||||
const { code, message, param, ...rest } = event
|
||||
if (code === undefined && message === undefined && param === undefined) return event
|
||||
// Flat errors (for example, Meta's) can also arrive through generic Responses endpoints.
|
||||
return { ...rest, error: { code, message, param } }
|
||||
}),
|
||||
encode: SchemaGetter.passthrough(),
|
||||
}),
|
||||
)
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
|
||||
@@ -380,15 +433,16 @@ const decodeEventValue = Schema.decodeUnknownEffect(Event)
|
||||
const decodeFrame = Schema.decodeUnknownEffect(ProviderShared.Json)
|
||||
|
||||
/**
|
||||
* Decodes one WebSocket frame. Some providers and gateways answer a rejected `response.create` with a bare
|
||||
* `{ "error": ... }` envelope and no event type; that reads as an error event so it classifies instead of
|
||||
* failing decoding.
|
||||
* Decodes one WebSocket frame. xAI answers a rejected `response.create` with `{ "error": { "message", "type" } }` and no
|
||||
* event type; that envelope reads as an error event so the failure classifies instead of failing decoding.
|
||||
*/
|
||||
export const decodeChannelEvent = (frame: string) =>
|
||||
decodeFrame(frame).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
decodeEventValue(
|
||||
ProviderShared.isRecord(value) && value.type === undefined && value.error != null
|
||||
ProviderShared.isRecord(value) &&
|
||||
value.type === undefined &&
|
||||
(typeof value.error === "string" || ProviderShared.isRecord(value.error))
|
||||
? { ...value, type: "error" }
|
||||
: value,
|
||||
),
|
||||
@@ -1368,21 +1422,22 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
return [{ ...current, lifecycle }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
/** Error code and message from wherever the frame put them; top-level fields win over nested ones. */
|
||||
export const errorDetail = (event: Event) => {
|
||||
const raw = event.error ?? event.response?.error
|
||||
const nested = typeof raw === "string" ? { message: raw } : ProviderShared.isRecord(raw) ? raw : undefined
|
||||
return {
|
||||
message: asText(event.message) ?? asText(nested?.message),
|
||||
code: asText(event.code) ?? asText(nested?.code),
|
||||
}
|
||||
// Build the prettiest summary available from whatever the provider supplied.
|
||||
// When both code and message are present, prefix the code so consumers see
|
||||
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
|
||||
// the bare message — production rate limits and context-length failures used
|
||||
// to be indistinguishable from generic stream drops. Returns undefined when
|
||||
// the payload carries no usable summary.
|
||||
const providerErrorMessage = (event: Event, nested: OpenResponsesErrorPayload | undefined): string | undefined => {
|
||||
const message = event.message || nested?.message || undefined
|
||||
const code = event.code || nested?.code || undefined
|
||||
if (message && code) return `${code}: ${message}`
|
||||
return message || code
|
||||
}
|
||||
|
||||
// Prefix the code when both are present (`rate_limit_exceeded: Slow down`) so the failure mode is
|
||||
// visible; fall back to the raw frame rather than a generic message when neither decodes.
|
||||
export const providerFailure = (event: Event, fallback: string, body = ProviderShared.encodeJson(event)) => {
|
||||
const detail = errorDetail(event)
|
||||
const summary = detail.message && detail.code ? `${detail.code}: ${detail.message}` : (detail.message ?? detail.code)
|
||||
const nested = event.error ?? event.response?.error ?? undefined
|
||||
const summary = providerErrorMessage(event, nested)
|
||||
const message = summary ?? (body === "{}" ? fallback : body)
|
||||
const status =
|
||||
typeof event.status === "number"
|
||||
@@ -1465,7 +1520,18 @@ export const step = (state: ParserState, event: NormalizedEvent) => {
|
||||
if (event.type === "response.output_item.done") return onOutputItemDone(state, event.item)
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
|
||||
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
|
||||
if (event.type === "error") return providerFailure(event, `${state.name} stream error`)
|
||||
if (event.type === "error")
|
||||
return decodeKnownErrorEvent(event).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(
|
||||
state.id,
|
||||
`${state.name} returned a malformed error event`,
|
||||
ProviderShared.encodeJson(event),
|
||||
cause,
|
||||
),
|
||||
),
|
||||
Effect.flatMap(() => providerFailure(event, `${state.name} stream error`)),
|
||||
)
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
}
|
||||
|
||||
|
||||
@@ -215,9 +215,7 @@ export const fromWebSocket = (
|
||||
): Effect.Effect<WebSocketConnection, AIError> =>
|
||||
Effect.gen(function* () {
|
||||
yield* waitOpen(ws, input)
|
||||
// The socket pushes frames synchronously and cannot be paused, so the hand-off to the consumer
|
||||
// fiber must absorb whole read buffers. Bun delivers over a thousand small frames in one tick.
|
||||
const messages = yield* Queue.unbounded<string | Uint8Array, AIError | Cause.Done<void>>()
|
||||
const messages = yield* Queue.bounded<string | Uint8Array, AIError | Cause.Done<void>>(128)
|
||||
|
||||
const oversized = (message: string | Uint8Array) =>
|
||||
typeof message === "string" ? new Blob([message]).size > MAX_FRAME_BYTES : message.byteLength > MAX_FRAME_BYTES
|
||||
@@ -240,7 +238,19 @@ export const fromWebSocket = (
|
||||
}
|
||||
const offer = (message: string | Uint8Array) => {
|
||||
if (rejectOversized(message)) return
|
||||
Queue.offerUnsafe(messages, message)
|
||||
if (Queue.offerUnsafe(messages, message)) return
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("WebSocket inbound queue overflow", {
|
||||
body: typeof message === "string" ? message : new TextDecoder().decode(message),
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: "queue-overflow",
|
||||
phase: "receive",
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
|
||||
@@ -4,22 +4,6 @@ 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,31 +215,13 @@ 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: 1 },
|
||||
{ service_tier: "fast" },
|
||||
{ metadata: { user_id: 42 } },
|
||||
{ cache_control: { type: "ephemeral", ttl: "future-ttl" } },
|
||||
{ cache_control: { type: "ephemeral", ttl: "2h" } },
|
||||
{ output_config: { format: { type: "text" } } },
|
||||
{ thinking: { type: "automatic" } },
|
||||
]
|
||||
|
||||
@@ -11,78 +11,66 @@ import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||
|
||||
it.effect("decodes error frames verbatim in shared SSE and WebSocket decoding", () =>
|
||||
it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
|
||||
Effect.gen(function* () {
|
||||
const frame = {
|
||||
type: "error",
|
||||
sequence_number: 4,
|
||||
code: "server_shutting_down",
|
||||
message: "Server is shutting down. Please retry your request.",
|
||||
param: null,
|
||||
}
|
||||
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
|
||||
for (const frame of [
|
||||
{ type: "error", sequence_number: 4, code: "server_shutting_down", message: "Shutting down", param: null },
|
||||
const event = yield* decode(JSON.stringify(frame))
|
||||
expect(event).toEqual({
|
||||
type: "error",
|
||||
sequence_number: 4,
|
||||
error: { code: frame.code, message: frame.message, param: null },
|
||||
})
|
||||
|
||||
for (const unchanged of [
|
||||
event,
|
||||
{ type: "error" },
|
||||
{ type: "error", error: "Gateway failed" },
|
||||
{ type: "error", error: { code: 429, message: "slow down" } },
|
||||
{ type: "error", error: 42 },
|
||||
{ type: "error", code: 500, message: ["not", "a", "string"] },
|
||||
{ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } },
|
||||
{ type: "response.failed", response: { id: "resp_failed", error: ["weird"] } },
|
||||
{
|
||||
type: "response.failed",
|
||||
response: { id: "resp_failed", error: { code: "server_error", message: "Internal server error" } },
|
||||
},
|
||||
{ type: "response.output_text.delta", item_id: "msg_text", delta: "Hello" },
|
||||
]) {
|
||||
expect(yield* decode(JSON.stringify(frame))).toEqual(frame)
|
||||
expect(yield* decode(JSON.stringify(unchanged))).toEqual(unchanged)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads bare WebSocket error envelopes as error events", () =>
|
||||
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const message = "gRPC error: Response with id=resp_missing not found"
|
||||
for (const error of [{ type: "api_error", message }, message, 42]) {
|
||||
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({ type: "error", error })
|
||||
}
|
||||
for (const frame of [{ error: null }, { message }]) {
|
||||
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame)).pipe(Effect.flip)).toBeDefined()
|
||||
for (const error of [{ type: "api_error", message }, message]) {
|
||||
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({
|
||||
type: "error",
|
||||
error: typeof error === "string" ? { message } : error,
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("extracts error details from every shape and falls back to the raw frame", () =>
|
||||
it.effect("normalizes string errors in shared SSE and WebSocket decoding", () =>
|
||||
Effect.gen(function* () {
|
||||
const cases: Array<[frame: Record<string, unknown>, message: string, tag: string]> = [
|
||||
[
|
||||
{ type: "error", code: "server_shutting_down", message: "Shutting down" },
|
||||
"server_shutting_down: Shutting down",
|
||||
"UnknownProvider",
|
||||
],
|
||||
[{ type: "error", error: "Gateway failed" }, "Gateway failed", "UnknownProvider"],
|
||||
[{ type: "error", error: { code: 429, message: "slow down" } }, "429: slow down", "UnknownProvider"],
|
||||
[{ type: "error", error: { message: "slow down" }, status: 429 }, "slow down", "RateLimit"],
|
||||
[{ type: "error", code: 500, message: ["not", "a", "string"] }, "500", "UnknownProvider"],
|
||||
[
|
||||
{ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } },
|
||||
"Gateway failed",
|
||||
"UnknownProvider",
|
||||
],
|
||||
]
|
||||
for (const [frame, message, tag] of cases) {
|
||||
const event = yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))
|
||||
const error = OpenResponses.providerFailure(event, "fallback", JSON.stringify(frame))
|
||||
expect(error.message).toBe(message)
|
||||
expect(error.reason._tag).toBe(tag)
|
||||
expect(error.reason.body).toBe(JSON.stringify(frame))
|
||||
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
|
||||
expect(yield* decode(JSON.stringify({ type: "error", error: "Gateway failed" }))).toEqual({
|
||||
type: "error",
|
||||
error: { message: "Gateway failed" },
|
||||
})
|
||||
expect(
|
||||
yield* decode(
|
||||
JSON.stringify({ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } }),
|
||||
),
|
||||
).toEqual({
|
||||
type: "response.failed",
|
||||
response: { id: "resp_failed", error: { message: "Gateway failed" } },
|
||||
})
|
||||
}
|
||||
for (const frame of [
|
||||
{ type: "error", error: 42 },
|
||||
{ type: "response.failed", response: { id: "resp_failed", error: ["weird"] } },
|
||||
]) {
|
||||
const event = yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))
|
||||
const error = OpenResponses.providerFailure(event, "fallback", JSON.stringify(frame))
|
||||
expect(error.message).toBe(JSON.stringify(frame))
|
||||
expect(error.reason._tag).toBe("UnknownProvider")
|
||||
}
|
||||
expect(OpenResponses.providerFailure({ type: "error" }, "fallback", "{}").message).toBe("fallback")
|
||||
expect(OpenResponses.providerFailure({ type: "error" }, "fallback", "{}").reason._tag).toBe("ProviderInternal")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessions.map((session) => ({ type: "session", server, sessionId: session.id }))),
|
||||
)
|
||||
},
|
||||
{ directory: fixture.directory, server: fixture.serverKey, sessions: fixture.sessions },
|
||||
)
|
||||
await page.goto("/")
|
||||
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
|
||||
await expect(page.locator('[data-slot="mobile-tabs-drawer"]')).toBeVisible()
|
||||
const drawer = page.locator('[data-slot="mobile-drawer-content"]')
|
||||
await expect
|
||||
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
|
||||
.toBe(0)
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
})
|
||||
|
||||
test("reorders session tabs with touch", async ({ page }) => {
|
||||
const tabs = page.locator('[data-slot="vertical-tabs"] a')
|
||||
await expect(tabs).toContainText([fixture.expected.sourceTitle, fixture.expected.targetTitle])
|
||||
|
||||
const target = tabs.filter({ hasText: fixture.expected.targetTitle })
|
||||
const source = tabs.filter({ hasText: fixture.expected.sourceTitle })
|
||||
const targetBox = await target.boundingBox()
|
||||
const sourceBox = await source.boundingBox()
|
||||
expect(targetBox).not.toBeNull()
|
||||
expect(sourceBox).not.toBeNull()
|
||||
|
||||
await touchDrag(page, source, {
|
||||
from: { x: sourceBox!.x + sourceBox!.width / 2, y: sourceBox!.y + sourceBox!.height / 2 },
|
||||
to: { x: targetBox!.x + targetBox!.width / 2, y: targetBox!.y + targetBox!.height / 2 },
|
||||
})
|
||||
|
||||
await expect(tabs).toContainText([fixture.expected.targetTitle, fixture.expected.sourceTitle])
|
||||
})
|
||||
|
||||
test("dismisses a touch context menu by tapping outside", async ({ page }) => {
|
||||
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
const tab = drawer.locator('[data-slot="tab-link"]').filter({ hasText: fixture.expected.sourceTitle })
|
||||
const home = drawer.getByRole("button", { name: "Home", exact: true })
|
||||
const homeBox = await home.boundingBox()
|
||||
const box = await tab.boundingBox()
|
||||
expect(homeBox).not.toBeNull()
|
||||
expect(box).not.toBeNull()
|
||||
const point = { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }
|
||||
|
||||
await tab.dispatchEvent("pointerdown", {
|
||||
pointerType: "touch",
|
||||
pointerId: 1,
|
||||
isPrimary: true,
|
||||
clientX: point.x,
|
||||
clientY: point.y,
|
||||
})
|
||||
const rename = page.getByRole("menuitem", { name: "Rename", exact: true })
|
||||
await expect(rename).toBeVisible()
|
||||
await expect(drawer).toBeVisible()
|
||||
await tab.filter({ visible: true }).dispatchEvent("pointerup", {
|
||||
pointerType: "touch",
|
||||
pointerId: 1,
|
||||
isPrimary: true,
|
||||
clientX: point.x,
|
||||
clientY: point.y,
|
||||
})
|
||||
|
||||
await page.touchscreen.tap(homeBox!.x + homeBox!.width / 2, homeBox!.y + homeBox!.height / 2)
|
||||
await expect(rename).toBeHidden()
|
||||
await expect(drawer).toBeVisible()
|
||||
})
|
||||
|
||||
async function touchDrag(
|
||||
page: Page,
|
||||
source: Locator,
|
||||
input: { from: { x: number; y: number }; to: { x: number; y: number } },
|
||||
) {
|
||||
const client = await page.context().newCDPSession(page)
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ ...input.from, id: 1 }],
|
||||
})
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: (input.from.x + input.to.x) / 2, y: (input.from.y + input.to.y) / 2, id: 1 }],
|
||||
})
|
||||
await expect(source).toHaveCount(2)
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ ...input.to, id: 1 }],
|
||||
})
|
||||
await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] })
|
||||
}
|
||||
@@ -166,45 +166,6 @@ test.describe("session timeline projection", () => {
|
||||
await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
|
||||
await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
})
|
||||
|
||||
test("does not repeat a review comment file as an attachment", async ({ page }) => {
|
||||
const message = userMessage([
|
||||
userText("what's goin on here", { id: "prt_user_review_comment" }),
|
||||
{
|
||||
id: "prt_user_review_file",
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: "LiveRuntime.ts",
|
||||
url: "file:///repo/LiveRuntime.ts?start=14&end=32",
|
||||
},
|
||||
{
|
||||
id: "prt_user_unrelated_file",
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: "notes.txt",
|
||||
url: "data:text/plain;base64,bm90ZXM=",
|
||||
},
|
||||
])
|
||||
message.metadata = {
|
||||
displayText: "what's goin on here",
|
||||
comments: [
|
||||
{
|
||||
path: "LiveRuntime.ts",
|
||||
comment: "what's goin on here",
|
||||
selection: { startLine: 14, startChar: 0, endLine: 32, endChar: 0 },
|
||||
origin: "review",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
await setupTimeline(page, { messages: [message, assistantMessage()] })
|
||||
|
||||
const user = page.locator('[data-component="user-message"]')
|
||||
await expect(user.getByText("LiveRuntime.ts:14-32", { exact: true })).toBeVisible()
|
||||
const attachments = user.locator('[data-slot="user-message-attachments"]')
|
||||
await expect(attachments.getByText("LiveRuntime.ts", { exact: true })).toHaveCount(0)
|
||||
await expect(attachments.getByText("notes.txt", { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
function patchFile(file: string, status: "added" | "modified" | "deleted") {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -10,8 +10,7 @@
|
||||
"./i18n/desktop-native": "./src/runtime/i18n/desktop-native.ts",
|
||||
"./updater": "./src/shell/updates/types.ts",
|
||||
"./wsl/types": "./src/servers/wsl/types.ts",
|
||||
"./ssh": "./src/servers/ssh/types.ts",
|
||||
"./ssh/schema": "./src/servers/ssh/schema.ts",
|
||||
"./ssh": "./src/servers/ssh/types.ts",
|
||||
"./vite": "./vite.js",
|
||||
"./index.css": "./src/index.css"
|
||||
},
|
||||
@@ -47,6 +46,7 @@
|
||||
"@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,6 +88,7 @@
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"luxon": "catalog:",
|
||||
"qr-scanner": "1.4.2",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
@@ -95,4 +96,3 @@
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import { prependHistoryEntry, removeHistoryEntry, type PromptHistoryComment } from "./entry"
|
||||
import { prependHistoryEntry, type PromptHistoryComment } from "./entry"
|
||||
import { Schema } from "effect"
|
||||
import { PromptHistoryState } from "../schema"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
@@ -36,24 +36,6 @@ 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,18 +48,6 @@ 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,7 +4,6 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import {
|
||||
clonePromptHistoryComments,
|
||||
prependHistoryEntry,
|
||||
removeHistoryEntry,
|
||||
type PromptHistoryComment,
|
||||
type PromptHistoryStoredEntry,
|
||||
} from "./entry"
|
||||
@@ -14,7 +13,6 @@ 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
|
||||
@@ -34,13 +32,6 @@ 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,12 +56,5 @@ 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,7 +264,6 @@ 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" }),
|
||||
|
||||
@@ -3,19 +3,7 @@ import { checksum } from "@opencode/util/encode"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
// Effect twins of workspaces/files/types until this module moves to plain codecs.
|
||||
const SelectedLineRange = Persistence.struct({
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
side: Persistence.optional(Schema.Literals(["additions", "deletions"])),
|
||||
endSide: Persistence.optional(Schema.Literals(["additions", "deletions"])),
|
||||
})
|
||||
const FileSelection = Persistence.struct({
|
||||
startLine: Schema.Number,
|
||||
startChar: Schema.Number,
|
||||
endLine: Schema.Number,
|
||||
endChar: Schema.Number,
|
||||
})
|
||||
import { FileSelection, SelectedLineRange } from "@/workspaces/files/types"
|
||||
|
||||
const PartBase = {
|
||||
content: Schema.String,
|
||||
@@ -240,5 +228,3 @@ export const PromptHistoryEntry = Schema.Union([HistoryEntry, HistoryPrompt]).pi
|
||||
export type PromptHistoryEntry = typeof PromptHistoryEntry.Type
|
||||
|
||||
export const PromptHistoryState = Persistence.struct({ entries: Persistence.array(PromptHistoryEntry) })
|
||||
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@ 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,
|
||||
@@ -61,9 +60,7 @@ function submitInput(
|
||||
commands,
|
||||
editor: () => undefined,
|
||||
queueScroll() {},
|
||||
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("")}`),
|
||||
addToHistory() {},
|
||||
resetHistory() {},
|
||||
setMode() {},
|
||||
closePopover() {},
|
||||
@@ -536,8 +533,12 @@ describe("Composer submission", () => {
|
||||
missingSelection() {},
|
||||
failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()),
|
||||
}
|
||||
const history: string[] = []
|
||||
const submission = submitInput(adapter, notify, "normal", () => [], history)
|
||||
const submission = submitInput(
|
||||
adapter,
|
||||
notify,
|
||||
"normal",
|
||||
() => [],
|
||||
)
|
||||
|
||||
await submission.submit(new Event("submit"))
|
||||
await first.promise
|
||||
@@ -548,8 +549,6 @@ 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,7 +32,6 @@ 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
|
||||
@@ -251,8 +250,6 @@ 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(
|
||||
|
||||
@@ -11,15 +11,15 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { createResource } from "solid-js"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import type { HomeController } from "../model"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
|
||||
import { SessionTransfer } from "@opencode/schema/session-transfer"
|
||||
import { useSshAuthenticate } from "@/servers/ssh/authenticate"
|
||||
|
||||
export const HomeServersSchema = Codec.struct({
|
||||
collapsed: Codec.lenientRecord(Codec.fallback(Codec.boolean, () => false)),
|
||||
export const HomeServersSchema = Schema.Struct({
|
||||
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
|
||||
})
|
||||
|
||||
export function createHomeProjectsController(home: HomeController) {
|
||||
@@ -114,13 +114,8 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
extensions: ["json"],
|
||||
},
|
||||
async (file) => {
|
||||
// Validating an imported file is the one place the shared Effect schema is needed here.
|
||||
const [{ Schema }, { SessionTransfer }] = await Promise.all([
|
||||
import("effect"),
|
||||
import("@opencode/schema/session-transfer"),
|
||||
])
|
||||
const data = await Schema.decodeUnknownPromise(Schema.fromJsonString(SessionTransfer.Data))(
|
||||
await file.text(),
|
||||
const data = await Schema.decodeUnknownPromise(Schema.fromJsonString(SessionTransfer.Data))(
|
||||
await file.text(),
|
||||
)
|
||||
const api = home.server.context(conn).sdk.api.session
|
||||
const imported = await api.import({
|
||||
@@ -189,4 +184,3 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
}
|
||||
|
||||
export type HomeProjectsController = ReturnType<typeof createHomeProjectsController>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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"
|
||||
@@ -326,19 +327,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 = 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 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 olderTitle =
|
||||
todaySessions.length === 0 && yesterdaySessions.length === 0
|
||||
? language.t("sidebar.project.recentSessions")
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Show, Suspense, createMemo, createSignal, lazy, onMount } from "solid-j
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { debounce } from "@solid-primitives/scheduled"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Schema } from "effect"
|
||||
import createPresence from "solid-presence"
|
||||
import { Composer } from "@/composer/composer"
|
||||
import { ComposerDropzone } from "@/composer/dropzone"
|
||||
@@ -21,6 +21,7 @@ import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import type { NewSessionWorkspaceController } from "./workspace/controller"
|
||||
import { NewSessionWordmark } from "./wordmark"
|
||||
import { SummaryPopover } from "@/session/summary/popover"
|
||||
@@ -33,12 +34,12 @@ const NewSessionSummary = lazy(async () => {
|
||||
|
||||
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
export const WorkspaceOnboardingSchema = Codec.struct({
|
||||
used: Codec.boolean,
|
||||
export const WorkspaceOnboardingSchema = Persistence.struct({
|
||||
used: Schema.Boolean,
|
||||
})
|
||||
|
||||
export const ProviderTipSchema = Codec.struct({
|
||||
dismissedAt: Codec.number,
|
||||
export const ProviderTipSchema = Persistence.struct({
|
||||
dismissedAt: Schema.Finite,
|
||||
})
|
||||
|
||||
export const WorkspaceTipSchema = ProviderTipSchema
|
||||
@@ -266,4 +267,3 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { normalizeProviderList } from "@/runtime/server/global-sync/utils"
|
||||
import { Iterable, pipe } from "effect"
|
||||
import { createEffect, createMemo, type Accessor } from "solid-js"
|
||||
import type { ProviderListResponse } from "@/runtime/server/types"
|
||||
import { useIntegrations } from "./integrations"
|
||||
@@ -52,24 +53,34 @@ export function useProviders(directory: Accessor<string | undefined>) {
|
||||
.filter((integration) => popularProviderSet.has(integration.id))
|
||||
.map((integration) => ({ id: integration.id, name: integration.name }))
|
||||
const seen = new Set(catalog.map((integration) => integration.id))
|
||||
const more = [...providers().all.values()]
|
||||
.filter((p) => popularProviderSet.has(p.id) && !seen.has(p.id))
|
||||
.map((p) => ({ id: p.id, name: p.name }))
|
||||
return [...catalog, ...more]
|
||||
return pipe(
|
||||
providers().all,
|
||||
Iterable.map(([, p]) => p),
|
||||
Iterable.filter((p) => popularProviderSet.has(p.id) && !seen.has(p.id)),
|
||||
Iterable.map((p) => ({ id: p.id, name: p.name })),
|
||||
(v) => [...catalog, ...v],
|
||||
)
|
||||
},
|
||||
connected: () => {
|
||||
const connected = new Set(providers().connected)
|
||||
return [...providers().all.values()].filter((p) => connected.has(p.id))
|
||||
return pipe(
|
||||
providers().all,
|
||||
Iterable.map(([, p]) => p),
|
||||
Iterable.filter((p) => connected.has(p.id)),
|
||||
(v) => Array.from(v),
|
||||
)
|
||||
},
|
||||
paid: () => {
|
||||
const connected = new Set(providers().connected)
|
||||
const paid = [...providers().all].filter(
|
||||
([id]) =>
|
||||
connected.has(id) &&
|
||||
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
|
||||
)
|
||||
const paid = [
|
||||
...Iterable.filter(
|
||||
providers().all,
|
||||
([id]) =>
|
||||
connected.has(id) &&
|
||||
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
|
||||
),
|
||||
]
|
||||
return paid
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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"
|
||||
@@ -8,8 +9,6 @@ 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}`
|
||||
@@ -30,23 +29,27 @@ 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) => [modelKey({ providerID: model.provider.id, modelID: model.id }), Date.parse(model.release_date)] as const,
|
||||
),
|
||||
available().map((model) => {
|
||||
const parsed = DateTime.fromISO(model.release_date)
|
||||
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const latest = createMemo(() =>
|
||||
pipe(
|
||||
available(),
|
||||
filter((x) => {
|
||||
const released = release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? NaN
|
||||
return Math.abs(Date.now() - released) < sixMonths
|
||||
}),
|
||||
filter(
|
||||
(x) =>
|
||||
Math.abs(
|
||||
(release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
|
||||
.diffNow()
|
||||
.as("months"),
|
||||
) < 6,
|
||||
),
|
||||
groupBy((x) => x.provider.id),
|
||||
mapValues((models) =>
|
||||
pipe(
|
||||
@@ -98,8 +101,9 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
if (state === "hide") return false
|
||||
if (state === "show") return true
|
||||
if (latestSet().has(key)) return true
|
||||
// Models without a parseable release date stay visible.
|
||||
return !Number.isFinite(release().get(key) ?? NaN)
|
||||
const date = release().get(key)
|
||||
if (!date?.isValid) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const setVisibility = (model: ModelKey, state: boolean) => {
|
||||
|
||||
@@ -3,11 +3,12 @@ import { base64Encode } from "@opencode/util/encode"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { useModels } from "@/providers/models/models"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { hasCustomAgent, resolveAgent } from "./agent"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./variant"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
@@ -17,45 +18,46 @@ import { useServerSDK } from "@/runtime/server/client"
|
||||
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
|
||||
import { useConfiguredModel } from "./configured"
|
||||
|
||||
const ModelKeySchema = Codec.struct({
|
||||
providerID: Codec.string,
|
||||
modelID: Codec.string,
|
||||
variant: Codec.optional(Codec.string),
|
||||
const ModelKeySchema = Schema.Struct({
|
||||
providerID: Schema.String,
|
||||
modelID: Schema.String,
|
||||
variant: Schema.optional(Schema.String),
|
||||
})
|
||||
export type ModelKey = typeof ModelKeySchema.Type
|
||||
|
||||
const ChoiceSchema = Codec.struct({
|
||||
model: Codec.lenientOptional(ModelKeySchema),
|
||||
variant: Codec.lenientOptional(Codec.nullOr(Codec.string)),
|
||||
const ChoiceSchema = Schema.Struct({
|
||||
model: Persistence.optional(ModelKeySchema),
|
||||
variant: Persistence.optional(Schema.NullOr(Schema.String)),
|
||||
})
|
||||
const StateSchema = Codec.struct({
|
||||
const StateSchema = Schema.Struct({
|
||||
...ChoiceSchema.fields,
|
||||
agent: Codec.lenientOptional(Codec.string),
|
||||
choices: Codec.lenientOptional(Codec.record(ChoiceSchema)),
|
||||
agent: Persistence.optional(Schema.String),
|
||||
choices: Persistence.optional(Schema.Record(Schema.String, ChoiceSchema)),
|
||||
})
|
||||
type State = typeof StateSchema.Type
|
||||
|
||||
const SessionsSchema = Codec.record(Codec.fallback(Codec.undefinedOr(StateSchema), () => undefined))
|
||||
|
||||
const Current = Codec.struct({ session: SessionsSchema })
|
||||
const StoredSelection = Codec.struct(
|
||||
{
|
||||
session: Codec.lenientOptional(Codec.record(Codec.unknown)),
|
||||
pick: Codec.lenientOptional(Codec.record(Codec.unknown)),
|
||||
},
|
||||
{ preserve: true },
|
||||
const SessionsSchema = Schema.Record(
|
||||
Schema.String,
|
||||
Schema.mutableKey(Persistence.fallback(Schema.UndefinedOr(StateSchema), () => undefined)),
|
||||
)
|
||||
|
||||
export const ModelSelectionSchema = Codec.migrate(
|
||||
const Current = Persistence.struct({ session: SessionsSchema })
|
||||
|
||||
export const ModelSelectionSchema = Persistence.migrate(
|
||||
Current,
|
||||
Codec.transform(StoredSelection, {
|
||||
decode: (value) => ({
|
||||
...value,
|
||||
session:
|
||||
value.session ?? Object.fromEntries(Object.entries(value.pick ?? {}).filter(([key]) => key !== WORKSPACE_KEY)),
|
||||
Schema.Struct({
|
||||
session: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
pick: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}).pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((value) => ({
|
||||
session:
|
||||
value.session ??
|
||||
Object.fromEntries(Object.entries(value.pick ?? {}).filter(([key]) => key !== WORKSPACE_KEY)),
|
||||
})),
|
||||
encode: SchemaGetter.transform((value) => value),
|
||||
}),
|
||||
encode: (value) => value,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { flatten, resolveTemplate, translator, type Flatten } from "@solid-primitives/i18n"
|
||||
import { createEffect, createMemo, createResource, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Option, Schema, SchemaGetter } from "effect"
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import {
|
||||
I18nProvider,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type UiPluralCategory,
|
||||
} from "@opencode/ui/context/i18n"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import en from "@/runtime/i18n/en"
|
||||
import { dict } from "@opencode/ui/i18n/en"
|
||||
import {
|
||||
@@ -55,9 +56,14 @@ function cookie(locale: Locale) {
|
||||
|
||||
const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES
|
||||
|
||||
const LocaleSchema = Codec.literals(DESKTOP_NATIVE_LOCALES)
|
||||
const StoredLocaleSchema = Codec.struct({
|
||||
locale: Codec.transform(Codec.string, { decode: normalizeLocale, encode: (locale) => locale }),
|
||||
const LocaleSchema = Schema.Literals(DESKTOP_NATIVE_LOCALES)
|
||||
const StoredLocaleSchema = Schema.Struct({
|
||||
locale: Schema.String.pipe(
|
||||
Schema.decodeTo(LocaleSchema, {
|
||||
decode: SchemaGetter.transform(normalizeLocale),
|
||||
encode: SchemaGetter.transform((locale) => locale),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const INTL = DESKTOP_NATIVE_LOCALE_TAGS
|
||||
@@ -154,11 +160,11 @@ function detectLocale(): Locale {
|
||||
}
|
||||
|
||||
export function normalizeLocale(value: string): Locale {
|
||||
return Codec.decodeOption(LocaleSchema, value) ?? "en"
|
||||
return Option.getOrElse(Schema.decodeUnknownOption(LocaleSchema)(value), () => "en")
|
||||
}
|
||||
|
||||
export const languageSchema = Codec.struct({
|
||||
locale: StoredLocaleSchema.fields.locale,
|
||||
export const languageSchema = Persistence.struct({
|
||||
locale: StoredLocaleSchema.fields.locale,
|
||||
})
|
||||
|
||||
function readStoredLocale() {
|
||||
@@ -166,7 +172,9 @@ function readStoredLocale() {
|
||||
try {
|
||||
const raw = localStorage.getItem("opencode.global.dat:language")
|
||||
if (!raw) return
|
||||
return Codec.decodeOption(Codec.fromJsonString(StoredLocaleSchema), raw)?.locale
|
||||
const next = Schema.decodeUnknownOption(Schema.fromJsonString(StoredLocaleSchema))(raw)
|
||||
if (Option.isNone(next)) return
|
||||
return next.value.locale
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
@@ -288,4 +296,3 @@ export function UiI18nBridge(props: { children?: JSX.Element }) {
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Codec } from "./codec"
|
||||
|
||||
// Type-level checks: these compile only if inference matches what `typeof schema.Type` gave callers.
|
||||
test("struct types infer optional and required fields", () => {
|
||||
const s = Codec.struct({ id: Codec.string, tab: Codec.optional(Codec.string), n: Codec.lenientOptional(Codec.number) })
|
||||
const value: typeof s.Type = { id: "x" }
|
||||
const tab: string | undefined = value.tab
|
||||
const n: number | undefined = value.n
|
||||
const t = Codec.transform(s, { decode: (old) => old.tab ?? old.id, encode: (v) => ({ id: v }) })
|
||||
const out: string | Codec.Invalid = t.decode({ id: "a" })
|
||||
const onlyOptional = Codec.struct({ tab: Codec.optional(Codec.string) })
|
||||
const empty: typeof onlyOptional.Type = {}
|
||||
const maybe: string | undefined = empty.tab
|
||||
const viaTransform = Codec.transform(onlyOptional, { decode: (old) => old.tab, encode: (tab) => ({ tab }) })
|
||||
expect([tab, n, out, maybe, viaTransform.decode({})]).toEqual([undefined, undefined, "a", undefined, undefined])
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Codec } from "./codec"
|
||||
|
||||
describe("Codec", () => {
|
||||
test("primitives reject the wrong shape and finite numbers only", () => {
|
||||
expect(Codec.string.decode("a")).toBe("a")
|
||||
expect(Codec.string.decode(1)).toBe(Codec.INVALID)
|
||||
expect(Codec.number.decode(1.5)).toBe(1.5)
|
||||
expect(Codec.number.decode(Number.NaN)).toBe(Codec.INVALID)
|
||||
expect(Codec.nonNegativeInt.decode(-1)).toBe(Codec.INVALID)
|
||||
expect(Codec.literals(["a", "b"]).decode("c")).toBe(Codec.INVALID)
|
||||
expect(Codec.literal("x").decode("x")).toBe("x")
|
||||
})
|
||||
|
||||
test("struct keeps optional fields absent and rejects invalid required ones", () => {
|
||||
const codec = Codec.struct({ id: Codec.string, title: Codec.optional(Codec.string), n: Codec.lenientOptional(Codec.number) })
|
||||
expect(codec.decode({ id: "1" })).toEqual({ id: "1" })
|
||||
expect(codec.decode({ id: "1", title: "t", n: "bad" })).toEqual({ id: "1", title: "t" })
|
||||
expect(codec.decode({ id: "1", title: 3 })).toBe(Codec.INVALID)
|
||||
expect(codec.decode({ title: "t" })).toBe(Codec.INVALID)
|
||||
expect(codec.decode([])).toBe(Codec.INVALID)
|
||||
expect(codec.encode({ id: "1" })).toEqual({ id: "1" })
|
||||
const value: typeof codec.Type = { id: "1", title: undefined }
|
||||
expect(value.id).toBe("1")
|
||||
})
|
||||
|
||||
test("lenient collections recover what they can", () => {
|
||||
const items = Codec.lenientArray(Codec.struct({ id: Codec.string }))
|
||||
expect(items.decode([{ id: "a" }, { id: 1 }, "x", { id: "b" }])).toEqual([{ id: "a" }, { id: "b" }])
|
||||
expect(items.decode("nope")).toEqual([])
|
||||
expect(Codec.array(Codec.string).decode(["a", 1])).toBe(Codec.INVALID)
|
||||
const map = Codec.lenientRecord(Codec.boolean)
|
||||
expect(map.decode({ a: true, b: "x" })).toEqual({})
|
||||
expect(map.decode({ a: true })).toEqual({ a: true })
|
||||
})
|
||||
|
||||
test("union, transform and fallback compose", () => {
|
||||
const session = Codec.struct({ type: Codec.literal("session"), id: Codec.string })
|
||||
const draft = Codec.struct({ type: Codec.literal("draft"), directory: Codec.string })
|
||||
const tab = Codec.union([session, draft])
|
||||
expect(tab.decode({ type: "draft", directory: "/x" })).toEqual({ type: "draft", directory: "/x" })
|
||||
expect(tab.decode({ type: "other" })).toBe(Codec.INVALID)
|
||||
const upper = Codec.transform(Codec.string, { decode: (s) => s.toUpperCase(), encode: (s) => s.toLowerCase() })
|
||||
expect(upper.decode("ab")).toBe("AB")
|
||||
expect(upper.encode("AB")).toBe("ab")
|
||||
const safe = Codec.fallback(Codec.number, () => 7)
|
||||
expect(safe.decode("x")).toBe(7)
|
||||
expect(safe.decode(undefined)).toBe(7)
|
||||
expect(safe.decode(2)).toBe(2)
|
||||
})
|
||||
|
||||
test("brand constructs and decodes as its base", () => {
|
||||
const Key = Codec.brand<"ServerConnection.Key">()
|
||||
const key = Key.make("http://a")
|
||||
expect(Key.decode(key)).toBe(key)
|
||||
expect(Key.decode(3)).toBe(Codec.INVALID)
|
||||
})
|
||||
|
||||
test("withInitial recovers field by field and merges new defaults", () => {
|
||||
const layout = Codec.struct({
|
||||
sidebar: Codec.struct({ opened: Codec.boolean, width: Codec.number }),
|
||||
theme: Codec.lenientOptional(Codec.literals(["light", "dark"])),
|
||||
})
|
||||
const initial: typeof layout.Type = { sidebar: { opened: true, width: 240 } }
|
||||
const codec = Codec.fromJsonString(Codec.withInitial(layout, initial))
|
||||
expect(codec.decode(JSON.stringify({ sidebar: { opened: false, width: "wide" }, theme: "dark" }))).toEqual({
|
||||
sidebar: { opened: false, width: 240 },
|
||||
theme: "dark",
|
||||
})
|
||||
expect(codec.decode(JSON.stringify({ sidebar: 5 }))).toEqual(initial)
|
||||
expect(codec.decode("{not json")).toBe(Codec.INVALID)
|
||||
expect(JSON.parse(codec.encode({ sidebar: { opened: true, width: 1 } }))).toEqual({ sidebar: { opened: true, width: 1 } })
|
||||
})
|
||||
|
||||
test("migrate reads the old shape first", () => {
|
||||
const current = Codec.struct({ tabs: Codec.array(Codec.string) })
|
||||
const previous = Codec.struct({ tab: Codec.optional(Codec.string) })
|
||||
const read = Codec.transform(previous, {
|
||||
decode: (old) => ({ tabs: old.tab ? [old.tab] : [] }),
|
||||
encode: (value) => ({ tab: value.tabs[0] }),
|
||||
})
|
||||
const codec = Codec.withInitial(Codec.migrate(current, read), { tabs: [] })
|
||||
expect(codec.decode({ tab: "a" })).toEqual({ tabs: ["a"] })
|
||||
})
|
||||
})
|
||||
@@ -1,366 +0,0 @@
|
||||
export * as Codec from "./codec"
|
||||
|
||||
// Plain codecs for persisted state. They replace Effect Schema in the renderer's initial module
|
||||
// graph, where Effect's own module initialisation was the single largest startup cost that was not
|
||||
// rendering. Semantics mirror the Persistence helpers: decoding never throws, `INVALID` marks a
|
||||
// value that cannot be recovered, and the lenient combinators recover what they can.
|
||||
|
||||
export const INVALID: unique symbol = Symbol.for("opencode/persistence/codec/invalid")
|
||||
export type Invalid = typeof INVALID
|
||||
|
||||
const tag: unique symbol = Symbol.for("opencode/persistence/codec")
|
||||
|
||||
export interface Of<T, E = unknown> {
|
||||
readonly [tag]: true
|
||||
/** Phantom: `typeof codec.Type` is the decoded type, as with Effect schemas. */
|
||||
readonly Type: T
|
||||
readonly Encoded: E
|
||||
readonly optional?: boolean
|
||||
decode(input: unknown): T | Invalid
|
||||
encode(value: T): E
|
||||
}
|
||||
|
||||
export type Any = Of<any, any>
|
||||
export type Type<C extends Any> = C["Type"]
|
||||
|
||||
export function isCodec(value: unknown): value is Any {
|
||||
return typeof value === "object" && value !== null && tag in value
|
||||
}
|
||||
|
||||
export function make<T, E = unknown>(decode: (input: unknown) => T | Invalid, encode: (value: T) => E): Of<T, E> {
|
||||
return { [tag]: true, decode, encode } as Of<T, E>
|
||||
}
|
||||
|
||||
export function is<T>(codec: Of<T>, input: unknown): input is T {
|
||||
return codec.decode(input) !== INVALID
|
||||
}
|
||||
|
||||
export function decodeOption<T>(codec: Of<T>, input: unknown): T | undefined {
|
||||
const value = codec.decode(input)
|
||||
return value === INVALID ? undefined : value
|
||||
}
|
||||
|
||||
export function decodeOrThrow<T>(codec: Of<T>, input: unknown): T {
|
||||
const value = codec.decode(input)
|
||||
if (value === INVALID) throw new Error("Value does not match its codec")
|
||||
return value
|
||||
}
|
||||
|
||||
/** Encodes and checks the result decodes, so an invalid in-memory value fails loudly instead of persisting. */
|
||||
export function encodeOrThrow<T, E>(codec: Of<T, E>, value: T): E {
|
||||
const encoded = codec.encode(value)
|
||||
if (codec.decode(encoded) === INVALID) throw new Error("Value does not match its codec")
|
||||
return encoded
|
||||
}
|
||||
|
||||
const identity = <T>(value: T) => value
|
||||
|
||||
export const string: Of<string, string> = make((v) => (typeof v === "string" ? v : INVALID), identity)
|
||||
export const boolean: Of<boolean, boolean> = make((v) => (typeof v === "boolean" ? v : INVALID), identity)
|
||||
export const unknown: Of<unknown, unknown> = make((v) => v, identity)
|
||||
/** Finite numbers only: NaN and infinities are not JSON and never valid state. */
|
||||
export const number: Of<number, number> = make((v) => (typeof v === "number" && Number.isFinite(v) ? v : INVALID), identity)
|
||||
export const int: Of<number, number> = make((v) => (typeof v === "number" && Number.isInteger(v) ? v : INVALID), identity)
|
||||
export const nonNegativeInt: Of<number, number> = make(
|
||||
(v) => (typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : INVALID),
|
||||
identity,
|
||||
)
|
||||
|
||||
export function literal<const L extends string | number | boolean | null>(value: L): Of<L, L> {
|
||||
return make((v) => (v === value ? value : INVALID), identity)
|
||||
}
|
||||
|
||||
export function literals<const L extends ReadonlyArray<string | number | boolean | null>>(values: L): Of<L[number], L[number]> {
|
||||
const set = new Set<unknown>(values)
|
||||
return make((v) => (set.has(v) ? (v as L[number]) : INVALID), identity)
|
||||
}
|
||||
|
||||
/** A string carrying a nominal brand, with the constructor Effect's `Schema.brand` gave callers. */
|
||||
export function brand<B extends string>(): Of<string & { readonly [K in B]: B }, string> & {
|
||||
make(value: string): string & { readonly [K in B]: B }
|
||||
} {
|
||||
return Object.assign(make<string & { readonly [K in B]: B }, string>((v) => (typeof v === "string" ? (v as never) : INVALID), identity), {
|
||||
make: (value: string) => value as never,
|
||||
})
|
||||
}
|
||||
|
||||
export function nullOr<T, E>(codec: Of<T, E>): Of<T | null, E | null> {
|
||||
return make((v) => (v === null ? null : codec.decode(v)), (v) => (v === null ? null : codec.encode(v)))
|
||||
}
|
||||
|
||||
export function undefinedOr<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> {
|
||||
return make((v) => (v === undefined ? undefined : codec.decode(v)), (v) => (v === undefined ? undefined : codec.encode(v)))
|
||||
}
|
||||
|
||||
/** A struct field that may be absent. Present but invalid values make the struct invalid. */
|
||||
export function optional<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> & { readonly optional: true } {
|
||||
return { ...undefinedOr(codec), optional: true } as never
|
||||
}
|
||||
|
||||
/** A struct field that may be absent, and whose invalid values are dropped rather than rejected. */
|
||||
export function lenientOptional<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> & { readonly optional: true } {
|
||||
return {
|
||||
...make<T | undefined, E | undefined>(
|
||||
(v) => {
|
||||
if (v === undefined) return undefined
|
||||
const value = codec.decode(v)
|
||||
return value === INVALID ? undefined : value
|
||||
},
|
||||
(v) => (v === undefined ? undefined : codec.encode(v)),
|
||||
),
|
||||
optional: true,
|
||||
} as never
|
||||
}
|
||||
|
||||
type Fields = Record<string, Any>
|
||||
type OptionalKeys<F extends Fields> = { [K in keyof F]: F[K] extends { optional: true } ? K : never }[keyof F]
|
||||
type RequiredKeys<F extends Fields> = Exclude<keyof F, OptionalKeys<F>>
|
||||
type Simplify<T> = { [K in keyof T]: T[K] } & {}
|
||||
export type StructType<F extends Fields> = Simplify<
|
||||
{ [K in RequiredKeys<F>]: F[K]["Type"] } & { [K in OptionalKeys<F>]?: F[K]["Type"] }
|
||||
>
|
||||
export type StructEncoded<F extends Fields> = Simplify<
|
||||
{ [K in RequiredKeys<F>]: F[K]["Encoded"] } & { [K in OptionalKeys<F>]?: F[K]["Encoded"] }
|
||||
>
|
||||
|
||||
export interface Struct<F extends Fields> extends Of<StructType<F>, StructEncoded<F>> {
|
||||
readonly fields: F
|
||||
}
|
||||
|
||||
// `preserve` keeps keys the struct does not declare, for migration shapes that only describe the
|
||||
// fields they rewrite (Effect's `onExcessProperty: "preserve"`); the current schema then decides.
|
||||
export function struct<const F extends Fields>(fields: F, options?: { preserve?: boolean }): Struct<F> {
|
||||
const entries = Object.entries(fields)
|
||||
return {
|
||||
...make<StructType<F>, StructEncoded<F>>(
|
||||
(input) => {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
|
||||
const record = input as Record<string, unknown>
|
||||
const out: Record<string, unknown> = options?.preserve ? { ...record } : {}
|
||||
for (const [key, codec] of entries) {
|
||||
const present = Object.hasOwn(record, key)
|
||||
if (!present && codec.optional) continue
|
||||
const value = codec.decode(record[key])
|
||||
if (value === INVALID) return INVALID
|
||||
if (value !== undefined || present) out[key] = value
|
||||
else delete out[key]
|
||||
}
|
||||
return out as StructType<F>
|
||||
},
|
||||
(value) => {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [key, codec] of entries) {
|
||||
const field = (value as Record<string, unknown>)[key]
|
||||
if (field === undefined && !Object.hasOwn(value as object, key)) continue
|
||||
out[key] = codec.encode(field)
|
||||
}
|
||||
return out as StructEncoded<F>
|
||||
},
|
||||
),
|
||||
fields,
|
||||
}
|
||||
}
|
||||
|
||||
export function array<T, E>(codec: Of<T, E>): Of<T[], E[]> {
|
||||
return make(
|
||||
(input) => {
|
||||
if (!Array.isArray(input)) return INVALID
|
||||
const out: T[] = []
|
||||
for (const item of input) {
|
||||
const value = codec.decode(item)
|
||||
if (value === INVALID) return INVALID
|
||||
out.push(value)
|
||||
}
|
||||
return out
|
||||
},
|
||||
(value) => value.map((item) => codec.encode(item)),
|
||||
)
|
||||
}
|
||||
|
||||
/** Keeps the items that decode and drops the rest, like `Persistence.array`. */
|
||||
export function lenientArray<T, E>(codec: Of<T, E>): Of<T[], E[]> {
|
||||
return make(
|
||||
(input) => {
|
||||
if (!Array.isArray(input)) return []
|
||||
return input.flatMap((item) => {
|
||||
const value = codec.decode(item)
|
||||
return value === INVALID ? [] : [value]
|
||||
})
|
||||
},
|
||||
(value) => value.map((item) => codec.encode(item)),
|
||||
)
|
||||
}
|
||||
|
||||
export function record<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
|
||||
return make(
|
||||
(input) => {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
|
||||
const out: Record<string, T> = {}
|
||||
for (const [key, item] of Object.entries(input)) {
|
||||
const value = codec.decode(item)
|
||||
if (value === INVALID) return INVALID
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
},
|
||||
(value) => Object.fromEntries(Object.entries(value).map(([key, item]) => [key, codec.encode(item)])),
|
||||
)
|
||||
}
|
||||
|
||||
/** A record that drops entries whose values are invalid, the replacement for `catchDecoding` to none. */
|
||||
export function sparseRecord<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
|
||||
return make(
|
||||
(input) => {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
|
||||
const out: Record<string, T> = {}
|
||||
for (const [key, item] of Object.entries(input)) {
|
||||
const value = codec.decode(item)
|
||||
if (value !== INVALID) out[key] = value
|
||||
}
|
||||
return out
|
||||
},
|
||||
(value) => Object.fromEntries(Object.entries(value).map(([key, item]) => [key, codec.encode(item)])),
|
||||
)
|
||||
}
|
||||
|
||||
/** An invalid record becomes empty rather than failing the whole store, like `Persistence.record`. */
|
||||
export function lenientRecord<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
|
||||
const strict = record(codec)
|
||||
return make(
|
||||
(input) => {
|
||||
const value = strict.decode(input)
|
||||
return value === INVALID ? {} : value
|
||||
},
|
||||
strict.encode,
|
||||
)
|
||||
}
|
||||
|
||||
export function union<const C extends ReadonlyArray<Any>>(codecs: C): Of<C[number]["Type"], C[number]["Encoded"]> {
|
||||
return make(
|
||||
(input) => {
|
||||
for (const codec of codecs) {
|
||||
const value = codec.decode(input)
|
||||
if (value !== INVALID) return value
|
||||
}
|
||||
return INVALID
|
||||
},
|
||||
(value) => {
|
||||
// Encode with the first member that accepts the value's shape; members are disjoint in practice.
|
||||
for (const codec of codecs) if (codec.decode(value) !== INVALID) return codec.encode(value)
|
||||
return value as C[number]["Encoded"]
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Maps a decoded value into another shape, the replacement for `decodeTo` + `SchemaGetter.transform`. */
|
||||
export function transform<T, E, T2>(
|
||||
codec: Of<T, E>,
|
||||
options: { decode: (value: T) => T2; encode: (value: T2) => T },
|
||||
): Of<T2, E> {
|
||||
return make(
|
||||
(input) => {
|
||||
const value = codec.decode(input)
|
||||
return value === INVALID ? INVALID : options.decode(value)
|
||||
},
|
||||
(value) => codec.encode(options.encode(value)),
|
||||
)
|
||||
}
|
||||
|
||||
/** Decodes with `source`, maps, then validates with `target`: Effect's `decodeTo` with a transform. */
|
||||
export function decodeTo<T, E, T2, E2>(
|
||||
source: Of<T, E>,
|
||||
target: Of<T2, E2>,
|
||||
options: { decode: (value: T) => E2; encode: (value: T2) => T },
|
||||
): Of<T2, E> {
|
||||
return make(
|
||||
(input) => {
|
||||
const value = source.decode(input)
|
||||
return value === INVALID ? INVALID : target.decode(options.decode(value))
|
||||
},
|
||||
(value) => source.encode(options.encode(value)),
|
||||
)
|
||||
}
|
||||
|
||||
/** Invalid and missing values become `value()`, like `Persistence.fallback`. */
|
||||
export function fallback<T, E>(codec: Of<T, E>, value: () => NoInfer<T>): Of<T, E> {
|
||||
return make(
|
||||
(input) => {
|
||||
if (input === undefined) return value()
|
||||
const decoded = codec.decode(input)
|
||||
return decoded === INVALID ? value() : decoded
|
||||
},
|
||||
codec.encode,
|
||||
)
|
||||
}
|
||||
|
||||
export function fromJsonString<T, E>(codec: Of<T, E>): Of<T, string> {
|
||||
return make(
|
||||
(input) => {
|
||||
if (typeof input !== "string") return INVALID
|
||||
try {
|
||||
return codec.decode(JSON.parse(input))
|
||||
} catch {
|
||||
return INVALID
|
||||
}
|
||||
},
|
||||
(value) => JSON.stringify(codec.encode(value)),
|
||||
)
|
||||
}
|
||||
|
||||
export type Decoder = Pick<Of<unknown>, "decode">
|
||||
export type Migrated<C extends Any> = { readonly current: C; readonly read: Decoder }
|
||||
|
||||
/** Older stored shapes go through `read` first; `current` describes what the store holds today. */
|
||||
export function migrate<C extends Any>(current: C, read: Decoder): Migrated<C> {
|
||||
return { current, read }
|
||||
}
|
||||
|
||||
function isMigrated<C extends Any>(definition: C | Migrated<C>): definition is Migrated<C> {
|
||||
return !isCodec(definition) && "current" in definition
|
||||
}
|
||||
|
||||
// Stored values recover field by field against the initial value: an object's valid fields are
|
||||
// kept, invalid or missing ones take their initial counterpart, and the result is merged over the
|
||||
// initial so new fields appear with their defaults. Mirrors `Persistence.withInitial`.
|
||||
export function withInitial<C extends Any>(definition: C | Migrated<C>, initial: Type<C>): Of<Type<C>, unknown> {
|
||||
const codec = isMigrated(definition) ? definition.current : definition
|
||||
const read = isMigrated(definition) ? definition.read : unknown
|
||||
return make(
|
||||
(input) => {
|
||||
const stored = read.decode(input)
|
||||
if (stored === INVALID) return INVALID
|
||||
return merge(initial, recover(codec, stored, initial))
|
||||
},
|
||||
(value) => codec.encode(value),
|
||||
)
|
||||
}
|
||||
|
||||
function recover(codec: Any, value: unknown, initial: unknown): unknown {
|
||||
if (value === undefined) return initial
|
||||
if ("fields" in codec && isObject(value)) {
|
||||
const fields = (codec as Struct<Fields>).fields
|
||||
return Object.fromEntries(
|
||||
Object.entries(fields).flatMap(([name, field]) => {
|
||||
const defaults = isObject(initial) ? initial[name] : undefined
|
||||
const next = recover(field, value[name], defaults)
|
||||
if (next === undefined && !Object.hasOwn(value, name) && defaults === undefined) return []
|
||||
return [[name, next]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
const decoded = codec.decode(value)
|
||||
return decoded === INVALID ? initial : decoded
|
||||
}
|
||||
|
||||
function merge(initial: unknown, value: unknown): unknown {
|
||||
if (value === undefined) return initial
|
||||
if (!isObject(initial) || !isObject(value)) return value
|
||||
return Object.fromEntries(
|
||||
[...new Set([...Object.keys(initial), ...Object.keys(value)])].map((key) => [key, merge(initial[key], value[key])]),
|
||||
)
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Schema } from "effect"
|
||||
import { WorkspaceOnboardingSchema, ProviderTipSchema, WorkspaceTipSchema } from "@/new-session/view"
|
||||
import { ModelSelectionSchema } from "@/providers/models/selection"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { FileViewsSchema } from "@/workspaces/files/view-cache"
|
||||
import { languageSchema } from "@/runtime/i18n/language"
|
||||
import { HomeServersSchema } from "@/home/projects/controller"
|
||||
@@ -11,9 +10,9 @@ import { ModelProvidersSchema } from "@/settings/models/models"
|
||||
|
||||
describe("persisted consumer schemas", () => {
|
||||
test("onboarding and provider tip retain defaults and validate stored values", () => {
|
||||
const onboarding = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(WorkspaceOnboardingSchema, { used: false }), input))
|
||||
const tip = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ProviderTipSchema, { dismissedAt: 0 }), input))
|
||||
const workspaceTip = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(WorkspaceTipSchema, { dismissedAt: 0 }), input))
|
||||
const onboarding = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceOnboardingSchema, { used: false }))
|
||||
const tip = Schema.decodeUnknownSync(Persistence.withInitial(ProviderTipSchema, { dismissedAt: 0 }))
|
||||
const workspaceTip = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceTipSchema, { dismissedAt: 0 }))
|
||||
expect(onboarding({})).toEqual({ used: false })
|
||||
expect(onboarding({ used: "true" })).toEqual({ used: false })
|
||||
expect(onboarding({ used: true })).toEqual({ used: true })
|
||||
@@ -26,7 +25,7 @@ describe("persisted consumer schemas", () => {
|
||||
|
||||
test("collapse records recover malformed entries without losing valid siblings", () => {
|
||||
for (const schema of [HomeServersSchema, ModelProvidersSchema]) {
|
||||
const decode = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(schema, { collapsed: {} }), input)
|
||||
const decode = Schema.decodeUnknownSync(Persistence.withInitial(schema, { collapsed: {} }))
|
||||
expect(decode({})).toEqual({ collapsed: {} })
|
||||
expect(decode({ collapsed: [] })).toEqual({ collapsed: {} })
|
||||
expect(decode({ collapsed: { open: false, closed: true, invalid: "false" } })).toEqual({
|
||||
@@ -36,19 +35,21 @@ describe("persisted consumer schemas", () => {
|
||||
})
|
||||
|
||||
test("model selection migrates legacy picks and omits workspace state", () => {
|
||||
const decode = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ModelSelectionSchema, { session: {} }), input)
|
||||
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))
|
||||
expect(decode({})).toEqual({ session: {} })
|
||||
const state = decode({ pick: { __workspace__: { agent: "plan" }, session1: { agent: "build" } } })
|
||||
expect(state.session.session1?.agent).toBe("build")
|
||||
expect(state.session.__workspace__).toBeUndefined()
|
||||
const encoded = Codec.fromJsonString(Codec.withInitial(ModelSelectionSchema, { session: {} })).encode(state)
|
||||
const encoded = Schema.encodeSync(
|
||||
Schema.fromJsonString(Persistence.withInitial(ModelSelectionSchema, { session: {} })),
|
||||
)(state)
|
||||
expect(JSON.parse(encoded)).toEqual({ session: { session1: { agent: "build" } } })
|
||||
expect(decode(JSON.parse(encoded))).toEqual(state)
|
||||
})
|
||||
|
||||
test("current model selections take precedence over legacy picks", () => {
|
||||
expect(
|
||||
Codec.decodeOrThrow(Codec.withInitial(ModelSelectionSchema, { session: {} }), {
|
||||
Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))({
|
||||
session: {},
|
||||
pick: { session1: { agent: "plan" } },
|
||||
}),
|
||||
@@ -56,7 +57,7 @@ describe("persisted consumer schemas", () => {
|
||||
})
|
||||
|
||||
test("model selection validates nested model keys and preserves explicit null variants", () => {
|
||||
const state = Codec.decodeOrThrow(Codec.withInitial(ModelSelectionSchema, { session: {} }), {
|
||||
const state = Schema.decodeUnknownSync(Persistence.withInitial(ModelSelectionSchema, { session: {} }))({
|
||||
session: {
|
||||
good: { agent: "build", model: { providerID: "provider", modelID: "model", variant: "high" }, variant: null },
|
||||
partial: { agent: "plan", model: { providerID: "provider", modelID: 42 }, variant: false },
|
||||
@@ -75,7 +76,7 @@ describe("persisted consumer schemas", () => {
|
||||
})
|
||||
|
||||
test("file views validate scroll positions and line sides independently", () => {
|
||||
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(FileViewsSchema, { file: {} }), input))
|
||||
const decode = Schema.decodeUnknownSync(Persistence.withInitial(FileViewsSchema, { file: {} }))
|
||||
expect(decode({})).toEqual({ file: {} })
|
||||
const state = decode({
|
||||
file: {
|
||||
@@ -102,7 +103,7 @@ describe("persisted consumer schemas", () => {
|
||||
})
|
||||
|
||||
test("language preserves runtime defaults and normalizes unsupported locales to English", () => {
|
||||
const decode = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(languageSchema, { locale: "fr" }), input)
|
||||
const decode = Schema.decodeUnknownSync(Persistence.withInitial(languageSchema, { locale: "fr" }))
|
||||
expect(decode({})).toEqual({ locale: "fr" })
|
||||
expect(decode({ locale: undefined })).toEqual({ locale: "fr" })
|
||||
expect(decode({ locale: 42 })).toEqual({ locale: "fr" })
|
||||
@@ -110,6 +111,3 @@ describe("persisted consumer schemas", () => {
|
||||
expect(decode({ locale: "ar" })).toEqual({ locale: "ar" })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
import { Codec } from "./codec"
|
||||
import { Option, Schema } from "effect"
|
||||
|
||||
export type BlobReference = { id: string; url: string }
|
||||
|
||||
@@ -312,12 +312,12 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
|
||||
getItem: async (key) => {
|
||||
const value = await driver.get(key)
|
||||
if (value === null) return null
|
||||
const parsed = Codec.fromJsonString(Codec.unknown).decode(value)
|
||||
// Let the owning persistence codec apply its invalid-document policy.
|
||||
if (parsed === Codec.INVALID) return value
|
||||
// A loaded document is live in the composer: pin its images before decode mints their URLs.
|
||||
retain(key, imageIDs(parsed), grace)
|
||||
return JSON.stringify(await decode(parsed))
|
||||
const parsed = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))(value)
|
||||
// Let the owning persistence codec apply its invalid-document policy.
|
||||
if (Option.isNone(parsed)) return value
|
||||
// A loaded document is live in the composer: pin its images before decode mints their URLs.
|
||||
retain(key, imageIDs(parsed.value), grace)
|
||||
return JSON.stringify(await decode(parsed.value))
|
||||
},
|
||||
setItem: (key, value) => setDocument(key, JSON.parse(value)),
|
||||
setDocument,
|
||||
@@ -455,4 +455,3 @@ export async function blobDataUrl(blob: BlobReference, mime: string) {
|
||||
export function createLegacyBlobReference(dataUrl: string): BlobReference {
|
||||
return { id: dataUrl, url: dataUrl }
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Option, Schema } from "effect"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
|
||||
import { Codec } from "./codec"
|
||||
import { persistStore } from "./persist"
|
||||
import { Persistence } from "./schema"
|
||||
|
||||
@@ -473,57 +472,25 @@ export function removePersisted(
|
||||
}
|
||||
}
|
||||
|
||||
type Definition<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any> =
|
||||
| S
|
||||
| Persistence.Migrated<Extract<S, Schema.ConstraintCodec<object, unknown>>>
|
||||
| Codec.Migrated<Extract<S, Codec.Any>>
|
||||
|
||||
// Persisted stores are moving from Effect Schema to the plain codecs in ./codec so the renderer
|
||||
// stops paying for Effect at startup; both are accepted while the migration is underway.
|
||||
function serializer<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any>(
|
||||
definition: Definition<S>,
|
||||
initial: S["Type"],
|
||||
) {
|
||||
if (Codec.isCodec(definition) || (!("current" in definition) ? false : Codec.isCodec(definition.current))) {
|
||||
const codec = Codec.withInitial(definition as Codec.Any | Codec.Migrated<Codec.Any>, initial)
|
||||
const json = Codec.fromJsonString(codec)
|
||||
return {
|
||||
decode: (raw: string) => Codec.decodeOption(json, raw) as S["Type"] | undefined,
|
||||
deserialize: (raw: unknown) => Codec.decodeOrThrow(json, raw) as S["Type"],
|
||||
serialize: (value: S["Type"]) => Codec.encodeOrThrow(json, value),
|
||||
encode: (value: S["Type"]) => Codec.encodeOrThrow(codec, value),
|
||||
initial: Codec.decodeOrThrow(codec, codec.encode(initial)) as S["Type"],
|
||||
}
|
||||
}
|
||||
const schema = definition as Schema.ConstraintCodec<object, unknown> | Persistence.Migrated<Schema.ConstraintCodec<object, unknown>>
|
||||
const initialized = Persistence.withInitial(schema, initial as object)
|
||||
const json = Schema.fromJsonString(initialized)
|
||||
const decode = Schema.decodeUnknownOption(json)
|
||||
return {
|
||||
decode: (raw: string) => Option.getOrUndefined(decode(raw)) as S["Type"] | undefined,
|
||||
deserialize: Schema.decodeUnknownSync(json) as (raw: unknown) => S["Type"],
|
||||
serialize: Schema.encodeSync(json) as (value: S["Type"]) => string,
|
||||
encode: Schema.encodeSync(initialized) as (value: S["Type"]) => unknown,
|
||||
initial: Schema.decodeUnknownSync(Schema.toType(initialized))(initial as object) as S["Type"],
|
||||
}
|
||||
}
|
||||
|
||||
export function persisted<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any>(
|
||||
export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
target: string | PersistTarget,
|
||||
schema: Definition<S>,
|
||||
schema: S | Persistence.Migrated<S>,
|
||||
initial: NoInfer<S["Type"]>,
|
||||
platformOverride?: Platform,
|
||||
): PersistedWithReady<S["Type"]> {
|
||||
const platform = platformOverride ?? usePlatform()
|
||||
const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform)
|
||||
|
||||
const codec = serializer<S>(schema, initial)
|
||||
const { encode, serialize } = codec
|
||||
const initialized = Persistence.withInitial(schema, initial)
|
||||
const json = Schema.fromJsonString(initialized)
|
||||
const decode = Schema.decodeUnknownOption(json)
|
||||
const encode = Schema.encodeSync(initialized)
|
||||
const serialize = Schema.encodeSync(json)
|
||||
const normalize = (raw: string) => {
|
||||
const value = codec.decode(raw)
|
||||
if (value !== undefined) return serialize(value)
|
||||
const value = decode(raw)
|
||||
if (Option.isSome(value)) return serialize(value.value)
|
||||
}
|
||||
const store = createStore<S["Type"]>(codec.initial)
|
||||
const store = createStore<S["Type"]>(Schema.decodeUnknownSync(Schema.toType(initialized))(initial))
|
||||
const isDesktop = platform.platform === "desktop" && !!platform.storage
|
||||
const draft = config.draft ? platform.draftStore : undefined
|
||||
const prefix = `${config.storage ?? "default"}:`
|
||||
@@ -635,7 +602,7 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown> | Co
|
||||
name: config.key,
|
||||
storage,
|
||||
serialize,
|
||||
deserialize: codec.deserialize,
|
||||
deserialize: Schema.decodeUnknownSync(json),
|
||||
sync: channel ? messageSync(channel) : undefined,
|
||||
// Drafts take the encoded document itself so large text is externalized without the store
|
||||
// re-parsing the serialized form on every save.
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { Brand } from "effect"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
|
||||
// The server key's brand is shared with the Effect schema in ./persistence.ts (type only, so this
|
||||
// module loads nothing of Effect), letting stores port to plain codecs one at a time.
|
||||
export type ServerKey = string & Brand.Brand<"ServerConnection.Key">
|
||||
|
||||
export const ServerKey: Codec.Of<ServerKey, string> & { make(value: string): ServerKey } = Object.assign(
|
||||
Codec.make<ServerKey, string>((v) => (typeof v === "string" ? (v as ServerKey) : Codec.INVALID), (v) => v),
|
||||
{ make: (value: string) => value as ServerKey },
|
||||
)
|
||||
@@ -1,14 +1,15 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { IconState, ModelState, ProjectState, VcsState, serverState } from "./persistence"
|
||||
import { createRoot } from "solid-js"
|
||||
import { isServer } from "solid-js/web"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
const initial = { list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {} }
|
||||
|
||||
function serverSchema(canonical?: () => string | undefined) {
|
||||
return Codec.withInitial(serverState(canonical), initial)
|
||||
return Persistence.withInitial(serverState(canonical), initial)
|
||||
}
|
||||
|
||||
describe("server persistence schema", () => {
|
||||
@@ -28,7 +29,7 @@ describe("server persistence schema", () => {
|
||||
],
|
||||
projects: { local: [{ worktree: "/project", expanded: true }] },
|
||||
}
|
||||
const state = Codec.decodeOrThrow(schema, input)
|
||||
const state = Schema.decodeUnknownSync(schema)(input)
|
||||
expect(state).toEqual({
|
||||
list: [
|
||||
{ type: "http", http: { url: "http://localhost:4096" } },
|
||||
@@ -47,13 +48,13 @@ describe("server persistence schema", () => {
|
||||
recentlyClosed: {},
|
||||
})
|
||||
expect(input.list[1]).toHaveProperty("username", "legacy")
|
||||
const encoded = schema.encode(state)
|
||||
const encoded = Schema.encodeSync(schema)(state)
|
||||
expect(encoded).toEqual(state)
|
||||
expect(Codec.decodeOrThrow(schema, encoded)).toEqual(state)
|
||||
expect(Schema.decodeUnknownSync(schema)(encoded)).toEqual(state)
|
||||
})
|
||||
|
||||
test("defaults missing or malformed fields and drops invalid entries independently", () => {
|
||||
const decode = ((input: unknown) => Codec.decodeOrThrow(serverSchema(), input))
|
||||
const decode = Schema.decodeUnknownSync(serverSchema())
|
||||
const empty = { list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {} }
|
||||
expect(decode({})).toEqual(empty)
|
||||
expect(decode({ list: null, hidden: [], projects: false, lastProject: 1, recentlyClosed: "bad" })).toEqual(empty)
|
||||
@@ -73,7 +74,7 @@ describe("server persistence schema", () => {
|
||||
|
||||
test("moves canonical project buckets without changing server keys or unrelated scopes", () => {
|
||||
const schema = serverSchema(() => "https://opencode.example.com")
|
||||
const state = Codec.decodeOrThrow(schema, {
|
||||
const state = Schema.decodeUnknownSync(schema)({
|
||||
list: ["https://opencode.example.com"],
|
||||
hidden: { "https://opencode.example.com": true },
|
||||
projects: {
|
||||
@@ -99,14 +100,14 @@ describe("server persistence schema", () => {
|
||||
expect(state.list[0]?.http.url).toBe("https://opencode.example.com")
|
||||
expect(state.hidden).toEqual({ "https://opencode.example.com": true })
|
||||
expect(state.recentlyClosed).toEqual({ local: ["/closed"], "https://opencode.example.com": ["/old-closed"] })
|
||||
expect(schema.encode(state)).toEqual(state)
|
||||
expect(Codec.decodeOrThrow(schema, state)).toEqual(state)
|
||||
expect(Schema.encodeSync(schema)(state)).toEqual(state)
|
||||
expect(Schema.decodeUnknownSync(schema)(state)).toEqual(state)
|
||||
})
|
||||
|
||||
test("reads the latest canonical local prop on each decode", () => {
|
||||
const props: { canonicalLocalServer?: string } = {}
|
||||
const schema = serverSchema(() => props.canonicalLocalServer)
|
||||
const decode = ((input: unknown) => Codec.decodeOrThrow(schema, input))
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
const input = {
|
||||
projects: { remote: [{ worktree: "/project", expanded: true }] },
|
||||
lastProject: { remote: "/project" },
|
||||
@@ -121,7 +122,7 @@ describe("server persistence schema", () => {
|
||||
})
|
||||
|
||||
test("migrates a last project without a project list", () => {
|
||||
expect(Codec.decodeOrThrow(serverSchema(() => "remote"), { lastProject: { remote: "/project" } })).toEqual({
|
||||
expect(Schema.decodeUnknownSync(serverSchema(() => "remote"))({ lastProject: { remote: "/project" } })).toEqual({
|
||||
list: [],
|
||||
hidden: {},
|
||||
projects: {},
|
||||
@@ -133,7 +134,7 @@ describe("server persistence schema", () => {
|
||||
|
||||
describe("model persistence schema", () => {
|
||||
test("defaults missing state and keeps valid entries beside malformed entries", () => {
|
||||
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ModelState, { user: [], recent: [], variant: {} }), input))
|
||||
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ModelState, { user: [], recent: [], variant: {} }))
|
||||
expect(decode({})).toEqual({ user: [], recent: [], variant: {} })
|
||||
expect(decode({ user: null, recent: 1, variant: [] })).toEqual({ user: [], recent: [], variant: {} })
|
||||
const state = decode({
|
||||
@@ -154,24 +155,24 @@ describe("model persistence schema", () => {
|
||||
recent: [{ providerID: "provider", modelID: "model" }],
|
||||
variant: { model: "high" },
|
||||
})
|
||||
expect(ModelState.encode(state)).toEqual(state)
|
||||
expect(Schema.encodeSync(ModelState)(state)).toEqual(state)
|
||||
})
|
||||
})
|
||||
|
||||
describe("directory cache schemas", () => {
|
||||
test("defaults missing and malformed VCS caches but retains optional branch metadata", () => {
|
||||
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(VcsState, { value: undefined }), input))
|
||||
const decode = Schema.decodeUnknownSync(Persistence.withInitial(VcsState, { value: undefined }))
|
||||
expect(decode({})).toEqual({ value: undefined })
|
||||
expect(decode({ value: null })).toEqual({ value: undefined })
|
||||
expect(decode({ value: { branch: 1 } })).toEqual({ value: undefined })
|
||||
expect(decode({ value: { default_branch: "main" } })).toEqual({ value: { default_branch: "main" } })
|
||||
const state = decode({ value: { branch: "feature", default_branch: "main", obsolete: true } })
|
||||
expect(state).toEqual({ value: { branch: "feature", default_branch: "main" } })
|
||||
expect(VcsState.encode(state)).toEqual(state)
|
||||
expect(Schema.encodeSync(VcsState)(state)).toEqual(state)
|
||||
})
|
||||
|
||||
test("validates project name, icon overrides and startup commands", () => {
|
||||
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(ProjectState, { value: undefined }), input))
|
||||
const decode = Schema.decodeUnknownSync(Persistence.withInitial(ProjectState, { value: undefined }))
|
||||
expect(decode({})).toEqual({ value: undefined })
|
||||
expect(decode({ value: [] })).toEqual({ value: undefined })
|
||||
expect(decode({ value: { icon: { override: 1 } } })).toEqual({ value: undefined })
|
||||
@@ -184,7 +185,7 @@ describe("directory cache schemas", () => {
|
||||
commands: { start: "bun dev" },
|
||||
},
|
||||
})
|
||||
expect(ProjectState.encode(state)).toEqual(state)
|
||||
expect(Schema.encodeSync(ProjectState)(state)).toEqual(state)
|
||||
expect(state.value).toEqual({
|
||||
name: "Project",
|
||||
icon: { override: "data:image/png;base64,abc", color: "blue" },
|
||||
@@ -193,12 +194,12 @@ describe("directory cache schemas", () => {
|
||||
})
|
||||
|
||||
test("validates optional icon strings", () => {
|
||||
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(IconState, { value: undefined }), input))
|
||||
const decode = Schema.decodeUnknownSync(Persistence.withInitial(IconState, { value: undefined }))
|
||||
expect(decode({})).toEqual({ value: undefined })
|
||||
expect(decode({ value: 42 })).toEqual({ value: undefined })
|
||||
expect(decode({ value: null })).toEqual({ value: undefined })
|
||||
expect(decode({ value: "" })).toEqual({ value: "" })
|
||||
expect(IconState.encode(decode({ value: "data:image/png;base64,abc" }))).toEqual({
|
||||
expect(Schema.encodeSync(IconState)(decode({ value: "data:image/png;base64,abc" }))).toEqual({
|
||||
value: "data:image/png;base64,abc",
|
||||
})
|
||||
})
|
||||
@@ -254,7 +255,7 @@ test.skipIf(isServer)(
|
||||
const stored = values.get("opencode.global.dat:server")
|
||||
expect(stored).toBeDefined()
|
||||
if (!stored) throw new Error("server state was not written")
|
||||
const decoded = Codec.decodeOrThrow(Codec.fromJsonString(serverSchema()), stored)
|
||||
const decoded = Schema.decodeUnknownSync(Schema.fromJsonString(serverSchema()))(stored)
|
||||
expect(decoded.projects.local).toEqual([{ worktree: "/project", expanded: true }])
|
||||
expect(stored).not.toContain("username")
|
||||
expect(decoded.list).toEqual(root.state[0].list)
|
||||
@@ -263,4 +264,3 @@ test.skipIf(isServer)(
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,127 +1,136 @@
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { ServerKey } from "./key"
|
||||
import { Effect, Option, Schema, SchemaGetter } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
export { ServerKey }
|
||||
export const ServerKey = Schema.String.pipe(Schema.brand("ServerConnection.Key"))
|
||||
|
||||
export const ServerHttpBase = Codec.struct({
|
||||
url: Codec.string,
|
||||
password: Codec.optional(Codec.string),
|
||||
export const ServerHttpBase = Persistence.struct({
|
||||
url: Schema.String,
|
||||
password: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export const ServerHttp = Codec.struct({
|
||||
type: Codec.literal("http"),
|
||||
export const ServerHttp = Persistence.struct({
|
||||
type: Schema.Literal("http"),
|
||||
http: ServerHttpBase,
|
||||
authToken: Codec.optional(Codec.boolean),
|
||||
displayName: Codec.optional(Codec.string),
|
||||
label: Codec.optional(Codec.string),
|
||||
authToken: Schema.optional(Schema.Boolean),
|
||||
displayName: Schema.optional(Schema.String),
|
||||
label: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
// Servers were stored as a URL string, then as the HTTP block alone, before the current shape.
|
||||
const StoredServer = Codec.decodeTo(Codec.union([ServerHttp, ServerHttpBase, Codec.string]), ServerHttp, {
|
||||
decode: (value) => {
|
||||
if (typeof value === "string") return { type: "http" as const, http: { url: value } }
|
||||
if ("http" in value) return value
|
||||
return { type: "http" as const, http: value }
|
||||
},
|
||||
encode: (value) => value,
|
||||
})
|
||||
|
||||
const ProjectList = Codec.lenientArray(
|
||||
Codec.struct({
|
||||
worktree: Codec.string,
|
||||
expanded: Codec.fallback(Codec.boolean, () => true),
|
||||
const StoredServer = Schema.Union([ServerHttp, ServerHttpBase, Schema.String]).pipe(
|
||||
Schema.decodeTo(ServerHttp, {
|
||||
decode: SchemaGetter.transform((value) => {
|
||||
if (typeof value === "string") return { type: "http", http: { url: value } }
|
||||
if ("http" in value) return value
|
||||
return { type: "http", http: value }
|
||||
}),
|
||||
encode: SchemaGetter.transform((value) => value),
|
||||
}),
|
||||
)
|
||||
const Projects = Codec.lenientRecord(ProjectList)
|
||||
const LastProject = Codec.fallback(Codec.sparseRecord(Codec.string), () => ({}))
|
||||
|
||||
const State = Codec.struct({
|
||||
list: Codec.lenientArray(StoredServer),
|
||||
hidden: Codec.sparseRecord(Codec.boolean),
|
||||
projects: Codec.record(ProjectList),
|
||||
lastProject: Codec.sparseRecord(Codec.string),
|
||||
recentlyClosed: Codec.record(Codec.lenientArray(Codec.string)),
|
||||
const ProjectList = Persistence.array(
|
||||
Persistence.struct({
|
||||
worktree: Schema.String,
|
||||
expanded: Persistence.fallback(Schema.Boolean, () => true),
|
||||
}),
|
||||
)
|
||||
const Projects = Persistence.record(ProjectList)
|
||||
const LastProject = Persistence.record(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none()))))
|
||||
|
||||
const State = Persistence.struct({
|
||||
list: Persistence.array(StoredServer),
|
||||
hidden: Schema.Record(
|
||||
Schema.String,
|
||||
Schema.mutableKey(Schema.Boolean.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))),
|
||||
),
|
||||
projects: Schema.Record(Schema.String, Schema.mutableKey(ProjectList)),
|
||||
lastProject: Schema.Record(
|
||||
Schema.String,
|
||||
Schema.mutableKey(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))),
|
||||
),
|
||||
recentlyClosed: Schema.Record(Schema.String, Schema.mutableKey(Persistence.array(Schema.String))),
|
||||
})
|
||||
|
||||
const StoredState = Codec.struct({ projects: Projects, lastProject: LastProject }, { preserve: true })
|
||||
|
||||
// Projects and last-opened entries recorded under the canonical local server's URL move under
|
||||
// "local" when that URL is known, so they survive the server changing address.
|
||||
export function serverState(canonicalLocalServer: () => string | undefined = () => undefined) {
|
||||
return Codec.migrate(
|
||||
return Persistence.migrate(
|
||||
State,
|
||||
Codec.transform(StoredState, {
|
||||
decode: (value) => {
|
||||
const canonical = canonicalLocalServer()
|
||||
if (!canonical || canonical === "local") return value
|
||||
const previous = value.projects[canonical]
|
||||
const last = value.lastProject[canonical]
|
||||
if (!previous && last === undefined) return value
|
||||
Schema.Struct({ projects: Projects, lastProject: LastProject }).pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((value) => {
|
||||
const canonical = canonicalLocalServer()
|
||||
if (!canonical || canonical === "local") return value
|
||||
const previous = value.projects[canonical]
|
||||
const last = value.lastProject[canonical]
|
||||
if (!previous && last === undefined) return value
|
||||
|
||||
const projects = { ...value.projects }
|
||||
if (previous) {
|
||||
const local = projects.local ?? []
|
||||
const worktrees = new Set(local.map((project) => project.worktree))
|
||||
projects.local = [
|
||||
...local,
|
||||
...previous.filter((project) => {
|
||||
if (worktrees.has(project.worktree)) return false
|
||||
worktrees.add(project.worktree)
|
||||
return true
|
||||
}),
|
||||
]
|
||||
delete projects[canonical]
|
||||
}
|
||||
const lastProject = { ...value.lastProject }
|
||||
if (last !== undefined) {
|
||||
lastProject.local ??= last
|
||||
delete lastProject[canonical]
|
||||
}
|
||||
return { ...value, projects, lastProject }
|
||||
},
|
||||
encode: (value) => value,
|
||||
}),
|
||||
const projects = { ...value.projects }
|
||||
if (previous) {
|
||||
const local = projects.local ?? []
|
||||
const worktrees = new Set(local.map((project) => project.worktree))
|
||||
projects.local = [
|
||||
...local,
|
||||
...previous.filter((project) => {
|
||||
if (worktrees.has(project.worktree)) return false
|
||||
worktrees.add(project.worktree)
|
||||
return true
|
||||
}),
|
||||
]
|
||||
delete projects[canonical]
|
||||
}
|
||||
const lastProject = { ...value.lastProject }
|
||||
if (last !== undefined) {
|
||||
lastProject.local ??= last
|
||||
delete lastProject[canonical]
|
||||
}
|
||||
return { ...value, projects, lastProject }
|
||||
}),
|
||||
encode: SchemaGetter.transform((value) => value),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const ModelState = Codec.struct({
|
||||
user: Codec.lenientArray(
|
||||
Codec.struct({
|
||||
providerID: Codec.string,
|
||||
modelID: Codec.string,
|
||||
visibility: Codec.literals(["show", "hide"]),
|
||||
favorite: Codec.optional(Codec.boolean),
|
||||
export const ModelState = Persistence.struct({
|
||||
user: Persistence.array(
|
||||
Persistence.struct({
|
||||
providerID: Schema.String,
|
||||
modelID: Schema.String,
|
||||
visibility: Schema.Literals(["show", "hide"]),
|
||||
favorite: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
recent: Codec.lenientArray(Codec.struct({ providerID: Codec.string, modelID: Codec.string })),
|
||||
variant: Codec.sparseRecord(Codec.undefinedOr(Codec.string)),
|
||||
recent: Persistence.array(Persistence.struct({ providerID: Schema.String, modelID: Schema.String })),
|
||||
variant: Schema.Record(
|
||||
Schema.String,
|
||||
Schema.mutableKey(
|
||||
Schema.UndefinedOr(Schema.String).pipe(Schema.catchDecoding(() => Effect.succeed(Option.none()))),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
export const VcsState = Codec.struct({
|
||||
value: Codec.optional(
|
||||
Codec.struct({
|
||||
branch: Codec.optional(Codec.string),
|
||||
default_branch: Codec.optional(Codec.string),
|
||||
export const VcsState = Persistence.struct({
|
||||
value: Schema.optional(
|
||||
Persistence.struct({
|
||||
branch: Schema.optional(Schema.String),
|
||||
default_branch: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const ProjectMeta = Codec.struct({
|
||||
name: Codec.optional(Codec.string),
|
||||
icon: Codec.optional(
|
||||
Codec.struct({
|
||||
override: Codec.optional(Codec.string),
|
||||
color: Codec.optional(Codec.string),
|
||||
const ProjectMeta = Persistence.struct({
|
||||
name: Schema.optional(Schema.String),
|
||||
icon: Schema.optional(
|
||||
Persistence.struct({
|
||||
override: Schema.optional(Schema.String),
|
||||
color: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
commands: Codec.optional(Codec.struct({ start: Codec.optional(Codec.string) })),
|
||||
commands: Schema.optional(Persistence.struct({ start: Schema.optional(Schema.String) })),
|
||||
})
|
||||
|
||||
export const ProjectState = Codec.struct({
|
||||
value: Codec.optional(ProjectMeta),
|
||||
export const ProjectState = Persistence.struct({
|
||||
value: Schema.optional(ProjectMeta),
|
||||
})
|
||||
|
||||
export const IconState = Codec.struct({
|
||||
value: Codec.optional(Codec.string),
|
||||
export const IconState = Persistence.struct({
|
||||
value: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { canRemoveServer, createServerProjects, resolveServerList, ServerConnection } from "./registry"
|
||||
import { Schema } from "effect"
|
||||
import { serverState } from "./persistence"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ServerScope } from "./scope"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
function serverSchema() {
|
||||
return Codec.withInitial(serverState(), {
|
||||
return Persistence.withInitial(serverState(), {
|
||||
list: [],
|
||||
hidden: {},
|
||||
projects: {},
|
||||
@@ -18,7 +19,7 @@ function serverSchema() {
|
||||
describe("resolveServerList", () => {
|
||||
test("lets startup auth_token credentials override a persisted same-url server", () => {
|
||||
const list = resolveServerList({
|
||||
stored: Codec.decodeOrThrow(serverSchema(), { list: [{ url: "https://server.example.test" }] }).list,
|
||||
stored: Schema.decodeUnknownSync(serverSchema())({ list: [{ url: "https://server.example.test" }] }).list,
|
||||
props: [
|
||||
{
|
||||
type: "http",
|
||||
@@ -43,7 +44,7 @@ describe("resolveServerList", () => {
|
||||
|
||||
test("keeps persisted credentials when startup has no auth_token", () => {
|
||||
const list = resolveServerList({
|
||||
stored: Codec.decodeOrThrow(serverSchema(), {
|
||||
stored: Schema.decodeUnknownSync(serverSchema())({
|
||||
list: [{ url: "https://server.example.test", password: "saved" }],
|
||||
}).list,
|
||||
props: [{ type: "http", http: { url: "https://server.example.test" } }],
|
||||
@@ -76,7 +77,7 @@ test("treats WSL sidecars as remote server connections", () => {
|
||||
})
|
||||
|
||||
test("keeps exact persisted server identities and prevents removing provided servers", () => {
|
||||
const stored = Codec.decodeOrThrow(serverSchema(), {
|
||||
const stored = Schema.decodeUnknownSync(serverSchema())({
|
||||
list: ["http://localhost:4096", "http://localhost:4096/", "http://127.0.0.1:4096"],
|
||||
}).list
|
||||
expect(resolveServerList({ stored }).map((server) => String(ServerConnection.key(server)))).toEqual([
|
||||
@@ -90,7 +91,7 @@ test("keeps exact persisted server identities and prevents removing provided ser
|
||||
})
|
||||
|
||||
test("project actions update schema-derived state and follow dynamic server scopes", () => {
|
||||
const [store, setStore] = createStore(Codec.decodeOrThrow(serverSchema(), {}))
|
||||
const [store, setStore] = createStore(Schema.decodeUnknownSync(serverSchema())({}))
|
||||
const props: { server: ServerConnection.Key; canonicalLocalServer?: ServerConnection.Key } = {
|
||||
server: ServerConnection.Key.make("https://remote.example"),
|
||||
}
|
||||
@@ -114,4 +115,3 @@ test("project actions update schema-derived state and follow dynamic server scop
|
||||
expect(store.projects.local).toEqual([{ worktree: "/local", expanded: true }])
|
||||
expect(store.projects[props.server]).toEqual([{ worktree: "/remote", expanded: false }])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Option, Schema } from "effect"
|
||||
import { normalizeServerUrl } from "@/runtime/server/registry"
|
||||
|
||||
const pairing = Codec.fromJsonString(
|
||||
Codec.struct({
|
||||
urls: Codec.array(Codec.string),
|
||||
username: Codec.literal("opencode"),
|
||||
password: Codec.string,
|
||||
}),
|
||||
const pairing = Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
urls: Schema.Array(Schema.String),
|
||||
username: Schema.Literal("opencode"),
|
||||
password: Schema.String,
|
||||
}),
|
||||
)
|
||||
|
||||
export function serverAddress(value: string) {
|
||||
@@ -20,10 +20,9 @@ export function serverAddress(value: string) {
|
||||
}
|
||||
|
||||
export function decodePairingCode(value: string) {
|
||||
const result = Codec.decodeOption(pairing, value)
|
||||
if (!result) return
|
||||
const urls = [...new Set(result.urls.map(serverAddress).filter((url) => url !== undefined))]
|
||||
if (!urls.length) return
|
||||
return { urls, password: result.password }
|
||||
const result = Schema.decodeUnknownOption(pairing)(value)
|
||||
if (Option.isNone(result)) return
|
||||
const urls = [...new Set(result.value.urls.map(serverAddress).filter((url) => url !== undefined))]
|
||||
if (!urls.length) return
|
||||
return { urls, password: result.value.password }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Effect, Fiber } from "effect"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { SshConfig, SshItem, SshPlatform } from "./types"
|
||||
@@ -23,8 +24,7 @@ export function createSshController(input: {
|
||||
| undefined
|
||||
>
|
||||
>({})
|
||||
// One in-flight request per server; cancelling settles it immediately and ignores its outcome.
|
||||
const tasks = new Map<string, { cancelled: boolean }>()
|
||||
const tasks = new Map<string, Fiber.Fiber<void>>()
|
||||
const item = (id: string) => input.items().find((item) => item.config.id === id)
|
||||
const settle = (id: string) => {
|
||||
const attempt = attempts[id]
|
||||
@@ -33,32 +33,31 @@ export function createSshController(input: {
|
||||
setAttempts(id, { active: false, onConnected: undefined })
|
||||
if (item(id)?.stage === "ready" && onConnected) queueMicrotask(onConnected)
|
||||
}
|
||||
const finish = (id: string, task: { cancelled: boolean }) => {
|
||||
if (task.cancelled) return
|
||||
task.cancelled = true
|
||||
if (tasks.get(id) === task) tasks.delete(id)
|
||||
setAttempts(id, "submitting", false)
|
||||
}
|
||||
const interrupt = (id: string) => {
|
||||
const task = tasks.get(id)
|
||||
if (task) finish(id, task)
|
||||
}
|
||||
const run = (id: string, work: () => Promise<unknown>) => {
|
||||
setAttempts(id, { submitting: true, error: false })
|
||||
const task = { cancelled: false }
|
||||
tasks.set(id, task)
|
||||
work().then(
|
||||
() => finish(id, task),
|
||||
() => {
|
||||
if (task.cancelled) return
|
||||
setAttempts(id, "error", true)
|
||||
if (!attempts[id]?.prompted) input.error()
|
||||
finish(id, task)
|
||||
},
|
||||
)
|
||||
}
|
||||
onCleanup(() => {
|
||||
for (const id of [...tasks.keys()]) interrupt(id)
|
||||
const run = (id: string, effect: Effect.Effect<unknown, unknown>) => {
|
||||
setAttempts(id, { submitting: true, error: false })
|
||||
tasks.set(
|
||||
id,
|
||||
Effect.runFork(
|
||||
effect.pipe(
|
||||
Effect.asVoid,
|
||||
Effect.catch(() =>
|
||||
Effect.sync(() => {
|
||||
setAttempts(id, "error", true)
|
||||
if (!attempts[id]?.prompted) input.error()
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
tasks.delete(id)
|
||||
setAttempts(id, "submitting", false)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
onCleanup(() => {
|
||||
Effect.runFork(Effect.forEach([...tasks.values()], Fiber.interrupt, { discard: true }))
|
||||
})
|
||||
createEffect(() => {
|
||||
for (const item of input.items()) {
|
||||
@@ -115,32 +114,40 @@ export function createSshController(input: {
|
||||
error: false,
|
||||
onConnected: options?.onConnected ?? (options?.replace ? attempts[config.id]?.onConnected : undefined),
|
||||
})
|
||||
run(config.id, async () => {
|
||||
await api.start({ ...config, replace: options?.replace })
|
||||
// Observe admission before treating an older disconnected snapshot as cancellation.
|
||||
await input.refresh()
|
||||
})
|
||||
run(
|
||||
config.id,
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.tryPromise(() => api.start({ ...config, replace: options?.replace }))
|
||||
// Observe admission before treating an older disconnected snapshot as cancellation.
|
||||
yield* Effect.tryPromise(input.refresh)
|
||||
}),
|
||||
)
|
||||
},
|
||||
respond: (id: string, prompt: string, value: string) => {
|
||||
const api = input.api
|
||||
if (!api || item(id)?.prompt?.id !== prompt || attempts[id]?.submitting) return
|
||||
if (attempts[id]?.answered === prompt && !attempts[id]?.error) return
|
||||
setAttempts(id, "answered", prompt)
|
||||
run(id, () => api.respond(id, prompt, value))
|
||||
run(
|
||||
id,
|
||||
Effect.tryPromise(() => api.respond(id, prompt, value)),
|
||||
)
|
||||
},
|
||||
cancel: (id: string) => {
|
||||
const api = input.api
|
||||
interrupt(id)
|
||||
setAttempts(id, undefined)
|
||||
if (!api) return
|
||||
void api
|
||||
.cancel(id)
|
||||
.then(() => (item(id)?.saved ? undefined : api.forget(id)))
|
||||
.catch(() => undefined)
|
||||
cancel: (id: string) => {
|
||||
const task = tasks.get(id)
|
||||
const api = input.api
|
||||
Effect.runFork(
|
||||
Effect.gen(function* () {
|
||||
if (task) yield* Fiber.interrupt(task)
|
||||
setAttempts(id, undefined)
|
||||
if (!api) return
|
||||
yield* Effect.tryPromise(() => api.cancel(id))
|
||||
if (!item(id)?.saved) yield* Effect.tryPromise(() => api.forget(id))
|
||||
}).pipe(Effect.ignore),
|
||||
)
|
||||
},
|
||||
restore: (config: SshConfig) => input.api?.start({ ...config, background: true }),
|
||||
disconnect: (id: string) => input.api?.disconnect(id),
|
||||
forget: (id: string) => input.api?.forget(id),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
// The SSH state contract between the desktop main process and its renderer. Only the main process
|
||||
// needs the schemas (its RPC server validates with them); the renderer imports the types from ./types.
|
||||
|
||||
export const SshConfig = Schema.Struct({ id: Schema.String, target: Schema.String, name: Schema.String })
|
||||
export type SshConfig = typeof SshConfig.Type
|
||||
export const SshHttp = Schema.Struct({ url: Schema.String, password: Schema.String })
|
||||
export type SshHttp = typeof SshHttp.Type
|
||||
export const SshStage = Schema.Literals([
|
||||
"disconnected",
|
||||
"connecting",
|
||||
"checking",
|
||||
"downloading",
|
||||
"uploading",
|
||||
"starting",
|
||||
"ready",
|
||||
"authentication",
|
||||
"incompatible",
|
||||
"failed",
|
||||
])
|
||||
export const SshPrompt = Schema.Struct({
|
||||
id: Schema.String,
|
||||
text: Schema.String,
|
||||
confirm: Schema.Boolean,
|
||||
})
|
||||
export const SshItem = Schema.Struct({
|
||||
config: SshConfig,
|
||||
saved: Schema.Boolean,
|
||||
destination: Schema.optional(Schema.String),
|
||||
stage: SshStage,
|
||||
http: Schema.optional(SshHttp),
|
||||
prompt: Schema.optional(SshPrompt),
|
||||
authenticatingElsewhere: Schema.optional(Schema.Boolean),
|
||||
detail: Schema.String,
|
||||
error: Schema.optional(
|
||||
Schema.Literals([
|
||||
"connection",
|
||||
"input",
|
||||
"platform",
|
||||
"version",
|
||||
"install",
|
||||
"service",
|
||||
"host-key",
|
||||
"ssh-missing",
|
||||
"unpublished",
|
||||
]),
|
||||
),
|
||||
})
|
||||
export type SshItem = typeof SshItem.Type
|
||||
export const SshState = Schema.Struct({ servers: Schema.Array(SshItem) })
|
||||
export type SshState = typeof SshState.Type
|
||||
export const SshStart = Schema.Struct({
|
||||
id: Schema.String,
|
||||
target: Schema.String,
|
||||
name: Schema.String,
|
||||
replace: Schema.optional(Schema.Boolean),
|
||||
background: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
export type SshStart = typeof SshStart.Type
|
||||
|
||||
@@ -1,9 +1,63 @@
|
||||
export { sshHostname, sshName } from "./name"
|
||||
export { isSshConnecting } from "./status"
|
||||
import type { SshConfig, SshHttp, SshItem, SshStart, SshState } from "./schema"
|
||||
|
||||
export type { SshConfig, SshHttp, SshItem, SshStart, SshState }
|
||||
|
||||
import { Schema } from "effect"
|
||||
export { sshHostname, sshName } from "./name"
|
||||
export { isSshConnecting } from "./status"
|
||||
|
||||
export const SshConfig = Schema.Struct({ id: Schema.String, target: Schema.String, name: Schema.String })
|
||||
export type SshConfig = typeof SshConfig.Type
|
||||
export const SshHttp = Schema.Struct({ url: Schema.String, password: Schema.String })
|
||||
export type SshHttp = typeof SshHttp.Type
|
||||
export const SshStage = Schema.Literals([
|
||||
"disconnected",
|
||||
"connecting",
|
||||
"checking",
|
||||
"downloading",
|
||||
"uploading",
|
||||
"starting",
|
||||
"ready",
|
||||
"authentication",
|
||||
"incompatible",
|
||||
"failed",
|
||||
])
|
||||
export const SshPrompt = Schema.Struct({
|
||||
id: Schema.String,
|
||||
text: Schema.String,
|
||||
confirm: Schema.Boolean,
|
||||
})
|
||||
export const SshItem = Schema.Struct({
|
||||
config: SshConfig,
|
||||
saved: Schema.Boolean,
|
||||
destination: Schema.optional(Schema.String),
|
||||
stage: SshStage,
|
||||
http: Schema.optional(SshHttp),
|
||||
prompt: Schema.optional(SshPrompt),
|
||||
authenticatingElsewhere: Schema.optional(Schema.Boolean),
|
||||
detail: Schema.String,
|
||||
error: Schema.optional(
|
||||
Schema.Literals([
|
||||
"connection",
|
||||
"input",
|
||||
"platform",
|
||||
"version",
|
||||
"install",
|
||||
"service",
|
||||
"host-key",
|
||||
"ssh-missing",
|
||||
"unpublished",
|
||||
]),
|
||||
),
|
||||
})
|
||||
export type SshItem = typeof SshItem.Type
|
||||
export const SshState = Schema.Struct({ servers: Schema.Array(SshItem) })
|
||||
export type SshState = typeof SshState.Type
|
||||
export const SshStart = Schema.Struct({
|
||||
id: Schema.String,
|
||||
target: Schema.String,
|
||||
name: Schema.String,
|
||||
replace: Schema.optional(Schema.Boolean),
|
||||
background: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
export type SshStart = typeof SshStart.Type
|
||||
|
||||
export type SshPlatform = {
|
||||
getState(): Promise<SshState>
|
||||
subscribe(callback: (state: SshState) => void): () => void
|
||||
@@ -16,5 +70,3 @@ export type SshPlatform = {
|
||||
forget(id: string): Promise<void>
|
||||
openConfig(): Promise<void>
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { OPEN_APPS, OpenAppPreferences } from "./open-in-app"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(OpenAppPreferences, { app: "finder" }), input))
|
||||
const decode = Schema.decodeUnknownSync(Persistence.withInitial(OpenAppPreferences, { app: "finder" }))
|
||||
|
||||
describe("open app preferences", () => {
|
||||
test.each([...OPEN_APPS])("preserves the %s preference", (app) => {
|
||||
@@ -17,4 +18,3 @@ describe("open app preferences", () => {
|
||||
expect(decode({})).toEqual({ app: "finder" })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { fileManagerApp } from "@/home/projects/file-manager"
|
||||
import { openInAppParentPath } from "@/session/files/open-in-app-path"
|
||||
|
||||
@@ -29,8 +30,8 @@ export const OPEN_APPS = [
|
||||
export type OpenApp = (typeof OPEN_APPS)[number]
|
||||
export type OpenAppOS = "macos" | "windows" | "linux" | "unknown"
|
||||
|
||||
export const OpenAppPreferences = Codec.struct({
|
||||
app: Codec.literals(OPEN_APPS),
|
||||
export const OpenAppPreferences = Persistence.struct({
|
||||
app: Schema.Literals(OPEN_APPS),
|
||||
})
|
||||
|
||||
const appExistence = new Map<string, Promise<boolean>>()
|
||||
@@ -241,4 +242,3 @@ function checkAppExists(platform: ReturnType<typeof usePlatform>, app: string) {
|
||||
appExistence.set(app, request)
|
||||
return request
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
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 "—"
|
||||
@@ -20,7 +14,7 @@ export function createSessionContextFormatter(locale: string) {
|
||||
},
|
||||
time(value: number | undefined) {
|
||||
if (!value) return "—"
|
||||
return dateTime.format(value)
|
||||
return DateTime.fromMillis(value).setLocale(locale).toLocaleString(DateTime.DATETIME_MED)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,20 +5,17 @@ import {
|
||||
type SessionReviewExpandMode,
|
||||
} from "@opencode/session-ui/v2/session-review-v2"
|
||||
import { createSignal } from "solid-js"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Schema } from "effect"
|
||||
import type { Platform } from "@/runtime/platform/platform"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
const ReviewPanel = Codec.struct({
|
||||
sidebarOpened: Codec.boolean,
|
||||
sidebarWidth: Codec.make<number, number>(
|
||||
(v) =>
|
||||
typeof v === "number" && v >= SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN && v <= SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX
|
||||
? v
|
||||
: Codec.INVALID,
|
||||
(v) => v,
|
||||
),
|
||||
expandMode: Codec.literals(["expand", "collapse"]),
|
||||
const ReviewPanel = Persistence.struct({
|
||||
sidebarOpened: Schema.Boolean,
|
||||
sidebarWidth: Schema.Finite.check(
|
||||
Schema.isBetween({ minimum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, maximum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX }),
|
||||
),
|
||||
expandMode: Schema.Literals(["expand", "collapse"]),
|
||||
})
|
||||
|
||||
export function createReviewPanelState(platform?: Platform) {
|
||||
@@ -50,4 +47,3 @@ export function createReviewPanelState(platform?: Platform) {
|
||||
}
|
||||
|
||||
export type ReviewPanelState = ReturnType<typeof createReviewPanelState>
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { Persist } from "@/runtime/persistence/storage"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import type { Platform } from "@/runtime/platform/platform"
|
||||
import { Schema } from "effect"
|
||||
|
||||
let getWorkspaceTerminalCacheKey: typeof import("./context").getWorkspaceTerminalCacheKey
|
||||
let clearWorkspaceTerminals: typeof import("./context").clearWorkspaceTerminals
|
||||
@@ -20,10 +21,10 @@ beforeAll(async () => {
|
||||
const mod = await import("./context")
|
||||
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
|
||||
clearWorkspaceTerminals = mod.clearWorkspaceTerminals
|
||||
const schema = Codec.withInitial(mod.TerminalState, { all: [] })
|
||||
decodeTerminalState = ((input: unknown) => Codec.decodeOrThrow(schema, input))
|
||||
const schema = Persistence.withInitial(mod.TerminalState, { all: [] })
|
||||
decodeTerminalState = Schema.decodeUnknownSync(schema)
|
||||
roundTripTerminalState = (value) =>
|
||||
Codec.decodeOrThrow(schema, schema.encode(Codec.decodeOrThrow(schema, value)))
|
||||
Schema.decodeUnknownSync(schema)(Schema.encodeSync(schema)(Schema.decodeUnknownSync(schema)(value)))
|
||||
})
|
||||
|
||||
describe("getWorkspaceTerminalCacheKey", () => {
|
||||
@@ -140,4 +141,3 @@ describe("TerminalState", () => {
|
||||
expect(roundTripTerminalState(value)).toEqual(value)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,20 +8,18 @@ import { base64Encode } from "@opencode/util/encode"
|
||||
import { defaultTitle, titleNumber } from "./title"
|
||||
import { Persist, persisted, removePersisted } from "@/runtime/persistence/storage"
|
||||
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
|
||||
const PTY = Codec.struct({
|
||||
id: Codec.make<string, string>(
|
||||
(v) => (typeof v === "string" && v.length > 0 ? v : Codec.INVALID),
|
||||
(v) => v,
|
||||
),
|
||||
title: Codec.fallback(Codec.string, () => ""),
|
||||
titleNumber: Codec.fallback(Codec.number, () => 0),
|
||||
rows: Codec.lenientOptional(Codec.number),
|
||||
cols: Codec.lenientOptional(Codec.number),
|
||||
buffer: Codec.lenientOptional(Codec.string),
|
||||
scrollY: Codec.lenientOptional(Codec.number),
|
||||
cursor: Codec.lenientOptional(Codec.number),
|
||||
const PTY = Persistence.struct({
|
||||
id: Schema.NonEmptyString,
|
||||
title: Persistence.fallback(Schema.String, () => ""),
|
||||
titleNumber: Persistence.fallback(Schema.Finite, () => 0),
|
||||
rows: Persistence.optional(Schema.Finite),
|
||||
cols: Persistence.optional(Schema.Finite),
|
||||
buffer: Persistence.optional(Schema.String),
|
||||
scrollY: Persistence.optional(Schema.Finite),
|
||||
cursor: Persistence.optional(Schema.Finite),
|
||||
})
|
||||
|
||||
export type LocalPTY = typeof PTY.Type
|
||||
@@ -33,26 +31,28 @@ function numberFromTitle(title: string) {
|
||||
return titleNumber(title, MAX_TERMINAL_SESSIONS)
|
||||
}
|
||||
|
||||
const State = Codec.struct({
|
||||
active: Codec.lenientOptional(Codec.string),
|
||||
all: Codec.lenientArray(PTY),
|
||||
const State = Persistence.struct({
|
||||
active: Persistence.optional(Schema.String),
|
||||
all: Persistence.array(PTY),
|
||||
})
|
||||
|
||||
export const TerminalState = Codec.transform(State, {
|
||||
decode: (value): typeof State.Type => {
|
||||
const seen = new Set<string>()
|
||||
const all = value.all.flatMap((pty) => {
|
||||
if (seen.has(pty.id)) return []
|
||||
seen.add(pty.id)
|
||||
return [{ ...pty, titleNumber: pty.titleNumber > 0 ? pty.titleNumber : (numberFromTitle(pty.title) ?? 0) }]
|
||||
})
|
||||
return {
|
||||
active: value.active && seen.has(value.active) ? value.active : all[0]?.id,
|
||||
all,
|
||||
}
|
||||
},
|
||||
encode: (value) => value,
|
||||
})
|
||||
export const TerminalState = State.pipe(
|
||||
Schema.decodeTo(Schema.toType(State), {
|
||||
decode: SchemaGetter.transform((value) => {
|
||||
const seen = new Set<string>()
|
||||
const all = value.all.flatMap((pty) => {
|
||||
if (seen.has(pty.id)) return []
|
||||
seen.add(pty.id)
|
||||
return [{ ...pty, titleNumber: pty.titleNumber > 0 ? pty.titleNumber : (numberFromTitle(pty.title) ?? 0) }]
|
||||
})
|
||||
return {
|
||||
active: value.active && seen.has(value.active) ? value.active : all[0]?.id,
|
||||
all,
|
||||
}
|
||||
}),
|
||||
encode: SchemaGetter.transform((value) => value),
|
||||
}),
|
||||
)
|
||||
|
||||
export function getWorkspaceTerminalCacheKey(dir: string, scope: ServerScope = ServerScope.local) {
|
||||
return ScopedKey.from(scope, dir, WORKSPACE_KEY)
|
||||
@@ -458,5 +458,3 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { GoUpsellState } from "./usage-exceeded-dialogs"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
|
||||
const schema = Codec.withInitial(GoUpsellState, {
|
||||
go_upsell_last_seen_at: null,
|
||||
go_upsell_dont_show: null,
|
||||
go_upsell_account_rate_limit_last_seen_at: null,
|
||||
go_upsell_account_rate_limit_dont_show: null,
|
||||
})
|
||||
const decode = (input: unknown) => Codec.decodeOrThrow(schema, input)
|
||||
|
||||
describe("usage exceeded preferences", () => {
|
||||
test("defaults unseen prompts", () => {
|
||||
expect(decode({})).toEqual({
|
||||
go_upsell_last_seen_at: null,
|
||||
go_upsell_dont_show: null,
|
||||
go_upsell_account_rate_limit_last_seen_at: null,
|
||||
go_upsell_account_rate_limit_dont_show: null,
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves timestamps while recovering malformed siblings", () => {
|
||||
expect(
|
||||
decode({
|
||||
go_upsell_last_seen_at: 123,
|
||||
go_upsell_dont_show: "true",
|
||||
go_upsell_account_rate_limit_last_seen_at: Infinity,
|
||||
go_upsell_account_rate_limit_dont_show: 456,
|
||||
}),
|
||||
).toEqual({
|
||||
go_upsell_last_seen_at: 123,
|
||||
go_upsell_dont_show: null,
|
||||
go_upsell_account_rate_limit_last_seen_at: null,
|
||||
go_upsell_account_rate_limit_dont_show: 456,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { GoUpsellState } from "./usage-exceeded-dialogs"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
const decode = Schema.decodeUnknownSync(
|
||||
Persistence.withInitial(GoUpsellState, {
|
||||
go_upsell_last_seen_at: null,
|
||||
go_upsell_dont_show: null,
|
||||
go_upsell_account_rate_limit_last_seen_at: null,
|
||||
go_upsell_account_rate_limit_dont_show: null,
|
||||
}),
|
||||
)
|
||||
|
||||
describe("usage exceeded preferences", () => {
|
||||
test("defaults unseen prompts", () => {
|
||||
expect(decode({})).toEqual({
|
||||
go_upsell_last_seen_at: null,
|
||||
go_upsell_dont_show: null,
|
||||
go_upsell_account_rate_limit_last_seen_at: null,
|
||||
go_upsell_account_rate_limit_dont_show: null,
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves timestamps while recovering malformed siblings", () => {
|
||||
expect(
|
||||
decode({
|
||||
go_upsell_last_seen_at: 123,
|
||||
go_upsell_dont_show: "true",
|
||||
go_upsell_account_rate_limit_last_seen_at: Infinity,
|
||||
go_upsell_account_rate_limit_dont_show: 456,
|
||||
}),
|
||||
).toEqual({
|
||||
go_upsell_last_seen_at: 123,
|
||||
go_upsell_dont_show: null,
|
||||
go_upsell_account_rate_limit_last_seen_at: null,
|
||||
go_upsell_account_rate_limit_dont_show: 456,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import type { SessionStatus } from "@opencode/client/promise"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { useSessionLayout } from "./session-layout"
|
||||
import { useDialog, useI18n } from "@opencode/ui/context"
|
||||
import { DialogUsageExceeded } from "@/providers/connect/usage-exceeded"
|
||||
@@ -14,11 +15,11 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_don
|
||||
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
|
||||
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
|
||||
|
||||
export const GoUpsellState = Codec.struct({
|
||||
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: Codec.nullOr(Codec.number),
|
||||
[GO_UPSELL_FREE_TIER_DONT_SHOW]: Codec.nullOr(Codec.number),
|
||||
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: Codec.nullOr(Codec.number),
|
||||
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: Codec.nullOr(Codec.number),
|
||||
export const GoUpsellState = Persistence.struct({
|
||||
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: Schema.NullOr(Schema.Finite),
|
||||
[GO_UPSELL_FREE_TIER_DONT_SHOW]: Schema.NullOr(Schema.Finite),
|
||||
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: Schema.NullOr(Schema.Finite),
|
||||
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: Schema.NullOr(Schema.Finite),
|
||||
})
|
||||
|
||||
function goUpsellKeys(status: SessionStatus) {
|
||||
@@ -105,4 +106,3 @@ export function useUsageExceededDialogs() {
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, lazy, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
@@ -18,6 +17,8 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
|
||||
const Icon = lazy(() => import("@opencode/ui/icon").then((module) => ({ default: module.Icon })))
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
const PALETTE_ID = "command.palette"
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import {
|
||||
settingsSchema,
|
||||
settingsPersistence,
|
||||
@@ -12,9 +13,9 @@ import {
|
||||
terminalFontFamily,
|
||||
} from "./model"
|
||||
|
||||
const schema = Codec.withInitial(settingsPersistence, defaultSettings)
|
||||
const decode = (input: unknown) => Codec.decodeOrThrow(schema, input)
|
||||
const encode = (value: typeof settingsSchema.Type) => schema.encode(value)
|
||||
const schema = Persistence.withInitial(settingsPersistence, defaultSettings)
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
const encode = Schema.encodeSync(schema)
|
||||
|
||||
describe("settings timeline detail migration", () => {
|
||||
test("migrates saved switches and round trips the current settings", () => {
|
||||
@@ -50,14 +51,14 @@ describe("settings schema", () => {
|
||||
general: { ...defaultSettings.general, timelineDetail: timelinePresets[4].value, autoSave: false },
|
||||
appearance: { ...defaultSettings.appearance, fontSize: 20 },
|
||||
}
|
||||
const restore = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(settingsPersistence, initial), input)
|
||||
const restore = Schema.decodeUnknownSync(Persistence.withInitial(settingsPersistence, initial))
|
||||
expect(restore({})).toEqual(initial)
|
||||
expect(restore({ general: { reasoningMode: "invalid", showReasoningSummaries: true } })).toEqual(initial)
|
||||
expect(restore({ general: { showReasoningSummaries: true } }).general.timelineDetail.thinking).toEqual({
|
||||
placement: "separate",
|
||||
details: "expanded",
|
||||
})
|
||||
expect(() => Codec.decodeOrThrow(settingsSchema, {})).toThrow()
|
||||
expect(() => Schema.decodeUnknownSync(settingsSchema)({})).toThrow()
|
||||
})
|
||||
|
||||
test("supplies the existing defaults for an empty document", () => {
|
||||
@@ -170,7 +171,7 @@ describe("settings schema", () => {
|
||||
|
||||
test("does not silently repair invalid values during encoding", () => {
|
||||
expect(() =>
|
||||
Codec.encodeOrThrow(settingsSchema, { ...decode({}), appearance: { fontSize: "large" } } as never),
|
||||
Schema.encodeUnknownSync(settingsSchema)({ ...decode({}), appearance: { fontSize: "large" } }),
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -202,6 +203,3 @@ describe("settings font families", () => {
|
||||
expect(terminalFontFamily(undefined)).toStartWith('"JetBrainsMono Nerd Font Mono", ')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
+511
-507
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,8 @@ import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { type Component, createEffect, For, on, onCleanup, Show } from "solid-js"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useModels } from "@/providers/models/models"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
@@ -19,8 +20,8 @@ type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
||||
|
||||
const PROVIDER_ICON_SIZE = 16
|
||||
|
||||
export const ModelProvidersSchema = Codec.struct({
|
||||
collapsed: Codec.lenientRecord(Codec.fallback(Codec.boolean, () => false)),
|
||||
export const ModelProvidersSchema = Schema.Struct({
|
||||
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
|
||||
})
|
||||
|
||||
export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }> = (props) => {
|
||||
@@ -210,4 +211,3 @@ export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Schema } from "effect"
|
||||
import {
|
||||
activeCommandRegistrations,
|
||||
addCommandRegistration,
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
} from "./command"
|
||||
|
||||
test("command catalog persistence validates metadata and omits executable fields", () => {
|
||||
const decode = ((input: unknown) => Codec.decodeOrThrow(CommandCatalog, input))
|
||||
const decode = Schema.decodeUnknownSync(CommandCatalog)
|
||||
const catalog = decode({ open: { title: "Open", keybind: "mod+o", hidden: false, onSelect: "invalid" } })
|
||||
expect(catalog).toEqual({ open: { title: "Open", keybind: "mod+o", hidden: false } })
|
||||
expect(decode({})).toEqual({})
|
||||
expect(() => decode({ open: { title: 1 } })).toThrow()
|
||||
expect(decode(CommandCatalog.encode(catalog))).toEqual(catalog)
|
||||
expect(decode(Schema.encodeSync(CommandCatalog)(catalog))).toEqual(catalog)
|
||||
})
|
||||
|
||||
const paletteOptions: CommandOption[] = [
|
||||
@@ -79,4 +79,3 @@ describe("resolveKeybindOption", () => {
|
||||
expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(fallback)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { type Accessor, batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
@@ -109,16 +109,16 @@ export function resolveKeybindOption(candidates: CommandOption[] | undefined, ev
|
||||
|
||||
type CommandSource = "palette" | "keybind" | "slash"
|
||||
|
||||
export const CommandCatalogItem = Codec.struct({
|
||||
title: Codec.string,
|
||||
description: Codec.optional(Codec.string),
|
||||
category: Codec.optional(Codec.string),
|
||||
keybind: Codec.optional(Codec.string),
|
||||
slash: Codec.optional(Codec.string),
|
||||
hidden: Codec.optional(Codec.boolean),
|
||||
export const CommandCatalogItem = Persistence.struct({
|
||||
title: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
category: Schema.optional(Schema.String),
|
||||
keybind: Schema.optional(Schema.String),
|
||||
slash: Schema.optional(Schema.String),
|
||||
hidden: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
export type CommandCatalogItem = typeof CommandCatalogItem.Type
|
||||
export const CommandCatalog = Codec.record(CommandCatalogItem)
|
||||
export const CommandCatalog = Schema.Record(Schema.String, Schema.mutableKey(CommandCatalogItem))
|
||||
export type CommandCatalog = typeof CommandCatalog.Type
|
||||
|
||||
export type CommandRegistration = {
|
||||
@@ -480,4 +480,3 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import type { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { Tab } from "@/shell/tabs/tabs"
|
||||
import { NotificationStore, openNotificationSession, type Notification } from "./notification"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
test("notification persistence validates and salvages individual notifications", () => {
|
||||
const valid: Notification[] = [
|
||||
{ type: "turn-complete", time: 123, viewed: false, session: "session-1" },
|
||||
{ type: "error", time: 124, viewed: true, error: { type: "api", message: "failed", status: 500 } },
|
||||
]
|
||||
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(NotificationStore, { list: [] }), input))
|
||||
const decode = Schema.decodeUnknownSync(Persistence.withInitial(NotificationStore, { list: [] }))
|
||||
const store = decode({
|
||||
list: [valid[0], null, { type: "unknown", time: 123, viewed: false }, { ...valid[1], error: "invalid" }, valid[1]],
|
||||
})
|
||||
expect(store.list).toEqual(valid)
|
||||
expect(decode({})).toEqual({ list: [] })
|
||||
expect(decode({ list: {} })).toEqual({ list: [] })
|
||||
expect(decode(NotificationStore.encode(store))).toEqual(store)
|
||||
expect(decode(Schema.encodeSync(NotificationStore)(store))).toEqual(store)
|
||||
})
|
||||
|
||||
test("opens notification sessions through the tab router", () => {
|
||||
@@ -40,4 +41,3 @@ test("opens notification sessions through the tab router", () => {
|
||||
|
||||
expect(calls).toEqual(["add:session-1", "route:session-1", "select:session-1"])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import type { SessionError } from "@opencode/schema/session-error"
|
||||
import { Schema } from "effect"
|
||||
import { SessionError } from "@opencode/schema/session-error"
|
||||
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import type { ServerSDK } from "@/runtime/server/client"
|
||||
@@ -10,7 +10,7 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { playSoundById } from "@/shell/notifications/sound"
|
||||
import type { createNotificationCoordinator } from "@/shell/notifications/coordinator"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
@@ -20,28 +20,19 @@ import { requireServerKey, sessionHref } from "@/shell/routes/session"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
|
||||
const NotificationBase = {
|
||||
directory: Codec.optional(Codec.string),
|
||||
session: Codec.optional(Codec.string),
|
||||
metadata: Codec.optional(Codec.unknown),
|
||||
time: Codec.number,
|
||||
viewed: Codec.boolean,
|
||||
}
|
||||
// The error payload is whatever the server reported; shape-checking it here would load the shared
|
||||
// Effect schema into the renderer's startup path for a value the server already validated.
|
||||
const StoredSessionError = Codec.make<SessionError.Error, unknown>(
|
||||
(value) =>
|
||||
typeof value === "object" && value !== null && "name" in value && "message" in value
|
||||
? (value as unknown as SessionError.Error)
|
||||
: Codec.INVALID,
|
||||
(value) => value,
|
||||
)
|
||||
export const Notification = Codec.union([
|
||||
Codec.struct({ ...NotificationBase, type: Codec.literal("turn-complete") }),
|
||||
Codec.struct({ ...NotificationBase, type: Codec.literal("error"), error: StoredSessionError }),
|
||||
const NotificationBase = {
|
||||
directory: Schema.optional(Schema.String),
|
||||
session: Schema.optional(Schema.String),
|
||||
metadata: Schema.optional(Schema.Unknown),
|
||||
time: Schema.Finite,
|
||||
viewed: Schema.Boolean,
|
||||
}
|
||||
export const Notification = Schema.Union([
|
||||
Persistence.struct({ ...NotificationBase, type: Schema.Literal("turn-complete") }),
|
||||
Persistence.struct({ ...NotificationBase, type: Schema.Literal("error"), error: SessionError.Error }),
|
||||
])
|
||||
export type Notification = typeof Notification.Type
|
||||
export const NotificationStore = Codec.struct({ list: Codec.lenientArray(Notification) })
|
||||
export const NotificationStore = Persistence.struct({ list: Persistence.array(Notification) })
|
||||
|
||||
type NotificationIndex = {
|
||||
session: {
|
||||
@@ -377,5 +368,3 @@ export const useNotification = () => {
|
||||
const server = useServer()
|
||||
return server.ctx.notification
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { Schema } from "effect"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { currentRoute, initialLayout, layoutPersistence, layoutSchema } from "./layout"
|
||||
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers"
|
||||
|
||||
@@ -10,21 +11,21 @@ test("settings has its own layout route", () => {
|
||||
})
|
||||
|
||||
describe("layout persistence", () => {
|
||||
const schema = Codec.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
|
||||
const decode = (input: unknown) => Codec.decodeOrThrow(schema, input)
|
||||
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
|
||||
test("uses supplied initial preferences after legacy migration", () => {
|
||||
const initial = initialLayout(ServerConnection.Key.make("remote"))
|
||||
initial.sidebar.width = 420
|
||||
initial.fileTree.width = 300
|
||||
initial.review.panelOpened = true
|
||||
const restore = (input: unknown) => Codec.decodeOrThrow(Codec.withInitial(layoutPersistence, initial), input)
|
||||
const restore = Schema.decodeUnknownSync(Persistence.withInitial(layoutPersistence, initial))
|
||||
expect(restore({})).toEqual(initial)
|
||||
expect(restore({ sidebar: { width: "bad" } }).sidebar.width).toBe(420)
|
||||
expect(restore({ fileTree: { width: 260 } }).fileTree.width).toBe(200)
|
||||
expect(restore({ fileTree: {} }).fileTree.width).toBe(300)
|
||||
expect(restore({ review: {}, fileTree: { opened: false } }).review.panelOpened).toBe(false)
|
||||
expect(() => Codec.decodeOrThrow(layoutSchema, {})).toThrow()
|
||||
expect(() => Schema.decodeUnknownSync(layoutSchema)({})).toThrow()
|
||||
})
|
||||
|
||||
test("restores shipped defaults for missing and invalid fields", () => {
|
||||
@@ -55,8 +56,8 @@ describe("layout persistence", () => {
|
||||
expect(value.sidebar).toEqual({ opened: false, width: 344, workspaces: {}, workspacesDefault: true })
|
||||
expect(value.review).toEqual({ diffStyle: "split", panelOpened: true })
|
||||
expect(value.fileTree).toEqual({ opened: true, width: 200, tab: "changes" })
|
||||
expect(schema.encode(value)).toEqual(value)
|
||||
expect(decode(schema.encode(value))).toEqual(value)
|
||||
expect(Schema.encodeSync(schema)(value)).toEqual(value)
|
||||
expect(decode(Schema.encodeSync(schema)(value))).toEqual(value)
|
||||
expect(decode({ fileTree: { opened: true } }).review.panelOpened).toBe(false)
|
||||
})
|
||||
|
||||
@@ -168,4 +169,3 @@ describe("pruneSessionKeys", () => {
|
||||
expect(drop).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,58 +1,63 @@
|
||||
export * as TabStorage from "./schema"
|
||||
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { ServerKey } from "@/runtime/server/key"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { ServerKey } from "@/runtime/server/persistence"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
export { ServerKey }
|
||||
|
||||
export const Session = Codec.struct({
|
||||
type: Codec.literal("session"),
|
||||
export const Session = Persistence.struct({
|
||||
type: Schema.Literal("session"),
|
||||
server: ServerKey,
|
||||
sessionId: Codec.string,
|
||||
routeSessionId: Codec.lenientOptional(Codec.string),
|
||||
routeParentId: Codec.lenientOptional(Codec.string),
|
||||
sessionId: Schema.String,
|
||||
routeSessionId: Persistence.optional(Schema.String),
|
||||
routeParentId: Persistence.optional(Schema.String),
|
||||
})
|
||||
|
||||
export const Draft = Codec.struct({
|
||||
type: Codec.literal("draft"),
|
||||
draftID: Codec.string,
|
||||
export const Draft = Persistence.struct({
|
||||
type: Schema.Literal("draft"),
|
||||
draftID: Schema.String,
|
||||
server: ServerKey,
|
||||
directory: Codec.string,
|
||||
worktree: Codec.lenientOptional(Codec.string),
|
||||
branch: Codec.lenientOptional(Codec.string),
|
||||
mcp: Codec.lenientOptional(Codec.struct({ target: Codec.string, states: Codec.lenientRecord(Codec.boolean) })),
|
||||
directory: Schema.String,
|
||||
worktree: Persistence.optional(Schema.String),
|
||||
branch: Persistence.optional(Schema.String),
|
||||
mcp: Persistence.optional(Persistence.struct({ target: Schema.String, states: Persistence.record(Schema.Boolean) })),
|
||||
})
|
||||
|
||||
// A stored route that only repeats the session id carries nothing; drop it and its parent.
|
||||
const SessionCodec = Codec.transform(Session, {
|
||||
decode: (tab) => ({
|
||||
type: tab.type,
|
||||
server: tab.server,
|
||||
sessionId: tab.sessionId,
|
||||
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
|
||||
? { routeSessionId: tab.routeSessionId, ...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}) }
|
||||
: {}),
|
||||
}),
|
||||
encode: (tab) => tab,
|
||||
})
|
||||
|
||||
export const Tab = Codec.union([Session, Draft])
|
||||
export const Tabs = Codec.lenientArray(Codec.union([SessionCodec, Draft]))
|
||||
export const Recent = Codec.struct({
|
||||
key: Codec.optional(Codec.string),
|
||||
})
|
||||
export const Info = Codec.struct({
|
||||
title: Codec.optional(Codec.string),
|
||||
directory: Codec.optional(Codec.string),
|
||||
})
|
||||
export const Infos = Codec.record(Info)
|
||||
export const Panes = Codec.record(
|
||||
Codec.struct({
|
||||
terminal: Codec.optional(Codec.boolean),
|
||||
review: Codec.optional(Codec.boolean),
|
||||
terminalHeight: Codec.optional(Codec.number),
|
||||
sessionWidth: Codec.optional(Codec.number),
|
||||
const SessionCodec = Session.pipe(
|
||||
Schema.decodeTo(Schema.toType(Session), {
|
||||
decode: SchemaGetter.transform((tab) => ({
|
||||
type: tab.type,
|
||||
server: tab.server,
|
||||
sessionId: tab.sessionId,
|
||||
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
|
||||
? { routeSessionId: tab.routeSessionId, ...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}) }
|
||||
: {}),
|
||||
})),
|
||||
encode: SchemaGetter.transform((tab) => tab),
|
||||
}),
|
||||
)
|
||||
export const ClosedTab = Codec.struct({ tab: SessionCodec, index: Codec.nonNegativeInt })
|
||||
export const Closed = Codec.lenientArray(ClosedTab)
|
||||
|
||||
export const Tab = Schema.Union([Session, Draft])
|
||||
export const Tabs = Persistence.array(Schema.Union([SessionCodec, Draft]))
|
||||
export const Recent = Persistence.struct({
|
||||
key: Schema.optional(Schema.String),
|
||||
})
|
||||
export const Info = Persistence.struct({
|
||||
title: Schema.optional(Schema.String),
|
||||
directory: Schema.optional(Schema.String),
|
||||
})
|
||||
export const Infos = Schema.Record(Schema.String, Schema.mutableKey(Info))
|
||||
export const Panes = Schema.Record(
|
||||
Schema.String,
|
||||
Schema.mutableKey(
|
||||
Persistence.struct({
|
||||
terminal: Schema.optional(Schema.Boolean),
|
||||
review: Schema.optional(Schema.Boolean),
|
||||
terminalHeight: Schema.optional(Schema.Finite),
|
||||
sessionWidth: Schema.optional(Schema.Finite),
|
||||
}),
|
||||
),
|
||||
)
|
||||
export const ClosedTab = Schema.Struct({ tab: SessionCodec, index: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) })
|
||||
export const Closed = Persistence.array(ClosedTab)
|
||||
|
||||
@@ -3,12 +3,13 @@ import { createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createTabMemory } from "./memory"
|
||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
|
||||
import { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
|
||||
import { Schema } from "effect"
|
||||
import { TabStorage } from "./schema"
|
||||
import type { ServerConnection } from "@/runtime/server/registry"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
|
||||
const decodeTabs = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Tabs, []), input))
|
||||
const decodeTabs = Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Tabs, []))
|
||||
|
||||
function sessionTab(sessionId: string): SessionTab {
|
||||
return { type: "session", server, sessionId }
|
||||
@@ -25,7 +26,7 @@ describe("tab migration", () => {
|
||||
}
|
||||
const restored = decodeTabs([legacy, draft])
|
||||
expect(restored).toEqual([legacy, draft])
|
||||
expect(decodeTabs(TabStorage.Tabs.encode(restored))).toEqual([legacy, draft])
|
||||
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(restored))).toEqual([legacy, draft])
|
||||
})
|
||||
|
||||
test("drops null and malformed persisted tabs", () => {
|
||||
@@ -63,13 +64,13 @@ describe("tab migration", () => {
|
||||
draft,
|
||||
])
|
||||
expect(tabs).toEqual([sessionTab("root"), draft])
|
||||
expect(TabStorage.Tabs.encode(tabs)).toEqual(tabs)
|
||||
expect(decodeTabs(TabStorage.Tabs.encode(tabs))).toEqual(tabs)
|
||||
expect(Schema.encodeSync(TabStorage.Tabs)(tabs)).toEqual(tabs)
|
||||
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(tabs))).toEqual(tabs)
|
||||
})
|
||||
|
||||
test("salvages valid closed session tabs", () => {
|
||||
expect(
|
||||
((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Closed, []), input))([
|
||||
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Closed, []))([
|
||||
{ tab: sessionTab("a"), index: 1 },
|
||||
{ tab: sessionTab("b"), index: -1 },
|
||||
{ tab: { type: "draft", server, draftID: "d", directory: "/project" }, index: 0 },
|
||||
@@ -80,16 +81,16 @@ describe("tab migration", () => {
|
||||
|
||||
test("validates auxiliary tab state", () => {
|
||||
expect(
|
||||
((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Recent, { key: undefined }), input))({ key: 1 }),
|
||||
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Recent, { key: undefined }))({ key: 1 }),
|
||||
).toEqual({ key: undefined })
|
||||
expect(Codec.decodeOrThrow(TabStorage.Infos, {})).toEqual({})
|
||||
expect(Codec.decodeOrThrow(TabStorage.Panes, {})).toEqual({})
|
||||
expect(Codec.decodeOrThrow(TabStorage.Infos, { tab: { title: "Title", directory: "/project" } })).toEqual({
|
||||
expect(Schema.decodeUnknownSync(TabStorage.Infos)({})).toEqual({})
|
||||
expect(Schema.decodeUnknownSync(TabStorage.Panes)({})).toEqual({})
|
||||
expect(Schema.decodeUnknownSync(TabStorage.Infos)({ tab: { title: "Title", directory: "/project" } })).toEqual({
|
||||
tab: { title: "Title", directory: "/project" },
|
||||
})
|
||||
const panes = Codec.decodeOrThrow(TabStorage.Panes, { tab: { terminal: true, terminalHeight: 300 } })
|
||||
expect(TabStorage.Panes.encode(panes)).toEqual({ tab: { terminal: true, terminalHeight: 300 } })
|
||||
expect(() => Codec.decodeOrThrow(TabStorage.Panes, { tab: { terminal: "yes" } })).toThrow()
|
||||
const panes = Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: true, terminalHeight: 300 } })
|
||||
expect(Schema.encodeSync(TabStorage.Panes)(panes)).toEqual({ tab: { terminal: true, terminalHeight: 300 } })
|
||||
expect(() => Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: "yes" } })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -208,5 +209,3 @@ describe("closed tab stack", () => {
|
||||
expect(nextTabAfterClose([sessionTab("a")], 0, true)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { canOpenTabRename, forwardTabRef, isTabCloseTarget } from "./tab-gesture"
|
||||
import { canOpenTabRename, canStartTabDrag, forwardTabRef, isTabCloseTarget } from "./tab-gesture"
|
||||
|
||||
describe("titlebar tab gestures", () => {
|
||||
test("excludes close controls from tab gestures", () => {
|
||||
@@ -24,4 +24,10 @@ describe("titlebar tab gestures", () => {
|
||||
expect(canOpenTabRename(false, false, false)).toBe(true)
|
||||
expect(canOpenTabRename(false, false, true)).toBe(false)
|
||||
})
|
||||
|
||||
test("preserves native panning for touch pointers", () => {
|
||||
expect(canStartTabDrag("mouse")).toBe(true)
|
||||
expect(canStartTabDrag("pen")).toBe(true)
|
||||
expect(canStartTabDrag("touch")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,10 @@ export function isTabCloseTarget(target: EventTarget | null) {
|
||||
return target instanceof Element && !!target.closest('[data-slot="tab-close"]')
|
||||
}
|
||||
|
||||
export function canStartTabDrag(pointerType: string) {
|
||||
return pointerType !== "touch"
|
||||
}
|
||||
|
||||
export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) {
|
||||
if (typeof ref === "function") ref(element)
|
||||
}
|
||||
|
||||
@@ -328,7 +328,6 @@ export function TabNavItem(props: {
|
||||
|
||||
return (
|
||||
<Menu.Context
|
||||
modal={false}
|
||||
onOpenChange={(open) => {
|
||||
setMenu("open", open)
|
||||
if (open) setPopoverOpen(false)
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { createTabComposerState } from "@/composer/persistence"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { isTabCloseTarget } from "./tab-gesture"
|
||||
import { canStartTabDrag, isTabCloseTarget } from "./tab-gesture"
|
||||
import { adjacentTabKey, mergeVisibleTabOrder } from "./tab-order"
|
||||
import type { SessionInfo } from "@opencode/client/promise"
|
||||
|
||||
@@ -292,11 +292,9 @@ export function TitlebarTabStrip(props: {
|
||||
<DragDropProvider
|
||||
sensors={[
|
||||
PointerSensor.configure({
|
||||
activationConstraints: (event) =>
|
||||
event.pointerType === "touch"
|
||||
? [new PointerActivationConstraints.Distance({ value: 8 })]
|
||||
: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||
preventActivation: (event) =>
|
||||
!canStartTabDrag(event.pointerType) ||
|
||||
isTabCloseTarget(event.target) ||
|
||||
(event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')),
|
||||
}),
|
||||
@@ -315,7 +313,6 @@ export function TitlebarTabStrip(props: {
|
||||
if (!source) return
|
||||
const tab = props.tabs.find((item) => tabKey(item) === source.id.toString())
|
||||
if (!tab) return
|
||||
if (vertical()) return
|
||||
const tabEl = source.element?.querySelector<HTMLDivElement>("[data-titlebar-tab]")
|
||||
props.onNavigate(tab, tabEl ?? undefined)
|
||||
}}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { HighlightsStore } from "./highlights"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
test("highlight persistence defaults missing or invalid versions and round-trips valid versions", () => {
|
||||
const decode = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(HighlightsStore, { version: undefined }), input))
|
||||
const decode = Schema.decodeUnknownSync(Persistence.withInitial(HighlightsStore, { version: undefined }))
|
||||
expect(decode({})).toEqual({ version: undefined })
|
||||
expect(decode({ version: null })).toEqual({ version: undefined })
|
||||
const value = decode({ version: "1.2.3", legacy: true })
|
||||
expect(value).toEqual({ version: "1.2.3" })
|
||||
expect(HighlightsStore.encode(value)).toEqual(value)
|
||||
expect(Schema.encodeSync(HighlightsStore)(value)).toEqual(value)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Schema } from "effect"
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { persisted } from "@/runtime/persistence/storage"
|
||||
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { DialogReleaseNotes, type Highlight } from "@/shell/updates/release-notes"
|
||||
|
||||
const CHANGELOG_URL = "https://opencode.ai/changelog.json"
|
||||
|
||||
export const HighlightsStore = Codec.struct({
|
||||
version: Codec.undefinedOr(Codec.string),
|
||||
export const HighlightsStore = Persistence.struct({
|
||||
version: Schema.UndefinedOr(Schema.String),
|
||||
})
|
||||
|
||||
type ParsedRelease = {
|
||||
@@ -233,4 +233,3 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import type { FileContent } from "@/runtime/server/types"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
export const FileSelection = Codec.struct({
|
||||
startLine: Codec.number,
|
||||
startChar: Codec.number,
|
||||
endLine: Codec.number,
|
||||
endChar: Codec.number,
|
||||
export const FileSelection = Persistence.struct({
|
||||
startLine: Schema.Number,
|
||||
startChar: Schema.Number,
|
||||
endLine: Schema.Number,
|
||||
endChar: Schema.Number,
|
||||
})
|
||||
export type FileSelection = typeof FileSelection.Type
|
||||
|
||||
export const SelectedLineRange = Codec.struct({
|
||||
start: Codec.number,
|
||||
end: Codec.number,
|
||||
side: Codec.lenientOptional(Codec.literals(["additions", "deletions"])),
|
||||
endSide: Codec.lenientOptional(Codec.literals(["additions", "deletions"])),
|
||||
export const SelectedLineRange = Persistence.struct({
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
side: Persistence.optional(Schema.Literals(["additions", "deletions"])),
|
||||
endSide: Persistence.optional(Schema.Literals(["additions", "deletions"])),
|
||||
})
|
||||
export type SelectedLineRange = typeof SelectedLineRange.Type
|
||||
|
||||
@@ -43,4 +44,3 @@ export function selectionFromLines(range: SelectedLineRange): FileSelection {
|
||||
endChar: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createEffect, createRoot } from "solid-js"
|
||||
import { produce } from "solid-js/store"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Schema } from "effect"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { createScopedCache } from "@/runtime/server/scoped-cache"
|
||||
import { SelectedLineRange } from "./types"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
@@ -10,14 +11,14 @@ const WORKSPACE_KEY = "__workspace__"
|
||||
const MAX_FILE_VIEW_SESSIONS = 20
|
||||
const MAX_VIEW_FILES = 500
|
||||
|
||||
const FileViewSchema = Codec.struct({
|
||||
scrollTop: Codec.lenientOptional(Codec.number),
|
||||
scrollLeft: Codec.lenientOptional(Codec.number),
|
||||
selectedLines: Codec.lenientOptional(Codec.nullOr(SelectedLineRange)),
|
||||
const FileViewSchema = Persistence.struct({
|
||||
scrollTop: Persistence.optional(Schema.Finite),
|
||||
scrollLeft: Persistence.optional(Schema.Finite),
|
||||
selectedLines: Persistence.optional(Schema.NullOr(SelectedLineRange)),
|
||||
})
|
||||
|
||||
export const FileViewsSchema = Codec.struct({
|
||||
file: Codec.lenientRecord(Codec.fallback(FileViewSchema, () => ({}))),
|
||||
export const FileViewsSchema = Schema.Struct({
|
||||
file: Persistence.record(Persistence.fallback(FileViewSchema, () => ({}))),
|
||||
})
|
||||
|
||||
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
|
||||
@@ -149,4 +150,3 @@ export function createFileViewCache(scope: ServerScope) {
|
||||
clear: () => cache.clear(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.base} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
Error while hot reloading
|
||||
</text>
|
||||
<text fg={theme.text.muted} onMouseUp={props.onClose}>
|
||||
<text fg={theme.text.subdued} onMouseUp={props.onClose}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.base}>
|
||||
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.default}>
|
||||
{props.error instanceof Error ? props.error.message : String(props.error)}
|
||||
</text>
|
||||
<text flexShrink={0} fg={theme.text.muted}>
|
||||
<text flexShrink={0} fg={theme.text.subdued}>
|
||||
{props.component} · Fix the component and save to retry.
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
import { Script } from "@opencode/script"
|
||||
import { $ } from "bun"
|
||||
import { mkdir, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { UpdateArtifact } from "../../../script/update-artifact"
|
||||
|
||||
if (Script.channel !== "beta" && Script.channel !== "latest") {
|
||||
throw new Error("Homebrew publishing requires the beta or latest channel")
|
||||
}
|
||||
const valid =
|
||||
Script.channel === "beta"
|
||||
? /^\d+\.\d+\.\d+-beta[.-]\d+(?:\.\d+)?$/.test(Script.version)
|
||||
: /^\d+\.\d+\.\d+$/.test(Script.version)
|
||||
if (!valid) throw new Error(`Expected a ${Script.channel} release version`)
|
||||
|
||||
const dir = fileURLToPath(new URL("..", import.meta.url))
|
||||
const root = path.resolve(process.env.OPENCODE_CLI_DIST ?? path.join(dir, "dist"))
|
||||
const outdir = path.join(root, "homebrew-tap")
|
||||
const dryRun = process.argv.includes("--dry-run")
|
||||
const name = Script.channel === "beta" ? "opencode-beta" : "opencode-v2"
|
||||
const formulaClass = Script.channel === "beta" ? "OpencodeBeta" : "OpencodeV2"
|
||||
|
||||
const targets = await Promise.all(
|
||||
[
|
||||
{ name: "darwin-arm64", archive: "zip" },
|
||||
{ name: "darwin-x64-baseline", archive: "zip" },
|
||||
{ name: "linux-arm64", archive: "tar.gz" },
|
||||
{ name: "linux-x64-baseline", archive: "tar.gz" },
|
||||
].map(async (target) => {
|
||||
const filename = `opencode-${target.name}.${target.archive}`
|
||||
const file = Bun.file(path.join(root, filename))
|
||||
if (!(await file.exists()) || !file.size) throw new Error(`Missing Homebrew archive: ${filename}`)
|
||||
const sha256 = new Bun.CryptoHasher("sha256")
|
||||
for await (const chunk of file.stream()) sha256.update(chunk)
|
||||
return {
|
||||
...target,
|
||||
url: `https://opencode.ai/files/bin/${encodeURIComponent(Script.version)}/${filename}`,
|
||||
sha256: sha256.digest("hex"),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
await rm(outdir, { recursive: true, force: true })
|
||||
if (dryRun) await mkdir(outdir, { recursive: true })
|
||||
if (!dryRun) {
|
||||
const token = process.env.GITHUB_TOKEN
|
||||
if (!token) throw new Error("GITHUB_TOKEN is required to update the Homebrew tap")
|
||||
await $`git clone ${`https://x-access-token:${token}@github.com/anomalyco/homebrew-tap.git`} ${outdir}`
|
||||
await $`git checkout -B master`.cwd(outdir)
|
||||
}
|
||||
|
||||
const target = (name: string) => {
|
||||
const result = targets.find((item) => item.name === name)
|
||||
if (!result) throw new Error(`Missing Homebrew target: ${name}`)
|
||||
return result
|
||||
}
|
||||
const macArm = target("darwin-arm64")
|
||||
const macIntel = target("darwin-x64-baseline")
|
||||
const linuxArm = target("linux-arm64")
|
||||
const linuxIntel = target("linux-x64-baseline")
|
||||
|
||||
await Bun.write(
|
||||
path.join(outdir, `${name}.rb`),
|
||||
[
|
||||
"# typed: false",
|
||||
"# frozen_string_literal: true",
|
||||
"",
|
||||
`class ${formulaClass} < Formula`,
|
||||
` desc "OpenCode V2${Script.channel === "beta" ? " beta" : ""} - the AI coding agent for the terminal"`,
|
||||
' homepage "https://github.com/anomalyco/opencode"',
|
||||
` version "${Script.version}"`,
|
||||
' license "MIT"',
|
||||
"",
|
||||
' depends_on "ripgrep"',
|
||||
' conflicts_with "opencode", because: "both install an opencode binary"',
|
||||
"",
|
||||
" on_macos do",
|
||||
" if Hardware::CPU.arm?",
|
||||
` url "${macArm.url}"`,
|
||||
` sha256 "${macArm.sha256}"`,
|
||||
" else",
|
||||
` url "${macIntel.url}"`,
|
||||
` sha256 "${macIntel.sha256}"`,
|
||||
" end",
|
||||
" end",
|
||||
"",
|
||||
" on_linux do",
|
||||
" if Hardware::CPU.arm?",
|
||||
` url "${linuxArm.url}"`,
|
||||
` sha256 "${linuxArm.sha256}"`,
|
||||
" else",
|
||||
` url "${linuxIntel.url}"`,
|
||||
` sha256 "${linuxIntel.sha256}"`,
|
||||
" end",
|
||||
" end",
|
||||
"",
|
||||
" def install",
|
||||
' bin.install "opencode"',
|
||||
" end",
|
||||
"end",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
console.log(`Prepared ${name} ${Script.version} in ${outdir}`)
|
||||
if (dryRun) process.exit(0)
|
||||
|
||||
await $`git add ${name + ".rb"}`.cwd(outdir)
|
||||
if ((await $`git diff --cached --quiet`.cwd(outdir).nothrow()).exitCode !== 0) {
|
||||
await $`git commit -m ${`chore: update ${name} to ${Script.version}`}`.cwd(outdir)
|
||||
await $`git push origin master`.cwd(outdir)
|
||||
}
|
||||
await UpdateArtifact.publish({
|
||||
channel: Script.channel,
|
||||
name: "cli",
|
||||
distribution: "homebrew",
|
||||
version: Script.version,
|
||||
metadata: { package: `anomalyco/tap/${name}` },
|
||||
})
|
||||
@@ -149,10 +149,6 @@ if (Script.channel === "latest" && Script.release && !dryRun) {
|
||||
|
||||
if ((Script.channel === "beta" || Script.channel === "latest") && Script.release) {
|
||||
await $`bun ./script/publish-aur.ts ${dryRun ? ["--dry-run"] : []}`.env({ ...process.env, OPENCODE_CLI_DIST: root })
|
||||
await $`bun ./script/publish-homebrew.ts ${dryRun ? ["--dry-run"] : []}`.env({
|
||||
...process.env,
|
||||
OPENCODE_CLI_DIST: root,
|
||||
})
|
||||
}
|
||||
|
||||
async function archive(bin: string, target: string, binary: string, directory: string) {
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { AppProcess } from "@opencode/util/process"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Option, Ref, Schema } from "effect"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Ref } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
import { action, parseReleaseVersion, type Policy } from "./updater-action"
|
||||
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn", "vp", "brew"] as const
|
||||
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
|
||||
export type Method = (typeof methods)[number]
|
||||
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
|
||||
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
|
||||
|
||||
const decodeVpPackages = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(Schema.Array(Schema.Struct({ name: Schema.String }))),
|
||||
)
|
||||
|
||||
export interface Interface {
|
||||
readonly run: (onInstall?: (version: string) => void) => Effect.Effect<RunResult | undefined>
|
||||
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
|
||||
@@ -101,13 +96,6 @@ const make = Effect.gen(function* () {
|
||||
process.platform === "win32" ? "opencode.exe" : "opencode",
|
||||
)
|
||||
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
|
||||
const executable = yield* fs.realPath(process.execPath).pipe(Effect.orElseSucceed(() => process.execPath))
|
||||
if (
|
||||
["opencode-beta", "opencode-v2"].some((name) =>
|
||||
executable.includes(`${path.sep}Cellar${path.sep}${name}${path.sep}`),
|
||||
)
|
||||
)
|
||||
return "brew"
|
||||
if (!installedPackage) return
|
||||
|
||||
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
|
||||
@@ -115,30 +103,22 @@ const make = Effect.gen(function* () {
|
||||
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", installedPackage] },
|
||||
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
|
||||
{ method: "yarn", command: ["yarn", "global", "list"] },
|
||||
{ method: "vp", command: ["vp", "list", "-g", "--json", installedPackage] },
|
||||
]
|
||||
const results = yield* Effect.forEach(
|
||||
checks,
|
||||
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return results.find((result) => {
|
||||
if (result.check.method !== "vp") return result.result.stdout.includes(installedPackage)
|
||||
// Vite+ repeats the filter in its successful no-match message, so substring detection would be a false positive.
|
||||
return Option.exists(decodeVpPackages(result.result.stdout), (packages) =>
|
||||
packages.some((item) => item.name === installedPackage),
|
||||
)
|
||||
})?.check.method
|
||||
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
|
||||
})
|
||||
|
||||
const removal = (method: Method) => {
|
||||
if (method === "curl" || method === "brew" || !installedPackage) return undefined
|
||||
if (method === "curl" || !installedPackage) return undefined
|
||||
const commands = {
|
||||
npm: ["npm", "uninstall", "--global", installedPackage],
|
||||
pnpm: ["pnpm", "remove", "--global", installedPackage],
|
||||
bun: ["bun", "remove", "--global", installedPackage],
|
||||
yarn: ["yarn", "global", "remove", installedPackage],
|
||||
vp: ["vp", "uninstall", "-g", installedPackage],
|
||||
}
|
||||
const command = commands[method]
|
||||
return {
|
||||
@@ -153,12 +133,11 @@ const make = Effect.gen(function* () {
|
||||
}
|
||||
}
|
||||
|
||||
const release = Effect.fnUntraced(function* (method?: Method) {
|
||||
const distribution = method === "brew" ? "homebrew" : "npm"
|
||||
const release = Effect.fnUntraced(function* () {
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
fetch(
|
||||
`https://opencode.ai/update/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/${distribution}?current=${encodeURIComponent(OPENCODE_VERSION)}`,
|
||||
`https://opencode.ai/update/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm?current=${encodeURIComponent(OPENCODE_VERSION)}`,
|
||||
{
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
||||
},
|
||||
@@ -174,11 +153,7 @@ const make = Effect.gen(function* () {
|
||||
return { package: data.metadata.package, version: data.version }
|
||||
})
|
||||
|
||||
const latest = () =>
|
||||
method().pipe(
|
||||
Effect.flatMap(release),
|
||||
Effect.map((data) => data.version),
|
||||
)
|
||||
const latest = () => release().pipe(Effect.map((data) => data.version))
|
||||
|
||||
const temporaryDirectory = (prefix: string) =>
|
||||
Effect.acquireRelease(fs.makeTempDirectory({ directory: global.cache, prefix }), (directory) =>
|
||||
@@ -188,12 +163,12 @@ const make = Effect.gen(function* () {
|
||||
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
|
||||
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
|
||||
const version = input.trim().replace(/^v/, "")
|
||||
const packageName = (yield* release(method)).package
|
||||
const packageName = (yield* release()).package
|
||||
const target = `${packageName}@${version}`
|
||||
if (installedPackage && packageName !== installedPackage && (method === "pnpm" || method === "yarn")) {
|
||||
return yield* Effect.fail(new Error(`Reinstall ${target} with ${method} to migrate from ${installedPackage}.`))
|
||||
}
|
||||
const commands: Record<Exclude<Method, "bun" | "curl" | "brew">, string[]> = {
|
||||
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
|
||||
// Keep the old package: uninstalling it can unlink the replacement command.
|
||||
npm: [
|
||||
"npm",
|
||||
@@ -207,10 +182,6 @@ const make = Effect.gen(function* () {
|
||||
],
|
||||
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
|
||||
yarn: ["yarn", "global", "add", target],
|
||||
vp:
|
||||
installedPackage && packageName !== installedPackage
|
||||
? ["vp", "install", "-g", "--force", target]
|
||||
: ["vp", "update", "-g", target],
|
||||
}
|
||||
const result = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
@@ -231,7 +202,6 @@ const make = Effect.gen(function* () {
|
||||
if (download.code !== 0) return download
|
||||
return yield* exec(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
|
||||
}
|
||||
if (method === "brew") return yield* exec(["brew", "upgrade", packageName], "5 minutes")
|
||||
return yield* exec(commands[method], "5 minutes")
|
||||
}),
|
||||
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
|
||||
|
||||
@@ -19,7 +19,6 @@ function fixture(
|
||||
} = () => ({}),
|
||||
name = "@opencode/cli",
|
||||
failCleanup = false,
|
||||
releasePackage = name,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
@@ -35,7 +34,7 @@ function fixture(
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
spyOn(globalThis, "fetch").mockImplementation(
|
||||
Object.assign(async () => Response.json({ version: "2.3.4", metadata: { package: releasePackage } }), {
|
||||
Object.assign(async () => Response.json({ version: "2.3.4", metadata: { package: name } }), {
|
||||
preconnect: fetch.preconnect,
|
||||
}),
|
||||
),
|
||||
@@ -107,7 +106,6 @@ const installs = [
|
||||
command: ["pnpm", "add", "--global", "--allow-build=@opencode/cli", "@opencode/cli@2.3.4-beta.1"],
|
||||
},
|
||||
{ method: "yarn", command: ["yarn", "global", "add", "@opencode/cli@2.3.4-beta.1"] },
|
||||
{ method: "vp", command: ["vp", "update", "-g", "@opencode/cli@2.3.4-beta.1"] },
|
||||
] as const
|
||||
|
||||
installs.forEach(({ method, command }) => {
|
||||
@@ -119,25 +117,6 @@ installs.forEach(({ method, command }) => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("vp force-installs a renamed V2 package that replaces the existing binary owner", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture(() => ({}), "@opencode/cli", false, "@opencode/cli-node")
|
||||
yield* test.updater.upgrade("vp", "v2.3.4-beta.1")
|
||||
expect(test.commands).toEqual([["vp", "install", "-g", "--force", "@opencode/cli-node@2.3.4-beta.1"]])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("vp removes the package from its managed global store", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture()
|
||||
const removal = test.updater.removal("vp")
|
||||
if (!removal) return yield* Effect.die("Expected vp removal command")
|
||||
expect(removal.command).toEqual(["vp", "uninstall", "-g", "@opencode/cli"])
|
||||
yield* removal.run
|
||||
expect(test.commands).toEqual([["vp", "uninstall", "-g", "@opencode/cli"]])
|
||||
}),
|
||||
)
|
||||
;[0, 1].forEach((exitCode) => {
|
||||
it.live(`bun isolates and removes its install cache after exit ${exitCode}`, () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -221,19 +200,11 @@ it.live("install failures expose stderr and process errors do not report success
|
||||
expect(missing.commands).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
;(["npm", "pnpm", "bun", "yarn", "vp", undefined] as const).forEach((method) => {
|
||||
;(["npm", "pnpm", "bun", "yarn", undefined] as const).forEach((method) => {
|
||||
it.live(`method detection identifies ${method ?? "an unknown installation"} using the V2 package`, () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture((command) => ({
|
||||
stdout: Buffer.from(
|
||||
command.command === method
|
||||
? method === "vp"
|
||||
? JSON.stringify([{ name: "@opencode/cli", version: "2.3.4" }])
|
||||
: "@opencode/cli@2.3.4"
|
||||
: command.command === "vp"
|
||||
? "[]"
|
||||
: "opencode-ai@1.0.0",
|
||||
),
|
||||
stdout: Buffer.from(command.command === method ? "@opencode/cli@2.3.4" : "opencode-ai@1.0.0"),
|
||||
}))
|
||||
expect(yield* test.updater.method()).toBe(method)
|
||||
expect(test.commands).toEqual([
|
||||
@@ -241,7 +212,6 @@ it.live("install failures expose stderr and process errors do not report success
|
||||
["pnpm", "list", "-g", "--depth=0", "@opencode/cli"],
|
||||
["bun", "pm", "ls", "-g"],
|
||||
["yarn", "global", "list"],
|
||||
["vp", "list", "-g", "--json", "@opencode/cli"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -255,16 +225,7 @@ it.live("method detection tolerates unavailable package managers", () =>
|
||||
: { error: new AppProcess.AppProcessError({ command: command.command }) },
|
||||
)
|
||||
expect(yield* test.updater.method()).toBe("yarn")
|
||||
expect(test.commands).toHaveLength(5)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("vp detection ignores no-match output that repeats the package name", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture((command) => ({
|
||||
stdout: Buffer.from(command.command === "vp" ? "No global packages matching '@opencode/cli'." : ""),
|
||||
}))
|
||||
expect(yield* test.updater.method()).toBeUndefined()
|
||||
expect(test.commands).toHaveLength(4)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -304,16 +265,13 @@ if (typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-no
|
||||
expect(yield* test.updater.method()).toBe("npm")
|
||||
yield* test.updater.upgrade("npm", "v2.3.4")
|
||||
yield* test.updater.upgrade("pnpm", "v2.3.4")
|
||||
yield* test.updater.upgrade("vp", "v2.3.4")
|
||||
expect(test.commands).toEqual([
|
||||
["npm", "list", "-g", "--depth=0", "@opencode/cli-node"],
|
||||
["pnpm", "list", "-g", "--depth=0", "@opencode/cli-node"],
|
||||
["bun", "pm", "ls", "-g"],
|
||||
["yarn", "global", "list"],
|
||||
["vp", "list", "-g", "--json", "@opencode/cli-node"],
|
||||
["npm", "install", "--global", "@opencode/cli-node@2.3.4"],
|
||||
["pnpm", "add", "--global", "--allow-build=@opencode/cli-node", "@opencode/cli-node@2.3.4"],
|
||||
["vp", "update", "-g", "@opencode/cli-node@2.3.4"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -35,12 +35,6 @@ describe("upgrade command", () => {
|
||||
expect(result.events).toEqual([{ method: "bun", version: "2.0.0" }])
|
||||
})
|
||||
|
||||
test("accepts vp as an explicit installation method", async () => {
|
||||
const result = await cli(["2.0.0", "--method", "vp"])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.events).toEqual([{ method: "vp", version: "2.0.0" }])
|
||||
})
|
||||
|
||||
test("skips the already installed version", async () => {
|
||||
const result = await cli(["v0.0.0-beta-old"])
|
||||
expect(result.exitCode).toBe(0)
|
||||
@@ -56,7 +50,7 @@ describe("upgrade command", () => {
|
||||
})
|
||||
|
||||
test("rejects unsupported methods before attempting an upgrade", async () => {
|
||||
const result = await cli(["--method", "apt"])
|
||||
const result = await cli(["--method", "brew"])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.events).toEqual([])
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,27 +1,9 @@
|
||||
#!/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,9 +131,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
}).pipe(
|
||||
Effect.repeat({
|
||||
until: Option.isSome,
|
||||
// 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 })),
|
||||
schedule: Schedule.max([Schedule.spaced(timing.pollInterval), Schedule.recurs(timing.attempts)]),
|
||||
}),
|
||||
Effect.ensuring(Effect.sync(() => contenders.forEach((contender) => contender.release()))),
|
||||
)
|
||||
|
||||
@@ -456,10 +456,6 @@ export type V2EventServerConnected = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type ModelSettings = { compaction?: ProviderCompaction } & { [x: string]: any }
|
||||
|
||||
export type ConfigModelSettings = { compaction?: ProviderCompaction } & { [x: string]: JsonValue | null }
|
||||
|
||||
export type ProviderSettings = {
|
||||
timeout?: number | false
|
||||
chunkTimeout?: number
|
||||
@@ -1660,19 +1656,19 @@ export type SessionInboxMove = {
|
||||
payload: SessionInboxMovePayload
|
||||
}
|
||||
|
||||
export type ModelVariant = {
|
||||
id: string
|
||||
settings?: ModelSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type ProviderRequest = {
|
||||
settings: ProviderSettings
|
||||
headers: { [x: string]: string }
|
||||
body: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type ModelVariant = {
|
||||
id: string
|
||||
settings?: ProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type ProviderInfo = {
|
||||
id: string
|
||||
canonical?: string
|
||||
@@ -1902,7 +1898,7 @@ export type ModelInfo = {
|
||||
name: string
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
settings?: ModelSettings
|
||||
settings?: ProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
capabilities: ModelCapabilities
|
||||
@@ -2111,13 +2107,13 @@ export type ConfigEntry =
|
||||
name?: string
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
settings?: ConfigModelSettings
|
||||
settings?: ConfigProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
capabilities?: ModelCapabilities
|
||||
variants?: Array<{
|
||||
id: string
|
||||
settings?: ConfigModelSettings
|
||||
settings?: ConfigProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type EnsureTiming = {
|
||||
readonly pollInterval: number
|
||||
readonly attempts: number
|
||||
readonly requestTimeout: number
|
||||
readonly spawnDelay: number
|
||||
readonly maxSpawnDelay: number
|
||||
@@ -10,11 +11,9 @@ 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: 25,
|
||||
pollInterval: 100,
|
||||
attempts: 1_200,
|
||||
requestTimeout: 2_000,
|
||||
spawnDelay: 5_000,
|
||||
maxSpawnDelay: 30_000,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { withEnsureTiming } from "../../src/service-timing"
|
||||
|
||||
const timing = {
|
||||
pollInterval: 20,
|
||||
attempts: 120,
|
||||
requestTimeout: 100,
|
||||
spawnDelay: 200,
|
||||
maxSpawnDelay: 1_200,
|
||||
|
||||
@@ -13,8 +13,8 @@ The idea of code mode was originally introduced by Cloudflare. See
|
||||
|
||||
## How it differs from JavaScript
|
||||
|
||||
- **Only supported APIs are available.** Programs can use the provided tools, supported JavaScript built-ins, and the
|
||||
globals the host adds through extensions. Timers, `process`, filesystem access, imports, and modules are unavailable.
|
||||
- **Only supported APIs are available.** Programs can use the provided tools and supported JavaScript built-ins. APIs
|
||||
such as `fetch`, timers, `process`, filesystem access, imports, and modules are unavailable.
|
||||
- **Unfinished work is interrupted.** Tool calls and async functions start when called. When the program finishes,
|
||||
anything still running is interrupted. Unhandled rejections from un-awaited promises are returned as warnings.
|
||||
- **REPL-style results.** Without an explicit `return`, the final top-level expression becomes the result. `undefined`
|
||||
@@ -94,19 +94,11 @@ receive `{ extension, name, args }`. An `after` hook also receives how the call
|
||||
`failure` with its error, or `interrupted`). A failing `before` hook denies the call, and the program catches the
|
||||
failure as a thrown error.
|
||||
|
||||
### `Extension.make`
|
||||
### `Values`
|
||||
|
||||
Extensions are host functions a program calls directly as globals, such as `fetch`. Unlike tools they are not in the
|
||||
catalog, not counted against `maxToolCalls`, and not described to the model; the host decides what they mean.
|
||||
|
||||
```ts
|
||||
const web = Extension.make({ name: "web", globals: { fetch: (url: string) => globalThis.fetch(url) } })
|
||||
const runtime = CodeMode.make({ tools, extensions: [web] })
|
||||
```
|
||||
|
||||
Every value crossing in either direction is converted, never shared: arguments come in as copies, results go out as
|
||||
copies, and a function inside a result is callable the same way. A global that shadows a built-in or another
|
||||
extension throws at `CodeMode.make`.
|
||||
`Values` exports the runtime's non-JSON value classes: `Values.URL`, `Values.URLSearchParams`, `Values.Date`,
|
||||
`Values.RegExp`, `Values.Map`, `Values.Set`, and `Values.Promise`. The interpreter recognizes these by class; a
|
||||
program's `new URL(...)` is a `Values.URL` wrapping the host `URL`. `Values.isValue` narrows to the data-like kinds.
|
||||
|
||||
### OpenAPI tools
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
## Source and execution model
|
||||
|
||||
- [x] JavaScript parsed with the latest syntax accepted by Acorn, then restricted by the interpreter allowlist.
|
||||
TypeScript-only syntax is rejected rather than stripped before execution.
|
||||
- [x] Erasable TypeScript syntax, including type annotations, type declarations, assertions, and non-null assertions.
|
||||
TypeScript is transpiled first; the emitted JavaScript must still use the supported subset.
|
||||
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
|
||||
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
|
||||
- [x] The host boundary is `JSON.stringify` plus a short table. The program result and tool arguments cross as
|
||||
@@ -28,7 +29,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
Uint8Array is rejected with a hint to encode as text, and own `__proto__` keys are dropped so merging tool
|
||||
inputs or results cannot replace a prototype. In-program `JSON.stringify` keeps JS behavior except for the
|
||||
Error form and a promise, which is a `TypeError` with an await hint rather than a silent `{}`.
|
||||
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Uint8Array values inside CodeMode.
|
||||
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, and Uint8Array values inside CodeMode.
|
||||
- [x] Tool calls through the host-provided `tools` tree only.
|
||||
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
|
||||
shadowable by program declarations like other globals.
|
||||
@@ -40,12 +41,14 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
expression match can still run long on a pathological pattern; the host regex engine has no interrupt hook.
|
||||
- [ ] Strict-mode early errors: duplicate parameter names, `yield` as an identifier, and a trailing comma after a
|
||||
rest parameter are accepted unless the program itself begins with `"use strict"`.
|
||||
- [ ] Valid JavaScript rejected by TypeScript transpilation before interpretation, such as `in` inside a destructuring
|
||||
default in a `for...of` head and Unicode-escaped keywords.
|
||||
|
||||
## Values and literals
|
||||
|
||||
- [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings.
|
||||
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, Headers, custom
|
||||
synchronous iterators, and synchronous generators.
|
||||
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, custom synchronous
|
||||
iterators, and synchronous generators.
|
||||
- [x] Object literals with shorthand, computed string/number keys, and spread following ToObject: data objects and
|
||||
arrays copy own enumerable keys, strings copy index keys, and other values contribute nothing.
|
||||
- [x] Template literals with interpolation.
|
||||
@@ -53,7 +56,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] `NaN` and `Infinity` globals.
|
||||
- [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries.
|
||||
- [ ] Arbitrary Symbol primitive values and symbol-keyed properties. The confined `Symbol.iterator` and
|
||||
`Symbol.asyncIterator` keys are available only for the iterator protocols.
|
||||
`Symbol.asyncIterator` keys are available only for custom iterator protocols.
|
||||
- [ ] Tagged-template calls.
|
||||
- [ ] Getter and setter definitions in object literals.
|
||||
|
||||
@@ -92,8 +95,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] `if`/`else` and conditional expressions.
|
||||
- [x] `switch`, including default clauses and fallthrough.
|
||||
- [x] `for`, `while`, and `do...while`.
|
||||
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, Headers, Uint8Arrays, built-in iterators, custom
|
||||
synchronous iterators, and confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
|
||||
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, custom synchronous iterators, and confined
|
||||
synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
|
||||
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references; other values iterate nothing.
|
||||
- [x] Unlabeled `break` and `continue`.
|
||||
- [x] `try`, `catch`, optional catch bindings, and `finally`.
|
||||
@@ -124,7 +127,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
string). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
|
||||
because `includes` is called without a string `this`.
|
||||
- [x] Constructors work as callbacks with JS call semantics: `Error` types construct (`messages.map(Error)`),
|
||||
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Headers`, `Promise`) throw a `TypeError`,
|
||||
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Promise`) throw a `TypeError`,
|
||||
like JS.
|
||||
- [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an
|
||||
arrow function.
|
||||
@@ -176,10 +179,10 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Sequence expressions (the comma operator).
|
||||
- [x] `await` for CodeMode promises and callable thenables; a plain value passes through unchanged, though every
|
||||
`await` still defers its continuation one reaction turn.
|
||||
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Promise. `new`
|
||||
on any other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number`
|
||||
say `new` is unsupported and point at the plain call, user-defined functions report the constructor gap below,
|
||||
and non-callable values are not constructors. Error constructors take the ES2022 options object, so
|
||||
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. `new` on any
|
||||
other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number` say
|
||||
`new` is unsupported and point at the plain call, user-defined functions report the constructor gap below, and
|
||||
non-callable values are not constructors. Error constructors take the ES2022 options object, so
|
||||
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present.
|
||||
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
|
||||
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
|
||||
@@ -287,10 +290,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Ordering: `sort`, `toSorted`, `reverse`, and `toReversed`.
|
||||
- [x] Access/copying: `at`, `slice`, `concat`, `flat`, `with`, and `join`.
|
||||
- [x] Mutation: `push`, `pop`, `shift`, `unshift`, `splice`, `fill`, and `copyWithin`.
|
||||
- [x] `keys`, `values`, `entries`, and `[Symbol.iterator]` (the same function as `values`) return live iterator objects
|
||||
with `next()` and `[Symbol.iterator]`, as in JS. Iterator objects are opaque references: they print as
|
||||
`[opaque reference]`, serialize to `{}`, and cannot be passed to extensions. Every built-in collection iterator
|
||||
shares one prototype, which is only observable through `getPrototypeOf`.
|
||||
- [x] Materialized iteration helpers: `keys`, `values`, and `entries` return arrays rather than iterators.
|
||||
- [x] `length`, numeric indexing, index assignment, spread, and `for...of`.
|
||||
- [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows.
|
||||
- [x] `Array.prototype.toSpliced`.
|
||||
@@ -305,6 +305,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
separator: JavaScript applies ToIntegerOrInfinity/ToString (including `valueOf`, strings, and `undefined`), the
|
||||
interpreter requires numbers and strings; `includes()`/`indexOf()` with no argument should search for
|
||||
`undefined`.
|
||||
- [ ] Iterator objects from `keys`, `values`, and `entries` with a live `next()`.
|
||||
|
||||
## Strings
|
||||
|
||||
@@ -316,7 +317,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Regular-expression integration: `match`, materialized `matchAll`, `replace`, `replaceAll`, `split`, and `search`.
|
||||
- [x] `localeCompare`; locale and options arguments are currently ignored.
|
||||
- [x] `isWellFormed` and `toWellFormed`.
|
||||
- [x] `toString`, `length`, numeric indexing, spread, `for...of`, and `[Symbol.iterator]` by Unicode code point.
|
||||
- [x] `toString`, `length`, numeric indexing, spread, and `for...of` by Unicode code point.
|
||||
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
|
||||
- [x] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` coerce like
|
||||
native JS, `split(undefined)` returns the whole string, and `includes`/`startsWith`/`endsWith` reject regular
|
||||
@@ -407,8 +408,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, and `forEach`.
|
||||
- [x] `new Set()` from synchronous iterables.
|
||||
- [x] Set `add`, `has`, `delete`, `clear`, `size`, and `forEach`.
|
||||
- [x] Live `keys`, `values`, `entries`, and `[Symbol.iterator]` iterators for Map and Set; a Set-like operand's `keys()`
|
||||
may return a built-in iterator or an array.
|
||||
- [x] Materialized `keys`, `values`, and `entries` arrays for Map and Set.
|
||||
- [x] Spread, `for...of`, `Array.from`, and `Object.fromEntries` integration.
|
||||
- [x] Map and Set values serialize to `{}` at host/JSON boundaries.
|
||||
- [x] Set composition and relation methods: `union`, `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`,
|
||||
@@ -424,7 +424,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Writable URL fields except `origin`.
|
||||
- [x] `new URLSearchParams()` from query strings, data objects, synchronous iterables of pairs, and URLSearchParams.
|
||||
- [x] URLSearchParams `append`, `delete`, `get`, `getAll`, `has`, `set`, `sort`, `forEach`, `keys`, `values`,
|
||||
`entries`, `[Symbol.iterator]`, `toString`, and `size`.
|
||||
`entries`, `toString`, and `size`.
|
||||
- [x] URL values serialize to their href; URLSearchParams serialize to `{}`.
|
||||
|
||||
## Uint8Array
|
||||
@@ -437,8 +437,7 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
|
||||
- [x] Index reads and writes with JS byte semantics: values wrap modulo 256, out-of-range writes are ignored, indexes
|
||||
cannot be deleted. `length` is a prototype accessor, so `Object.keys` lists only indexes.
|
||||
- [x] `at`, `slice`, `subarray` (a view on the same bytes), `set`, `fill`, `reverse`, `indexOf`, `lastIndexOf`,
|
||||
`includes`, `join`, `toString`, `toBase64`, `toHex`, and live `keys`, `values`, `entries`, and `[Symbol.iterator]`
|
||||
iterators.
|
||||
`includes`, `join`, `toString`, `toBase64`, `toHex`, and materialized `keys`, `values`, and `entries` arrays.
|
||||
- [x] Spread, destructuring, `for...of`, `yield*`, `Array.from`, and `new Set(bytes)`. `Array.isArray` is false.
|
||||
- [x] String coercion joins with commas; `JSON.stringify` gives `{"0":1,...}`; `console.log` prints
|
||||
`Uint8Array(n) [...]`.
|
||||
@@ -452,13 +451,7 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
|
||||
- [x] `crypto.randomUUID()` and `crypto.getRandomValues(uint8Array)`.
|
||||
- [x] `TextEncoder` and `TextDecoder` for UTF-8 only: any other label is a `RangeError`. `TextDecoder` accepts the
|
||||
`fatal` and `ignoreBOM` options; `decode` takes a Uint8Array or nothing.
|
||||
- [x] `new Headers()` from records, synchronous iterables of pairs, and Headers, wrapping the host's `Headers`: names
|
||||
fold to lowercase, values are normalized and combined, and invalid names or values throw a `TypeError`.
|
||||
- [x] Headers `append`, `delete`, `get`, `getSetCookie`, `has`, `set`, `forEach`, `keys`, `values`, `entries`, and
|
||||
`[Symbol.iterator]`; iteration is live and sorted by name, with `set-cookie` values kept apart.
|
||||
- [x] Headers serialize to a `{ name: value }` object in JSON, in results, and in tool arguments.
|
||||
- [ ] `Request`, `Response`, and `Blob`.
|
||||
- [ ] `crypto.subtle` and `TextDecoder` streaming or non-UTF-8 encodings.
|
||||
- [ ] `crypto.subtle`, `Blob`, and `TextDecoder` streaming or non-UTF-8 encodings.
|
||||
|
||||
## Extensions
|
||||
|
||||
@@ -468,8 +461,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
- [x] Each global is a function, callable but not constructible, run with `this` undefined. A global that shadows
|
||||
a built-in or another extension throws at `make`.
|
||||
- [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied,
|
||||
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Headers`, `Map`, `Set`, and `Uint8Array` become fresh copies with
|
||||
their contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
|
||||
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Map`, `Set`, and `Uint8Array` become fresh copies with their
|
||||
contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
|
||||
errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators,
|
||||
un-awaited promises, and symbols cannot be passed in; a class instance, a symbol, or a BigInt cannot come out.
|
||||
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
|
||||
@@ -501,8 +494,9 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
|
||||
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
|
||||
`catch`.
|
||||
- [x] Source locations on unsupported-syntax diagnostics. The diagnostic names the rejected node type and attaches a
|
||||
short orientation to the supported subset; this matrix is the full reference.
|
||||
- [x] Source locations on unsupported-syntax diagnostics for JavaScript-shaped input; TypeScript transpilation may
|
||||
shift them. The diagnostic names the rejected node type and attaches a short orientation to the supported
|
||||
subset; this matrix is the full reference.
|
||||
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
|
||||
- [x] Caught errors do not distinguish user throws, interpreter failures, and tool failures; a program sees one
|
||||
Error-shaped value with `name` and `message` in `catch`, rejection handlers, and `Promise.allSettled` reasons.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -19,6 +19,12 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#transpile": {
|
||||
"workerd": "./src/interpreter/transpile.workerd.ts",
|
||||
"default": "./src/interpreter/transpile.node.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
@@ -26,7 +32,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:"
|
||||
"effect": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
|
||||
@@ -15,7 +15,7 @@ const pkg = JSON.parse(originalText) as {
|
||||
name: string
|
||||
version: string
|
||||
exports: Record<string, string | { import: string; types: string }>
|
||||
imports?: Record<string, Record<string, string>>
|
||||
imports: Record<string, Record<string, string>>
|
||||
}
|
||||
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
|
||||
const output = (value: string, types = false) =>
|
||||
@@ -41,14 +41,12 @@ try {
|
||||
]
|
||||
}),
|
||||
)
|
||||
if (pkg.imports) {
|
||||
pkg.imports = Object.fromEntries(
|
||||
Object.entries(pkg.imports).map(([key, conditions]) => [
|
||||
key,
|
||||
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
|
||||
]),
|
||||
)
|
||||
}
|
||||
pkg.imports = Object.fromEntries(
|
||||
Object.entries(pkg.imports).map(([key, conditions]) => [
|
||||
key,
|
||||
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
|
||||
]),
|
||||
)
|
||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
|
||||
await rm(tarball, { force: true })
|
||||
await $`bun pm pack`
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
record,
|
||||
SetObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./interpreter/objects.js"
|
||||
import { typeofValue } from "./interpreter/references.js"
|
||||
|
||||
@@ -70,7 +69,6 @@ const walk = <R>(
|
||||
)
|
||||
}
|
||||
if (boundary && value instanceof URLSearchParamsObj) return value.params.toString()
|
||||
if (value instanceof HeadersObj) return Object.fromEntries(value.headers)
|
||||
const target = boundary && value instanceof SetObj ? new Arr(ctx.builtins.Array, [...value.set]) : value
|
||||
if (stack.has(target)) throw typeError("Converting circular structure to JSON.")
|
||||
stack.add(target)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { parse, type Program } from "acorn"
|
||||
import { Cause, Effect, Scope } from "effect"
|
||||
// #transpile: conditional import — full typescript on node/bun, an identity
|
||||
// pass-through on workerd (the compiler is ~11 MiB and can't init there).
|
||||
import { transpile } from "#transpile"
|
||||
import type { DataValue, Diagnostic, ResolvedExecutionLimits, Result } from "../codemode.js"
|
||||
import { toBoundary } from "../data.js"
|
||||
import { ToolRuntime } from "../tool-runtime.js"
|
||||
import { normalizeError } from "./errors.js"
|
||||
import { createBuiltins } from "./intrinsics.js"
|
||||
import { PendingThrow } from "./model.js"
|
||||
import { Pending } from "./promises.js"
|
||||
import { Interpreter } from "./interpreter.js"
|
||||
|
||||
@@ -106,7 +110,16 @@ export const executeProgram = <R>(
|
||||
}
|
||||
|
||||
const parseProgram = (code: string): Program => {
|
||||
return parse(code, {
|
||||
const transpiled = transpile(`async function __codemode__() {\n${code}\n}`)
|
||||
|
||||
if (transpiled.error !== undefined) {
|
||||
throw new PendingThrow("SyntaxError", `Failed to parse TypeScript: ${transpiled.error}`, undefined, "ParseError")
|
||||
}
|
||||
|
||||
const bodyStart = transpiled.outputText.indexOf("{") + 1
|
||||
const bodyEnd = transpiled.outputText.lastIndexOf("}")
|
||||
const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd)
|
||||
return parse(executableCode, {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "script",
|
||||
allowReturnOutsideFunction: true,
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
DateObj,
|
||||
ErrorObj,
|
||||
GeneratorObj,
|
||||
IteratorObj,
|
||||
MapObj,
|
||||
Obj,
|
||||
PromiseObj,
|
||||
@@ -28,7 +27,6 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./objects.js"
|
||||
import { describeValue } from "./references.js"
|
||||
|
||||
@@ -54,7 +52,6 @@ export const extensionGlobals = <R>(
|
||||
if (value instanceof RegExpObj) return new RegExp(value.regex.source, value.regex.flags)
|
||||
if (value instanceof URLObj) return new URL(value.url.href)
|
||||
if (value instanceof URLSearchParamsObj) return new URLSearchParams(value.params)
|
||||
if (value instanceof HeadersObj) return new Headers(value.headers)
|
||||
const next = (item: unknown) => toHost(item, label, depth + 1, seen)
|
||||
if (value instanceof MapObj) return new Map([...value.map].map(([key, item]) => [next(key), next(item)]))
|
||||
if (value instanceof SetObj) return new Set([...value.set].map(next))
|
||||
@@ -62,7 +59,6 @@ export const extensionGlobals = <R>(
|
||||
!(value instanceof Obj) ||
|
||||
value instanceof Callable ||
|
||||
value instanceof GeneratorObj ||
|
||||
value instanceof IteratorObj ||
|
||||
value instanceof PromiseObj
|
||||
) {
|
||||
throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`)
|
||||
@@ -129,7 +125,6 @@ export const extensionGlobals = <R>(
|
||||
if (value instanceof URLSearchParams) {
|
||||
return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value))
|
||||
}
|
||||
if (value instanceof Headers) return new HeadersObj(builtins.Headers, new Headers(value))
|
||||
if (value instanceof Map) {
|
||||
const wrapped = new MapObj(builtins.Map)
|
||||
for (const [key, item] of value) wrapped.map.set(next(key, label), next(item, label))
|
||||
|
||||
@@ -11,8 +11,6 @@ import { objectGlobal } from "../stdlib/object.js"
|
||||
import { regexpGlobal } from "../stdlib/regexp.js"
|
||||
import { stringGlobal } from "../stdlib/string.js"
|
||||
import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
|
||||
import { headersGlobal } from "../stdlib/headers.js"
|
||||
import { iteratorGlobals } from "../stdlib/iterator.js"
|
||||
import { coercion } from "../stdlib/value.js"
|
||||
import { base64Global, cryptoGlobal } from "../stdlib/web.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
@@ -82,7 +80,6 @@ const table: Record<string, Factory> = {
|
||||
Set: (ctx) => setGlobal(ctx),
|
||||
URL: (ctx) => urlGlobal(ctx),
|
||||
URLSearchParams: (ctx) => urlSearchParamsGlobal(ctx),
|
||||
Headers: (ctx) => headersGlobal(ctx),
|
||||
Uint8Array: (ctx) => uint8ArrayGlobal(ctx),
|
||||
TextEncoder: (ctx) => textEncoderGlobal(ctx),
|
||||
TextDecoder: (ctx) => textDecoderGlobal(ctx),
|
||||
@@ -102,6 +99,5 @@ export const globalNames: ReadonlySet<string> = new Set(Object.keys(table))
|
||||
/** The immutable global bindings of every program, in declaration order. */
|
||||
export const globals = <R>(ctx: Interpreter<R>): ReadonlyArray<readonly [string, unknown]> => {
|
||||
generatorGlobals(ctx)
|
||||
iteratorGlobals(ctx)
|
||||
return Object.entries(table).map(([name, factory]) => [name, factory(ctx)] as const)
|
||||
}
|
||||
|
||||
@@ -79,13 +79,11 @@ import {
|
||||
DateObj,
|
||||
Fn,
|
||||
GeneratorObj,
|
||||
IteratorObj,
|
||||
MapObj,
|
||||
Obj,
|
||||
PromiseObj,
|
||||
SetObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
record,
|
||||
remove,
|
||||
set,
|
||||
@@ -651,11 +649,11 @@ class Frame<R> {
|
||||
if (declared?.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
|
||||
const right = yield* self.evaluateExpression(node.right)
|
||||
|
||||
const cursor = self.hostCursor(right)
|
||||
const iterator = cursor === undefined ? yield* self.customIterator(right, node, awaiting) : undefined
|
||||
const iterator = yield* self.customIterator(right, node, awaiting)
|
||||
const cursor = iterator === undefined ? yield* self.iterate(right, node) : undefined
|
||||
if (iterator === undefined && cursor === undefined) {
|
||||
throw invalidData(
|
||||
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, URLSearchParams, or Headers, or custom iterator value.`,
|
||||
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams, or custom iterator value.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
@@ -747,20 +745,6 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
iterate(value: unknown, node?: AstNode) {
|
||||
const cursor = this.hostCursor(value)
|
||||
if (cursor !== undefined) return Effect.succeed(cursor)
|
||||
const self = this
|
||||
return Effect.map(this.customIterator(value, node, false), (iterator) =>
|
||||
iterator === undefined
|
||||
? undefined
|
||||
: {
|
||||
next: self.nextIteratorResult(iterator, node, false),
|
||||
close: Effect.suspend(() => self.closeIterator(iterator, node, false)),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private hostCursor(value: unknown) {
|
||||
const iterator =
|
||||
value instanceof Arr
|
||||
? value.items[Symbol.iterator]()
|
||||
@@ -772,25 +756,31 @@ class Frame<R> {
|
||||
? value.set.values()
|
||||
: value instanceof URLSearchParamsObj
|
||||
? value.params.entries()
|
||||
: value instanceof HeadersObj
|
||||
? value.headers.entries()
|
||||
: value instanceof Bytes
|
||||
? value.bytes.values()
|
||||
: value instanceof IteratorObj
|
||||
? value.iterator
|
||||
: undefined
|
||||
if (iterator === undefined) return undefined
|
||||
const proto = this.ctx.builtins.Array
|
||||
return {
|
||||
next: Effect.sync(() => {
|
||||
const step = iterator.next()
|
||||
return {
|
||||
done: Boolean(step.done),
|
||||
value: Array.isArray(step.value) ? new Arr(proto, step.value) : step.value,
|
||||
}
|
||||
}),
|
||||
close: Effect.void,
|
||||
: value instanceof Bytes
|
||||
? value.bytes.values()
|
||||
: undefined
|
||||
if (iterator !== undefined) {
|
||||
const proto = this.ctx.builtins.Array
|
||||
return Effect.succeed({
|
||||
next: Effect.sync(() => {
|
||||
const step = iterator.next()
|
||||
return {
|
||||
done: Boolean(step.done),
|
||||
value: Array.isArray(step.value) ? new Arr(proto, step.value) : step.value,
|
||||
}
|
||||
}),
|
||||
close: Effect.void,
|
||||
})
|
||||
}
|
||||
const self = this
|
||||
return Effect.map(this.customIterator(value, node, false), (iterator) =>
|
||||
iterator === undefined
|
||||
? undefined
|
||||
: {
|
||||
next: self.nextIteratorResult(iterator, node, false),
|
||||
close: Effect.suspend(() => self.closeIterator(iterator, node, false)),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private customIterator(value: unknown, node: AstNode | undefined, allowAsync = true) {
|
||||
@@ -1858,7 +1848,6 @@ class Frame<R> {
|
||||
value instanceof MapObj ||
|
||||
value instanceof SetObj ||
|
||||
value instanceof URLSearchParamsObj ||
|
||||
value instanceof HeadersObj ||
|
||||
value instanceof Bytes
|
||||
) {
|
||||
const cursor = yield* self.iterate(value, node)
|
||||
|
||||
@@ -29,7 +29,6 @@ const builtins = [
|
||||
"Set",
|
||||
"URL",
|
||||
"URLSearchParams",
|
||||
"Headers",
|
||||
"Uint8Array",
|
||||
"TextEncoder",
|
||||
"TextDecoder",
|
||||
@@ -81,7 +80,6 @@ export const createBuiltins = (): Builtins => {
|
||||
Set: plain(),
|
||||
URL: plain(),
|
||||
URLSearchParams: plain(),
|
||||
Headers: plain(),
|
||||
Uint8Array: plain(),
|
||||
TextEncoder: plain(),
|
||||
TextDecoder: plain(),
|
||||
|
||||
@@ -122,16 +122,6 @@ export class GeneratorObj extends Obj {
|
||||
}
|
||||
}
|
||||
|
||||
/** A built-in collection iterator: live over the host collection, yielding program values. */
|
||||
export class IteratorObj extends Obj {
|
||||
constructor(
|
||||
proto: Obj,
|
||||
readonly iterator: IteratorObject<unknown>,
|
||||
) {
|
||||
super(proto)
|
||||
}
|
||||
}
|
||||
|
||||
export class DateObj extends Obj {
|
||||
constructor(
|
||||
proto: Obj,
|
||||
@@ -166,15 +156,6 @@ export class URLSearchParamsObj extends Obj {
|
||||
}
|
||||
}
|
||||
|
||||
export class HeadersObj extends Obj {
|
||||
constructor(
|
||||
proto: Obj,
|
||||
readonly headers: Headers,
|
||||
) {
|
||||
super(proto)
|
||||
}
|
||||
}
|
||||
|
||||
export class URLObj extends Obj {
|
||||
readonly searchParams: URLSearchParamsObj
|
||||
constructor(
|
||||
@@ -200,14 +181,13 @@ export class Bytes extends Obj {
|
||||
/** Built-in objects that wrap a host value; data-like, but never plain data. */
|
||||
export const isWrapper = (
|
||||
value: unknown,
|
||||
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | HeadersObj | Bytes =>
|
||||
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | Bytes =>
|
||||
value instanceof DateObj ||
|
||||
value instanceof RegExpObj ||
|
||||
value instanceof MapObj ||
|
||||
value instanceof SetObj ||
|
||||
value instanceof URLObj ||
|
||||
value instanceof URLSearchParamsObj ||
|
||||
value instanceof HeadersObj ||
|
||||
value instanceof Bytes
|
||||
|
||||
const MAX_ARRAY_INDEX = 4_294_967_295
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Bytes,
|
||||
DateObj,
|
||||
GeneratorObj,
|
||||
IteratorObj,
|
||||
MapObj,
|
||||
Obj,
|
||||
PromiseObj,
|
||||
@@ -17,14 +16,12 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./objects.js"
|
||||
|
||||
/** Values that cannot cross the data boundary. */
|
||||
export const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof Callable ||
|
||||
value instanceof GeneratorObj ||
|
||||
value instanceof IteratorObj ||
|
||||
value instanceof ToolReference ||
|
||||
value instanceof PromiseObj ||
|
||||
isWrapper(value)
|
||||
@@ -88,10 +85,8 @@ export const describeValue = (value: unknown): string => {
|
||||
if (value instanceof SetObj) return "a Set"
|
||||
if (value instanceof URLObj) return "a URL"
|
||||
if (value instanceof URLSearchParamsObj) return "a URLSearchParams"
|
||||
if (value instanceof HeadersObj) return "a Headers"
|
||||
if (value instanceof Bytes) return "a Uint8Array"
|
||||
if (value instanceof GeneratorObj) return "a generator"
|
||||
if (value instanceof IteratorObj) return "an iterator"
|
||||
if (isRuntimeReference(value)) return "a function"
|
||||
if (typeof value === "object") return "a data object"
|
||||
return `a ${typeof value}`
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
|
||||
|
||||
export interface TranspileResult {
|
||||
readonly outputText: string
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
// Full TypeScript transpilation on node/bun runtimes.
|
||||
export const transpile = (source: string): TranspileResult => {
|
||||
const transpiled = transpileModule(source, {
|
||||
reportDiagnostics: true,
|
||||
compilerOptions: {
|
||||
target: ScriptTarget.ESNext,
|
||||
module: ModuleKind.ESNext,
|
||||
},
|
||||
})
|
||||
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
|
||||
if (diagnostic) {
|
||||
return {
|
||||
outputText: transpiled.outputText,
|
||||
error: flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
|
||||
}
|
||||
}
|
||||
return { outputText: transpiled.outputText }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface TranspileResult {
|
||||
readonly outputText: string
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
// workerd profile: the typescript compiler is ~11 MiB and probes node
|
||||
// internals at module init, so codemode programs are passed through
|
||||
// untranspiled. Plain-JS programs (the overwhelmingly common case) parse
|
||||
// fine downstream via acorn; TypeScript-only syntax surfaces as a parse
|
||||
// error from the interpreter instead of a transpile diagnostic.
|
||||
export const transpile = (source: string): TranspileResult => ({ outputText: source })
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Effect } from "effect"
|
||||
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
|
||||
import { checkArrayLength, checkStringLength, MAX_ARRAY_LENGTH } from "../interpreter/limits.js"
|
||||
import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpreter/model.js"
|
||||
import { define, get, hidden, Arr, GeneratorObj, IteratorObj, Obj } from "../interpreter/objects.js"
|
||||
import { invalidData, rangeError, typeError } from "../interpreter/model.js"
|
||||
import { get, Arr, GeneratorObj, Obj } from "../interpreter/objects.js"
|
||||
import { describeValue, rejectCircularInsertion } from "../interpreter/references.js"
|
||||
import { applyCollectionCallback, preserveConsumerError } from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
@@ -340,18 +340,13 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return target
|
||||
},
|
||||
],
|
||||
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").items.keys())],
|
||||
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").items.values())],
|
||||
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").items.keys()))],
|
||||
["values", 0, (thisValue) => wrap([...self(thisValue, "values").items])],
|
||||
[
|
||||
"entries",
|
||||
0,
|
||||
(thisValue) =>
|
||||
new IteratorObj(
|
||||
builtins.Iterator,
|
||||
self(thisValue, "entries")
|
||||
.items.entries()
|
||||
.map(([index, item]) => wrap([index, item])),
|
||||
),
|
||||
wrap(Array.from(self(thisValue, "entries").items.entries(), ([index, item]) => wrap([index, item]))),
|
||||
],
|
||||
iterate("map", 1, (target, receiver, apply) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -495,6 +490,5 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
}),
|
||||
),
|
||||
])
|
||||
define(proto, IteratorSymbol, get(proto, "values"), hidden)
|
||||
return array
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user