Compare commits

..
Author SHA1 Message Date
usrnk1 12ab6d5ade refactor(app): simplify settings navigation and autosave 2026-09-08 17:36:28 +02:00
usrnk1 3244e0613c fix(app): polish project hints and icon preview 2026-09-08 17:23:34 +02:00
usrnk1 356a070afc refactor(app): standardize settings catalog list spacing 2026-09-08 17:23:00 +02:00
usrnk1 739f58aadf feat(app): show configured language servers in project settings 2026-09-08 17:02:10 +02:00
usrnk1 2e57ac20ff feat(app): add nested server and project settings
Scope settings by server, replace project dialogs with autosaving views, preserve WSL management, and refine extension lists and controls.
2026-09-08 17:00:48 +02:00
usrnk1 cef086e5db feat(server): expose global preferences and shell discovery 2026-09-08 16:59:17 +02:00
usrnk1 223e9a8c41 refactor(ui): refresh settings navigation icons 2026-09-08 12:07:04 +02:00
usrnk1 23b87714e3 refactor(app): unify settings layout spacing 2026-09-08 12:06:43 +02:00
usrnk1 6e87e858e8 refactor(app): reorganize desktop settings 2026-09-08 12:06:17 +02:00
199 changed files with 4235 additions and 6853 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
- name: Deploy update service
if: github.ref_name == 'v2'
working-directory: services/update
working-directory: services/updates
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+70 -71
View File
@@ -168,7 +168,6 @@
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2",
"solid-refresh": "0.6.3",
"vite": "catalog:",
"vite-plugin-solid": "catalog:",
},
@@ -1038,8 +1037,8 @@
"wrangler": "4.110.0",
},
},
"services/update": {
"name": "@opencode/update",
"services/updates": {
"name": "@opencode/updates",
"version": "1.18.4",
"dependencies": {
"jose": "6.0.11",
@@ -2246,7 +2245,7 @@
"@opencode/ui": ["@opencode/ui@workspace:packages/ui"],
"@opencode/update": ["@opencode/update@workspace:services/update"],
"@opencode/updates": ["@opencode/updates@workspace:services/updates"],
"@opencode/util": ["@opencode/util@workspace:packages/util"],
@@ -6420,11 +6419,11 @@
"@opencode/ui/vite-plugin-solid": ["vite-plugin-solid@2.11.10", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw=="],
"@opencode/update/@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
"@opencode/updates/@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
"@opencode/update/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@opencode/updates/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@opencode/update/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
"@opencode/updates/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
"@opencode/web/@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.1", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20250507.0", "tinyglobby": "^0.2.13", "vite": "^6.3.5", "wrangler": "^4.14.1" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-xhJptF5tU2k5eo70nIMyL1Udma0CqmUEnGSlGyFflLqSY82CRQI6nWZ/xZt0ZvmXuErUjIx0YYQNfZsz5CNjLQ=="],
@@ -7318,15 +7317,15 @@
"@opencode/storybook/@tailwindcss/vite/tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
"@opencode/update/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
"@opencode/updates/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
"@opencode/update/wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
"@opencode/updates/wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
"@opencode/update/wrangler/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"@opencode/updates/wrangler/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"@opencode/update/wrangler/miniflare": ["miniflare@4.20260708.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260708.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-c94O9zRDISdqO18EHt6l0iF/fWgWt8p18PJvRsA/L/NJZ9Cfke3s/F5Blg1XXF7WDutVRzWVWy8Vy4LaT5ifsA=="],
"@opencode/updates/wrangler/miniflare": ["miniflare@4.20260708.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260708.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-c94O9zRDISdqO18EHt6l0iF/fWgWt8p18PJvRsA/L/NJZ9Cfke3s/F5Blg1XXF7WDutVRzWVWy8Vy4LaT5ifsA=="],
"@opencode/update/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
"@opencode/updates/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
"@opencode/web/@astrojs/cloudflare/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.1", "", {}, "sha512-7dwEVigz9vUWDw3nRwLQ/yH/xYovlUA0ZD86xoeKEBmkz9O6iELG1yri67PgAPW6VLL/xInA4t7H0CK6VmtkKQ=="],
@@ -8254,71 +8253,71 @@
"@opencode/storybook/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.11", "", { "os": "win32", "cpu": "x64" }, "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg=="],
"@opencode/update/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@opencode/updates/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@opencode/update/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"@opencode/updates/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"@opencode/update/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"@opencode/updates/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"@opencode/update/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"@opencode/updates/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"@opencode/update/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"@opencode/updates/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"@opencode/update/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"@opencode/updates/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"@opencode/update/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"@opencode/updates/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"@opencode/update/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"@opencode/updates/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"@opencode/update/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"@opencode/updates/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"@opencode/update/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"@opencode/updates/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"@opencode/update/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"@opencode/updates/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"@opencode/update/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"@opencode/updates/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"@opencode/update/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"@opencode/updates/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"@opencode/update/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"@opencode/updates/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"@opencode/update/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"@opencode/updates/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"@opencode/update/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"@opencode/updates/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"@opencode/update/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"@opencode/updates/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"@opencode/update/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"@opencode/updates/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"@opencode/update/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"@opencode/updates/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"@opencode/update/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"@opencode/updates/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"@opencode/update/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"@opencode/updates/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"@opencode/update/wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"@opencode/updates/wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"@opencode/update/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"@opencode/updates/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"@opencode/update/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"@opencode/updates/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"@opencode/update/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"@opencode/updates/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"@opencode/update/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"@opencode/updates/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"@opencode/update/wrangler/miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"@opencode/updates/wrangler/miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"@opencode/update/wrangler/miniflare/undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
"@opencode/updates/wrangler/miniflare/undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
"@opencode/update/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260708.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg=="],
"@opencode/updates/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260708.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg=="],
"@opencode/update/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260708.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA=="],
"@opencode/updates/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260708.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA=="],
"@opencode/update/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260708.1", "", { "os": "linux", "cpu": "x64" }, "sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA=="],
"@opencode/updates/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260708.1", "", { "os": "linux", "cpu": "x64" }, "sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA=="],
"@opencode/update/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260708.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA=="],
"@opencode/updates/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260708.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA=="],
"@opencode/update/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260708.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA=="],
"@opencode/updates/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260708.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA=="],
"@opencode/web/@astrojs/cloudflare/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
@@ -8922,55 +8921,55 @@
"@opencode/storybook/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@opencode/update/wrangler/miniflare/sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"@opencode/updates/wrangler/miniflare/sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"@opencode/web/@astrojs/cloudflare/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
@@ -9154,7 +9153,7 @@
"@opencode/storybook/@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@opencode/update/wrangler/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@opencode/updates/wrangler/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@opencode/web/@astrojs/cloudflare/wrangler/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-yzCk746pospz8EVakHRcDhYJkhGYGSt9dHOPbzO4OYo=",
"aarch64-linux": "sha256-MFJVLos4v2r9jazmmr3ldVCJKLrO+Qp/BpGpyM/1lf8=",
"aarch64-darwin": "sha256-k8r/HVSgdRSTlJ1lI7EebqbxeA3AElnaw1sDYOPEdQw=",
"x86_64-darwin": "sha256-ACJdJfz12xLBQvWIkbuve86znSoGJ2PLDQbgh0cT6/g="
"x86_64-linux": "sha256-EKhY3iZDrbNrBhntWpSdtLcmNLte6yVBxpIrCxr1uNM=",
"aarch64-linux": "sha256-0OjDGZHgcnnk6IxkfK6ogeeqsGTqY/dcaZ/XzT23sgA=",
"aarch64-darwin": "sha256-Zk51gnOicaLtPuqCYfgARhm2TjL222w1Y0Em288o0YY=",
"x86_64-darwin": "sha256-hvDZ9zCV6zOSx6i7JZ1kVUMht+JI/jc8/y+aYrNHQ1E="
}
}
-2
View File
@@ -9,8 +9,6 @@
"scripts": {
"dev": "bun run --cwd packages/cli src/index.ts",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:vite": "bun run --cwd packages/cli --conditions=browser dev/vite.ts",
"dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
@@ -1,25 +1,6 @@
import { expect, test } from "bun:test"
import type { Page, Route } from "@playwright/test"
import { createMockServerHandler, mockOpenCodeServer } from "../../utils/mock-server"
test("serves an empty config document list for composer defaults", async () => {
const server = createMockServerHandler({
provider: {},
directory: "C:/OpenCode",
project: {},
sessions: [],
pageMessages: () => ({ items: [] }),
})
try {
const response = await server.handler(
new Request("http://localhost/api/config?location%5Bdirectory%5D=C%3A%2FOpenCode"),
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual([])
} finally {
await server.dispose()
}
})
import { mockOpenCodeServer } from "../../utils/mock-server"
test("applies message latency after a list response gate is released", async () => {
const events: string[] = []
@@ -0,0 +1,129 @@
import { expect, test } from "@playwright/test"
import type { ConfigEntry } from "@opencode/client/promise"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/repo/configured-lsp"
const entries: ConfigEntry[] = [
{
type: "document",
path: "/config/opencode.json",
info: {
lsp: {
typescript: { command: ["typescript-language-server", "--stdio"], extensions: [".ts", ".tsx"] },
},
},
},
{
type: "document",
path: `${directory}/opencode.jsonc`,
info: {
lsp: {
typescript: { disabled: true },
rust: { command: ["rust-analyzer"], extensions: [".rs"] },
},
},
},
]
test.use({ viewport: { width: 1280, height: 900 } })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_configured_lsp",
canonical: directory,
name: "Configured LSP project",
sandboxes: [],
time: { created: 1, updated: 1 },
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
}, directory)
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: "Configured LSP project", exact: true }).click()
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
})
test("shows inherited and project-configured LSP entries with config-only status", async ({ page }) => {
const ready = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === "/api/config",
async (route) => {
await ready.promise
await route.fulfill({ json: entries })
},
)
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/config")
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
expect(new URL((await requested).url()).searchParams.get("location[directory]")).toBe(directory)
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
await expect(panel.getByText("Loading", { exact: true })).toBeVisible()
ready.resolve()
await expect(panel.getByText("typescript", { exact: true })).toBeVisible()
await expect(panel.getByText("rust", { exact: true })).toBeVisible()
const typescript = panel.locator(".project-settings-extension-row").filter({ hasText: "typescript" })
await expect(typescript).toContainText("Disabled in config")
await expect(typescript).toContainText(".ts, .tsx")
await expect(panel.locator(".project-settings-extension-row").filter({ hasText: "rust" })).toContainText(
"Enabled in config",
)
await expect(panel.getByRole("switch")).toHaveCount(0)
await expect(panel.getByText("Setup required", { exact: true })).toHaveCount(0)
await page.setViewportSize({ width: 390, height: 844 })
await expect(panel.getByText("rust", { exact: true })).toBeInViewport()
await expect
.poll(() => settings.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(1)
})
for (const lsp of [true, false]) {
test(`handles boolean lsp=${lsp} without inventing detected servers`, async ({ page }) => {
await page.route(
(url) => url.pathname === "/api/config",
(route) =>
route.fulfill({
json: [{ type: "document", info: { lsp } }],
}),
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
await expect(
panel.getByText(lsp ? "No language servers configured" : "Language servers disabled", { exact: true }),
).toBeVisible()
await expect(panel.locator(".project-settings-extension-row")).toHaveCount(0)
})
}
test("keeps configuration load failures inside the tab and allows retry", async ({ page }) => {
const state = { fail: true }
await page.route(
(url) => url.pathname === "/api/config",
(route) =>
route.fulfill({
status: state.fail ? 404 : 200,
json: state.fail ? {} : entries,
}),
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
await expect(panel.getByText("Could not load language server configuration", { exact: true })).toBeVisible()
state.fail = false
await panel.getByRole("button", { name: "Retry", exact: true }).click()
await expect(panel.getByText("typescript", { exact: true })).toBeVisible()
await settings.getByRole("tab", { name: "Skills", exact: true }).click()
await expect(settings.getByRole("tabpanel", { name: "Skills", exact: true })).toBeVisible()
})
@@ -20,6 +20,7 @@ test("session settings use the remote server context", async ({ page }) => {
// one toggle sweeps every connected server, not just the focused one.
await mockServers(page, permissionRequests, permissionResponses, {
pending: { [serverA]: [pendingPermission("permission-pending-a", sessionA.id)] },
preferencesUnavailable: true,
})
await configureServers(page)
@@ -36,6 +37,8 @@ test("session settings use the remote server context", async ({ page }) => {
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px")
await expect(sessionHeading).toBeHidden()
await expect(settings.getByText("Servers", { exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveCount(0)
const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]')
const input = autoAccept.getByRole("switch")
await expect(autoAccept).toBeVisible()
@@ -63,9 +66,21 @@ test("session settings use the remote server context", async ({ page }) => {
},
])
await settings.getByRole("tab", { name: "127.0.0.1:4097", exact: true }).click()
await expect(settings.getByRole("button", { name: "Back to settings", exact: true })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
await expect(settings.getByRole("tab")).toHaveText([
"127.0.0.1:4097",
"Projects",
"Worktrees",
"Providers",
"Models",
"Extensions",
])
await settings.getByRole("tab", { name: "Models" }).click()
await expect(settings.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
await settings.getByRole("button", { name: "Back to settings" }).click()
await settings.getByRole("button", { name: "Back to app" }).click()
await expect(settings).toBeHidden()
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
@@ -73,7 +88,7 @@ test("session settings use the remote server context", async ({ page }) => {
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title)
await page.keyboard.press("Control+]")
await expect(page).toHaveURL("/settings")
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
@@ -311,6 +326,7 @@ type MockServerOptions = {
listFailures?: Record<string, number>
// Records /api/session/:id GETs so tests can assert session resyncs.
sessionGets?: string[]
preferencesUnavailable?: boolean
}
async function mockServers(
@@ -342,7 +358,6 @@ async function mockServers(
if (route.request().method() === "GET" && sessionPermission)
return json(route, { data: options.sessionPending?.[sessionPermission[1]!] ?? [] })
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/config") return json(route, [])
if (url.pathname === "/api/provider")
return json(route, {
location: { directory },
@@ -402,6 +417,11 @@ async function mockServers(
directory,
project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory },
})
if (url.pathname === "/api/config/preferences") return json(route, {}, options.preferencesUnavailable ? 404 : 200)
if (url.pathname === "/api/config/shell")
return json(route, options.preferencesUnavailable ? {} : [], options.preferencesUnavailable ? 404 : 200)
if (url.pathname === "/api/websearch/provider")
return json(route, { location: { directory }, data: [] }, options.preferencesUnavailable ? 404 : 200)
if (url.pathname === "/api/worktree") return json(route, [{ directory }])
if (url.pathname === "/api/vcs")
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
@@ -27,8 +27,12 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) =>
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await expect(page.getByRole("dialog")).toHaveCount(0)
await settings.getByRole("tab", { name: "Servers" }).click()
await settings.getByRole("button", { name: "Add server" }).click()
const add = settings.getByRole("button", { name: "Add server" })
const group = settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" })
await expect(add).toHaveCSS("opacity", "0")
await group.hover()
await expect(add).toHaveCSS("opacity", "1")
await add.click()
const editor = page.getByRole("dialog", { name: "Add server" })
await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused()
@@ -198,14 +198,15 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(settings).toBeFocused()
await expect(page.getByRole("tooltip")).toBeHidden()
await page.keyboard.press("Enter")
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("heading", { name: copy["dialog.project.edit.title"], exact: true })).toBeVisible()
await expect(dialog.getByRole("textbox", { name: copy["dialog.project.edit.name"], exact: true })).toHaveValue(
project.name,
)
const settingsScreen = page.getByTestId("settings-screen")
await expect(settingsScreen.getByRole("heading", { name: project.name, exact: true })).toBeVisible()
await expect(
settingsScreen.getByRole("textbox", { name: en["project.settings.name.title"], exact: true }),
).toHaveValue(project.name)
await expect(menu).toBeHidden()
await dialog.getByRole("button", { name: copy["common.cancel"], exact: true }).click()
await expect(dialog).toBeHidden()
await settingsScreen.getByRole("button", { name: en["settings.backToProjects"], exact: true }).click()
await settingsScreen.getByRole("button", { name: en["settings.backToApp"], exact: true }).click()
await expect(settingsScreen).toBeHidden()
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await page.setViewportSize({ width: 1440, height: 900 })
@@ -272,12 +273,13 @@ for (const state of ["closed", "unopened"] as const) {
await expect(menu.getByRole("menuitem", { name: fixture.project.name, exact: true })).toBeEnabled()
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
await menu.getByRole("menuitem", { name: "Edit project", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox", { name: en["dialog.project.edit.name"], exact: true })).toHaveValue(
fixture.project.name,
)
await dialog.getByRole("button", { name: en["common.cancel"], exact: true }).click()
await expect(dialog).toBeHidden()
const settingsScreen = page.getByTestId("settings-screen")
await expect(
settingsScreen.getByRole("textbox", { name: en["project.settings.name.title"], exact: true }),
).toHaveValue(fixture.project.name)
await settingsScreen.getByRole("button", { name: en["settings.backToProjects"], exact: true }).click()
await settingsScreen.getByRole("button", { name: en["settings.backToApp"], exact: true }).click()
await expect(settingsScreen).toBeHidden()
}
await trigger.click()
await menu.getByRole("menuitem", { name: fixture.project.name, exact: true }).click()
@@ -13,11 +13,20 @@ test.beforeEach(async ({ page }) => {
id: "proj_settings_demo",
canonical: directory,
name: "Settings demo",
icon: {
color: "orange",
override:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3E%3Crect width='16' height='16' fill='red'/%3E%3C/svg%3E",
},
commands: { start: "echo setup" },
vcs: "git",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes,
},
provider: { all: [], connected: [], default: {} },
preferences: { shell: "zsh", websearch: { provider: "exa" } },
shells: [{ path: "/bin/zsh", name: "zsh", acceptable: true }],
websearchProviders: [{ id: "exa", name: "Exa" }],
sessions: sandboxes.map((directory, index) => ({
id: `ses_settings_${index + 1}`,
title: `Workspace ${index + 1} session`,
@@ -56,6 +65,130 @@ test("settings has its own route and returns through app history", async ({ page
await expect(home).toHaveAttribute("aria-pressed", "true")
})
test("single-server settings expose scoped pages without a server picker", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings.getByRole("tab", { name: "Server", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Servers", exact: true })).toHaveCount(0)
for (const name of ["Projects", "Worktrees", "Providers", "Models", "Extensions"]) {
await settings.getByRole("tab", { name, exact: true }).click()
await expect(settings.locator('[data-action="settings-server-select"]')).toHaveCount(0)
}
await settings.getByRole("tab", { name: "Server", exact: true }).click()
await expect(settings.getByRole("button", { name: "Add server", exact: true })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
await expect(settings.getByText("Terminal shell", { exact: true })).toBeVisible()
await expect(settings.getByText("Third-party search", { exact: true })).toBeVisible()
await expect(settings.getByText("zsh", { exact: true })).toBeVisible()
await expect(settings.getByText("Exa", { exact: true })).toBeVisible()
await settings.getByText("Exa", { exact: true }).click()
const updated = page.waitForRequest(
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/config/preferences",
)
await page.getByRole("option", { name: "Any", exact: true }).click()
expect((await updated).postDataJSON()).toEqual({ websearch: { provider: "random" } })
})
test("server details tolerate unavailable preference endpoints", async ({ page }) => {
await page.route(
(url) =>
url.pathname === "/api/config/preferences" ||
url.pathname === "/api/config/shell" ||
url.pathname === "/api/websearch/provider",
(route) => route.fulfill({ status: 404, json: {} }),
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Server", exact: true }).click()
const connection = settings.locator('[data-component="settings-server-connection"]')
await expect(connection.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
await expect(connection.locator('[data-component="settings-list"]')).toHaveCSS("padding-left", "16px")
await expect(connection.locator(".settings-servers-row")).toHaveCSS("padding-top", "20px")
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "4px")
await expect(connection.locator(".settings-servers-copy")).toHaveCSS("row-gap", "6px")
await expect(page.getByText("Server request failed", { exact: true })).toHaveCount(0)
})
test("project settings open as a nested autosaving view", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: "Settings demo", exact: true }).click()
await expect(settings.getByRole("button", { name: "Back to projects", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "General", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Worktrees", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Scripts", exact: true })).toHaveCount(0)
const name = settings.getByRole("textbox", { name: "Project name", exact: true })
const saved = page.waitForRequest(
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/project/proj_settings_demo",
)
await name.fill("Renamed project")
await name.blur()
expect((await saved).postDataJSON()).toEqual({ name: "Renamed project" })
const startup = settings.getByRole("textbox", { name: "Worktree startup script", exact: true })
const scriptSaved = page.waitForRequest(
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/project/proj_settings_demo",
)
await startup.fill("bun install")
await startup.blur()
expect((await scriptSaved).postDataJSON()).toEqual({ commands: { start: "bun install" } })
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Worktrees", exact: true })).toBeVisible()
await page.keyboard.press("Escape")
await expect(settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
})
test("clearing project fields sends explicit removal values", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: "Settings demo", exact: true }).click()
const startup = settings.getByRole("textbox", { name: "Worktree startup script", exact: true })
await expect(startup).toHaveValue("echo setup")
const scriptSaved = page.waitForResponse(
(response) =>
response.request().method() === "PATCH" && new URL(response.url()).pathname === "/api/project/proj_settings_demo",
)
await startup.clear()
await startup.blur()
const scriptResponse = await scriptSaved
expect(scriptResponse.ok()).toBe(true)
expect(scriptResponse.request().postDataJSON()).toEqual({ commands: { start: "" } })
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
const icon = settings.getByRole("button", { name: "Project icon", exact: true })
await expect(icon.locator("img")).toHaveCount(1)
const iconSaved = page.waitForResponse(
(response) =>
response.request().method() === "PATCH" && new URL(response.url()).pathname === "/api/project/proj_settings_demo",
)
await icon.hover()
await icon.click()
const iconResponse = await iconSaved
expect(iconResponse.ok()).toBe(true)
expect(iconResponse.request().postDataJSON()).toEqual({ icon: { color: "orange", override: "" } })
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
const color = settings.getByRole("button", { name: "Select orange color", exact: true })
await expect(color).toHaveAttribute("aria-pressed", "true")
const colorSaved = page.waitForResponse(
(response) =>
response.request().method() === "PATCH" && new URL(response.url()).pathname === "/api/project/proj_settings_demo",
)
await color.click()
const colorResponse = await colorSaved
expect(colorResponse.ok()).toBe(true)
expect(colorResponse.request().postDataJSON()).toEqual({ icon: { color: "", override: "" } })
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
})
test("new session shortcut leaves settings and opens a new session screen", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeFocused()
@@ -50,12 +50,12 @@ for (const viewport of [
"Appearance",
"Notifications",
"Shortcuts",
"Servers",
"Projects",
"Worktrees",
"Providers",
"Models",
"Extensions",
"Server",
"Experimental",
"About",
]) {
@@ -0,0 +1,48 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
test.use({ viewport: { width: 1280, height: 900 } })
for (const mode of ["failed", "stopped", "ready"] as const) {
test(`manages a ${mode} configured WSL server from nested settings`, async ({ page }) => {
await mockOpenCodeServer(page, {
directory: "/repo",
project: {
id: "proj_wsl_settings",
canonical: "/repo",
name: "WSL project",
sandboxes: [],
time: { created: 1, updated: 1 },
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.goto(`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server, mode })}`)
const settings = page.getByTestId("settings-screen")
await expect(settings.getByRole("tab", { name: "Local Server", exact: true })).toBeEnabled()
const ubuntu = settings.getByRole("tab", { name: "Ubuntu", exact: true })
await expect(ubuntu).toHaveCount(1)
await ubuntu.click()
await expect(settings.getByRole("heading", { name: "Ubuntu", exact: true })).toBeVisible()
const connection = settings.locator('[data-component="settings-server-connection"]')
if (mode !== "ready") {
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toBeDisabled()
await connection.getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Retry start", exact: true }).click()
await expect(page.getByLabel("WSL actions")).toHaveText("start:wsl:Ubuntu")
}
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toBeEnabled()
await connection.getByRole("button", { name: "Update OpenCode", exact: true }).click()
await expect(page.getByLabel("WSL actions")).toContainText("update:Ubuntu")
await expect(connection.getByRole("button", { name: "Update OpenCode", exact: true })).toHaveCount(0)
await connection.getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Remove", exact: true }).click()
await expect(page.getByLabel("WSL actions")).toContainText("remove:wsl:Ubuntu")
await expect(settings.getByRole("tab", { name: "Ubuntu", exact: true })).toHaveCount(0)
await expect(settings.getByRole("tab", { name: "Server", exact: true })).toBeEnabled()
})
}
@@ -602,7 +602,6 @@ async function mockServer(page: Page) {
if (url.origin !== server) return route.fallback()
if (url.pathname === `/api/session/${unresolvedSessionID}`) return new Promise(() => {})
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/config") return json(route, [])
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
+16 -1
View File
@@ -39,7 +39,6 @@ const Group = HttpApiGroup.make("mock")
}),
)
.add(HttpApiEndpoint.get("reference", "/api/reference", { success: Json }))
.add(HttpApiEndpoint.get("config", "/api/config", { success: Json }))
.add(HttpApiEndpoint.get("agent", "/api/agent", { success: Json }))
.add(HttpApiEndpoint.get("provider", "/api/provider", { success: Json }))
.add(HttpApiEndpoint.get("model", "/api/model", { success: Json }))
@@ -70,7 +69,23 @@ const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("mcp", "/api/mcp", { success: Json }))
.add(HttpApiEndpoint.get("mcpResource", "/api/mcp/resource", { success: Json }))
.add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json }))
.add(
HttpApiEndpoint.patch("projectUpdate", "/api/project/:projectID", {
params: { projectID: Schema.String },
payload: JsonPayload,
success: Json,
}),
)
.add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json }))
.add(HttpApiEndpoint.get("configPreferences", "/api/config/preferences", { success: Json }))
.add(
HttpApiEndpoint.patch("configUpdatePreferences", "/api/config/preferences", {
payload: JsonPayload,
success: Json,
}),
)
.add(HttpApiEndpoint.get("configShells", "/api/config/shell", { success: Json }))
.add(HttpApiEndpoint.get("websearchProviders", "/api/websearch/provider", { success: Json }))
.add(
HttpApiEndpoint.get("worktreeList", "/api/worktree", {
success: Json,
+22 -2
View File
@@ -9,6 +9,9 @@ export interface MockServerConfig {
provider: unknown | (() => unknown)
integrationMethods?: Record<string, unknown[]>
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
preferences?: Record<string, unknown>
shells?: unknown[]
websearchProviders?: unknown[]
directory: string
project: unknown
sessions: ({ id: string } & Record<string, unknown>)[]
@@ -179,13 +182,14 @@ export function createMockServerHandler(config: MockServerConfig) {
const corsHeaders = {
"access-control-allow-origin": "*",
"access-control-allow-headers": "*",
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
"access-control-expose-headers": "x-next-cursor",
}
function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, string>; nextCursor: number }) {
const noContent = Effect.succeed(HttpApiSchema.NoContent.make())
const delay = config.messageDelay === undefined ? Effect.void : Effect.sleep(Duration.millis(config.messageDelay))
const preferences = { current: config.preferences ?? {} }
return HttpApiBuilder.group(MockApi, "mock", (handlers) =>
handlers
.handleRaw("event", () => {
@@ -207,7 +211,6 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
)
.handleAll({
health: () => Effect.succeed({ healthy: true, version: "2.0.0", pid: 1 }),
config: () => Effect.succeed([]),
reference: () =>
Effect.succeed({
location: {
@@ -263,12 +266,29 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
return Effect.succeed([{ ...project, canonical: project.canonical ?? project.worktree ?? config.directory }])
},
projectUpdate: (ctx) => {
const project = config.project as { canonical?: string }
return Effect.succeed({
...project,
...ctx.payload,
id: ctx.params.projectID,
canonical: project.canonical ?? config.directory,
})
},
projectCurrent: () =>
Effect.succeed({
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
}),
configPreferences: () => Effect.succeed(preferences.current),
configUpdatePreferences: (ctx) =>
Effect.sync(() => {
preferences.current = { ...preferences.current, ...ctx.payload }
return preferences.current
}),
configShells: () => Effect.succeed(config.shells ?? []),
websearchProviders: () => Effect.succeed({ location: location(config), data: config.websearchProviders ?? [] }),
worktreeList: () =>
Effect.succeed([
{ directory: config.directory },
@@ -0,0 +1,135 @@
import { MemoryRouter, createMemoryHistory } from "@solidjs/router"
import { createMemo, Show } from "solid-js"
import { createStore, unwrap } from "solid-js/store"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "../../src/app"
import { PlatformProvider, type Platform } from "../../src/runtime/platform/platform"
import { ServerConnection } from "../../src/runtime/server/registry"
import { useWslServers } from "../../src/servers/wsl/context"
import type { WslServersEvent, WslServersPlatform, WslServersState } from "../../src/servers/wsl/types"
export function mount(input: { server: string; mode: "failed" | "stopped" | "ready" }) {
const root = document.getElementById("root")
if (!root) throw new Error("Missing fixture root")
const history = createMemoryHistory()
history.set({ value: "/settings", replace: true, scroll: false })
render(() => {
const [store, setStore] = createStore<{ calls: string[]; state: WslServersState }>({
calls: [],
state: {
runtime: { available: true, version: "2", error: null },
installed: [],
online: [],
distroProbes: {},
pendingRestart: false,
job: null,
servers: [
{
config: { id: "wsl:Ubuntu", distro: "Ubuntu" },
runtime:
input.mode === "ready"
? { kind: "ready", url: input.server, password: null }
: input.mode === "failed"
? { kind: "failed", message: "WSL failed to start" }
: { kind: "stopped" },
},
],
opencodeChecks: {
Ubuntu: {
distro: "Ubuntu",
resolvedPath: "/usr/bin/opencode",
version: "old",
expectedVersion: "current",
matchesDesktop: false,
error: null,
},
},
},
})
const listeners = new Set<(event: WslServersEvent) => void>()
const publish = () =>
listeners.forEach((listener) => listener({ type: "state", state: structuredClone(unwrap(store.state)) }))
const unused = async () => {
throw new Error("Unexpected fixture action")
}
const wsl: WslServersPlatform = {
getState: async () => structuredClone(unwrap(store.state)),
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
probeRuntime: unused,
refreshDistros: unused,
installWsl: unused,
installDistro: unused,
probeAddable: unused,
openTerminal: unused,
addServer: unused,
async installOpencode(distro) {
setStore("calls", (calls) => [...calls, `update:${distro}`])
setStore("state", "opencodeChecks", distro, { version: "current", matchesDesktop: true })
publish()
},
async startServer(id) {
setStore("calls", (calls) => [...calls, `start:${id}`])
setStore("state", "servers", (server) => server.config.id === id, "runtime", {
kind: "ready",
url: input.server,
password: null,
})
publish()
},
async removeServer(id) {
setStore("calls", (calls) => [...calls, `remove:${id}`])
setStore("state", "servers", (servers) => servers.filter((server) => server.config.id !== id))
publish()
},
}
const platform: Platform = {
platform: "desktop",
os: "windows",
windowID: "settings-wsl-test",
openExternal: () => undefined,
openDirectoryPickerDialog: async () => null,
notify: async () => undefined,
restart: unused,
wslServers: wsl,
}
function Interface() {
const wsl = useWslServers()
const servers = createMemo<ServerConnection.Any[]>(() => [
{ type: "sidecar", variant: "base", displayName: "Local Server", http: { url: input.server } },
...(wsl.data?.servers ?? []).flatMap((item): ServerConnection.Any[] =>
item.runtime.kind === "ready"
? [
{
type: "sidecar",
variant: "wsl",
distro: item.config.distro,
displayName: item.config.distro,
http: { url: item.runtime.url },
},
]
: [],
),
])
return (
<Show when={wsl.data}>
<AppInterface
servers={servers()}
defaultServer={ServerConnection.Key.make("sidecar")}
router={(props) => <MemoryRouter {...props} history={history} />}
/>
</Show>
)
}
return (
<PlatformProvider value={platform}>
<AppBaseProviders locale="en">
<output aria-label="WSL actions">{store.calls.join(",")}</output>
<Interface />
</AppBaseProviders>
</PlatformProvider>
)
}, root)
}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
</head>
<body>
<div id="root"></div>
<script type="module">
import { mount } from "./settings-wsl.fixture.tsx"
const query = new URLSearchParams(location.search)
mount({ server: query.get("server"), mode: query.get("mode") })
</script>
</body>
</html>
+1
View File
@@ -40,6 +40,7 @@ export default defineConfig({
reuseExistingServer: !built,
timeout: 120_000,
env: {
VITE_OPENCODE_TEST_FIXTURES: "1",
VITE_OPENCODE_SERVER_HOST: serverHost,
VITE_OPENCODE_SERVER_PORT: serverPort,
},
+38 -68
View File
@@ -1,5 +1,4 @@
import { batch, type Accessor, createEffect, createMemo, on } from "solid-js"
import { createStore } from "solid-js/store"
import { batch, type Accessor, createMemo, startTransition } from "solid-js"
import type { ComposerControls } from "./adapter"
import { useLayout } from "@/shell/state/layout"
import { useLocal, type ModelKey, type ModelSelection } from "@/providers/models/selection"
@@ -10,7 +9,6 @@ import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
import { useModels } from "@/providers/models/models"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/providers/models/variant"
import { useComposerState } from "./persistence"
import { useConfiguredModel } from "@/providers/models/configured"
export function createComposerControls(input: { sessionKey: Accessor<string>; model?: ModelSelection }) {
const layout = useLayout()
@@ -33,7 +31,6 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
selection: input.model ?? local.model,
paid: providers.paid().length > 0,
loading:
!(input.model ?? local.model).ready() ||
(local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) ||
!providers.ready(),
},
@@ -46,31 +43,15 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
}
export function createComposerModelSelection(input: {
agent: () => { name: string; model?: ModelKey; variant?: string } | undefined
agent: () => { model?: ModelKey; variant?: string } | undefined
}) {
const sdk = useWorkspaceLocation()
const models = useModels()
const local = useLocal()
const prompt = useComposerState()
const configuredModel = useConfiguredModel()
const [remembered, setRemembered] = createStore<Record<string, ModelKey | undefined>>({})
createEffect(
on(
() => input.agent()?.name,
(name, previous) => {
if (!name || !previous || name === previous) return
batch(() => {
const model = prompt.model.current()
setRemembered(previous, model ? { providerID: model.providerID, modelID: model.modelID } : undefined)
prompt.model.set(remembered[name] ? { ...remembered[name] } : undefined)
})
},
),
)
const providers = useProviders(() => sdk().directory)
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const valid = (model: Pick<ModelKey, "providerID" | "modelID">) => {
const valid = (model: ModelKey) => {
const provider = providers.all().get(model.providerID)
return !!provider?.models[model.modelID] && connected().has(model.providerID)
}
@@ -81,8 +62,7 @@ export function createComposerModelSelection(input: {
return modelID ? [{ providerID: provider.id, modelID }] : []
})[0]
const current = () => {
if (!configuredModel.ready()) return
const key = [prompt.model.current(), input.agent()?.model, configuredModel(), recent(), fallback()].find(
const key = [prompt.model.current(), input.agent()?.model, recent(), fallback()].find(
(item): item is ModelKey => !!item && valid(item),
)
return key ? models.find(key) : undefined
@@ -94,9 +74,7 @@ export function createComposerModelSelection(input: {
.filter((item): item is NonNullable<typeof item> => !!item),
)
const selection = {
trackSessionCommit: local.model.trackSessionCommit,
remembered: () => Object.fromEntries(Object.entries(remembered).map(([name, model]) => [name, { model }])),
ready: Object.assign(() => models.ready() && configuredModel.ready(), { promise: models.ready.promise }),
ready: models.ready,
current,
recent: recentModels,
list: models.list,
@@ -105,24 +83,19 @@ export function createComposerModelSelection(input: {
const item = current()
if (!item) return
const index = items.findIndex((entry) => entry.provider.id === item.provider.id && entry.id === item.id)
const next =
items[
index === -1 ? (direction === 1 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
]
if (index === -1) return
const next = items[(index + direction + items.length) % items.length]
if (next) selection.set({ providerID: next.provider.id, modelID: next.id })
},
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => {
if (item && !valid(item)) return
const previous = current()
const same = item && previous?.provider.id === item.providerID && previous.id === item.modelID
prompt.model.set(
item ? { ...item, variant: same ? (selection.variant.current() ?? null) : undefined } : undefined,
)
if (!item) return
models.setVisibility(item, true)
if (options?.recent) models.recent.push(item)
})
void startTransition(() =>
batch(() => {
prompt.model.set(item ? { ...item, variant: prompt.model.current()?.variant } : undefined)
if (!item) return
models.setVisibility(item, true)
if (options?.recent) models.recent.push(item)
}),
)
},
visible: models.visible,
setVisibility: models.setVisibility,
@@ -131,41 +104,38 @@ export function createComposerModelSelection(input: {
const item = input.agent()
const model = current()
if (!item || !model) return
const global = configuredModel()
return (
getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
}) ??
getConfiguredAgentVariant({
agent: { model: global, variant: global?.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
)
return getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
},
selected() {
const model = prompt.model.current()
return model && valid(model) ? model.variant : undefined
return prompt.model.current()?.variant
},
current() {
const model = current()
return resolveModelVariant({
const resolved = resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
preferred: model ? models.variant.get({ providerID: model.provider.id, modelID: model.id }) : undefined,
})
if (resolved) return resolved
const model = current()
if (!model) return
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
if (saved && this.list().includes(saved)) return saved
},
list() {
return Object.keys(current()?.variants ?? {})
},
set(value: string | undefined) {
batch(() => {
const model = current()
if (!model) return
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
})
void startTransition(() =>
batch(() => {
const model = current()
if (!model) return
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
}),
)
},
cycle() {
const variants = this.list()
@@ -173,13 +143,13 @@ export function createComposerModelSelection(input: {
this.set(
cycleModelVariant({
variants,
selected: this.current() ?? null,
configured: undefined,
selected: this.selected(),
configured: this.configured(),
}),
)
},
},
}
} satisfies ModelSelection
return selection satisfies ModelSelection
return selection
}
+6 -152
View File
@@ -91,8 +91,6 @@ function session(input: {
admitted?: (messageID: string) => boolean
shell?: () => Promise<unknown>
command?: ComposerSession["api"]["command"]
switchAgent?: ComposerSession["api"]["switchAgent"]
switchModel?: ComposerSession["api"]["switchModel"]
}): ComposerSession {
return {
id: "session-1",
@@ -101,16 +99,12 @@ function session(input: {
current: input.current ?? (() => undefined),
admitted: input.admitted ?? (() => false),
api: {
switchAgent:
input.switchAgent ??
(async () => {
input.calls.push("switch-agent")
}),
switchModel:
input.switchModel ??
(async () => {
input.calls.push("switch-model")
}),
switchAgent: async () => {
input.calls.push("switch-agent")
},
switchModel: async () => {
input.calls.push("switch-model")
},
shell: input.shell ?? (async () => undefined),
command: input.command ?? (async () => undefined),
},
@@ -128,146 +122,6 @@ function session(input: {
}
describe("Composer submission", () => {
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
const calls: string[] = []
const selected = controls()
const agent = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
const committed = Promise.withResolvers<void>()
const completed = Promise.withResolvers<void>()
const target = session({
calls,
prompt: async () => {
throw new Error("command must not call prompt")
},
switchAgent: async (request) => {
expect(request.agent).toBe("build")
calls.push("agent")
started.resolve()
await agent.promise
},
switchModel: async (request) => {
expect(request.model).toEqual({ providerID: "provider-1", id: "model-1", variant: "balanced" })
calls.push("model")
await committed.promise
},
command: async (request) => {
expect(request).toMatchObject({ command: "review", text: "changes", delivery: "steer" })
expect(request).not.toHaveProperty("model")
expect(request).not.toHaveProperty("agent")
calls.push("command")
completed.resolve()
},
})
selected.model.selection = {
...selection,
trackSessionCommit: (_id, value) => {
expect(value).toEqual({
agent: "build",
model: { providerID: "provider-1", modelID: "model-1" },
variant: "balanced",
})
calls.push("track")
return () => calls.push("cancel")
},
}
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls: () => selected,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(adapter, undefined, "normal", () => [{ name: "review" }]).submit(new Event("submit"))
await started.promise
expect(calls).toEqual(["track", "agent"])
selected.agents.current = "plan"
selected.model.selection = { ...selection, variant: { ...selection.variant, current: () => "high" } }
agent.resolve()
committed.resolve()
await completed.promise
expect(calls).toEqual(["track", "agent", "model", "command"])
})
test("commits the model even when cached session state already matches", async () => {
const state = createMemoryComposerState({ prompt: "continue" }).capture()
const calls: string[] = []
const done = Promise.withResolvers<void>()
const target = session({
calls,
current: () => ({ agent: "build", model: { providerID: "provider-1", id: "model-1", variant: "balanced" } }),
prompt: async () => done.resolve(),
})
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(adapter).submit(new Event("submit"))
await done.promise
expect(calls).toEqual(["switch-model", "prompt"])
})
test("cancels selection tracking and does not execute a command when selection fails", async () => {
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
const calls: string[] = []
const selected = controls()
const failed = Promise.withResolvers<unknown>()
const error = new Error("model unavailable")
const target = session({
calls,
prompt: async () => {
calls.push("prompt")
},
switchModel: async () => {
throw error
},
command: async () => {
calls.push("command")
},
})
selected.model.selection = {
...selection,
trackSessionCommit: () => {
calls.push("track")
return () => {
calls.push("cancel")
}
},
}
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls: () => selected,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(
adapter,
{ missingSelection() {}, failed: (_kind, error) => failed.resolve(error) },
"normal",
() => [{ name: "review" }],
).submit(new Event("submit"))
expect(await failed.promise).toBe(error)
expect(calls).toEqual(["track", "switch-agent", "cancel"])
expect(state.current()[0]).toMatchObject({ content: "/review changes" })
})
test("submits a slash skill with its trailing text and attachments", async () => {
const state = createMemoryComposerState({ prompt: "/show-me explain " }).capture()
state.set([
+23 -38
View File
@@ -11,7 +11,6 @@ import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
import { setCursorPosition } from "./editor/dom"
import { blobDataUrl } from "@/runtime/persistence/drafts"
import type { ModelSelection } from "@/providers/models/selection"
const submitting = new WeakSet<object>()
@@ -89,7 +88,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
session.handoff?.set(handoffMessage(value))
const optimisticBusy = !input.adapter.working()
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
const sending = sendPrompt(session, value).then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
)
@@ -125,12 +124,9 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
// Commands always steer: the server applies a command's configured
// agent and model immediately at admission, so queueing one would
// reconfigure the turn it is supposed to wait behind.
void sendCommand(
session,
{ ...value, delivery: "steer" },
command,
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
void sendCommand(session, { ...value, delivery: "steer" }, command).catch((error) =>
failSubmission(input, session, "command", error, restore, value.id),
)
return
}
} finally {
@@ -319,10 +315,8 @@ async function sendCommand(
session: ComposerSession,
value: ComposerSubmission,
command: { command: string; arguments: string },
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
await applySelection(session, value.selection, track)
await session.api.command({
sessionID: session.id,
command: command.command,
@@ -334,33 +328,7 @@ async function sendCommand(
})
}
async function applySelection(
session: ComposerSession,
selection: ComposerSelection,
track?: ModelSelection["trackSessionCommit"],
) {
const cancel = track?.(session.id, selection)
try {
const current = session.current()
if (current?.agent !== selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: selection.agent })
}
// The server deduplicates unchanged selections; cached SSE state may still be behind an earlier switch.
await session.api.switchModel({
sessionID: session.id,
model: { id: selection.model.modelID, providerID: selection.model.providerID, variant: selection.variant },
})
} catch (error) {
cancel?.()
throw error
}
}
async function sendPrompt(
session: ComposerSession,
value: ComposerSubmission,
track?: ModelSelection["trackSessionCommit"],
) {
async function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
const request = await buildSubmissionRequest(session, value)
// Switching agent or model reconfigures the session immediately, and with it
// the remainder of a running turn. A steer targets that turn, so its
@@ -368,7 +336,24 @@ async function sendPrompt(
// waits behind, so it runs with the session selection at delivery time (the
// intended selection stays recorded in its metadata).
if (value.delivery === "steer") {
await applySelection(session, value.selection, track)
const current = session.current()
if (current?.agent !== value.selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
}
if (
current?.model?.providerID !== value.selection.model.providerID ||
current.model.id !== value.selection.model.modelID ||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
) {
await session.api.switchModel({
sessionID: session.id,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
})
}
}
const admission = {
@@ -1,6 +1,7 @@
import { useDirectoryPicker } from "@/workspaces/selection/picker"
import { useServerActionsController } from "@/servers/registry/controller"
import { useSettingsCommand } from "@/settings/command"
import { useSettingsSurface } from "@/settings/surface"
import { type LocalProject } from "@/shell/state/layout"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
@@ -26,6 +27,7 @@ export function createHomeProjectsController(home: HomeController) {
const dialog = useDialog()
const language = useLanguage()
const openSettings = useSettingsCommand()
const settings = useSettingsSurface()
const serverManagement = useServerActionsController()
const global = useGlobal()
const [_state, setState, _, ready] = persisted(Persist.global("home.servers"), HomeServersSchema, { collapsed: {} })
@@ -110,8 +112,9 @@ export function createHomeProjectsController(home: HomeController) {
})
},
edit: (conn: ServerConnection.Any, project: LocalProject) => {
void import("@/settings/workspaces/project-dialog").then(({ DialogEditProject }) => {
void dialog.show(() => <DialogEditProject server={conn} project={project} />)
settings.openProject({
server: ServerConnection.key(conn),
project: project.worktree,
})
},
unseenCount: (conn: ServerConnection.Any, project: LocalProject) => {
@@ -110,7 +110,6 @@ export function createNewSessionComposerAdapter(props: {
agent: selection.agent,
model: selection.model,
variant: selection.variant ?? null,
choices: model.remembered(),
})
if (!pending) tabs.promoteDraft(draftID, { server: server.key, sessionId: created.id })
submission.retarget(
+12 -2
View File
@@ -1,7 +1,8 @@
import { createPromptProjectController } from "@/new-session/project/selector"
import { useSettingsDialog } from "@/settings/command"
import { useSettingsSurface } from "@/settings/surface"
import { useSettings } from "@/settings/model"
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
import { useSettingsServers } from "@/settings/servers/inventory"
import { useSearchParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, untrack } from "solid-js"
import { createComposerModel } from "@/composer/model"
@@ -16,10 +17,19 @@ export default function NewSessionPage(props: { draftId: string }) {
const settings = useSettings()
const [search, setSearch] = useSearchParams<{ draftId?: string; prompt?: string }>()
const tabs = useTabs()
const openWorkspaces = useSettingsDialog("workspaces")
const servers = useSettingsServers()
const settingsSurface = useSettingsSurface()
const draftTab = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
)
const openWorkspaces = () => {
const draft = draftTab()
if (servers().length > 1 && draft) {
settingsSurface.openServer(draft.server, "workspaces")
return
}
settingsSurface.open("workspaces")
}
const workspace = createNewSessionWorkspaceController({
selectedWorktree: () => draftTab()?.worktree,
selectedBranch: () => draftTab()?.branch,
@@ -1,28 +0,0 @@
import { createEffect, createMemo, on } from "solid-js"
import { useData } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
export function useConfiguredModel() {
const data = useData()
const location = useWorkspaceLocation()
const server = useServerSDK()
createEffect(
on(
() => [location().directory, server.connection.status()] as const,
([directory]) => {
void data.location.config.sync({ directory }).catch(() => undefined)
},
),
)
const documents = () => data.location.config.list({ directory: location().directory })
const model = createMemo(() => {
const entry = documents()?.findLast((entry) => entry.type === "document" && entry.info.model !== undefined)
const model = entry?.type === "document" ? entry.info.model : undefined
if (!model) return
if (typeof model !== "string") return { providerID: model.providerID, modelID: model.model, variant: model.variant }
const [providerID, ...parts] = model.split("/")
return { providerID, modelID: parts.join("/"), variant: undefined }
})
return Object.assign(model, { ready: () => documents() !== undefined })
}
+2 -2
View File
@@ -149,7 +149,7 @@ export const DialogManageModels: Component = () => {
</span>
<span class="settings-models-group-label">
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
<span class="settings-section-title">{group.items[0].provider.name}</span>
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
</span>
</button>
<Switch
@@ -162,7 +162,7 @@ export const DialogManageModels: Component = () => {
</Switch>
</div>
<Show when={expanded()}>
<SettingsList>
<SettingsList variant="catalog">
<For each={group.items}>
{(item) => (
<SettingsRow title={item.name} description="">
+2 -2
View File
@@ -122,10 +122,10 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
const setVariant = (model: ModelKey, value: string | undefined) => {
const key = variantKey(model)
if (!store.variant) {
setStore("variant", { [key]: value ?? "default" })
setStore("variant", { [key]: value })
return
}
setStore("variant", key, value ?? "default")
setStore("variant", key, value)
}
return {
@@ -1,7 +1,7 @@
import { Popover } from "@kobalte/core/popover"
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
import { useLocal } from "@/providers/models/selection"
import { useDialog } from "@opencode/ui/context/dialog"
import { popularProviders } from "@/providers/catalog/providers"
import { Button } from "@opencode/ui/button"
@@ -27,7 +27,7 @@ import "@/settings/settings.css"
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
type ModelState = ModelSelection
type ModelState = ReturnType<typeof useLocal>["model"]
type ModelItem = ReturnType<ModelState["list"]>[number]
const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}`
@@ -162,12 +162,12 @@ const ModelList: Component<{
</span>
<span class="settings-models-group-label">
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
<span class="settings-section-title">{group.items[0].provider.name}</span>
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
</span>
</button>
</h3>
<Show when={open()}>
<SettingsList>
<SettingsList variant="catalog">
<For each={group.items}>
{(item) => (
<button
+83 -144
View File
@@ -1,7 +1,7 @@
import { createSimpleContext } from "@opencode/ui/context"
import { base64Encode } from "@opencode/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema, SchemaGetter } from "effect"
import { useModels } from "@/providers/models/models"
@@ -16,7 +16,6 @@ import { useData } from "@/runtime/server/current"
import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
import { useServerSDK } from "@/runtime/server/client"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
import { useConfiguredModel } from "./configured"
const ModelKeySchema = Schema.Struct({
providerID: Schema.String,
@@ -25,15 +24,11 @@ const ModelKeySchema = Schema.Struct({
})
export type ModelKey = typeof ModelKeySchema.Type
const ChoiceSchema = Schema.Struct({
const StateSchema = Schema.Struct({
agent: Persistence.optional(Schema.String),
model: Persistence.optional(ModelKeySchema),
variant: Persistence.optional(Schema.NullOr(Schema.String)),
})
const StateSchema = Schema.Struct({
...ChoiceSchema.fields,
agent: Persistence.optional(Schema.String),
choices: Persistence.optional(Schema.Record(Schema.String, ChoiceSchema)),
})
type State = typeof StateSchema.Type
const SessionsSchema = Schema.Record(
@@ -83,7 +78,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const providers = useProviders(() => sdk().directory)
const models = useModels()
const settings = useSettings()
const configuredModel = useConfiguredModel()
const id = createMemo(() => params.id || undefined)
const list = createMemo(() =>
@@ -104,9 +98,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
current?: string
draft?: State
promoting?: State
last?: {
type: "agent" | "model" | "variant"
agent?: string
model?: ModelKey | null
variant?: string | null
}
}>({
current: list()[0]?.name,
draft: undefined,
last: undefined,
})
const validModel = (model: ModelKey) => {
@@ -175,17 +176,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
const fallback = createMemo(() => firstModel(configuredModel, recentModel, defaultModel))
const durable = () => {
const session = id()
return session ? data.session.get(session) : undefined
}
const fallback = createMemo<ModelKey | undefined>(() => recentModel() ?? defaultModel())
const agent = {
list,
visible: agentsVisible,
current() {
return pickAgent(scope()?.agent ?? durable()?.agent ?? (agentsVisible() ? store.current : "build"))
return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build")
},
set(name: string | undefined) {
const item = pickAgent(name)
@@ -195,24 +192,25 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
batch(() => {
const previous = snapshot()
if (previous.agent === item.name) return
const prev = scope()
const choices = {
...prev?.choices,
...(previous.agent ? { [previous.agent]: { model: previous.model, variant: previous.variant } } : {}),
}
setStore("current", item.name)
setStore("last", {
type: "agent",
agent: item.name,
model: item.model,
variant: item.variant ?? null,
})
const prev = scope()
const next = {
agent: item.name,
model: choices[item.name]?.model,
variant: choices[item.name]?.variant,
choices,
model: item.model ?? prev?.model,
variant: item.variant ?? prev?.variant,
} satisfies State
write(next)
// Pin both choices while the agent and model acknowledgments arrive separately.
const selected = current()
if (selected) model.set({ providerID: selected.provider.id, modelID: selected.id })
const session = id()
if (session) {
setSaved("session", session, next)
return
}
setStore("draft", next)
})
},
move(direction: 1 | -1) {
@@ -232,15 +230,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const current = createMemo(() => {
if (!configuredModel.ready()) return
const item = firstModel(
() => scope()?.model,
() => {
const session = durable()
if (session?.agent && session.agent !== agent.current()?.name) return
const model = session?.model
return model && { providerID: model.providerID, modelID: model.id }
},
() => agent.current()?.model,
fallback,
)
@@ -252,41 +243,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const item = agent.current()
const model = current()
if (!item || !model) return
const global = configuredModel()
return (
getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
}) ??
getConfiguredAgentVariant({
agent: { model: global, variant: global?.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
)
return getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
}
const selected = () => {
const draft = scope()
if (draft?.model && validModel(draft.model)) return draft.variant
const session = durable()
if (session?.agent && session.agent !== agent.current()?.name) return
const value = session?.model
if (value && validModel({ providerID: value.providerID, modelID: value.id })) return value.variant ?? null
}
const selected = () => scope()?.variant
const snapshot = () => {
const selected = current()
const model = current()
return {
agent: agent.current()?.name,
model: selected ? { providerID: selected.provider.id, modelID: selected.id } : undefined,
variant: selected ? (model.variant.current() ?? null) : undefined,
model: model ? { providerID: model.provider.id, modelID: model.id } : undefined,
variant: selected(),
} satisfies State
}
const write = (next: Partial<State>) => {
const state = {
...scope(),
agent: agent.current()?.name,
...(scope() ?? { agent: agent.current()?.name }),
...next,
} satisfies State
@@ -298,59 +274,22 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setStore("draft", state)
}
const recent = createMemo(() => models.recent.list().filter(validModel).map(models.find).filter(Boolean))
const pending = new Map<string, State>()
const sameSelection = (a: State, b: State) =>
a.agent === b.agent &&
a.model?.providerID === b.model?.providerID &&
a.model?.modelID === b.model?.modelID &&
(a.variant ?? "default") === (b.variant ?? "default")
const reconcile = (sessionID: string) => {
const expected = pending.get(sessionID)
const session = data.session.get(sessionID)
if (!expected || !session?.model) return
if (
!sameSelection(expected, {
agent: session.agent,
model: { providerID: session.model.providerID, modelID: session.model.id },
variant: session.model.variant,
})
)
return
pending.delete(sessionID)
const draft = saved.session[sessionID]
if (id() !== sessionID || !draft || !sameSelection(draft, expected)) return
setSaved("session", sessionID, { agent: undefined, model: undefined, variant: undefined })
}
onCleanup(serverSDK.event.on("session.model.selected", (event) => reconcile(event.data.sessionID)))
onCleanup(serverSDK.event.on("session.agent.selected", (event) => reconcile(event.data.sessionID)))
onCleanup(
serverSDK.event.on("session.deleted", (event) => {
pending.delete(event.data.sessionID)
setSaved("session", event.data.sessionID, undefined)
}),
)
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
const model = {
ready: Object.assign(() => models.ready() && configuredModel.ready(), { promise: models.ready.promise }),
ready: models.ready,
current,
recent,
list: models.list,
trackSessionCommit(sessionID: string, selection: { agent: string; model: ModelKey; variant?: string }) {
pending.set(sessionID, selection)
reconcile(sessionID)
return () => {
if (pending.get(sessionID) === selection) pending.delete(sessionID)
}
},
cycle(direction: 1 | -1) {
const items = recent()
const item = current()
if (!item) return
const index = items.findIndex((entry) => entry?.provider.id === item.provider.id && entry?.id === item.id)
let next = index === -1 ? (direction === 1 ? 0 : items.length - 1) : index + direction
if (index === -1) return
let next = index + direction
if (next < 0) next = items.length - 1
if (next >= items.length) next = 0
@@ -359,18 +298,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model.set({ providerID: entry.provider.id, modelID: entry.id })
},
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => {
if (item && !validModel(item)) return
const previous = current()
const same = item && previous?.provider.id === item.providerID && previous.id === item.modelID
write({ model: item, variant: same ? (model.variant.current() ?? null) : undefined })
if (!item) return
// A session draft owns its variant even when preferences change in another session.
if (id() && !same) write({ variant: model.variant.current() ?? null })
models.setVisibility(item, true)
if (!options?.recent) return
models.recent.push(item)
})
startTransition(() =>
batch(() => {
setStore("last", {
type: "model",
agent: agent.current()?.name,
model: item ?? null,
variant: selected(),
})
write({ model: item })
if (!item) return
models.setVisibility(item, true)
if (!options?.recent) return
models.recent.push(item)
}),
)
},
visible(item: ModelKey) {
return models.visible(item)
@@ -382,13 +324,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
configured,
selected,
current() {
const model = current()
return resolveModelVariant({
const resolved = resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
preferred: model ? models.variant.get({ providerID: model.provider.id, modelID: model.id }) : undefined,
})
if (resolved) return resolved
const model = current()
if (!model) return
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
if (saved && this.list().includes(saved)) return saved
},
list() {
const item = current()
@@ -396,12 +341,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return Object.keys(item.variants)
},
set(value: string | undefined) {
batch(() => {
const model = current()
if (!model) return
write({ model: { providerID: model.provider.id, modelID: model.id }, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
})
startTransition(() =>
batch(() => {
const model = current()
setStore("last", {
type: "variant",
agent: agent.current()?.name,
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
variant: value ?? null,
})
write({ variant: value ?? null })
if (model) {
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
}
}),
)
},
cycle() {
const items = this.list()
@@ -409,8 +363,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
this.set(
cycleModelVariant({
variants: items,
selected: this.current() ?? null,
configured: undefined,
selected: this.selected(),
configured: this.configured(),
}),
)
},
@@ -429,34 +383,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
promote(dir: string, session: string, state?: State) {
const next = clone(state ?? snapshot())
if (!next) return
// Creation already owns the active selection; keep only agent memory once it is in the read model.
// Otherwise a first-message command's configured overrides would stay hidden behind this handoff.
const created = data.session.get(session)
const selection = created?.model
const committed =
selection &&
sameSelection(next, {
agent: created.agent,
model: { providerID: selection.providerID, modelID: selection.id },
variant: selection.variant,
})
? { choices: next.choices }
: next
const key = handoffKey(serverSDK.scope, dir, session)
handoff.set(key, committed)
handoff.set(key, next)
if (dir === sdk().directory) {
setSaved("session", session, committed)
setSaved("session", session, next)
}
setStore("promoting", committed)
setStore("promoting", next)
setStore("draft", undefined)
},
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
const session = id()
if (!session) return
if (msg.sessionID !== session) return
if (durable()?.model) return
if (saved.session[session] !== undefined) return
if (handoff.has(handoffKey(serverSDK.scope, sdk().directory, session))) return
@@ -472,5 +412,4 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
})
export type ModelSelection = Omit<ReturnType<typeof useLocal>["model"], "trackSessionCommit"> &
Partial<Pick<ReturnType<typeof useLocal>["model"], "trackSessionCommit">>
export type ModelSelection = ReturnType<typeof useLocal>["model"]
+2 -2
View File
@@ -6,13 +6,13 @@ import { Tooltip } from "@opencode/ui/tooltip"
import { useDialog } from "@opencode/ui/context/dialog"
import { useTheme } from "@opencode/ui/theme"
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
import { useLocal } from "@/providers/models/selection"
import { useIntegrations } from "@/providers/catalog/integrations"
import { decode64 } from "@/runtime/persistence/base64"
import { useLanguage } from "@/runtime/i18n/language"
import { ModelTooltip } from "./tooltip"
type ModelState = ModelSelection
type ModelState = ReturnType<typeof useLocal>["model"]
const featuredProviders = ["opencode-go", "opencode", "openai", "anthropic", "google", "github-copilot"]
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
@@ -64,14 +64,14 @@ describe("model variant", () => {
expect(value).toBe("xhigh")
})
test("cycles from configured last variant to default", () => {
test("wraps from configured last variant to first", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: undefined,
configured: "xhigh",
})
expect(value).toBeUndefined()
expect(value).toBe("low")
})
test("cycles from an explicit default to the first variant", () => {
@@ -83,22 +83,4 @@ describe("model variant", () => {
expect(value).toBe("low")
})
test("prefers a saved variant to configuration, including explicit Default", () => {
const input = { variants: ["low", "high"], selected: undefined, configured: "high" }
expect(resolveModelVariant({ ...input, preferred: "low" })).toBe("low")
expect(resolveModelVariant({ ...input, preferred: "default" })).toBeUndefined()
expect(resolveModelVariant({ ...input, preferred: "low", selected: null })).toBeUndefined()
expect(resolveModelVariant({ ...input, preferred: "low", selected: "high" })).toBe("high")
expect(cycleModelVariant({ ...input, preferred: "high" })).toBeUndefined()
expect(cycleModelVariant({ ...input, preferred: "default" })).toBe("low")
})
test("normalizes unavailable selections instead of silently applying another variant", () => {
expect(resolveModelVariant({ variants: ["low"], selected: "high", configured: "low" })).toBeUndefined()
expect(
resolveModelVariant({ variants: ["low"], selected: undefined, preferred: "high", configured: "low" }),
).toBeUndefined()
expect(cycleModelVariant({ variants: [], selected: undefined, configured: undefined })).toBeUndefined()
})
})
+17 -5
View File
@@ -16,7 +16,6 @@ type VariantInput = {
variants: string[]
selected: string | null | undefined
configured: string | undefined
preferred?: string
}
export function getConfiguredAgentVariant(input: { agent: Agent | undefined; model: Model | undefined }) {
@@ -25,16 +24,29 @@ export function getConfiguredAgentVariant(input: { agent: Agent | undefined; mod
if (!input.model?.variants) return undefined
if (input.agent.model.providerID !== input.model.providerID) return undefined
if (input.agent.model.modelID !== input.model.modelID) return undefined
if (!(input.agent.variant in input.model.variants)) return undefined
return input.agent.variant
}
export function resolveModelVariant(input: VariantInput) {
if (input.selected === null) return undefined
const value = input.selected ?? input.preferred ?? input.configured
return value && value !== "default" && input.variants.includes(value) ? value : undefined
if (input.selected && input.variants.includes(input.selected)) return input.selected
if (input.configured && input.variants.includes(input.configured)) return input.configured
return undefined
}
export function cycleModelVariant(input: VariantInput) {
const current = resolveModelVariant(input)
return input.variants[current ? input.variants.indexOf(current) + 1 : 0]
if (input.variants.length === 0) return undefined
if (input.selected === null) return input.variants[0]
if (input.selected && input.variants.includes(input.selected)) {
const index = input.variants.indexOf(input.selected)
if (index === input.variants.length - 1) return undefined
return input.variants[index + 1]
}
if (input.configured && input.variants.includes(input.configured)) {
const index = input.variants.indexOf(input.configured)
if (index === input.variants.length - 1) return input.variants[0]
return input.variants[index + 1]
}
return input.variants[0]
}
+29
View File
@@ -398,6 +398,7 @@ export const dict = {
"dialog.server.menu.default": "Set as default",
"dialog.server.menu.defaultRemove": "Remove default",
"dialog.server.menu.delete": "Delete",
"dialog.server.menu.remove": "Remove",
"dialog.server.menu.hide": "Hide from project list",
"dialog.server.menu.show": "Show in project list",
"dialog.server.current": "Current Server",
@@ -944,6 +945,8 @@ export const dict = {
"settings.section.desktop": "Desktop",
"settings.section.server": "Server",
"settings.backToApp": "Back to app",
"settings.backToSettings": "Back to settings",
"settings.backToProjects": "Back to projects",
"settings.tab.general": "General",
"settings.tab.preferences": "Preferences",
"settings.tab.shortcuts": "Shortcuts",
@@ -981,6 +984,10 @@ export const dict = {
"settings.notifications.description": "Choose when to receive notifications and hear sounds",
"settings.shortcuts.description": "Customize shortcuts for common actions",
"settings.servers.description": "Manage server connections",
"settings.server.description": "Manage this servers connection and preferences",
"settings.server.section.connection": "Connection",
"settings.server.preferences.websearch.title": "Third-party search",
"settings.server.preferences.websearch.description": "Select the search provider agents use to search the web",
"settings.projects.title": "Projects",
"settings.projects.description": "Manage project settings on this server",
"settings.projects.empty": "No projects found",
@@ -998,14 +1005,36 @@ export const dict = {
"dialog.server.authenticate.title": "Authenticate",
"project.settings.title": "Edit project",
"project.settings.general.description": "Manage project name and appearance",
"project.settings.name.title": "Project name",
"project.settings.name.description": "The name shown for this project throughout OpenCode",
"project.settings.icon.description": "Recommended: 128×128px. Click or drag to upload an image.",
"project.settings.color.description": "Used for the project icon when no custom image is set",
"project.settings.worktree.startup.description": "Runs once after creating a new worktree",
"project.settings.worktree.startup.hint.base": "Use $OPENCODE_WORKTREE_BASE for the base worktree.",
"project.settings.worktree.startup.hint.new": "Use $OPENCODE_WORKTREE_PATH for the new worktree.",
"project.settings.scripts": "Scripts",
"project.settings.scripts.description": "Configure scripts for this project",
"project.settings.extensions.description": "View extensions available to this project",
"project.settings.extensions.tab.lsps": "LSPs",
"project.settings.extensions.added": "Added to this project",
"project.settings.extensions.shared": "Shared with all projects",
"project.settings.extensions.empty.mcps.title": "No MCPs yet",
"project.settings.extensions.empty.mcps.description": "MCPs available to OpenCode will appear here",
"project.settings.extensions.empty.plugins.title": "No plugins yet",
"project.settings.extensions.empty.plugins.description": "Plugins available to OpenCode will appear here",
"project.settings.extensions.empty.skills.title": "No skills yet",
"project.settings.extensions.empty.skills.description": "Skills available to OpenCode will appear here",
"project.settings.extensions.lsp.detected": "Detected language servers",
"project.settings.extensions.lsp.description": "Auto-detected from file types",
"project.settings.extensions.lsp.configured": "Configured language servers",
"project.settings.extensions.lsp.status.enabled": "Enabled in config",
"project.settings.extensions.lsp.status.disabled": "Disabled in config",
"project.settings.extensions.lsp.empty.title": "No language servers configured",
"project.settings.extensions.lsp.empty.description": "Language servers configured for this project will appear here",
"project.settings.extensions.lsp.disabled.title": "Language servers disabled",
"project.settings.extensions.lsp.disabled.description": "LSP is disabled in this projects configuration",
"project.settings.extensions.lsp.loadFailed": "Could not load language server configuration",
"project.settings.extensions.lsp.retry": "Retry",
"project.settings.extensions.setupRequired": "Setup required",
"settings.general.section.appearance": "Appearance",
@@ -230,72 +230,3 @@ describe("draft store text externalization", () => {
expect(JSON.parse(memory.documents.get("doc")!).prompt[0].content.blob.ids).toHaveLength(1)
})
})
describe("draft store image retention", () => {
const image = (byte: number) => new Blob([new Uint8Array(6).fill(byte)], { type: "image/png" })
const fresh = (grace = 0) => {
const memory = memoryDriver()
return { memory, store: createDraftStore(memory.driver, { grace }) }
}
// Release timers fire on the macrotask queue; a zero grace has fired after one tick.
const tick = () => new Promise((resolve) => setTimeout(resolve, 5))
// An image with no object URL left gets a new one when its bytes are uploaded again.
const released = async (store: ReturnType<typeof createDraftStore>, byte: number, url: string) =>
(await store.putBlob(image(byte))).url !== url
test("an uploaded image no document references is released after the grace", async () => {
const { store } = fresh()
const orphan = await store.putBlob(image(1))
await tick()
expect(await released(store, 1, orphan.url)).toBe(true)
})
test("an image referenced within the grace is kept", async () => {
const { store } = fresh(50)
const pasted = await store.putBlob(image(2))
await store.setDocument("pinned", { prompt: [{ type: "image", blob: pasted }] })
await new Promise((resolve) => setTimeout(resolve, 80))
expect(await released(store, 2, pasted.url)).toBe(false)
})
test("saving a document without an image or removing the document releases it", async () => {
const { store } = fresh()
const dropped = await store.putBlob(image(3))
const removed = await store.putBlob(image(4))
await store.setDocument("edited", { prompt: [{ type: "image", blob: dropped }] })
await store.setDocument("closed", { prompt: [{ type: "image", blob: removed }] })
await tick()
expect(await released(store, 3, dropped.url)).toBe(false)
expect(await released(store, 4, removed.url)).toBe(false)
await store.setDocument("edited", { prompt: [{ type: "text", content: "typed over it" }] })
await store.removeItem("closed")
await tick()
expect(await released(store, 3, dropped.url)).toBe(true)
expect(await released(store, 4, removed.url)).toBe(true)
})
test("an image referenced by two documents survives until both drop it", async () => {
const { store } = fresh()
const shared = await store.putBlob(image(5))
await store.setDocument("composer", { prompt: [{ type: "image", blob: shared }] })
await store.setDocument("history", { entries: [{ prompt: [{ type: "image", blob: shared }] }] })
await store.setDocument("composer", { prompt: [] })
await tick()
expect(await released(store, 5, shared.url)).toBe(false)
await store.setDocument("history", { entries: [] })
await tick()
expect(await released(store, 5, shared.url)).toBe(true)
})
test("loading a document pins the images it references", async () => {
const { memory, store } = fresh()
const id = await memory.driver.putBlob(image(6))
memory.documents.set("loaded", JSON.stringify({ prompt: [{ type: "image", blob: { id } }] }))
const url = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob.url
await tick()
expect(await released(store, 6, url)).toBe(false)
await store.removeItem("loaded")
await tick()
expect(await released(store, 6, url)).toBe(true)
})
})
+21 -84
View File
@@ -28,77 +28,23 @@ export const draftTextThreshold = 16 * 1024
export const draftTextChunk = 64 * 1024
const textCacheLimit = 64
// Decoded image bytes the renderer pins through object URLs. Every consumer of a `blob.url` is a
// persisted draft document (composer prompt, prompt history), so an image is pinned exactly while a
// stored document references it. Once the last reference disappears (removed from a draft, sent, or
// a discarded duplicate paste) the URL is revoked after this grace, which covers the persist delay
// between a paste and the save that references it, and the submit → history handoff.
export const retainedBlobGrace = 30_000
type Retained = { blob: Blob; url: string; release: ReturnType<typeof setTimeout> | undefined }
const retained = new Map<string, Retained>()
// Document keys that reference each image id; an id with no keys is released after the grace.
const refs = new Map<string, Set<string>>()
const urls = new Map<string, string>()
// The object URL already pins the Blob for the page's lifetime; keeping the Blob itself lets a
// collected image be uploaded again without fetching the URL.
const held = new Map<string, Blob>()
// Image ids that were restored under a different id (a store without WebCrypto assigns fresh
// ones); live references still carry the original.
const aliases = new Map<string, string>()
function blobUrl(id: string, blob: Blob, grace?: number) {
const existing = retained.get(id)
if (existing) return existing.url
function blobUrl(id: string, blob: Blob) {
const existing = urls.get(id)
if (existing) return existing
const url = URL.createObjectURL(blob)
// Without a grace the image has no store to reference it from and stays for the page's lifetime.
const release = grace === undefined || refs.get(id)?.size ? undefined : setTimeout(() => revoke(id), grace)
retained.set(id, { blob, url, release })
urls.set(id, url)
held.set(id, blob)
return url
}
// Record which image ids `key` now references; ids it dropped are released once no other document
// references them, ids it gained stay pinned.
function retain(key: string, ids: ReadonlySet<string>, grace: number) {
for (const [id, keys] of refs) {
if (ids.has(id) || !keys.delete(key) || keys.size) continue
refs.delete(id)
const entry = retained.get(id)
if (entry) entry.release = setTimeout(() => revoke(id), grace)
}
for (const id of ids) {
const keys = refs.get(id) ?? new Set<string>()
keys.add(key)
refs.set(id, keys)
const entry = retained.get(id)
if (!entry) continue
clearTimeout(entry.release)
entry.release = undefined
}
}
function revoke(id: string) {
const entry = retained.get(id)
if (!entry) return
URL.revokeObjectURL(entry.url)
retained.delete(id)
for (const [from, to] of aliases) if (to === id) aliases.delete(from)
}
// Image ids a document references: `{ blob: { id } }` parts, not text chunk lists.
function imageIDs(value: unknown, into = new Set<string>()): Set<string> {
if (Array.isArray(value)) {
value.forEach((entry) => imageIDs(entry, into))
return into
}
if (!value || typeof value !== "object") return into
const item = value as Record<string, unknown>
const blob = item.blob
if (blob && typeof blob === "object" && !("kind" in blob)) {
const id = (blob as Record<string, unknown>).id
if (typeof id === "string") into.add(id)
return into
}
Object.values(item).forEach((entry) => imageIDs(entry, into))
return into
}
async function blobID(blob: Blob) {
const bytes = crypto.subtle
? new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()))
@@ -114,25 +60,24 @@ export async function createBlobReference(blob: Blob): Promise<BlobReference> {
return { id, url: blobUrl(id, blob) }
}
export function createDraftStore(driver: Driver, options: { grace?: number } = {}): DraftStore {
const grace = options.grace ?? retainedBlobGrace
export function createDraftStore(driver: Driver): DraftStore {
const versions = new Map<string, number>()
const loading = new Map<string, Promise<string | undefined>>()
const loadBlobUrl = (id: string) => {
const existing = retained.get(id)
if (existing) return existing.url
const existing = urls.get(id)
if (existing) return existing
const pending = loading.get(id)
if (pending) return pending
const next = driver
.getBlob(id)
.then((blob) => (blob ? blobUrl(id, blob, grace) : undefined))
.then((blob) => (blob ? blobUrl(id, blob) : undefined))
.finally(() => loading.delete(id))
loading.set(id, next)
return next
}
const putBlob = async (blob: Blob) => {
const id = await driver.putBlob(blob)
return { id, url: blobUrl(id, blob, grace) }
return { id, url: blobUrl(id, blob) }
}
// Keyed by chunk content so unchanged chunks are never hashed or sent again while the draft is
// edited. Bounded because each entry pins up to draftTextChunk characters. A hit is safe even if
@@ -200,8 +145,8 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
if (typeof blob.id === "string") {
// A live reference keeps the id it was created with; publish the id its bytes now live under.
const id = aliases.get(blob.id) ?? blob.id
const kept = retained.get(id)?.blob
const url = typeof blob.url === "string" ? blob.url : retained.get(id)?.url
const kept = held.get(id)
const url = typeof blob.url === "string" ? blob.url : urls.get(id)
if (kept) sources.set(id, { blob: async () => kept })
else if (url) sources.set(id, { blob: () => fetch(url).then((response) => response.blob()) })
return { ...item, blob: { id } }
@@ -247,7 +192,8 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
remember(chunks, next, source.chunk)
return
}
blobUrl(next, blob, grace)
held.set(next, blob)
blobUrl(next, blob)
if (next === id) return
// Later encodes of the still-live reference resolve straight to the new id. Re-point any
// earlier alias chain so lookups stay one step.
@@ -279,19 +225,14 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
// stays visible until the bytes are back. Covers a blob collected while a cache, another tab,
// or the composer's history still held its id.
const missing = await driver.set(key, JSON.stringify(encoded), true)
if (missing.length === 0) {
retain(key, imageIDs(encoded), grace)
return
}
if (missing.length === 0) return
const renamed = await restore(missing, sources)
if (versions.get(key) !== version) return
const unrestored = missing.filter((id) => !renamed.has(id))
if (unrestored.length)
console.error(`[persistence] draft ${key} references blobs with no bytes to restore`, unrestored)
// Anything still missing has no bytes anywhere; the owning codec drops such references on read.
const final = rename(encoded, renamed)
await driver.set(key, JSON.stringify(final), false)
retain(key, imageIDs(final), grace)
await driver.set(key, JSON.stringify(rename(encoded, renamed)), false)
}
return {
getItem: async (key) => {
@@ -300,8 +241,6 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
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)),
@@ -309,7 +248,6 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
removeItem: async (key) => {
versions.set(key, (versions.get(key) ?? 0) + 1)
await driver.remove(key)
retain(key, new Set(), grace)
},
putBlob,
}
@@ -422,8 +360,7 @@ function referenced(json: string) {
}
export async function blobDataUrl(blob: BlobReference, mime: string) {
const kept = retained.get(aliases.get(blob.id) ?? blob.id)
const data = kept ? kept.blob : await fetch(blob.url).then((response) => response.blob())
const data = await fetch(blob.url).then((response) => response.blob())
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener("error", () => reject(reader.error))
@@ -1,6 +1,5 @@
import { createSimpleContext } from "@opencode/ui/context"
import { Accessor, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
import { createStore } from "solid-js/store"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry"
import { pathKey } from "@/workspaces/path-key"
import { useServerHealth } from "@/runtime/server/health"
@@ -25,24 +24,8 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
() => server.list,
() => true,
)
const [store, setStore] = createStore({
settings: {
serverKey: undefined as ServerConnection.Key | undefined,
},
})
const models = createGlobalModels()
const settingsServer = createMemo(() => {
const list = server.list
return list.find((conn) => ServerConnection.key(conn) === store.settings.serverKey) ?? list[0]
})
createEffect(() => {
const conn = settingsServer()
const key = conn ? ServerConnection.key(conn) : undefined
if (store.settings.serverKey !== key) setStore("settings", "serverKey", key)
})
const serverCtxs = new Map<ServerConnection.Key, ReturnType<typeof createServerController>>()
const serverCtxDisposers = new Map<ServerConnection.Key, () => void>()
@@ -82,17 +65,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
list: () => server.list,
health: serverHealth,
},
settings: {
server: {
get key() {
return store.settings.serverKey
},
selected: settingsServer,
set(key: ServerConnection.Key) {
if (store.settings.serverKey !== key) setStore("settings", "serverKey", key)
},
},
},
models,
ensureServerCtx(conn: ServerConnection.Any) {
return ensureServerCtx(conn)
+8 -3
View File
@@ -25,11 +25,15 @@ type FormMode = "list" | "add" | "edit"
export const DialogServer: Component<{
mode: "add" | "edit"
server?: ServerConnection.Http
onSave?: (server: ServerConnection.Http) => void
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const form = createFormController({
onSelect: () => dialog.close(),
onSelect: (server) => {
props.onSave?.(server)
dialog.close()
},
})
const [opened, setOpened] = createSignal(false)
@@ -133,7 +137,7 @@ export const DialogServer: Component<{
)
}
function createFormController(options: { onSelect?: () => void } = {}) {
function createFormController(options: { onSelect?: (server: ServerConnection.Http) => void } = {}) {
const platform = usePlatform()
const server = useServers()
const tabs = useTabs()
@@ -220,13 +224,14 @@ function createFormController(options: { onSelect?: () => void } = {}) {
if (original?.type === "http") {
if (normalized === original.http.url) add(connection)
if (normalized !== original.http.url) replace(ServerConnection.key(original), connection)
options.onSelect?.(connection)
reset()
return
}
reset()
add(connection)
options.onSelect?.()
options.onSelect?.(connection)
},
}))
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/runtime/server/registry"
import { sortServerConnections } from "./controller"
const server = (url: string): ServerConnection.Http => ({ type: "http", http: { url } })
describe("sortServerConnections", () => {
test("places the default first and preserves health and insertion ordering", () => {
const first = server("http://first")
const offline = server("http://offline")
const preferred = server("http://preferred")
const unknown = server("http://unknown")
const result = sortServerConnections({
servers: [first, offline, preferred, unknown],
health: {
[ServerConnection.key(first)]: { healthy: true },
[ServerConnection.key(offline)]: { healthy: false },
},
defaultKey: ServerConnection.key(preferred),
})
expect(result).toEqual([preferred, first, unknown, offline])
})
})
+30 -22
View File
@@ -1,10 +1,8 @@
import { useNavigate } from "@solidjs/router"
import { createMemo, createResource } from "solid-js"
import { useGlobal } from "@/runtime/server/runtime"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useSettings } from "@/settings/model"
import { useTabs } from "@/shell/tabs/tabs"
import { type ServerHealth } from "@/runtime/server/health"
import { showToast } from "@/shell/notifications/toast"
@@ -48,6 +46,27 @@ function useDefaultServer() {
}
}
export function sortServerConnections(input: {
servers: ServerConnection.Any[]
health: Record<string, ServerHealth | undefined>
defaultKey: ServerConnection.Key | null
}) {
const order = new Map(input.servers.map((item, index) => [item, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return input.servers.slice().sort((a, b) => {
const preferred =
Number(ServerConnection.key(b) === input.defaultKey) - Number(ServerConnection.key(a) === input.defaultKey)
if (preferred !== 0) return preferred
const health = rank(input.health[ServerConnection.key(a)]) - rank(input.health[ServerConnection.key(b)])
if (health !== 0) return health
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
}
export function useServerActionsController() {
const server = useServers()
const tabs = useTabs()
@@ -75,8 +94,8 @@ export function useServerActionsController() {
const conn = server.list.find((item) => ServerConnection.key(item) === key)
return server.visible.length > 1 && !!conn && ServerConnection.builtin(conn)
},
isHidden: server.isHidden,
setHidden: server.setHidden,
isHidden: (key: ServerConnection.Key) => server.isHidden(key),
setHidden: (key: ServerConnection.Key, hidden: boolean) => server.setHidden(key, hidden),
},
}
}
@@ -86,27 +105,16 @@ export type ServerActionsController = ReturnType<typeof useServerActionsControll
export function useServerCollectionController() {
const server = useServers()
const global = useGlobal()
const settings = useSettings()
const actions = useServerActionsController()
const items = createMemo(() => server.list)
const sorted = createMemo(() => {
const raw = items()
const list = raw
if (!list.length) return list
const order = new Map(list.map((item, index) => [item, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
const sorted = createMemo(() =>
sortServerConnections({
servers: items(),
health: global.servers.health,
defaultKey: actions.defaults.key(),
}),
)
return {
collection: {
@@ -43,7 +43,7 @@ export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
edit: language.t("dialog.server.menu.edit"),
default: language.t("dialog.server.menu.default"),
defaultRemove: language.t("dialog.server.menu.defaultRemove"),
delete: language.t("dialog.server.menu.delete"),
remove: language.t("dialog.server.menu.remove"),
hide: language.t("dialog.server.menu.hide"),
show: language.t("dialog.server.menu.show"),
}
@@ -104,7 +104,7 @@ export const ServerRowMenuView: Component<{
</Show>
<Show when={props.canRemove}>
<Menu.Separator />
<Menu.Item onSelect={props.onRemove}>{props.labels.delete}</Menu.Item>
<Menu.Item onSelect={props.onRemove}>{props.labels.remove}</Menu.Item>
</Show>
</Menu.Group>
</Menu.Content>
+38 -9
View File
@@ -16,12 +16,13 @@ import { showToast } from "@/shell/notifications/toast"
import { DialogAddWslServer } from "./dialog"
import { useWslServers } from "./context"
import { wslOpencodeAction, wslRuntimeRetryable } from "./model"
import type { WslServerItem } from "./types"
export function isWslServer(server: ServerConnection.Any) {
return server.type === "sidecar" && server.variant === "wsl"
}
export function AddServerMenu(props: { onAddServer: () => void }) {
export function AddServerMenu(props: { onAddServer: () => void; compact?: boolean }) {
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
@@ -32,15 +33,41 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
<Show
when={platform.wslServers}
fallback={
<Button variant="ghost-muted" icon="plus" onClick={props.onAddServer}>
{language.t("dialog.server.add.button")}
</Button>
<Show
when={props.compact}
fallback={
<Button variant="ghost-muted" icon="plus" onClick={props.onAddServer}>
{language.t("dialog.server.add.button")}
</Button>
}
>
<IconButton
variant="ghost-muted"
size="small"
icon={<Icon name="plus" />}
aria-label={language.t("dialog.server.add.button")}
onClick={props.onAddServer}
/>
</Show>
}
>
<Menu gutter={4} modal={false} placement="bottom-end">
<Menu.Trigger as={Button} variant="ghost-muted" icon="plus">
{language.t("dialog.server.add.button")}
</Menu.Trigger>
<Show
when={props.compact}
fallback={
<Menu.Trigger as={Button} variant="ghost-muted" icon="plus">
{language.t("dialog.server.add.button")}
</Menu.Trigger>
}
>
<Menu.Trigger
as={IconButton}
variant="ghost-muted"
size="small"
icon={<Icon name="plus" />}
aria-label={language.t("dialog.server.add.button")}
/>
</Show>
<Menu.Portal>
<Menu.Content>
<Menu.Item onSelect={props.onAddServer}>{language.t("dialog.server.add.button")}</Menu.Item>
@@ -66,7 +93,7 @@ export function useFilteredWslServers(filter: Accessor<string>) {
export function WslServerSettings(props: {
domain: Pick<ServerCollectionController, "collection" | "defaults" | "connection">
servers: ReturnType<typeof useFilteredWslServers>
servers: Accessor<readonly WslServerItem[]>
}) {
const platform = usePlatform()
const language = useLanguage()
@@ -154,7 +181,9 @@ export function WslServerSettings(props: {
</Menu.Item>
</Show>
<Menu.Separator />
<Menu.Item onSelect={() => remove(key)}>{language.t("dialog.server.menu.delete")}</Menu.Item>
<Menu.Item disabled={request.isPending} onSelect={() => remove(key)}>
{language.t("dialog.server.menu.remove")}
</Menu.Item>
</Menu.Group>
</Menu.Content>
</Menu.Portal>
+6 -2
View File
@@ -20,7 +20,7 @@ import { useComposerCommands } from "@/composer/commands"
import { useSessionCommands } from "../commands/use-session-commands"
import type { SessionModel } from "../model"
import type { SessionScreenLayout } from "../screen-layout"
import { syncPromptModel, syncSessionModel } from "../session-model-helpers"
import { restorePromptModel, syncPromptModel, syncSessionModel } from "../session-model-helpers"
import type { SessionTimelineInteraction } from "../timeline/interaction"
import { createSessionRevert } from "../revert"
import { SessionComposerRegion } from "./session-composer-region"
@@ -62,10 +62,14 @@ export function createActiveSessionRegion(input: {
},
),
)
let restoredModelSession: string | undefined
createEffect(() => {
const id = input.session.identity.params.id
if (!id || !prompt.ready() || !local.session.ready()) return
// Prompt model is a submission mirror. Local drafts and durable session state own selection.
if (restoredModelSession !== id) {
restoredModelSession = id
if (restorePromptModel(local, prompt)) return
}
syncPromptModel(local, prompt)
})
createEffect(
@@ -1,5 +1,4 @@
import type { SessionInfo } from "@opencode/client/promise"
import { useDialog } from "@opencode/ui/context/dialog"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
@@ -10,12 +9,14 @@ import { useNavigate } from "@solidjs/router"
import { createMemo, Show, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { useServer } from "@/runtime/server/current"
import { ServerConnection } from "@/runtime/server/registry"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { displayName, errorMessage, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/shell/state/layout"
import { tabKey, useTabs } from "@/shell/tabs/tabs"
import { useSettings } from "@/settings/model"
import { useSettingsSurface } from "@/settings/surface"
import { pathKey } from "@/workspaces/path-key"
import { isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
import { sessionHref } from "@/shell/routes/session"
@@ -42,9 +43,9 @@ export function SessionProjectMenu(props: {
}) {
const server = useServer()
const language = useLanguage()
const dialog = useDialog()
const platform = usePlatform()
const layout = useLayout()
const settingsSurface = useSettingsSurface()
const navigate = useNavigate()
const [state, setState] = createStore({
open: false,
@@ -64,11 +65,13 @@ export function SessionProjectMenu(props: {
}),
)
}
const openProjectSettings = async () => {
const openProjectSettings = () => {
const current = props.project
if (!current) return
const { DialogEditProject } = await import("@/settings/workspaces/project-dialog")
dialog.push(() => <DialogEditProject project={{ expanded: false, ...current }} server={server.conn} />)
settingsSurface.openProject({
server: ServerConnection.key(server.conn),
project: current.worktree,
})
}
return (
@@ -191,7 +194,7 @@ export function SessionProjectMenu(props: {
</Menu.Item>
</Tooltip>
<Menu.Separator />
<Menu.Item disabled={!props.project} onSelect={() => void openProjectSettings()}>
<Menu.Item disabled={!props.project} onSelect={openProjectSettings}>
<Icon name="settings-gear" class="text-v2-icon-icon-muted" />
{language.t("project.settings.title")}
</Menu.Item>
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { resetSessionModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
const message = (input?: { agent?: string; model?: { providerID: string; modelID: string; variant?: string } }) => ({
sessionID: "session",
@@ -54,7 +54,8 @@ describe("syncPromptModel", () => {
{
model: {
current: () => ({ id: "claude-sonnet-4", provider: { id: "anthropic" } }),
variant: { current: () => "high" },
set() {},
variant: { current: () => "high", set() {} },
},
},
{
@@ -76,7 +77,8 @@ describe("syncPromptModel", () => {
{
model: {
current: () => ({ id: model.modelID, provider: { id: model.providerID } }),
variant: { current: () => model.variant },
set() {},
variant: { current: () => model.variant, set() {} },
},
},
{
@@ -91,26 +93,54 @@ describe("syncPromptModel", () => {
})
})
describe("stale prompt model", () => {
test("replaces the submission mirror without changing the effective selection", () => {
describe("restorePromptModel", () => {
test("restores the persisted prompt model into session selection", () => {
const calls: unknown[] = []
syncPromptModel(
const restored = restorePromptModel(
{
model: {
current: () => ({ id: "gpt", provider: { id: "openai" } }),
set: (model) => calls.push(model),
variant: {
current: () => undefined,
set: (variant) => calls.push(variant),
},
},
},
{
model: {
current: () => ({ providerID: "anthropic", modelID: "claude", variant: "high" }),
set: (value) => calls.push(value),
set() {},
},
},
)
expect(calls).toEqual([{ providerID: "openai", modelID: "gpt", variant: undefined }])
expect(restored).toBe(true)
expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude" }, "high"])
})
test("does nothing without a persisted prompt model", () => {
const calls: unknown[] = []
const restored = restorePromptModel(
{
model: {
current: () => ({ id: "gpt", provider: { id: "openai" } }),
set: (model) => calls.push(model),
variant: {
current: () => undefined,
set: (variant) => calls.push(variant),
},
},
},
{
model: {
current: () => undefined,
set() {},
},
},
)
expect(restored).toBe(false)
expect(calls).toEqual([])
})
})
@@ -12,8 +12,10 @@ type Local = {
type ModelSelection = {
model: {
current(): { id: string; provider: { id: string } } | undefined
set(model: { providerID: string; modelID: string }): void
variant: {
current(): string | undefined
set(variant: string | undefined): void
}
}
}
@@ -49,3 +51,18 @@ export const syncPromptModel = (local: ModelSelection, prompt: PromptState) => {
return
prompt.model.set(next)
}
export const restorePromptModel = (local: ModelSelection, prompt: PromptState) => {
const model = prompt.model.current()
if (!model) return false
const current = local.model.current()
if (
current?.provider.id === model.providerID &&
current.id === model.modelID &&
local.model.variant.current() === (model.variant ?? undefined)
)
return true
local.model.set({ providerID: model.providerID, modelID: model.modelID })
local.model.variant.set(model.variant ?? undefined)
return true
}
+2 -1
View File
@@ -1,8 +1,9 @@
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettingsSurface } from "./surface"
import type { SettingsRootTab } from "./surface"
export function useSettingsDialog(defaultValue?: string) {
export function useSettingsDialog(defaultValue?: SettingsRootTab) {
const settings = useSettingsSurface()
return () => settings.open(defaultValue)
}
@@ -1,4 +1,5 @@
import { createMemo, createResource, onMount, type Accessor } from "solid-js"
import type { ConfigPreferences, ConfigUpdatePreferencesInput } from "@opencode/client/promise"
import type { ColorScheme } from "@opencode/ui/theme/context"
import { useTheme } from "@opencode/ui/theme/context"
import {
@@ -14,32 +15,100 @@ import {
useSettings,
} from "@/settings/model"
import { playSoundById, SOUND_OPTIONS } from "@/shell/notifications/sound"
import { createSoundPreviewController, type ShellOption } from "./behavior"
import { createSoundPreviewController } from "./behavior"
import { ServerConnection } from "@/runtime/server/registry"
import { useServerCtx } from "@/runtime/server/runtime"
import { useLanguage } from "@/runtime/i18n/language"
import { showToast } from "@/shell/notifications/toast"
export { createShellOptions, createSoundPreviewController } from "./behavior"
export type { ShellOption, ShellSelectOption } from "./behavior"
export function createShellSettingsController(server: Accessor<ServerConnection.Any | undefined>) {
export function createServerPreferencesController(server: Accessor<ServerConnection.Any>) {
const language = useLanguage()
const serverCtx = useServerCtx(server)
const [shells] = createResource(
async () => {
// TODO: Dax is considering the V2 shell discovery and config update APIs.
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
{ initialValue: [] as ShellOption[] },
const source = () => ServerConnection.key(server())
const [preferences, preferencesActions] = createResource<ConfigPreferences, ServerConnection.Key>(
source,
() =>
serverCtx()
.sdk.api.config.preferences()
.catch(() => ({})),
{ initialValue: {} },
)
const current = createMemo(() => serverCtx()?.sync.data.config.shell ?? "")
const [shells] = createResource(
source,
() =>
serverCtx()
.sdk.api.config.shells()
.catch(() => []),
{ initialValue: [] },
)
const [providers] = createResource(
source,
() =>
serverCtx()
.sdk.api.websearch.providers()
.then((result) => result.data)
.catch(() => []),
{ initialValue: [] },
)
const update = async (patch: ConfigUpdatePreferencesInput) => {
const context = serverCtx()
const previous = preferences.latest
preferencesActions.mutate({
...previous,
...(patch.shell === undefined ? {} : { shell: patch.shell ?? undefined }),
...(patch.websearch === undefined ? {} : { websearch: patch.websearch ?? undefined }),
})
await context.sdk.api.config
.updatePreferences(patch)
.then(preferencesActions.mutate)
.catch((error: unknown) => {
preferencesActions.mutate(previous)
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
})
}
const websearchOptions = createMemo(() => {
const options = providers.latest.map((provider) => ({ value: provider.id, label: provider.name }))
const selected = preferences.latest.websearch
const configured = selected && selected.provider !== "random" ? selected.provider : undefined
return [
{ value: "random" as const, label: language.t("session.websearch.any") },
...options,
...(configured && !options.some((option) => option.value === configured)
? [{ value: configured, label: configured }]
: []),
{ value: false as const, label: language.t("session.websearch.disable") },
]
})
const websearchCurrent = createMemo(() => {
const selection = preferences.latest.websearch
const value = selection === false ? false : (selection?.provider ?? "random")
return websearchOptions().find((option) => option.value === value) ?? websearchOptions()[0]
})
return {
shells: () => shells.latest,
current,
select: (value: string) => {
if (value === current()) return
// TODO: Dax is considering the V2 shell discovery and config update APIs.
// void serverSync.updateConfig({ shell: value })
shell: {
shells: () => shells.latest,
current: () => preferences.latest.shell ?? "",
select: (value: string) => {
if (value === (preferences.latest.shell ?? "")) return
void update({ shell: value || null })
},
},
websearch: {
options: websearchOptions,
current: websearchCurrent,
select: (value: string | false) => {
void update({ websearch: value === false ? false : { provider: value } })
},
},
}
}
@@ -139,6 +208,6 @@ export function createSoundSettingsController() {
}
}
export type ShellSettingsController = ReturnType<typeof createShellSettingsController>
export type ShellSettingsController = ReturnType<typeof createServerPreferencesController>["shell"]
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
export type SoundSettingsController = ReturnType<typeof createSoundSettingsController>
+14 -31
View File
@@ -21,12 +21,10 @@ import { SettingsRow } from "@/settings/row"
import {
createAppearanceSettingsController,
createShellOptions,
createShellSettingsController,
type AppearanceSettingsController,
type ShellSettingsController,
} from "./controllers"
import "@/settings/settings.css"
import { ServerConnection } from "@/runtime/server/registry"
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
const fontSettings = {
@@ -97,7 +95,7 @@ const WorkspaceDestinationSetting: Component = () => {
)
}
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
export const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
const language = useLanguage()
const options = createMemo(() =>
createShellOptions({
@@ -295,15 +293,12 @@ const LanguageSetting = () => {
)
}
export const SettingsGeneral: Component<{
server?: ServerConnection.Any
}> = (props) => {
export const SettingsGeneral: Component = () => {
const language = useLanguage()
const platform = usePlatform()
const settings = useSettings()
const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction()
const shell = createShellSettingsController(() => props.server)
const desktop = createMemo(() => platform.platform === "desktop")
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
@@ -328,10 +323,20 @@ export const SettingsGeneral: Component<{
<WorkspaceDestinationSetting />
<AutoApprovePermissionsSetting />
<ShellSetting controller={shell} />
<TerminalPlacementSetting />
<FollowUpBehaviorSetting />
<Show when={desktop()}>
<SettingsRow
title={language.t("settings.general.row.pinchZoom.title")}
description={language.t("settings.general.row.pinchZoom.description")}
>
<div data-action="settings-pinch-zoom">
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
</div>
</SettingsRow>
</Show>
<SettingsRow
title={language.t("session.review.wrapLines")}
description={language.t("settings.general.row.mobileDiffWrap.description")}
@@ -482,26 +487,6 @@ export const SettingsGeneral: Component<{
</div>
)
// We can probably remove this, right?
const DisplaySection = () => (
<Show when={desktop()}>
<div class="settings-section">
<h3 class="settings-section-title">{language.t("settings.general.section.display")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.pinchZoom.title")}
description={language.t("settings.general.row.pinchZoom.description")}
>
<div data-action="settings-pinch-zoom">
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
</div>
</SettingsRow>
</SettingsList>
</div>
</Show>
)
return (
<>
<div class="settings-tab-header">
@@ -514,7 +499,7 @@ export const SettingsGeneral: Component<{
</div>
</div>
</div>
<div class="settings-tab-body">
<div class="settings-tab-body settings-tab-body--sectioned">
<GeneralSection />
<section class="settings-section" aria-label={language.t("settings.timeline.title")}>
@@ -533,8 +518,6 @@ export const SettingsGeneral: Component<{
<UpdatesSection />
</Show>
<DisplaySection />
<AdvancedSection />
</div>
</>
@@ -417,7 +417,7 @@ function SettingsKeybindsView(props: {
</div>
</div>
<div class="settings-tab-body">
<div class="settings-shortcuts flex flex-col gap-8">
<div class="settings-shortcuts settings-section-stack">
<For each={props.groups}>
{(group) => (
<Show when={(filtered().get(group) ?? []).length > 0}>
+6 -2
View File
@@ -1,6 +1,10 @@
import type { Component, JSX } from "solid-js"
import "@/settings/settings.css"
export const SettingsList: Component<{ children: JSX.Element }> = (props) => {
return <div data-component="settings-list">{props.children}</div>
export const SettingsList: Component<{ children: JSX.Element; variant?: "catalog" }> = (props) => {
return (
<div data-component="settings-list" data-variant={props.variant}>
{props.children}
</div>
)
}
+2 -4
View File
@@ -12,7 +12,6 @@ import { useModels } from "@/providers/models/models"
import { useServerSDK } from "@/runtime/server/client"
import { popularProviders } from "@/providers/catalog/providers"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { InlineServerSelect } from "@/settings/server-select"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import "@/settings/settings.css"
@@ -65,7 +64,6 @@ export const SettingsModels: Component = () => {
<h2 class="settings-tab-title">{language.t("settings.models.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.models.description")}</span>
</div>
<InlineServerSelect />
</div>
<div class="settings-tab-search">
<TextInput
@@ -160,12 +158,12 @@ export const SettingsModels: Component = () => {
height={PROVIDER_ICON_SIZE}
class="settings-models-provider-icon shrink-0"
/>
<span class="settings-section-title">{group.items[0].provider.name}</span>
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
</span>
</button>
</h3>
<Show when={expanded()}>
<SettingsList>
<SettingsList variant="catalog">
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
+104
View File
@@ -0,0 +1,104 @@
import { Button } from "@opencode/ui/button"
import { Icon } from "@opencode/ui/icon"
import { Menu } from "@opencode/ui/menu"
import { Tabs } from "@opencode/ui/tabs"
import { For, Show, type ComponentProps, type JSX } from "solid-js"
export type SettingsNavItem = {
value: string
label: string
icon: ComponentProps<typeof Icon>["name"]
disabled?: boolean
}
export type SettingsNavGroup = {
label?: string
action?: JSX.Element
items: readonly SettingsNavItem[]
}
export function SettingsNavigation(props: {
value: string
groups: readonly SettingsNavGroup[]
backLabel: string
onBack: () => void
onChange: (value: string) => void
mobileAction?: JSX.Element
children: JSX.Element
}) {
const current = () => props.groups.flatMap((group) => group.items).find((item) => item.value === props.value)
return (
<Tabs orientation="vertical" variant="settings" value={props.value} onChange={props.onChange} class="settings">
<div class="settings-mobile-nav">
<button type="button" class="settings-back" onClick={props.onBack}>
<Icon name="arrow-left" size="small" class="settings-back-icon" />
<span>{props.backLabel}</span>
</button>
<div class="settings-mobile-actions">
{props.mobileAction}
<Menu placement="bottom-end" gutter={8}>
<Menu.Trigger as={Button} size="normal" variant="outline" class="settings-mobile-menu-trigger">
<span>{current()?.label}</span>
<Icon name="chevron-down" size="small" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="settings-mobile-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
<Menu.RadioGroup value={props.value} onChange={props.onChange}>
<For each={props.groups}>
{(group, index) => (
<>
<Show when={index() > 0}>
<Menu.Separator />
</Show>
<For each={group.items}>
{(item) => (
<Menu.RadioItem value={item.value} disabled={item.disabled} closeOnSelect>
<Icon name={item.icon} />
{item.label}
</Menu.RadioItem>
)}
</For>
</>
)}
</For>
</Menu.RadioGroup>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
</div>
<Tabs.List>
<div class="settings-nav">
<button type="button" class="settings-back" onClick={props.onBack}>
<Icon name="arrow-left" size="small" class="settings-back-icon" />
<span>{props.backLabel}</span>
</button>
<div class="settings-nav-groups">
<For each={props.groups}>
{(group) => (
<div class="settings-nav-group">
<Show when={group.label || group.action}>
<div class="settings-nav-group-header" data-component="settings-nav-group-header">
<span>{group.label}</span>
{group.action}
</div>
</Show>
<For each={group.items}>
{(item) => (
<Tabs.Trigger value={item.value} disabled={item.disabled}>
<Icon name={item.icon} />
{item.label}
</Tabs.Trigger>
)}
</For>
</div>
)}
</For>
</div>
</div>
</Tabs.List>
{props.children}
</Tabs>
)
}
@@ -71,7 +71,7 @@ export const SettingsNotifications: Component = () => {
</div>
</div>
<div class="settings-tab-body">
<div class="settings-tab-body settings-tab-body--sectioned">
<div class="settings-section">
<h3 class="settings-section-title">{language.t("settings.general.section.notifications")}</h3>
<SettingsList>
@@ -8,7 +8,7 @@ import { useServerSDK } from "@/runtime/server/client"
import { useMcpToggle } from "@/providers/connect/mcp"
import { pluginLabels } from "@/providers/catalog/plugin"
import { ExternalLink } from "@/runtime/platform/external-link"
import { InlineServerSelect } from "@/settings/server-select"
import { SettingsList } from "@/settings/list"
import "@/settings/settings.css"
interface McpRowItem {
@@ -63,12 +63,11 @@ export const SettingsExtensions: Component = () => {
<h2 class="settings-tab-title">{language.t("settings.tab.extensions")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.extensions.description")}</span>
</div>
<InlineServerSelect />
</div>
</div>
<div class="settings-tab-body">
<Tabs variant="pill" defaultValue="mcps" class="settings-extensions-tabs">
<Tabs variant="pill" defaultValue="mcps" class="settings-extensions-tabs settings-subtabs">
<Tabs.List>
<Tabs.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</Tabs.Trigger>
<Tabs.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</Tabs.Trigger>
@@ -78,18 +77,20 @@ export const SettingsExtensions: Component = () => {
<Tabs.Content value="mcps">
<div class="settings-section">
<div class="flex items-center justify-between">
<span class="text-13-medium text-v2-text-text-base">
<span class="settings-extension-heading text-13-medium">
{language.t("settings.extensions.availableAll")}
</span>
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
<span class="text-13-regular text-v2-text-text-muted">
{language.t("settings.extensions.manageConfig")}
</span>
</div>
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
<SettingsList variant="catalog">
<For each={mcps()}>
{(item) => (
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
<div class="flex items-center gap-2.5 min-w-0">
<div class="settings-extension-row">
<div class="settings-extension-lead">
<Icon name="mcp" class="text-v2-icon-icon-muted shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate">{item.name}</span>
<span class="settings-extension-name truncate">{item.name}</span>
</div>
<Switch checked={item.enabled} onChange={(checked) => handleMcpToggle(item, checked)} hideLabel>
{item.name}
@@ -97,58 +98,57 @@ export const SettingsExtensions: Component = () => {
</div>
)}
</For>
</div>
</SettingsList>
</div>
</Tabs.Content>
<Tabs.Content value="plugins">
<div class="settings-section">
<div class="flex items-center justify-between">
<span class="text-13-medium text-v2-text-text-base">
<span class="settings-extension-heading text-13-medium">
{language.t("settings.extensions.availableAll")}
</span>
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
<span class="text-13-regular text-v2-text-text-muted">
{language.t("settings.extensions.manageConfig")}
</span>
</div>
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
<SettingsList variant="catalog">
<For each={plugins()}>
{(plugin) => (
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
<div class="flex items-center gap-2.5 min-w-0">
<div class="settings-extension-row">
<div class="settings-extension-lead">
<Icon name="cube" class="text-v2-icon-icon-muted shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate font-mono">{plugin.name}</span>
<span class="settings-extension-name truncate">{plugin.name}</span>
</div>
</div>
)}
</For>
</div>
</SettingsList>
</div>
</Tabs.Content>
<Tabs.Content value="skills">
<div class="settings-section">
<div class="flex items-center justify-between">
<span class="text-13-medium text-v2-text-text-base">
<span class="settings-extension-heading text-13-medium">
{language.t("settings.extensions.availableAll")}
</span>
<ExternalLink
class="text-13-regular text-v2-text-accent hover:underline"
href="https://opencode.ai/docs/skills/"
>
<ExternalLink class="settings-extension-link text-13-regular" href="https://opencode.ai/docs/skills/">
{language.t("settings.extensions.addSkills")}
</ExternalLink>
</div>
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
<SettingsList variant="catalog">
<For each={skills()}>
{(skill) => (
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
<div class="flex items-center gap-2.5 min-w-0">
<div class="settings-extension-row">
<div class="settings-extension-lead">
<Icon name="post-skill" class="text-v2-icon-icon-muted shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate">{skill.name}</span>
<span class="settings-extension-name truncate">{skill.name}</span>
</div>
</div>
)}
</For>
</div>
</SettingsList>
</div>
</Tabs.Content>
</Tabs>
@@ -9,8 +9,6 @@ import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { DialogConnectProvider, useProviderConnectController } from "@/providers/connect/dialog"
import { SettingsServerScope } from "@/settings/server-scope"
import { InlineServerSelect } from "@/settings/server-select"
import { SettingsList } from "@/settings/list"
import "@/settings/settings.css"
@@ -44,11 +42,7 @@ export const SettingsProviders: Component<{
const connect = (provider?: string) => {
providerConnect.select(provider)
void dialog.show(() => (
<SettingsServerScope directory={props.directory}>
<DialogConnectProvider directory={props.directory} controller={providerConnect} />
</SettingsServerScope>
))
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
}
const connected = createMemo(() => {
@@ -134,14 +128,13 @@ export const SettingsProviders: Component<{
<h2 class="settings-tab-title">{language.t("settings.providers.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.providers.description")}</span>
</div>
<InlineServerSelect />
</div>
</div>
<div class="settings-tab-body settings-providers">
<div class="settings-tab-body settings-tab-body--sectioned settings-providers">
<div class="settings-section" data-component="connected-providers-section">
<h3 class="settings-section-title">{language.t("settings.providers.section.connected")}</h3>
<SettingsList>
<SettingsList variant="catalog">
<Show
when={connected().length > 0}
fallback={<div class="settings-provider-empty">{language.t("settings.providers.connected.empty")}</div>}
@@ -182,7 +175,7 @@ export const SettingsProviders: Component<{
<div class="settings-section">
<h3 class="settings-section-title">{language.t("settings.providers.section.popular")}</h3>
<SettingsList>
<SettingsList variant="catalog">
<For each={popular()}>
{(item) => (
<div class="settings-provider-row">
+1 -15
View File
@@ -1,22 +1,8 @@
import { type ParentProps, Show } from "solid-js"
import { useGlobal } from "@/runtime/server/runtime"
import { type ParentProps } from "solid-js"
import { ModelsProvider } from "@/providers/models/models"
import { ServerProvider } from "@/runtime/server/current"
import { ServerConnection } from "@/runtime/server/registry"
export function SettingsServerScope(props: ParentProps<{ directory?: string }>) {
const global = useGlobal()
return (
<Show when={global.settings.server.selected()} keyed fallback={props.children}>
{(server) => (
<SettingsServerDataScope server={server} directory={props.directory}>
{props.children}
</SettingsServerDataScope>
)}
</Show>
)
}
export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any; directory?: string }>) {
return (
<ServerProvider conn={props.server}>
@@ -1,48 +0,0 @@
import { Show, createMemo, type Component } from "solid-js"
import { Select } from "@opencode/ui/select"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, serverName } from "@/runtime/server/registry"
const allServers = { type: "all" } as const
type ServerOption = ServerConnection.Any | typeof allServers
export const InlineServerSelect: Component<{
all?: {
label: string
selected: () => boolean
onSelect: () => void
}
onServerSelect?: () => void
}> = (props) => {
const global = useGlobal()
const options = createMemo<ServerOption[]>(() => [...(props.all ? [allServers] : []), ...global.servers.list()])
const current = () => (props.all?.selected() ? allServers : global.settings.server.selected())
return (
<Show when={options().length > 1}>
<Select
data-action="settings-server-select"
options={options()}
current={current()}
value={(server) => (server.type === "all" ? server.type : ServerConnection.key(server))}
label={(server) =>
server.type === "all" ? (props.all?.label ?? "") : serverName(server) || ServerConnection.key(server)
}
optionDisabled={(server) =>
server.type === "all" ? false : global.servers.health[ServerConnection.key(server)]?.healthy === false
}
placement="bottom-end"
gutter={6}
onSelect={(server) => {
if (!server) return
if (server.type === "all") {
props.all?.onSelect()
return
}
global.settings.server.set(ServerConnection.key(server))
props.onServerSelect?.()
}}
/>
</Show>
)
}
@@ -0,0 +1,43 @@
import { createMemo } from "solid-js"
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
import { useWslServers } from "@/servers/wsl/context"
import type { WslServerItem } from "@/servers/wsl/types"
export type SettingsServer = {
key: ServerConnection.Key
name: string
connection?: ServerConnection.Any
wsl?: WslServerItem
}
export function settingsServers(connections: readonly ServerConnection.Any[], wsl: readonly WslServerItem[]) {
const configured = new Map(wsl.map((item) => [item.config.id, item]))
const connected = new Set(connections.map(ServerConnection.key))
return [
...connections.map((connection): SettingsServer => {
const key = ServerConnection.key(connection)
const item = configured.get(key)
return {
key,
name: item?.config.distro ?? (serverName(connection) || key),
connection: item && item.runtime.kind !== "ready" ? undefined : connection,
wsl: item,
}
}),
...wsl
.filter((item) => !connected.has(ServerConnection.Key.make(item.config.id)))
.map(
(item): SettingsServer => ({
key: ServerConnection.Key.make(item.config.id),
name: item.config.distro,
wsl: item,
}),
),
]
}
export function useSettingsServers() {
const servers = useServers()
const wsl = useWslServers()
return createMemo(() => settingsServers(servers.list, wsl.data?.servers ?? []))
}
+96 -102
View File
@@ -1,135 +1,129 @@
import { Badge } from "@opencode/ui/badge"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { TextInput } from "@opencode/ui/text-input"
import { Select } from "@opencode/ui/select"
import { useDialog } from "@opencode/ui/context/dialog"
import fuzzysort from "fuzzysort"
import { type Component, For, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { createMemo, Show, type Component } from "solid-js"
import { ServerRowMenu } from "@/servers/registry/row-menu"
import { ServerHealthIndicator } from "@/servers/registry/row"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection, serverName } from "@/runtime/server/registry"
import { useServerCollectionController } from "@/servers/registry/controller"
import { DialogServer } from "@/servers/connect/dialog"
import { AddServerMenu, WslServerSettings } from "@/servers/wsl/settings"
import { SettingsList } from "@/settings/list"
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/servers/wsl/settings"
import { SettingsRow } from "@/settings/row"
import { ShellSetting } from "@/settings/general/general"
import { createServerPreferencesController } from "@/settings/general/controllers"
import type { SettingsServer } from "./inventory"
import "@/settings/settings.css"
export const SettingsServers: Component = () => {
const WebSearchSetting: Component<{
controller: ReturnType<typeof createServerPreferencesController>["websearch"]
}> = (props) => {
const language = useLanguage()
return (
<SettingsRow
title={language.t("settings.server.preferences.websearch.title")}
description={language.t("settings.server.preferences.websearch.description")}
>
<Select
options={props.controller.options()}
current={props.controller.current()}
value={(option) => String(option.value)}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && props.controller.select(option.value)}
/>
</SettingsRow>
)
}
export const SettingsServerGeneral: Component<{
entry: SettingsServer
nested?: boolean
onAddServer?: () => void
onServerChange?: (server: ServerConnection.Any) => void
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const controller = useServerCollectionController()
const [store, setStore] = createStore({ filter: "" })
const wslServers = useFilteredWslServers(() => store.filter)
const showSearch = createMemo(
() => controller.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
)
const filtered = createMemo(() => {
const items = controller.collection.items().filter((item) => !isWslServer(item))
const query = store.filter.trim()
if (!query) return items
return fuzzysort
.go(query, items, {
keys: [(item) => serverName(item), (item) => item.http.url],
})
.map((result) => result.obj)
})
const openAdd = () => {
void dialog.push(() => <DialogServer mode="add" />)
}
const openEdit = (server: ServerConnection.Http) => {
void dialog.push(() => <DialogServer mode="edit" server={server} />)
}
const health = createMemo(() => controller.collection.health()[props.entry.key])
const edit = (server: ServerConnection.Http) =>
void dialog.push(() => <DialogServer mode="edit" server={server} onSave={props.onServerChange} />)
return (
<>
<div
class="settings-tab-header settings-servers-header"
classList={{ "settings-tab-header--stacked": showSearch() }}
>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">{language.t("status.popover.tab.servers")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.servers.description")}</span>
<h2 class="settings-tab-title">
{props.nested ? props.entry.name : language.t("settings.section.server")}
</h2>
<span class="text-11-regular text-v2-text-text-muted">
{language.t(props.nested ? "settings.server.description" : "settings.servers.description")}
</span>
</div>
<AddServerMenu onAddServer={openAdd} />
<Show when={!props.nested && props.onAddServer}>
<AddServerMenu onAddServer={() => props.onAddServer?.()} />
</Show>
</div>
<Show when={showSearch()}>
<div class="settings-tab-search">
<TextInput
type="search"
appearance="base"
value={store.filter}
onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={language.t("dialog.server.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("dialog.server.search.placeholder")}
/>
<Show when={store.filter}>
<IconButton
type="button"
variant="ghost-muted"
size="small"
class="settings-tab-search-clear"
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setStore("filter", "")}
/>
</Show>
</div>
</Show>
</div>
<div class="settings-tab-body settings-servers">
<Show
when={filtered().length > 0 || wslServers().length > 0}
fallback={
<div class="settings-servers-status">
<span>{store.filter ? language.t("palette.empty") : language.t("dialog.server.empty")}</span>
<Show when={store.filter}>
<span class="settings-servers-status-filter">&quot;{store.filter}&quot;</span>
</Show>
</div>
}
>
<div class="settings-tab-body settings-tab-body--sectioned">
<section class="settings-section settings-server-connection" data-component="settings-server-connection">
<h3 class="settings-section-title">{language.t("settings.server.section.connection")}</h3>
<SettingsList>
<WslServerSettings domain={controller} servers={wslServers} />
<For each={filtered()}>
{(item) => {
const key = ServerConnection.key(item)
const health = () => controller.collection.health()[key]
const isDefault = () => controller.defaults.key() === key
return (
<div class="settings-servers-row">
<div class="settings-servers-lead">
<ServerHealthIndicator health={health()} />
<div class="settings-servers-copy">
<span class="settings-servers-name">{serverName(item)}</span>
<Show when={health()?.version}>
<span class="settings-servers-meta">v{health()?.version}</span>
<Show
when={props.entry.wsl}
fallback={
<Show when={props.entry.connection}>
{(server) => (
<div class="settings-servers-row">
<div class="settings-servers-lead">
<ServerHealthIndicator health={health()} />
<div class="settings-servers-copy">
<bdi class="settings-servers-name" dir="auto">
{serverName(server()) || props.entry.key}
</bdi>
<bdi class="settings-servers-meta" dir="ltr">
{server().http.url}
</bdi>
</div>
</div>
<div class="settings-servers-actions">
<Show when={controller.defaults.available() && controller.defaults.key() === props.entry.key}>
<Badge>{language.t("dialog.server.status.default")}</Badge>
</Show>
<ServerRowMenu server={server()} domain={controller} onEdit={edit} />
</div>
</div>
<div class="settings-servers-actions">
<Show when={controller.defaults.available() && isDefault()}>
<Badge>{language.t("dialog.server.status.default")}</Badge>
</Show>
<ServerRowMenu server={item} domain={controller} onEdit={openEdit} />
</div>
</div>
)
}}
</For>
)}
</Show>
}
>
{(item) => <WslServerSettings domain={controller} servers={() => [item()]} />}
</Show>
</SettingsList>
</section>
<Show when={props.entry.connection} keyed>
{(server) => <ServerPreferences server={server} />}
</Show>
</div>
</>
)
}
function ServerPreferences(props: { server: ServerConnection.Any }) {
const language = useLanguage()
const preferences = createServerPreferencesController(() => props.server)
return (
<section class="settings-section">
<h3 class="settings-section-title">{language.t("settings.tab.preferences")}</h3>
<SettingsList>
<ShellSetting controller={preferences.shell} />
<WebSearchSetting controller={preferences.websearch} />
</SettingsList>
</section>
)
}
+173 -130
View File
@@ -54,8 +54,7 @@
}
.settings-screen .settings-tab-header {
padding: 48px 0 32px;
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
padding: 48px 0 0;
}
.settings-screen .settings-tab-body {
@@ -70,6 +69,56 @@
gap: 16px;
}
.settings-nav-groups {
display: flex;
width: 100%;
flex-direction: column;
gap: 16px;
}
.settings-nav-group {
display: flex;
width: 100%;
flex-direction: column;
gap: 4px;
}
.settings-nav-group-header {
display: flex;
height: 28px;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-inline: 6px;
border-radius: 4px;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
}
.settings-nav-group-header [data-component="icon-button-v2"] {
opacity: 0;
}
.settings-nav-group-header:focus-within [data-component="icon-button-v2"] {
opacity: 1;
}
.settings-nav-group-header:focus-within {
background: var(--v2-background-bg-layer-03);
}
@media (hover: hover) {
.settings-nav-group-header:hover {
background: var(--v2-background-bg-layer-03);
}
.settings-nav-group-header:hover [data-component="icon-button-v2"] {
opacity: 1;
}
}
.settings-mobile-nav {
display: none;
}
@@ -78,6 +127,13 @@
min-width: 0;
}
.settings-mobile-actions {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.settings-mobile-menu-trigger > span {
overflow: hidden;
text-overflow: ellipsis;
@@ -249,8 +305,18 @@
position: sticky;
top: 0;
z-index: 10;
padding: 40px 40px 32px;
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
padding: 40px 40px 0;
background: var(--v2-background-bg-base);
}
.settings-tab-header::before {
content: "";
position: absolute;
inset-inline: 0;
top: 100%;
height: 24px;
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
pointer-events: none;
}
.settings-tab-header-row {
@@ -271,9 +337,13 @@
.settings-tab-body {
display: flex;
flex-direction: column;
gap: 36px;
gap: 32px;
width: 100%;
padding: 0 40px calc(80px + var(--settings-bottom-inset, 0px));
padding: 24px 40px calc(80px + var(--settings-bottom-inset, 0px));
}
.settings-tab-body--sectioned {
padding-top: 32px;
}
[data-slot="settings-row-description"] a.settings-link {
@@ -293,16 +363,18 @@
}
.settings-section-title {
padding-bottom: 8px;
font-size: 15px;
font-weight: 640;
line-height: 1;
padding: 0;
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
color: var(--v2-text-text-base);
}
.settings-section-title + [data-component="settings-list"] {
margin-top: -4px;
margin-bottom: 0;
.settings-section-stack {
display: flex;
width: 100%;
flex-direction: column;
gap: 32px;
}
[data-component="settings-list"] {
@@ -312,6 +384,12 @@
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
}
[data-component="settings-list"][data-variant="catalog"] {
--settings-list-row-padding: 16px;
--settings-list-icon-gap: 8px;
padding-inline: 16px;
}
.settings-interface-feature [data-component="settings-list"] {
background-color: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-raised);
@@ -322,7 +400,7 @@
flex-wrap: wrap;
align-items: center;
gap: 16px;
padding-block: 20px;
padding-block: var(--settings-list-row-padding, 20px);
border-bottom: 0.5px solid var(--v2-border-border-base);
}
@@ -375,6 +453,12 @@
padding: 4px;
}
[data-component="settings-list"][data-variant="catalog"]
[data-slot="settings-row-control"]
> :is(div:has([data-component="switch"]), [data-component="switch"]) {
padding: 0;
}
@media (min-width: 640px) {
[data-slot="settings-row-control"] {
width: auto;
@@ -465,8 +549,7 @@
padding-top: 12px;
}
.settings-screen .settings-tab-title,
.settings-screen .settings-section-title {
.settings-screen .settings-tab-title {
line-height: var(--line-height-base);
}
@@ -505,7 +588,7 @@
align-items: center;
justify-content: space-between;
gap: 16px;
padding-block: 20px;
padding-block: var(--settings-list-row-padding, 20px);
border-bottom: 0.5px solid var(--v2-border-border-base);
}
@@ -534,7 +617,7 @@
min-width: 0;
flex: 1;
align-items: flex-start;
gap: 10px;
gap: var(--settings-list-icon-gap, 10px);
}
.settings-provider-lead:not(:has(.settings-provider-copy)) {
@@ -560,12 +643,12 @@
.settings-provider-name {
font-size: 13px;
font-weight: 530;
line-height: 16px;
line-height: var(--line-height-compact);
color: var(--v2-text-text-base);
}
.settings-provider-description {
margin-block: -3.5px;
margin: 0;
font-size: 13px;
font-weight: 440;
line-height: 20px;
@@ -573,7 +656,7 @@
}
.settings-provider-empty {
padding-block: 20px;
padding-block: var(--settings-list-row-padding, 20px);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
@@ -612,30 +695,10 @@
color: var(--v2-text-text-accent-hover);
}
.settings-tab-body.settings-providers {
gap: 32px;
}
.settings-tab-header:has(+ .settings-tab-body.settings-providers) {
padding-bottom: 32px;
}
.settings-providers .settings-section-title {
padding-bottom: 0;
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
}
.settings-providers .settings-section-title + [data-component="settings-list"] {
margin-top: 16px;
}
.settings-tab-header.settings-tab-header--stacked {
display: flex;
flex-direction: column;
gap: 32px;
padding-bottom: 32px;
gap: 24px;
}
.settings-tab-header--stacked > .settings-tab-header-row {
@@ -728,25 +791,16 @@
gap: 8px;
}
.settings-models .settings-section-title {
padding-bottom: 0;
.settings-models-group-title {
font-size: 13px;
font-weight: 530;
line-height: 16px;
line-height: var(--line-height-compact);
}
.settings-models [data-component="provider-icon"] {
color: var(--v2-icon-icon-base);
}
.settings-models [data-component="settings-list"] {
padding-inline: 16px;
}
.settings-models .settings-section-title + [data-component="settings-list"] {
margin-top: 0;
}
.settings-models [data-slot="settings-row-description"]:empty {
display: none;
}
@@ -792,17 +846,6 @@
color: var(--v2-text-text-base);
}
.settings-shortcuts .settings-section {
gap: 16px;
}
.settings-shortcuts .settings-section-title {
padding-bottom: 0;
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
}
.settings-shortcuts [data-component="settings-list"] {
display: flex;
flex-direction: column;
@@ -891,34 +934,6 @@
color: var(--v2-text-text-base);
}
.settings-tab-body.settings-servers {
gap: 0;
}
.settings-tab-header.settings-servers-header {
padding-bottom: 24px;
}
.settings-servers-header .settings-tab-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-tab-header.settings-servers-header.settings-tab-header--stacked {
gap: 24px;
padding-bottom: 24px;
}
.settings-servers [data-component="settings-list"] {
display: flex;
flex-direction: column;
gap: 0;
padding: 20px;
border-radius: 6px;
}
.settings-servers-row {
display: flex;
align-items: center;
@@ -926,6 +941,18 @@
gap: 16px;
}
.settings-server-connection [data-component="settings-list"] {
padding-inline: 16px;
}
.settings-server-connection .settings-servers-row {
padding-block: 20px;
}
.settings-server-connection .settings-servers-lead {
gap: 4px;
}
.settings-servers-row:not(:last-child) {
padding-bottom: 16px;
margin-bottom: 16px;
@@ -970,34 +997,8 @@
color: var(--v2-text-text-muted);
}
.settings-servers-status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding-block: 48px;
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
text-align: center;
}
.settings-servers-status-filter {
color: var(--v2-text-text-base);
}
.settings-tab-header.settings-workspaces-header {
padding-bottom: 24px;
}
.settings-workspaces-header .settings-tab-title {
font-weight: 610;
}
.settings-tab-body.settings-workspaces {
gap: 16px;
gap: 0;
}
.settings-workspaces-toolbar {
@@ -1006,13 +1007,7 @@
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-workspaces-count {
font-size: 15px;
font-weight: 530;
line-height: 1;
color: var(--v2-text-text-base);
padding-bottom: 8px;
}
.settings-workspaces-toolbar-actions {
@@ -1255,7 +1250,7 @@
@media (max-width: 639px) {
.settings-workspaces-header {
padding: 24px 20px 20px;
padding: 24px 20px 0;
}
.settings-tab-body.settings-workspaces {
@@ -1371,20 +1366,68 @@
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
width: min(280px, 100%);
}
.settings-subtabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
padding-inline: 0 !important;
}
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
.settings-subtabs > [data-slot="tabs-v2-content"] {
padding-top: 24px;
}
.settings-extension-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-block: var(--settings-list-row-padding, 16px);
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-extension-row:last-child {
border-bottom: 0;
}
.settings-extension-lead {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
gap: var(--settings-list-icon-gap, 8px);
}
.settings-extension-name {
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
}
.settings-extension-heading,
.settings-extension-name {
color: var(--text-strong);
}
.settings-extension-link {
color: var(--v2-text-text-accent) !important;
text-decoration: underline;
}
.settings-extension-link:hover {
color: var(--v2-text-text-accent-hover) !important;
}
.settings-subtabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
display: none;
}
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
.settings-subtabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger-wrapper"] {
border-radius: 6px;
}
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
.settings-subtabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger"] {
padding-inline: 8px;
+379 -203
View File
@@ -1,10 +1,17 @@
import { Component, createEffect, createMemo, For, Show, onMount, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { Tabs } from "@opencode/ui/tabs"
import { Icon } from "@opencode/ui/icon"
import { Menu } from "@opencode/ui/menu"
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { createEffect, createMemo, onMount, Show, Switch, Match, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { useTabs } from "@/shell/tabs/tabs"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import type { LocalProject } from "@/shell/state/layout"
import { useServerCollectionController } from "@/servers/registry/controller"
import { AddServerMenu } from "@/servers/wsl/settings"
import { DialogServer } from "@/servers/connect/dialog"
import { LocationProvider } from "@/workspaces/location"
import { SettingsGeneral } from "./general/general"
import { SettingsAppearance } from "./appearance/appearance"
import { SettingsExperimental } from "./experimental/experimental"
@@ -12,93 +19,100 @@ import { SettingsKeybinds } from "./keybinds/keybinds"
import { SettingsNotifications } from "./notifications/notifications"
import { SettingsProviders } from "./providers/providers"
import { SettingsModels } from "./models/models"
import { SettingsServers } from "./servers/servers"
import { SettingsServerGeneral } from "./servers/servers"
import { useSettingsServers, type SettingsServer } from "./servers/inventory"
import { SettingsWorkspaces } from "./workspaces/workspaces"
import { SettingsProjects } from "./workspaces/projects"
import { SettingsExtensions } from "./providers/extensions"
import { SettingsAbout } from "./about/about"
import { SettingsServerScope } from "./server-scope"
import { useDialog } from "@opencode/ui/context/dialog"
import { useLayout } from "@/shell/state/layout"
import { useTabs } from "@/shell/tabs/tabs"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { SettingsServerDataScope } from "./server-scope"
import { SettingsNavigation, type SettingsNavGroup } from "./navigation"
import { SettingsProjectGeneral } from "./workspaces/project"
import { ProjectSettingsExtensions } from "./workspaces/project-extensions"
import { useSettingsSurface } from "./surface"
import "@/settings/settings.css"
const sections = [
[
{ value: "general", icon: "sliders", label: "settings.tab.preferences" },
{ value: "appearance", icon: "appearance", label: "settings.general.section.appearance" },
{ value: "notifications", icon: "notifications", label: "settings.tab.notifications" },
{ value: "shortcuts", icon: "keyboard", label: "settings.tab.shortcuts" },
],
[
{ value: "servers", icon: "server", label: "status.popover.tab.servers" },
{ value: "projects", icon: "folder", label: "settings.tab.projects" },
{ value: "workspaces", icon: "outline-worktree", label: "settings.tab.workspaces" },
],
[
{ value: "providers", icon: "providers", label: "settings.providers.title" },
{ value: "models", icon: "models", label: "settings.models.title" },
{ value: "extensions", icon: "extensions", label: "settings.tab.extensions" },
],
const rootClientTabs = [
{ value: "general", icon: "sliders", label: "settings.tab.preferences" },
{ value: "appearance", icon: "appearance", label: "settings.general.section.appearance" },
{ value: "notifications", icon: "notifications", label: "settings.tab.notifications" },
{ value: "shortcuts", icon: "keyboard", label: "settings.tab.shortcuts" },
] as const
const serverTabs = [
{ value: "projects", icon: "folder", label: "settings.tab.projects" },
{ value: "workspaces", icon: "outline-worktree", label: "settings.tab.workspaces" },
{ value: "providers", icon: "providers", label: "settings.providers.title" },
{ value: "models", icon: "models", label: "settings.models.title" },
{ value: "extensions", icon: "extensions", label: "settings.tab.extensions" },
] as const
const trailingTabs = [
[{ value: "experimental", icon: "flask", label: "settings.tab.experimental" }],
[{ value: "about", icon: "info", label: "settings.tab.about" }],
] as const
export const SettingsScreen: Component = () => {
const language = useLanguage()
const dialog = useDialog()
const nestedServerTabs = [
{ value: "general", icon: "server", label: "settings.general.section.general" },
...serverTabs,
] as const
const nestedProjectTabs = [
{ value: "general", icon: "folder", label: "settings.general.section.general" },
{ value: "workspaces", icon: "outline-worktree", label: "settings.tab.workspaces" },
{ value: "extensions", icon: "extensions", label: "settings.tab.extensions" },
] as const
export function SettingsScreen() {
const surface = useSettingsSurface()
const layout = useLayout()
const servers = useServers()
const tabs = useTabs()
const dialog = useDialog()
const servers = useSettingsServers()
const global = useGlobal()
const [state, setState] = createStore({ worktreeFilterReset: 0 })
let root: HTMLDivElement | undefined
let viewType = surface.view().type
onMount(() => {
root?.focus({ preventScroll: true })
})
const server = createMemo(() => {
const route = surface.route()
switch (route.type) {
case "draft": {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return servers.list.find((item) => ServerConnection.key(item) === draft?.server)
}
case "session":
return servers.list.find((item) => ServerConnection.key(item) === route.server)
case "home":
return servers.list.find((item) => ServerConnection.key(item) === layout.home.selection().server)
}
})
const serverCtx = useServerCtx(server)
onMount(() => root?.focus({ preventScroll: true }))
createEffect(() => {
const current = server()
if (current) global.settings.server.set(ServerConnection.key(current))
const next = surface.view().type
if (next === viewType) return
viewType = next
queueMicrotask(() => root?.focus({ preventScroll: true }))
})
const directory = createMemo(() => {
const selected = global.settings.server.selected()
const current = server()
if (!selected || !current || ServerConnection.key(selected) !== ServerConnection.key(current)) return
const route = surface.route()
if (route.type === "draft") {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" ? draft.directory : undefined
}
if (route.type === "session") return serverCtx()?.data.session.get(route.sessionId)?.location.directory
return undefined
})
const showProviders = () => {
dialog.close()
surface.open("providers")
const connection = (key: string) => servers().find((item) => item.key === key)
const project = (server: ServerConnection.Any, directory: string) => {
const context = global.ensureServerCtx(server)
const value =
context.projects.list().find((item) => item.worktree === directory) ??
context.sync.data.project.find((item) => item.worktree === directory)
return value ? { expanded: false, ...value } : undefined
}
const targetServer = createMemo(() => {
const view = surface.view()
if (view.type === "root") return undefined
return connection(view.server)
})
const targetProject = createMemo(() => {
const view = surface.view()
const server = targetServer()
if (view.type !== "project" || !server) return undefined
return server.connection && project(server.connection, view.project)
})
createEffect(() => {
const view = surface.view()
if (view.type === "root") return
const target = targetServer()
if (!target) {
surface.back()
return
}
if (view.type === "project" && !target.connection) surface.replaceServer(target.key)
})
createEffect(() => {
const view = surface.view()
if (view.type !== "server" || servers().length !== 1) return
surface.open(view.tab === "general" ? "servers" : view.tab)
})
return (
<div
@@ -109,138 +123,300 @@ export const SettingsScreen: Component = () => {
onKeyDown={(event) => {
if (event.key !== "Escape" || event.defaultPrevented || dialog.active) return
event.preventDefault()
surface.close()
surface.back()
}}
>
<Tabs
orientation="vertical"
variant="settings"
value={surface.tab()}
onChange={(value) => void startTransition(() => surface.open(value))}
class="settings"
>
<div class="settings-mobile-nav">
<button type="button" class="settings-back" onClick={surface.close}>
<Icon name="arrow-left" size="small" class="settings-back-icon" />
<span>{language.t("settings.backToApp")}</span>
</button>
<Menu placement="bottom-end" gutter={8}>
<Menu.Trigger as={Button} size="normal" variant="outline" class="settings-mobile-menu-trigger">
<span>
{language.t(
sections.flat().find((section) => section.value === surface.tab())?.label ??
"settings.tab.preferences",
)}
</span>
<Icon name="chevron-down" size="small" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="settings-mobile-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
<Menu.RadioGroup
value={surface.tab()}
onChange={(value) => void startTransition(() => surface.open(value))}
>
<For each={sections}>
{(group, index) => (
<>
<Show when={index() > 0}>
<Menu.Separator />
</Show>
<For each={group}>
{(section) => (
<Menu.RadioItem
value={section.value}
closeOnSelect
onSelect={() => {
if (section.value === "workspaces")
setState("worktreeFilterReset", (value) => value + 1)
}}
>
<Icon name={section.icon} />
{language.t(section.label)}
</Menu.RadioItem>
)}
</For>
</>
)}
</For>
</Menu.RadioGroup>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
<Tabs.List>
<div class="settings-nav">
<button type="button" class="settings-back" onClick={surface.close}>
<Icon name="arrow-left" size="small" class="settings-back-icon" />
<span>{language.t("settings.backToApp")}</span>
</button>
<div class="flex flex-col gap-4 w-full">
<For each={sections}>
{(group) => (
<div class="flex flex-col gap-1 w-full">
<For each={group}>
{(section) => (
<Tabs.Trigger
value={section.value}
onClick={() => {
if (section.value === "workspaces") setState("worktreeFilterReset", (value) => value + 1)
}}
>
<Icon name={section.icon} />
{language.t(section.label)}
</Tabs.Trigger>
)}
</For>
</div>
)}
</For>
</div>
</div>
</Tabs.List>
<Tabs.Content value="general" class="settings-panel">
<SettingsGeneral server={server()} />
</Tabs.Content>
<Tabs.Content value="appearance" class="settings-panel">
<SettingsAppearance />
</Tabs.Content>
<Tabs.Content value="notifications" class="settings-panel">
<SettingsNotifications />
</Tabs.Content>
<Tabs.Content value="shortcuts" class="settings-panel">
<SettingsKeybinds />
</Tabs.Content>
<Tabs.Content value="experimental" class="settings-panel">
<SettingsExperimental />
</Tabs.Content>
<Tabs.Content value="servers" class="settings-panel">
<SettingsServers />
</Tabs.Content>
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects />
</Tabs.Content>
<SettingsServerScope directory={directory()}>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces
activeDirectory={directory()}
resetProjectFilter={() => state.worktreeFilterReset}
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={directory()} onBack={showProviders} />
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels />
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions />
</Tabs.Content>
</SettingsServerScope>
<Tabs.Content value="about" class="settings-panel settings-about">
<SettingsAbout active={surface.tab() === "about"} />
</Tabs.Content>
</Tabs>
<Switch>
<Match when={surface.view().type === "root"}>
<RootSettings />
</Match>
<Match when={surface.view().type === "server"}>
<Show when={targetServer()}>{(server) => <ServerSettings entry={server()} />}</Show>
</Match>
<Match when={surface.view().type === "project"}>
<Show when={targetServer()?.connection} keyed>
{(server) => (
<Show when={targetProject()}>{(project) => <ProjectSettings server={server} project={project()} />}</Show>
)}
</Show>
</Match>
</Switch>
</div>
)
}
function RootSettings() {
const language = useLanguage()
const dialog = useDialog()
const surface = useSettingsSurface()
const layout = useLayout()
const tabs = useTabs()
const servers = useServerCollectionController()
const inventory = useSettingsServers()
const [state, setState] = createStore({ worktreeFilterReset: 0 })
const list = servers.collection.items
const singleEntry = createMemo(() => (inventory().length === 1 ? inventory()[0] : undefined))
const single = createMemo(() => singleEntry()?.connection)
const multiple = createMemo(() => inventory().length > 1)
const ordered = createMemo(() => {
const order = new Map(list().map((server, index) => [ServerConnection.key(server), index]))
return inventory().toSorted((a, b) => {
const preferred = Number(b.key === servers.defaults.key()) - Number(a.key === servers.defaults.key())
if (preferred) return preferred
return (order.get(a.key) ?? list().length) - (order.get(b.key) ?? list().length)
})
})
const sourceServer = createMemo(() => {
const route = surface.route()
if (route.type === "session") return connectionFor(list(), route.server)
if (route.type === "draft") {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return connectionFor(list(), draft?.server)
}
return connectionFor(list(), layout.home.selection().server)
})
const sourceDirectory = useSettingsDirectory(sourceServer)
const addServer = () =>
void dialog.push(() => (
<DialogServer mode="add" onSave={(server) => surface.openServer(ServerConnection.key(server))} />
))
const groups = createMemo<SettingsNavGroup[]>(() => [
{ items: rootClientTabs.map((item) => ({ ...item, label: language.t(item.label) })) },
...(multiple()
? [
{
label: language.t("status.popover.tab.servers"),
action: <AddServerMenu compact onAddServer={addServer} />,
items: ordered().map((server) => ({
value: `server:${server.key}`,
icon: "server" as const,
label: server.name,
})),
},
]
: [
{
items: [
...serverTabs.map((item) => ({ ...item, label: language.t(item.label), disabled: !single() })),
{ value: "servers", icon: "server" as const, label: language.t("settings.section.server") },
],
},
]),
...trailingTabs.map((items) => ({ items: items.map((item) => ({ ...item, label: language.t(item.label) })) })),
])
createEffect(() => {
const view = surface.view()
if (view.type !== "root" || !multiple()) return
if (["projects", "workspaces", "providers", "models", "extensions", "servers"].includes(view.tab))
surface.open("general")
})
const change = (value: string) => {
if (value.startsWith("server:")) {
surface.openServer(value.slice("server:".length))
return
}
if (value === "workspaces") setState("worktreeFilterReset", (current) => current + 1)
surface.select(value)
}
return (
<SettingsNavigation
value={surface.view().tab}
groups={groups()}
backLabel={language.t("settings.backToApp")}
onBack={() => surface.close()}
onChange={change}
mobileAction={multiple() ? <AddServerMenu compact onAddServer={addServer} /> : undefined}
>
<Tabs.Content value="general" class="settings-panel">
<SettingsGeneral />
</Tabs.Content>
<Tabs.Content value="appearance" class="settings-panel">
<SettingsAppearance />
</Tabs.Content>
<Tabs.Content value="notifications" class="settings-panel">
<SettingsNotifications />
</Tabs.Content>
<Tabs.Content value="shortcuts" class="settings-panel">
<SettingsKeybinds />
</Tabs.Content>
<Tabs.Content value="experimental" class="settings-panel">
<SettingsExperimental />
</Tabs.Content>
<Tabs.Content value="about" class="settings-panel settings-about">
<SettingsAbout active={surface.view().tab === "about"} />
</Tabs.Content>
<Show when={single()} keyed>
{(server) => (
<SettingsServerDataScope server={server}>
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects
server={server}
onOpenProject={(project) =>
surface.openProject({
server: ServerConnection.key(server),
project: project.worktree,
})
}
/>
</Tabs.Content>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces
activeDirectory={sourceServer() === server ? sourceDirectory() : undefined}
resetProjectFilter={() => state.worktreeFilterReset}
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={undefined} onBack={() => surface.select("providers")} />
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels />
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions />
</Tabs.Content>
</SettingsServerDataScope>
)}
</Show>
<Show when={singleEntry()}>
{(entry) => (
<Tabs.Content value="servers" class="settings-panel">
<SettingsServerGeneral entry={entry()} onAddServer={addServer} />
</Tabs.Content>
)}
</Show>
</SettingsNavigation>
)
}
function ServerSettings(props: { entry: SettingsServer }) {
const language = useLanguage()
const surface = useSettingsSurface()
const activeDirectory = useSettingsDirectory(() => props.entry.connection)
const [state, setState] = createStore({ worktreeFilterReset: 0 })
const groups = createMemo<SettingsNavGroup[]>(() => [
{
items: nestedServerTabs.map((item) => ({
...item,
label: item.value === "general" ? props.entry.name : language.t(item.label),
disabled: item.value !== "general" && !props.entry.connection,
})),
},
])
createEffect(() => {
if (!props.entry.connection && surface.view().tab !== "general") surface.select("general")
})
const change = (value: string) => {
if (value === "workspaces") setState("worktreeFilterReset", (current) => current + 1)
surface.select(value)
}
return (
<SettingsNavigation
value={surface.view().tab}
groups={groups()}
backLabel={language.t("settings.backToSettings")}
onBack={() => surface.back()}
onChange={change}
>
<Tabs.Content value="general" class="settings-panel">
<SettingsServerGeneral
entry={props.entry}
nested
onServerChange={(server) => surface.replaceServer(ServerConnection.key(server))}
/>
</Tabs.Content>
<Show when={props.entry.connection} keyed>
{(server) => (
<SettingsServerDataScope server={server}>
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects
server={server}
onOpenProject={(project) =>
surface.openProject({
server: props.entry.key,
project: project.worktree,
})
}
/>
</Tabs.Content>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces
activeDirectory={activeDirectory()}
resetProjectFilter={() => state.worktreeFilterReset}
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={undefined} onBack={() => surface.select("providers")} />
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels />
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions />
</Tabs.Content>
</SettingsServerDataScope>
)}
</Show>
</SettingsNavigation>
)
}
function ProjectSettings(props: { server: ServerConnection.Any; project: LocalProject }) {
const language = useLanguage()
const surface = useSettingsSurface()
const activeDirectory = useSettingsDirectory(() => props.server)
const groups = createMemo<SettingsNavGroup[]>(() => [
{ items: nestedProjectTabs.map((item) => ({ ...item, label: language.t(item.label) })) },
])
return (
<SettingsServerDataScope server={props.server} directory={props.project.worktree}>
<LocationProvider directory={props.project.worktree}>
<SettingsNavigation
value={surface.view().tab}
groups={groups()}
backLabel={language.t("settings.backToProjects")}
onBack={() => surface.back()}
onChange={(value) => surface.select(value)}
>
<Tabs.Content value="general" class="settings-panel">
<SettingsProjectGeneral
server={props.server}
project={props.project}
onOpenServer={() => surface.replaceServer(ServerConnection.key(props.server))}
/>
</Tabs.Content>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces projectID={props.project.id} activeDirectory={activeDirectory()} />
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<ProjectSettingsExtensions />
</Tabs.Content>
</SettingsNavigation>
</LocationProvider>
</SettingsServerDataScope>
)
}
function connectionFor(list: readonly ServerConnection.Any[], key: string | undefined) {
return list.find((item) => ServerConnection.key(item) === key)
}
function useSettingsDirectory(server: Accessor<ServerConnection.Any | undefined>) {
const surface = useSettingsSurface()
const tabs = useTabs()
const serverCtx = useServerCtx(server)
return createMemo(() => {
const current = server()
if (!current) return undefined
const key = ServerConnection.key(current)
const route = surface.route()
if (route.type === "session" && route.server === key)
return serverCtx()?.data.session.get(route.sessionId)?.location.directory
if (route.type !== "draft") return undefined
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" && draft.server === key ? draft.directory : undefined
})
}
+124 -10
View File
@@ -3,6 +3,75 @@ import { createEffect, on } from "solid-js"
import { createSimpleContext } from "@opencode/ui/context"
import { useLayout, type LayoutRoute } from "@/shell/state/layout"
import { useCommand } from "@/shell/commands/command"
import { useSettingsServers } from "./servers/inventory"
export type SettingsRootTab =
| "general"
| "appearance"
| "notifications"
| "shortcuts"
| "projects"
| "workspaces"
| "providers"
| "models"
| "extensions"
| "servers"
| "experimental"
| "about"
export type SettingsServerTab = "general" | "projects" | "workspaces" | "providers" | "models" | "extensions"
export type SettingsProjectTab = "general" | "workspaces" | "extensions"
export type SettingsView =
| { type: "root"; tab: SettingsRootTab }
| { type: "server"; server: string; tab: SettingsServerTab }
| {
type: "project"
server: string
project: string
tab: SettingsProjectTab
parent: "root" | "server"
}
const rootTabs: Record<SettingsRootTab, true> = {
general: true,
appearance: true,
notifications: true,
shortcuts: true,
projects: true,
workspaces: true,
providers: true,
models: true,
extensions: true,
servers: true,
experimental: true,
about: true,
}
const serverTabs: Record<SettingsServerTab, true> = {
general: true,
projects: true,
workspaces: true,
providers: true,
models: true,
extensions: true,
}
const projectTabs: Record<SettingsProjectTab, true> = {
general: true,
workspaces: true,
extensions: true,
}
function isRootTab(value: string): value is SettingsRootTab {
return value in rootTabs
}
function isServerTab(value: string): value is SettingsServerTab {
return value in serverTabs
}
function isProjectTab(value: string): value is SettingsProjectTab {
return value in projectTabs
}
export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = createSimpleContext({
name: "SettingsSurface",
@@ -11,13 +80,24 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
const navigate = useNavigate()
const layout = useLayout()
const command = useCommand()
const servers = useSettingsServers()
const location = useLocation<{
settings?: { route: Exclude<LayoutRoute, { type: "settings" }>; tab: string }
settings?: { route: Exclude<LayoutRoute, { type: "settings" }>; view: SettingsView }
}>()
const open = () => layout.route().type === "settings"
const source = () => location.state?.settings?.route ?? { type: "home" as const }
const view = (): SettingsView => location.state?.settings?.view ?? { type: "root", tab: "general" }
let focus: HTMLElement | undefined
const show = (view: SettingsView, replace: boolean) => {
const route = layout.route()
if (route.type !== "settings" && document.activeElement instanceof HTMLElement) focus = document.activeElement
navigate("/settings", {
replace,
state: { settings: { route: route.type === "settings" ? source() : route, view } },
})
}
createEffect(
on(
open,
@@ -33,16 +113,50 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
return {
active: open,
route: source,
tab: () => location.state?.settings?.tab ?? "general",
open(tab = "general") {
const route = layout.route()
if (route.type !== "settings") {
if (document.activeElement instanceof HTMLElement) focus = document.activeElement
view,
open(tab: SettingsRootTab = "general") {
show({ type: "root", tab }, open())
},
openServer(server: string, tab: SettingsServerTab = "general") {
show({ type: "server", server, tab }, false)
},
replaceServer(server: string, tab: SettingsServerTab = "general") {
show({ type: "server", server, tab }, true)
},
openProject(input: { server: string; project: string; tab?: SettingsProjectTab }) {
show(
{
type: "project",
...input,
parent: servers().length > 1 ? "server" : "root",
tab: input.tab ?? "general",
},
false,
)
},
select(tab: string) {
const current = view()
const next: SettingsView =
current.type === "root" && isRootTab(tab)
? { ...current, tab }
: current.type === "server" && isServerTab(tab)
? { ...current, tab }
: current.type === "project" && isProjectTab(tab)
? { ...current, tab }
: current
show(next, true)
},
back() {
const current = view()
if (current.type === "root") {
command.trigger("common.goBack")
return
}
navigate("/settings", {
replace: open(),
state: { settings: { route: route.type === "settings" ? source() : route, tab } },
})
const parent: SettingsView =
current.type === "server" || current.parent === "root"
? { type: "root", tab: current.type === "server" ? "general" : "projects" }
: { type: "server", server: current.server, tab: "projects" }
show(parent, true)
},
close() {
if (open()) command.trigger("common.goBack")
@@ -1,238 +0,0 @@
.project-settings-dialog [data-slot="dialog-container"] {
background: var(--v2-background-bg-base);
}
.project-settings-dialog [data-slot="dialog-body"] {
padding: 0;
overflow: hidden;
}
.project-settings-v2 {
height: 100%;
}
.project-settings-nav {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.project-settings-panel {
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
overflow: hidden !important;
user-select: none;
}
.project-settings-panel :is(input, textarea, [contenteditable="true"]) {
user-select: text;
}
.project-settings-form {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.project-settings-scroll {
display: flex;
flex: 1;
flex-direction: column;
gap: 24px;
min-height: 0;
padding: 40px;
overflow-y: auto;
}
.project-settings-page-header {
display: flex;
flex-direction: column;
gap: 6px;
}
.project-settings-page-header h2 {
color: var(--v2-text-text-base);
font-size: 15px;
font-weight: 640;
line-height: 1;
}
.project-settings-page-header > span {
color: var(--v2-text-text-muted);
font-size: 11px;
font-weight: 440;
line-height: 1;
}
.project-settings-extensions {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 40px;
}
.project-settings-extension-tabs {
flex: 1;
height: auto;
min-height: 0;
margin-top: 24px;
overflow: visible;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
width: auto;
padding-inline: 0 !important;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
display: none;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger-wrapper"] {
border-radius: 6px;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger"] {
padding-inline: 8px;
}
.project-settings-extension-tabs [data-slot="tabs-v2-content"] {
overflow-y: auto;
}
.project-settings-extension-section {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 20px;
}
.project-settings-extension-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
}
.project-settings-extension-section-header > :last-child {
color: var(--v2-text-text-faint);
font-size: 11px;
font-weight: 440;
}
.project-settings-extension-link {
color: var(--v2-text-text-accent) !important;
text-decoration: none;
}
.project-settings-extension-link:hover {
text-decoration: underline;
}
.project-settings-extension-card {
padding-inline: 12px;
overflow: hidden;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 8px;
background: var(--v2-background-bg-base);
}
.project-settings-extension-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 48px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.project-settings-extension-row:last-child {
border-bottom: 0;
}
.project-settings-extension-row-main {
display: flex;
flex: 1;
align-items: center;
gap: 10px;
min-width: 0;
}
.project-settings-extension-row-icon {
flex-shrink: 0;
color: var(--v2-icon-icon-muted);
}
.project-settings-extension-row-name {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-settings-extension-row-status {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
color: var(--v2-text-text-muted);
font-size: 11px;
}
.project-settings-extension-row-status-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--v2-state-fg-warning);
}
.project-settings-shared {
display: flex;
flex-direction: column;
gap: 12px;
}
.project-settings-shared-trigger {
display: flex;
align-items: center;
align-self: flex-start;
gap: 6px;
color: var(--v2-text-text-base);
font-size: 11px;
font-weight: 530;
}
.project-settings-shared-chevron {
width: 12px;
height: 12px;
color: var(--v2-icon-icon-muted);
transition: transform 120ms ease;
}
.project-settings-shared-chevron.open {
transform: rotate(90deg);
}
.project-settings-shared-count {
padding: 1px 4px;
border-radius: 3px;
background: var(--v2-background-bg-layer-02);
color: var(--v2-text-text-muted);
font-size: 9px;
}
@@ -1,219 +0,0 @@
import { Button } from "@opencode/ui/button"
import { Dialog, DialogFooter } from "@opencode/ui/dialog"
import { Field } from "@opencode/ui/field"
import { Icon } from "@opencode/ui/icon"
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode/ui/project-avatar"
import { Tabs } from "@opencode/ui/tabs"
import { Textarea } from "@opencode/ui/textarea"
import { TextInput } from "@opencode/ui/text-input"
import { For, Show, createSignal, startTransition } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { getProjectAvatarVariant, type LocalProject } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
import { LocationProvider } from "@/workspaces/location"
import { displayName } from "@/shell/layout/helpers"
import { ProjectIcon } from "@/shell/layout/project-icon"
import { createEditProjectModel } from "./project-model"
import { ProjectSettingsExtensions } from "./project-extensions"
import { SettingsServerDataScope } from "@/settings/server-scope"
import "@/settings/settings.css"
import "./project-dialog.css"
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
return (
<SettingsServerDataScope server={props.server}>
<LocationProvider directory={props.project.worktree}>
<ProjectSettingsDialog project={props.project} server={props.server} />
</LocationProvider>
</SettingsServerDataScope>
)
}
function ProjectSettingsDialog(props: { project: LocalProject; server: ServerConnection.Any }) {
const language = useLanguage()
const model = createEditProjectModel(props)
const projectName = () => displayName(props.project)
const [tab, setTab] = createSignal("general")
const Footer = () => (
<DialogFooter>
<Button type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
{language.t("common.cancel")}
</Button>
<Button type="submit" variant="contrast" disabled={model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</Button>
</DialogFooter>
)
return (
<Dialog size="x-large" variant="settings" class="project-settings-dialog">
<Tabs
orientation="vertical"
variant="settings"
value={tab()}
onChange={(value) => void startTransition(() => setTab(value))}
class="project-settings-v2"
>
<Tabs.List>
<div class="project-settings-nav">
<Tabs.Trigger value="general">
<ProjectIcon project={props.project} class="!size-4 shrink-0" />
<span class="truncate">{projectName()}</span>
</Tabs.Trigger>
<Tabs.Trigger value="scripts">
<Icon name="code" size="small" />
{language.t("project.settings.scripts")}
</Tabs.Trigger>
<Tabs.Trigger value="extensions">
<Icon name="extensions" size="small" />
{language.t("settings.tab.extensions")}
</Tabs.Trigger>
</div>
</Tabs.List>
<Tabs.Content value="general" class="project-settings-panel">
<form onSubmit={model.submit} class="project-settings-form">
<div class="project-settings-scroll">
<div class="project-settings-page-header">
<h2>{language.t("dialog.project.edit.title")}</h2>
<span>{language.t("project.settings.general.description")}</span>
</div>
<Field>
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
<TextInput
autofocus
appearance="large"
class="!w-full"
value={model.store.name}
placeholder={model.folderName()}
onInput={(event) => model.setStore("name", event.currentTarget.value)}
/>
</Field>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.icon")}
</div>
<div class="flex items-center gap-3">
<button
type="button"
aria-label={language.t("dialog.project.edit.icon.alt")}
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
}}
onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => model.setStore("iconHover", false)}
onDrop={model.drop}
onDragOver={model.dragOver}
onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<ProjectIcon
project={props.project}
fallback={model.store.name || model.defaultName()}
icon={{
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
}}
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
/>
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
classList={{
"opacity-100": model.store.iconHover,
"opacity-0": !model.store.iconHover,
}}
>
<Icon name={model.store.iconOverride ? "close" : "share"} />
</span>
</button>
<input
ref={(element) => model.setIconInput(element)}
type="file"
accept="image/*"
class="hidden"
onChange={model.inputChange}
/>
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
</div>
</div>
</div>
<Show when={!model.store.iconOverride}>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.color")}
</div>
<div class="-ml-1 flex gap-1.5">
<For each={PROJECT_AVATAR_VARIANTS}>
{(color) => (
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
getProjectAvatarVariant(model.store.color) === color,
}}
onClick={() => {
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
model.setStore(
"color",
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
)
}}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
variant={getProjectAvatarVariant(color)}
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
/>
</button>
)}
</For>
</div>
</div>
</Show>
</div>
<Footer />
</form>
</Tabs.Content>
<Tabs.Content value="scripts" class="project-settings-panel">
<form onSubmit={model.submit} class="project-settings-form">
<div class="project-settings-scroll">
<div class="project-settings-page-header">
<h2>{language.t("project.settings.scripts")}</h2>
<span>{language.t("project.settings.scripts.description")}</span>
</div>
<Field>
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
<Textarea
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
rows={5}
value={model.store.startup}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
spellcheck={false}
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
/>
</Field>
</div>
<Footer />
</form>
</Tabs.Content>
<Tabs.Content value="extensions" class="project-settings-panel">
<ProjectSettingsExtensions />
</Tabs.Content>
</Tabs>
</Dialog>
)
}
@@ -1,7 +1,19 @@
import { Icon } from "@opencode/ui/icon"
import { Switch } from "@opencode/ui/switch"
import { Tabs } from "@opencode/ui/tabs"
import { type Component, For, Show, createEffect, createMemo, createResource, createSignal, type JSX } from "solid-js"
import { Button } from "@opencode/ui/button"
import { useQuery } from "@tanstack/solid-query"
import {
type Component,
For,
Show,
createEffect,
createMemo,
createResource,
createSignal,
onCleanup,
type JSX,
} from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useMcpToggle } from "@/providers/connect/mcp"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -9,6 +21,9 @@ import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { pluginLabels } from "@/providers/catalog/plugin"
import { ExternalLink } from "@/runtime/platform/external-link"
import { configuredLanguageServers } from "./project-lsp"
import { SettingsList } from "@/settings/list"
import "./project.css"
type SkillItem = {
name: string
@@ -18,23 +33,54 @@ type SkillItem = {
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
const ExtensionCard: Component<{ children: JSX.Element }> = (props) => (
<div class="project-settings-extension-card">{props.children}</div>
<SettingsList variant="catalog">{props.children}</SettingsList>
)
const ExtensionRow: Component<{
icon: "mcp" | "cube" | "post-skill"
icon: "mcp" | "cube" | "post-skill" | "code"
name: string
description?: JSX.Element
children?: JSX.Element
}> = (props) => (
<div class="project-settings-extension-row">
<div class="project-settings-extension-row-main">
<div class="settings-extension-row project-settings-extension-row">
<div class="settings-extension-lead">
<Icon name={props.icon} class="project-settings-extension-row-icon" />
<span class="project-settings-extension-row-name">{props.name}</span>
<div class="project-settings-extension-row-copy">
<span class="project-settings-extension-row-name settings-extension-name">{props.name}</span>
<Show when={props.description}>
<span class="project-settings-extension-row-description">{props.description}</span>
</Show>
</div>
</div>
{props.children}
</div>
)
const ProjectSectionHeader: Component<{
kind: "mcps" | "plugins" | "skills"
empty: boolean
action: JSX.Element
}> = (props) => {
const language = useLanguage()
return (
<div class="project-settings-extension-section-header">
<div class="project-settings-extension-section-copy">
<span>
{props.empty
? language.t(`project.settings.extensions.empty.${props.kind}.title`)
: language.t("project.settings.extensions.added")}
</span>
<Show when={props.empty}>
<span class="project-settings-extension-empty-description">
{language.t(`project.settings.extensions.empty.${props.kind}.description`)}
</span>
</Show>
</div>
{props.action}
</div>
)
}
const SharedSection: Component<{
count: number
children: JSX.Element
@@ -46,13 +92,21 @@ const SharedSection: Component<{
<div class="project-settings-shared">
<button
type="button"
class="project-settings-shared-trigger"
class="settings-models-group-trigger project-settings-shared-trigger"
aria-expanded={open()}
onClick={() => setOpen((value) => !value)}
>
<Icon name="chevron-right" classList={{ "project-settings-shared-chevron": true, open: open() }} />
<span>{language.t("project.settings.extensions.shared")}</span>
<span class="project-settings-shared-count">{props.count}</span>
<span class="settings-models-group-chevron">
<Icon
name="fill-triangle-down"
size="small"
classList={{ "project-settings-shared-chevron": true, open: open() }}
/>
</span>
<span class="project-settings-shared-label">
<span class="settings-models-group-title">{language.t("project.settings.extensions.shared")}</span>
<span class="project-settings-shared-count">{props.count}</span>
</span>
</button>
<Show when={open()}>
<ExtensionCard>{props.children}</ExtensionCard>
@@ -62,6 +116,95 @@ const SharedSection: Component<{
)
}
const ProjectLanguageServers: Component = () => {
const language = useLanguage()
const server = useServerSDK()
const location = useWorkspaceLocation()
const config = useQuery(() => ({
queryKey: [server.scope, "settings-project-language-servers", location().directory],
enabled: server.connection.status() === "connected",
queryFn: () => server.api.config.get({ location: { directory: location().directory } }),
}))
const configured = createMemo(() =>
configuredLanguageServers(config.isPending || config.isError ? [] : (config.data ?? [])),
)
const empty = () => !config.isPending && !config.isError && configured().servers.length === 0
onCleanup(
server.event.on("config.updated", (event) => {
if (event.location && event.location.directory !== location().directory) return
void config.refetch()
}),
)
return (
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<div class="project-settings-extension-section-copy">
<span>
{language.t(
empty()
? configured().disabled
? "project.settings.extensions.lsp.disabled.title"
: "project.settings.extensions.lsp.empty.title"
: "project.settings.extensions.lsp.configured",
)}
</span>
<Show when={empty()}>
<span class="project-settings-extension-empty-description">
{language.t(
configured().disabled
? "project.settings.extensions.lsp.disabled.description"
: "project.settings.extensions.lsp.empty.description",
)}
</span>
</Show>
</div>
<span>{language.t("project.settings.extensions.lsp.description")}</span>
</div>
<Show
when={!config.isPending}
fallback={<p class="project-settings-extension-empty-description">{language.t("common.loading")}</p>}
>
<Show
when={!config.isError}
fallback={
<div class="project-settings-extension-section-copy" role="status">
<span class="project-settings-extension-empty-description">
{language.t("project.settings.extensions.lsp.loadFailed")}
</span>
<Button variant="ghost-muted" onClick={() => void config.refetch()}>
{language.t("project.settings.extensions.lsp.retry")}
</Button>
</div>
}
>
<Show when={configured().servers.length > 0}>
<ExtensionCard>
<For each={configured().servers}>
{(item) => (
<ExtensionRow
icon="code"
name={item.name}
description={item.extensions.length ? <bdi dir="ltr">{item.extensions.join(", ")}</bdi> : undefined}
>
<span class="project-settings-extension-row-status">
{language.t(
item.disabled
? "project.settings.extensions.lsp.status.disabled"
: "project.settings.extensions.lsp.status.enabled",
)}
</span>
</ExtensionRow>
)}
</For>
</ExtensionCard>
</Show>
</Show>
</Show>
</div>
)
}
export const ProjectSettingsExtensions: Component = () => {
const language = useLanguage()
const serverSDK = useServerSDK()
@@ -145,61 +288,77 @@ export const ProjectSettingsExtensions: Component = () => {
)
return (
<div class="project-settings-extensions">
<div class="project-settings-page-header">
<h2>{language.t("settings.tab.extensions")}</h2>
<span>{language.t("project.settings.extensions.description")}</span>
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.tab.extensions")}</h2>
<span class="text-11-regular text-v2-text-text-muted">
{language.t("project.settings.extensions.description")}
</span>
</div>
</div>
</div>
<Tabs variant="pill" defaultValue="mcps" class="project-settings-extension-tabs">
<Tabs.List>
<Tabs.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</Tabs.Trigger>
<Tabs.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</Tabs.Trigger>
<Tabs.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</Tabs.Trigger>
{/* TODO: Restore LSP status when V2 exposes it. */}
</Tabs.List>
<div class="settings-tab-body">
<Tabs variant="pill" defaultValue="mcps" class="project-settings-extension-tabs settings-subtabs">
<Tabs.List>
<Tabs.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</Tabs.Trigger>
<Tabs.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</Tabs.Trigger>
<Tabs.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</Tabs.Trigger>
<Tabs.Trigger value="lsps">{language.t("project.settings.extensions.tab.lsps")}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="mcps">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
<span>{language.t("settings.extensions.manageConfig")}</span>
<Tabs.Content value="mcps">
<div class="project-settings-extension-section">
<ProjectSectionHeader
kind="mcps"
empty={projectMcpNames().length === 0}
action={<span>{language.t("settings.extensions.manageConfig")}</span>}
/>
<Show when={projectMcpNames().length > 0}>
<ExtensionCard>{mcpRows(projectMcpNames())}</ExtensionCard>
</Show>
<SharedSection count={globalMcpNames().length}>{mcpRows(globalMcpNames())}</SharedSection>
</div>
<Show when={projectMcpNames().length > 0}>
<ExtensionCard>{mcpRows(projectMcpNames())}</ExtensionCard>
</Show>
<SharedSection count={globalMcpNames().length}>{mcpRows(globalMcpNames())}</SharedSection>
</div>
</Tabs.Content>
</Tabs.Content>
<Tabs.Content value="plugins">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
<span>{language.t("settings.extensions.manageConfig")}</span>
<Tabs.Content value="plugins">
<div class="project-settings-extension-section">
<ProjectSectionHeader
kind="plugins"
empty={projectPlugins().length === 0}
action={<span>{language.t("settings.extensions.manageConfig")}</span>}
/>
<Show when={projectPlugins().length > 0}>
<ExtensionCard>{pluginRows(projectPlugins())}</ExtensionCard>
</Show>
<SharedSection count={globalPlugins().length}>{pluginRows(globalPlugins())}</SharedSection>
</div>
<Show when={projectPlugins().length > 0}>
<ExtensionCard>{pluginRows(projectPlugins())}</ExtensionCard>
</Show>
<SharedSection count={globalPlugins().length}>{pluginRows(globalPlugins())}</SharedSection>
</div>
</Tabs.Content>
</Tabs.Content>
<Tabs.Content value="skills">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
<ExternalLink class="project-settings-extension-link" href="https://opencode.ai/docs/skills/">
{language.t("settings.extensions.addSkills")}
</ExternalLink>
<Tabs.Content value="skills">
<div class="project-settings-extension-section">
<ProjectSectionHeader
kind="skills"
empty={projectSkills().length === 0}
action={
<ExternalLink class="settings-extension-link" href="https://opencode.ai/docs/skills/">
{language.t("settings.extensions.addSkills")}
</ExternalLink>
}
/>
<Show when={projectSkills().length > 0}>
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
</Show>
<SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
</div>
<Show when={projectSkills().length > 0}>
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
</Show>
<SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
</div>
</Tabs.Content>
</Tabs>
</div>
</Tabs.Content>
<Tabs.Content value="lsps">
<ProjectLanguageServers />
</Tabs.Content>
</Tabs>
</div>
</>
)
}
@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test"
import type { ConfigEntry } from "@opencode/client/promise"
import { configuredLanguageServers } from "./project-lsp"
const global: ConfigEntry = {
type: "document",
path: "/config/opencode.json",
info: {
lsp: {
typescript: { command: ["typescript-language-server", "--stdio"], extensions: [".ts", ".tsx"] },
eslint: { disabled: true },
},
},
}
describe("configuredLanguageServers", () => {
test("merges inherited entries and project overrides by name", () => {
expect(
configuredLanguageServers([
global,
{ type: "directory", path: "/project/.opencode" },
{
type: "document",
path: "/project/opencode.jsonc",
info: {
lsp: {
typescript: { disabled: true },
eslint: { command: ["eslint-lsp"], disabled: false, extensions: [".js"] },
rust: { command: ["rust-analyzer"], extensions: [".rs"] },
},
},
},
]),
).toEqual({
disabled: false,
servers: [
{ name: "eslint", disabled: false, extensions: [".js"] },
{ name: "rust", disabled: false, extensions: [".rs"] },
{ name: "typescript", disabled: true, extensions: [".ts", ".tsx"] },
],
})
})
test("does not invent servers for omitted or boolean-only configuration", () => {
expect(configuredLanguageServers([])).toEqual({ disabled: false, servers: [] })
expect(configuredLanguageServers([global, { type: "document", info: { lsp: false } }])).toEqual({
disabled: true,
servers: [],
})
expect(configuredLanguageServers([global, { type: "document", info: { lsp: true } }])).toEqual({
disabled: false,
servers: [],
})
})
test("allows named configuration after a disabled parent without reviving earlier entries", () => {
expect(
configuredLanguageServers([
global,
{ type: "document", info: { lsp: false } },
{ type: "document", info: { lsp: { custom: { command: ["custom-lsp"] } } } },
]),
).toEqual({ disabled: false, servers: [{ name: "custom", disabled: false, extensions: [] }] })
})
})
@@ -0,0 +1,28 @@
import type { ConfigEntry } from "@opencode/client/promise"
type ConfiguredServer = { name: string; disabled: boolean; extensions: readonly string[] }
export function configuredLanguageServers(entries: readonly ConfigEntry[]) {
const state = entries.reduce(
(state, entry) => {
if (entry.type !== "document" || entry.info.lsp === undefined) return state
const config = entry.info.lsp
// A boolean replaces the object form, so earlier named entries no longer apply.
if (typeof config === "boolean") return { disabled: !config, servers: new Map<string, ConfiguredServer>() }
Object.entries(config).forEach(([name, server]) => {
const previous = state.servers.get(name)
state.servers.set(name, {
name,
disabled: server.disabled ?? previous?.disabled ?? false,
extensions: ("extensions" in server ? server.extensions : undefined) ?? previous?.extensions ?? [],
})
})
return { disabled: false, servers: state.servers }
},
{ disabled: false, servers: new Map<string, ConfiguredServer>() },
)
return {
disabled: state.disabled,
servers: [...state.servers.values()].sort((a, b) => a.name.localeCompare(b.name)),
}
}
@@ -1,14 +1,17 @@
import { getFilename } from "@opencode/util/path"
import { useDialog } from "@opencode/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import type { ProjectUpdateInput } from "@opencode/client/promise"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/runtime/server/runtime"
import { type LocalProject } from "@/shell/state/layout"
import { useLanguage } from "@/runtime/i18n/language"
import type { LocalProject } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
import { showToast } from "@/shell/notifications/toast"
type ProjectPatch = Pick<ProjectUpdateInput, "name" | "icon" | "commands">
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
const dialog = useDialog()
const language = useLanguage()
const global = useGlobal()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
const folderName = createMemo(() => getFilename(props.project.worktree))
@@ -20,8 +23,62 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
startup: props.project.commands?.start ?? "",
dragOver: false,
iconHover: false,
saving: 0,
})
const saved = {
name: props.project.name ?? "",
startup: store.startup.trim(),
color: store.color,
iconOverride: store.iconOverride,
}
let iconInput: HTMLInputElement | undefined
let queue = Promise.resolve()
const persist = (patch: ProjectPatch, complete: () => void) => {
setStore("saving", (value) => value + 1)
queue = queue
.then(async () => {
if (props.project.id && props.project.id !== "global") {
const project = await serverCtx().sdk.api.project.update({ projectID: props.project.id, ...patch })
serverCtx().sync.project.update(project)
return
}
serverCtx().sync.project.meta(props.project.worktree, patch)
})
.then(complete)
.catch((error: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
})
.finally(() => setStore("saving", (value) => value - 1))
}
const saveName = () => {
const value = store.name.trim() === folderName() ? "" : store.name.trim()
if (value === saved.name) return
persist({ name: value }, () => {
saved.name = value
})
}
const saveStartup = () => {
const value = store.startup.trim()
if (value === saved.startup) return
persist({ commands: { start: value } }, () => {
saved.startup = value
})
}
const saveIcon = (color = store.color, override = store.iconOverride) => {
if (color === saved.color && override === saved.iconOverride) return
persist({ icon: { color: color ?? "", override: override ?? "" } }, () => {
saved.color = color
saved.iconOverride = override
})
}
function selectFile(file: File) {
if (!file.type.startsWith("image/")) return
@@ -31,85 +88,46 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
if (typeof result !== "string") return
setStore("iconOverride", result)
setStore("iconHover", false)
saveIcon(store.color, result)
}
reader.readAsDataURL(file)
}
function drop(event: DragEvent) {
event.preventDefault()
setStore("dragOver", false)
const file = event.dataTransfer?.files[0]
if (file) selectFile(file)
}
function dragOver(event: DragEvent) {
event.preventDefault()
setStore("dragOver", true)
}
function dragLeave() {
setStore("dragOver", false)
}
function inputChange(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0]
if (file) selectFile(file)
}
function iconClick() {
if (store.iconOverride && store.iconHover) {
setStore("iconOverride", "")
return
}
iconInput?.click()
}
const save = useMutation(() => ({
mutationFn: async () => {
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
const project = await serverCtx().sdk.api.project.update({
projectID: props.project.id,
name,
icon: { color: store.color ?? "", override: store.iconOverride ?? "" },
commands: { start },
})
serverCtx().sync.project.update(project)
dialog.close()
return
}
serverCtx().sync.project.meta(props.project.worktree, {
name,
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },
})
dialog.close()
},
}))
function submit(event: SubmitEvent) {
event.preventDefault()
if (save.isPending) return
save.mutate()
}
return {
store,
setStore,
folderName,
defaultName,
save,
submit,
drop,
dragOver,
dragLeave,
inputChange,
iconClick,
close() {
dialog.close()
saveName,
saveStartup,
setColor(value: string | undefined) {
setStore("color", value)
saveIcon(value, store.iconOverride)
},
drop(event: DragEvent) {
event.preventDefault()
setStore("dragOver", false)
const file = event.dataTransfer?.files[0]
if (file) selectFile(file)
},
dragOver(event: DragEvent) {
event.preventDefault()
setStore("dragOver", true)
},
dragLeave() {
setStore("dragOver", false)
},
inputChange(input: HTMLInputElement) {
const file = input.files?.[0]
if (file) selectFile(file)
},
iconClick() {
if (store.iconOverride && store.iconHover) {
setStore("iconOverride", "")
saveIcon(store.color, "")
return
}
iconInput?.click()
},
setIconInput(input: HTMLInputElement) {
iconInput = input
@@ -0,0 +1,299 @@
.project-settings-extension-tabs {
flex: 1;
height: auto;
min-height: 0;
overflow: visible;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
width: auto;
}
.project-settings-extension-tabs [data-slot="tabs-v2-content"] {
overflow-y: auto;
}
.project-settings-extension-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.project-settings-extension-section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
}
.project-settings-extension-section-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
color: var(--text-strong);
}
.project-settings-extension-section-header > :last-child {
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
}
.project-settings-extension-empty-description {
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-base);
}
.project-settings-extension-row-icon {
flex-shrink: 0;
color: var(--v2-icon-icon-muted);
}
.project-settings-extension-row-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-settings-extension-row-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.project-settings-extension-row-description {
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-base);
overflow-wrap: anywhere;
}
.project-settings-extension-row-status {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
color: var(--v2-text-text-muted);
font-size: 11px;
line-height: var(--line-height-compact);
}
.project-settings-extension-row-status-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--v2-state-fg-warning);
}
.project-settings-shared {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
}
.project-settings-shared-trigger {
width: fit-content;
align-self: flex-start;
}
.project-settings-shared-label {
display: flex;
align-items: center;
gap: 6px;
}
.project-settings-shared-chevron {
color: var(--v2-icon-icon-muted);
transition: transform 120ms ease;
transform: rotate(-90deg);
}
.project-settings-shared-chevron.open {
transform: rotate(0deg);
}
[dir="rtl"] .project-settings-shared-chevron {
transform: rotate(90deg);
}
[dir="rtl"] .project-settings-shared-chevron.open {
transform: rotate(0deg);
}
.project-settings-shared-count {
display: inline-flex;
min-width: 16px;
height: 16px;
align-items: center;
justify-content: center;
padding-inline: 4px;
border-radius: 3px;
background: var(--v2-background-bg-layer-02);
color: var(--v2-text-text-muted);
font-size: 11px;
font-weight: 530;
line-height: var(--line-height-compact);
}
.project-settings-name {
width: min(280px, 100%);
}
.project-settings-server-link {
width: fit-content;
padding: 0;
border: 0;
background: transparent;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
text-align: start;
cursor: pointer;
}
.project-settings-server-link:hover,
.project-settings-server-link:focus-visible {
color: var(--v2-text-text-base);
text-decoration: underline;
}
.project-settings-server-link:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
.project-settings-icon {
position: relative;
display: flex;
width: 32px;
height: 32px;
flex-shrink: 0;
overflow: hidden;
border: 0;
border-radius: 6px;
outline: 1px solid transparent;
background: transparent;
cursor: pointer;
}
.project-settings-icon--active,
.project-settings-icon:focus-visible {
outline-color: var(--v2-border-border-focus);
}
.project-settings-icon-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: color-mix(in srgb, var(--v2-background-bg-contrast) 80%, transparent);
color: var(--v2-icon-icon-contrast);
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
}
.project-settings-icon-overlay.visible {
opacity: 1;
}
.project-settings-colors {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.project-settings-color {
position: relative;
display: flex;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 6px;
outline: none;
background: transparent;
transition: box-shadow 120ms ease;
}
.project-settings-color:focus-visible,
.project-settings-color--selected {
box-shadow:
0 0 0 2px var(--v2-background-bg-layer-01),
0 0 0 4px var(--v2-border-border-focus);
}
@media (hover: hover) {
.project-settings-color:not(.project-settings-color--selected):hover {
box-shadow:
0 0 0 2px var(--v2-background-bg-layer-01),
0 0 0 4px var(--v2-border-border-strong);
}
}
.project-settings-color-check {
position: absolute;
color: var(--v2-icon-icon-contrast);
}
.project-settings-startup {
display: flex;
flex-direction: column;
gap: 12px;
padding-block: 20px;
}
.project-settings-startup-copy {
display: flex;
flex-direction: column;
gap: 8px;
}
.project-settings-startup-title {
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
}
.project-settings-startup-description,
.project-settings-startup-hint {
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-base);
}
.project-settings-startup-hint code {
padding-inline: 4px;
border-radius: 4px;
background: var(--v2-background-bg-layer-02);
color: var(--v2-text-text-base);
font-family: var(--font-family-mono);
font-size: 12px;
unicode-bidi: isolate;
}
@media (prefers-reduced-motion: reduce) {
.project-settings-icon-overlay,
.project-settings-color,
.project-settings-shared-chevron {
transition: none;
}
}
@@ -0,0 +1,167 @@
import { Icon } from "@opencode/ui/icon"
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode/ui/project-avatar"
import { Textarea } from "@opencode/ui/textarea"
import { TextInput } from "@opencode/ui/text-input"
import { For, Show, type Component } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { getProjectAvatarVariant, type LocalProject } from "@/shell/state/layout"
import { ServerConnection, serverName } from "@/runtime/server/registry"
import { useSettingsServers } from "@/settings/servers/inventory"
import { displayName } from "@/shell/layout/helpers"
import { ProjectIcon } from "@/shell/layout/project-icon"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import { createEditProjectModel } from "./project-model"
import "./project.css"
export const SettingsProjectGeneral: Component<{
project: LocalProject
server: ServerConnection.Any
onOpenServer: () => void
}> = (props) => {
const language = useLanguage()
const model = createEditProjectModel(props)
const servers = useSettingsServers()
return (
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="settings-tab-title truncate">
<bdi dir="auto">{model.store.name || displayName(props.project)}</bdi>
</h2>
<Show when={servers().length > 1}>
<button type="button" class="project-settings-server-link" onClick={() => props.onOpenServer()}>
<bdi dir="auto">{serverName(props.server) || ServerConnection.key(props.server)}</bdi>
</button>
</Show>
</div>
</div>
</div>
<div class="settings-tab-body project-settings-general" aria-busy={model.store.saving > 0}>
<SettingsList>
<SettingsRow
title={language.t("project.settings.name.title")}
description={language.t("project.settings.name.description")}
>
<div class="project-settings-name">
<TextInput
type="text"
appearance="base"
value={model.store.name}
placeholder={model.folderName()}
aria-label={language.t("project.settings.name.title")}
onInput={(event) => model.setStore("name", event.currentTarget.value)}
onBlur={model.saveName}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("dialog.project.edit.icon")}
description={language.t("project.settings.icon.description")}
>
<button
type="button"
aria-label={language.t("dialog.project.edit.icon.alt")}
class="project-settings-icon"
classList={{ "project-settings-icon--active": model.store.dragOver }}
onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => model.setStore("iconHover", false)}
onDrop={(event) => model.drop(event)}
onDragOver={(event) => model.dragOver(event)}
onDragLeave={() => model.dragLeave()}
onClick={() => model.iconClick()}
>
<ProjectIcon
project={props.project}
fallback={model.store.name || model.defaultName()}
icon={{
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
}}
class="!size-8 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[16px]"
/>
<span classList={{ "project-settings-icon-overlay": true, visible: model.store.iconHover }}>
<Icon name={model.store.iconOverride ? "close" : "share"} />
</span>
</button>
<input
ref={(element) => model.setIconInput(element)}
type="file"
accept="image/*"
class="hidden"
onChange={(event) => model.inputChange(event.currentTarget)}
/>
</SettingsRow>
<Show when={!model.store.iconOverride}>
<SettingsRow
title={language.t("dialog.project.edit.color")}
description={language.t("project.settings.color.description")}
>
<div class="project-settings-colors">
<For each={PROJECT_AVATAR_VARIANTS}>
{(color) => {
const selected = () => getProjectAvatarVariant(model.store.color) === color
return (
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={selected()}
class="project-settings-color"
classList={{ "project-settings-color--selected": selected() }}
onClick={() => model.setColor(selected() ? undefined : color)}
>
<ProjectAvatar
fallback=""
variant={color}
class="!size-5 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
/>
<Show when={selected()}>
<Icon name="check" size="small" class="project-settings-color-check" />
</Show>
</button>
)
}}
</For>
</div>
</SettingsRow>
</Show>
<div class="project-settings-startup">
<div class="project-settings-startup-copy">
<span class="project-settings-startup-title">{language.t("dialog.project.edit.worktree.startup")}</span>
<span class="project-settings-startup-description">
{language.t("project.settings.worktree.startup.description")}
</span>
</div>
<Textarea
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
rows={5}
value={model.store.startup}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
aria-label={language.t("dialog.project.edit.worktree.startup")}
spellcheck={false}
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
onBlur={model.saveStartup}
/>
<div class="project-settings-startup-hint flex flex-col">
<span>{inlineVariables(language.t("project.settings.worktree.startup.hint.base"))}</span>
<span>{inlineVariables(language.t("project.settings.worktree.startup.hint.new"))}</span>
</div>
</div>
</SettingsList>
</div>
</>
)
}
function inlineVariables(text: string) {
return text
.split(/(\$[A-Z][A-Z0-9_]*)/g)
.map((part, index) => (index % 2 === 0 ? part : <code dir="ltr">{part}</code>))
}
+32 -116
View File
@@ -1,69 +1,20 @@
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { IconButton } from "@opencode/ui/icon-button"
import { For, Show, createMemo, type Component } from "solid-js"
import { Icon } from "@opencode/ui/icon"
import { useDialog } from "@opencode/ui/context/dialog"
import { useLanguage } from "@/runtime/i18n/language"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, serverName } from "@/runtime/server/registry"
import { ServerConnection } from "@/runtime/server/registry"
import { displayName } from "@/shell/layout/helpers"
import { ProjectIcon } from "@/shell/layout/project-icon"
import { InlineServerSelect } from "@/settings/server-select"
import { DialogEditProject } from "./project-dialog"
import type { LocalProject } from "@/shell/state/layout"
import "@/settings/settings.css"
export const SettingsProjects: Component = () => {
const dialog = useDialog()
export const SettingsProjects: Component<{
server: ServerConnection.Any
onOpenProject: (project: LocalProject) => void
}> = (props) => {
const language = useLanguage()
const global = useGlobal()
const [allServers, setAllServers] = createSignal(true)
const selected = global.settings.server.selected
const multiple = createMemo(() => global.servers.list().length > 1)
const projects = createMemo(() => {
const server = selected()
if (!server) return []
return global.ensureServerCtx(server).projects.list()
})
type ProjectItem = ReturnType<typeof projects>[number]
const groups = createMemo(() =>
global.servers
.list()
.map((server) => ({ server, projects: global.ensureServerCtx(server).projects.list() }))
.filter((group) => group.projects.length > 0),
)
const openProjectSettings = (project: ProjectItem, server = selected()) => {
if (!server) return
dialog.push(() => <DialogEditProject project={project} server={server} />)
}
const ProjectRow: Component<{ project: ProjectItem; server: ServerConnection.Any }> = (props) => {
const name = () => displayName(props.project)
return (
<div
class="group mx-px flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-all hover:bg-v2-background-bg-layer-01"
onClick={() => openProjectSettings(props.project, props.server)}
>
<div class="flex items-center gap-2.5 min-w-0 flex-1">
<ProjectIcon project={props.project} class="shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate">{name()}</span>
</div>
<div class="flex items-center gap-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<IconButton
type="button"
variant="ghost-muted"
size="small"
icon={<Icon name="settings-gear" size="small" class="text-v2-icon-icon-muted" />}
onClick={(event: MouseEvent) => {
event.stopPropagation()
openProjectSettings(props.project, props.server)
}}
/>
</div>
</div>
)
}
const projects = createMemo(() => global.ensureServerCtx(props.server).projects.list())
return (
<>
@@ -73,74 +24,39 @@ export const SettingsProjects: Component = () => {
<h2 class="settings-tab-title">{language.t("settings.projects.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.projects.description")}</span>
</div>
<Show when={multiple()}>
<InlineServerSelect
all={{
label: language.t("settings.projects.server.all"),
selected: allServers,
onSelect: () => setAllServers(true),
}}
onServerSelect={() => setAllServers(false)}
/>
</Show>
</div>
</div>
<div class="settings-tab-body">
<Show
when={allServers()}
when={projects().length > 0}
fallback={
<div class="flex flex-col gap-2 w-full">
<Show
when={projects().length > 0}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<Show when={selected()} keyed>
{(server) => (
<div class="settings-section">
<Show when={multiple()}>
<h3 class="settings-section-title">{serverName(server) || ServerConnection.key(server)}</h3>
</Show>
<div class="flex flex-col gap-2 w-full">
<For each={projects()}>{(project) => <ProjectRow project={project} server={server} />}</For>
</div>
</div>
)}
</Show>
</Show>
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<div class="flex flex-col gap-8 w-full">
<Show
when={groups().length > 0}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<For each={groups()}>
{(group) => (
<div class="settings-section">
<Show when={multiple()}>
<h3 class="settings-section-title">
{serverName(group.server) || ServerConnection.key(group.server)}
</h3>
</Show>
<div class="flex flex-col gap-2 w-full">
<For each={group.projects}>
{(project) => <ProjectRow project={project} server={group.server} />}
</For>
</div>
</div>
)}
</For>
</Show>
<div class="flex w-full flex-col gap-2">
<For each={projects()}>
{(project) => (
<button
type="button"
aria-label={displayName(project)}
class="group mx-px flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-[background-color] hover:bg-v2-background-bg-layer-01 text-start"
onClick={() => props.onOpenProject(project)}
>
<span class="flex items-center gap-2.5 min-w-0 flex-1">
<ProjectIcon project={project} class="shrink-0" />
<bdi class="text-13-medium text-v2-text-text-base truncate">{displayName(project)}</bdi>
</span>
<Icon
name="chevron-right"
size="small"
class="shrink-0 text-v2-icon-icon-muted opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
/>
</button>
)}
</For>
</div>
</Show>
</div>
@@ -21,7 +21,6 @@ import { getRelativeTime } from "@/shell/time"
import { sessionLabel } from "@/session/title"
import { pathKey } from "@/workspaces/path-key"
import { SettingsList } from "@/settings/list"
import { InlineServerSelect } from "@/settings/server-select"
import { useTabs } from "@/shell/tabs/tabs"
import { usePlatform } from "@/runtime/platform/platform"
import { clearWorkspaceTerminals } from "@/session/terminal/context"
@@ -48,9 +47,11 @@ type Workspace = {
project: Project
}
export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProjectFilter: () => number }> = (
props,
) => {
export const SettingsWorkspaces: Component<{
activeDirectory?: string
resetProjectFilter?: () => number
projectID?: string
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
@@ -64,7 +65,11 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
removing: [] as string[],
})
createEffect(() => {
props.resetProjectFilter()
if (props.projectID) {
setStore("project", props.projectID)
return
}
props.resetProjectFilter?.()
setStore("project", "all")
})
@@ -91,7 +96,11 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
...projects().map((project) => ({ id: project.id, label: projectName(project) })),
])
const selectedProject = createMemo(() =>
store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all",
props.projectID
? props.projectID
: store.project === "all" || projects().some((project) => project.id === store.project)
? store.project
: "all",
)
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
const captureDeleteContext = () => {
@@ -328,18 +337,17 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
<h2 class="settings-tab-title">{language.t("settings.tab.workspaces")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.workspaces.description")}</span>
</div>
<InlineServerSelect />
</div>
</div>
<div class="settings-tab-body settings-workspaces">
<Show when={filtered().length > 0}>
<div class="settings-workspaces-toolbar">
<span class="settings-workspaces-count">
<span class="settings-section-title">
{language.plural("settings.workspaces.count", filtered().length)}
</span>
<div class="settings-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
<Show when={!props.projectID && projects().length > 1}>
<Menu placement="bottom-end" gutter={6}>
<Menu.Trigger as={Button} size="small" variant="ghost-muted" class="max-w-48">
<span class="min-w-0 truncate">
@@ -1,459 +0,0 @@
import { afterEach, expect, mock, test } from "bun:test"
import { createRequire } from "node:module"
import { createComponent, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import type { Agent } from "@/runtime/server/types"
import type { ModelKey } from "@/providers/models/selection"
import { ServerScope } from "@/runtime/server/scope"
// Bun does not compile Solid JSX. Compile the real context provider with the
// same presets as Vite instead of replacing LocalProvider's implementation.
const require = createRequire(import.meta.url)
const solid = createRequire(require.resolve("vite-plugin-solid"))
const { transformSync } = solid("@babel/core")
Bun.plugin({
name: "selection-solid-context",
setup(build) {
build.onLoad({ filter: /[\\/]ui[\\/]src[\\/]context[\\/]helper\.tsx$/ }, async (args) => ({
contents: transformSync(await Bun.file(args.path).text(), {
filename: args.path,
presets: [solid.resolve("babel-preset-solid"), solid.resolve("@babel/preset-typescript")],
}).code,
loader: "js",
}))
},
})
type Commit = { agent?: string; model?: { providerID: string; id: string; variant?: string } }
type Event = { data: { sessionID: string } }
type ConfigModel = string | { providerID: string; model: string; variant?: string }
const key = (modelID: string): ModelKey => ({ providerID: "provider", modelID })
const durable = (modelID: string, variant?: string, agent = "build"): Commit => ({
agent,
model: { providerID: "provider", id: modelID, variant },
})
const agent = (name: string, model?: ModelKey, variant?: string): Agent => ({
name,
mode: "primary",
permission: [],
options: {},
model,
variant,
})
let active: ReturnType<typeof fixture>
mock.module("@solidjs/router", () => ({ useParams: () => active.state.route }))
mock.module("@/runtime/server/current", () => ({ useData: () => active.data }))
mock.module("@/runtime/server/client", () => ({ useServerSDK: () => active.sdk }))
mock.module("@/runtime/server/runtime", () => ({ useGlobal: () => ({ models: active.preferences }) }))
mock.module("@/workspaces/location", () => ({ useWorkspaceLocation: () => () => ({ directory: active.directory }) }))
mock.module("@/settings/model", () => ({
useSettings: () => ({ visibility: { customAgents: () => active.state.visible } }),
}))
mock.module("@/composer/persistence", () => ({ useComposerState: () => active.prompt }))
mock.module("@/shell/state/layout", () => ({ useLayout: () => undefined }))
mock.module("@/runtime/platform/platform", () => ({
usePlatform: () => ({
platform: "web",
openExternal() {},
restart: async () => {},
notify: async () => {},
}),
}))
const { LocalProvider, useLocal } = await import("@/providers/models/selection")
const { ModelsProvider } = await import("@/providers/models/models")
const { Persist } = await import("@/runtime/persistence/storage")
const { createMemoryComposerState } = await import("@/composer/state")
const { createComposerModelSelection } = await import("@/composer/selection")
const cleanups: Array<() => void> = []
afterEach(() =>
cleanups
.splice(0)
.reverse()
.forEach((dispose) => dispose()),
)
function fixture(input: { session?: Commit; agents?: Agent[]; config?: ConfigModel; preferred?: string } = {}) {
const directory = `/selection-test/${crypto.randomUUID()}`
const [state, set] = createStore({
visible: true,
configLoaded: true,
connection: "connected",
route: { id: "ses_a" as string | undefined },
agents: input.agents ?? [agent("build"), agent("plan")],
config: input.config as ConfigModel | undefined,
sessions: { ses_a: input.session } as Record<string, Commit | undefined>,
providers: [{ id: "provider", name: "Provider", package: "@ai-sdk/test", activation: "enabled" }],
models: ["a", "b", "c"].map((id) => ({
id,
modelID: id,
providerID: "provider",
name: `Model ${id}`,
settings: {},
headers: {},
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: ["low", "high"].map((id) => ({ id, settings: {} })),
time: { released: 1 },
cost: [],
status: "active",
enabled: true,
limit: { context: 128_000, output: 8192 },
})),
})
const [preferences, setPreferences] = createStore({
user: [] as Array<ModelKey & { visibility: "show" | "hide" }>,
recent: [] as ModelKey[],
variant: (input.preferred ? { "provider/a": input.preferred } : {}) as Record<string, string>,
})
const events = new Map<string, Set<(event: Event) => void>>()
const configLoads: string[] = []
const result = {
prompt: createMemoryComposerState(),
directory,
state,
set,
setPreferences,
preferences: { store: preferences, set: setPreferences, ready: () => true, recent: () => preferences.recent },
data: {
session: { get: (id: string) => state.sessions[id] },
location: {
agent: { list: () => state.agents },
config: {
list: () => (state.configLoaded ? [{ type: "document", info: { model: state.config } }] : undefined),
sync: async () => {
configLoads.push(state.connection)
},
},
provider: { list: () => state.providers },
model: { list: () => state.models },
integration: { list: () => [] },
},
},
sdk: {
scope: ServerScope.local,
connection: { status: () => state.connection },
event: {
on(type: string, handler: (event: Event) => void) {
const handlers = events.get(type) ?? new Set()
events.set(type, handlers)
handlers.add(handler)
return () => handlers.delete(handler)
},
},
},
emit(type: string, sessionID = "ses_a") {
events.get(type)?.forEach((handler) => handler({ data: { sessionID } }))
},
configLoads,
mount(draft = false) {
active = result
let local!: ReturnType<typeof useLocal>
let composer: ReturnType<typeof createComposerModelSelection> | undefined
const dispose = createRoot((dispose) => {
createComponent(ModelsProvider, {
directory,
get children() {
return createComponent(LocalProvider, {
get children() {
local = useLocal()
if (draft) composer = createComposerModelSelection({ agent: local.agent.current })
return null
},
})
},
})
return dispose
})
cleanups.push(dispose)
return { local, composer, dispose }
},
}
const target = Persist.serverWorkspace(ServerScope.local, directory, "model-selection")
cleanups.push(() => localStorage.removeItem(`${target.storage}:${target.key}`))
return result
}
function selection(local: ReturnType<typeof useLocal>) {
return {
agent: local.agent.current()?.name,
model: local.model.current()?.id,
variant: local.model.variant.current(),
}
}
test("restores durable agents even when the agent selector is hidden", () => {
const f = fixture({ session: durable("b", "high", "plan") })
f.set("visible", false)
const { local } = f.mount()
expect(local.agent.visible()).toBe(false)
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
})
test("waits for initial configuration and reloads it after reconnecting", () => {
const f = fixture({ config: "provider/b" })
f.set("configLoaded", false)
const { local } = f.mount()
expect(local.model.ready()).toBe(false)
expect(local.model.current()).toBeUndefined()
f.set("connection", "reconnecting")
f.set("connection", "connected")
expect(f.configLoads).toEqual(["connected", "reconnecting", "connected"])
f.set("configLoaded", true)
expect(local.model.ready()).toBe(true)
expect(local.model.current()?.id).toBe("b")
})
test("new-session promotion does not mask a command's durable overrides", () => {
const f = fixture({ session: durable("a", "low") })
const { local } = f.mount()
local.session.promote(f.directory, "ses_a", { agent: "build", model: key("a"), variant: "low" })
f.set("sessions", "ses_a", durable("b", "high", "plan"))
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
})
test("new-session drafts remember each agent's model and hand off inactive choices", () => {
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("b"))] })
f.set("route", "id", undefined)
const { local, composer } = f.mount(true)
if (!composer) throw new Error("missing draft composer")
composer.set(key("c"))
composer.variant.set("high")
local.agent.set("plan")
expect(composer.current()?.id).toBe("b")
expect(composer.variant.current()).toBeUndefined()
local.agent.set("build")
expect(composer.current()?.id).toBe("c")
expect(composer.variant.current()).toBe("high")
local.agent.set("plan")
const choices = composer.remembered()
f.set("sessions", "ses_a", durable("b", undefined, "plan"))
local.session.promote(f.directory, "ses_a", { agent: "plan", model: key("b"), choices })
f.set("route", "id", "ses_a")
local.agent.set("build")
expect(local.model.current()?.id).toBe("c")
})
test("session model picks snapshot the variant rather than following another session's preferences", () => {
const f = fixture({ session: durable("a") })
const { local } = f.mount()
f.setPreferences("variant", "provider/b", "low")
local.model.set(key("b"))
f.setPreferences("variant", "provider/b", "high")
expect(local.model.variant.current()).toBe("low")
})
test("remembers distinct variants for agents using the same model, scoped to the session", () => {
const f = fixture({ session: durable("a", "low"), agents: [agent("build", key("a")), agent("plan", key("a"))] })
const { local } = f.mount()
local.model.variant.set("high")
local.agent.set("plan")
local.model.variant.set("low")
local.agent.set("build")
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
local.agent.set("plan")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
f.set("sessions", "ses_b", durable("b", "high"))
f.set("route", "id", "ses_b")
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "high" })
f.set("route", "id", "ses_a")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
})
test("restores each agent's model and variant after provider remount", () => {
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("b"))] })
const first = f.mount()
first.local.model.variant.set("high")
first.local.agent.set("plan")
first.local.model.set(key("c"))
first.local.model.variant.set("low")
first.dispose()
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
local.agent.set("build")
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
local.agent.set("plan")
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
})
test("changing models drops the old variant even when both models support it", () => {
const f = fixture({ session: durable("a", "high") })
const { local } = f.mount()
local.model.set(key("b"))
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: undefined })
f.setPreferences("variant", "provider/c", "low")
local.model.set(key("c"))
expect(local.model.variant.current()).toBe("low")
})
test("restores durable selection ahead of agent, global, and saved variant defaults", () => {
const f = fixture({
session: durable("a", "low", "plan"),
agents: [agent("build"), agent("plan", key("b"), "high")],
config: { providerID: "provider", model: "c", variant: "high" },
preferred: "high",
})
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
local.session.restore({ sessionID: "ses_a", agent: "build", model: key("c") })
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
})
test("uses historical message selection only when durable and local selection are absent", () => {
const f = fixture()
const { local } = f.mount()
local.session.restore({ sessionID: "ses_b", agent: "plan", model: key("b") })
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: undefined })
local.session.restore({ sessionID: "ses_a", agent: "plan", model: { ...key("b"), variant: "low" } })
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "low" })
local.model.variant.set("high")
local.session.restore({ sessionID: "ses_a", agent: "build", model: key("c") })
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
})
test("invalid durable models fall through agent, global, recent, and connected defaults", () => {
const f = fixture({
session: durable("removed", "high"),
agents: [agent("build", key("b"), "low")],
config: "provider/c",
})
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "low" })
f.set("agents", [agent("build", key("removed"))])
expect(local.model.current()?.id).toBe("c")
f.setPreferences("recent", [key("removed"), key("b")])
f.set("config", "provider/removed")
expect(local.model.current()?.id).toBe("b")
f.setPreferences("recent", [key("removed")])
expect(local.model.current()?.id).toBe("a")
f.set("providers", [])
expect(local.model.current()).toBeUndefined()
})
test("global and agent model/variant defaults react to configuration replacement", () => {
const f = fixture({ config: { providerID: "provider", model: "a", variant: "low" } })
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "low" })
f.set("config", { providerID: "provider", model: "b", variant: "high" })
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "high" })
f.set("agents", [agent("build", key("a"), "low")])
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "low" })
f.set("agents", [agent("build", key("c"), "high")])
expect(selection(local)).toEqual({ agent: "build", model: "c", variant: "high" })
f.set("agents", [agent("build")])
f.set("config", "provider/b")
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: undefined })
})
test("durable and explicitly selected Default override a saved variant preference", () => {
const f = fixture({
session: durable("a"),
preferred: "high",
config: { providerID: "provider", model: "a", variant: "low" },
})
const { local } = f.mount()
expect(local.model.variant.current()).toBeUndefined()
local.model.variant.set(undefined)
expect(f.preferences.store.variant["provider/a"]).toBe("default")
f.set("route", "id", "ses_b")
expect(local.model.variant.current()).toBeUndefined()
f.set("route", "id", "ses_a")
f.setPreferences("variant", "provider/a", "high")
expect(local.model.variant.current()).toBeUndefined()
f.set("route", "id", "ses_b")
expect(local.model.variant.current()).toBe("high")
f.set("route", "id", "ses_a")
expect(local.model.variant.current()).toBeUndefined()
})
test("waits for both commit acknowledgments, then releases only the matching draft", () => {
const f = fixture({ session: durable("b", "low") })
const { local } = f.mount()
local.agent.set("plan")
local.model.set(key("a"))
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "plan", model: key("a"), variant: "high" })
f.set("sessions", "ses_a", durable("b", "low", "plan"))
f.emit("session.agent.selected")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "high" })
f.set("sessions", "ses_a", durable("a", "high", "plan"))
f.emit("session.model.selected")
f.set("sessions", "ses_a", durable("c", "low", "plan"))
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
})
test.each(["a", "b"])("a delayed commit preserves the newer %s/low selection", (model) => {
const f = fixture()
const { local } = f.mount()
local.model.set(key("a"))
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
local.model.set(key(model))
local.model.variant.set("low")
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
expect(selection(local)).toEqual({ agent: "build", model, variant: "low" })
})
test("a delayed commit preserves a newer agent even when model and variant match", () => {
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("a"))] })
const { local } = f.mount()
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
local.agent.set("plan")
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "high" })
})
test("a commit received while its session is inactive does not discard its local selection", () => {
const f = fixture()
const { local } = f.mount()
local.model.set(key("a"))
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
f.set("sessions", "ses_b", durable("b", "low"))
f.set("route", "id", "ses_b")
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "low" })
f.set("sessions", "ses_a", durable("c", "low"))
f.set("route", "id", "ses_a")
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
})
test("cancelling a failed commit retains the draft and does not cancel a newer commit", () => {
const f = fixture()
const { local } = f.mount()
local.model.set(key("a"))
local.model.variant.set("high")
const cancel = local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
cancel()
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
f.set("sessions", "ses_a", durable("c", "low"))
expect(local.model.current()?.id).toBe("a")
local.model.set(key("b"))
local.model.variant.set("low")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("b"), variant: "low" })
cancel()
f.set("sessions", "ses_a", durable("b", "low"))
f.emit("session.model.selected")
f.set("sessions", "ses_a", durable("c", "high"))
expect(selection(local)).toEqual({ agent: "build", model: "c", variant: "high" })
})
test.each([1, -1] as const)("cycles %p from outside recents to the correct end and wraps", (direction) => {
const f = fixture({ session: durable("a") })
f.setPreferences("recent", [key("removed"), key("b"), key("c")])
const { local } = f.mount()
local.model.cycle(direction)
expect(local.model.current()?.id).toBe(direction === 1 ? "b" : "c")
local.model.cycle(direction)
expect(local.model.current()?.id).toBe(direction === 1 ? "c" : "b")
local.model.cycle(direction)
expect(local.model.current()?.id).toBe(direction === 1 ? "b" : "c")
})
@@ -1,23 +0,0 @@
import { expect, test } from "bun:test"
import { fileURLToPath } from "node:url"
test("LocalProvider selection integration", async () => {
// Isolate provider module substitutions from the rest of the browser suite.
const child = Bun.spawn(
[
process.execPath,
"test",
"--conditions=browser",
"--preload",
"./happydom.ts",
"./test-browser/fixtures/model-selection.ts",
],
{ cwd: fileURLToPath(new URL("..", import.meta.url)), stdout: "pipe", stderr: "pipe" },
)
const [status, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
expect(status, stdout + stderr).toBe(0)
}, 30_000)
+3
View File
@@ -35,6 +35,9 @@ export default defineConfig({
port: 3000,
},
build: {
...(process.env.VITE_OPENCODE_TEST_FIXTURES === "1"
? { rolldownOptions: { input: ["index.html", "e2e/utils/settings-wsl.html"] } }
: {}),
assetsDir: "_assets",
target: "esnext",
sourcemap: true,
-25
View File
@@ -1,25 +0,0 @@
# Vite TUI entrypoint
From the repository root:
```sh
bun run dev:vite:live /path/to/project
```
This uses the normal CLI and its real TUI through Vite + `solid-refresh`. For an explicit server or private backend, use `dev:vite` with `--server URL` or `--standalone` respectively. Plain `dev:vite` uses normal CLI service discovery; `dev:vite:live` explicitly connects to the installed server without replacing it.
- Component edits hot-update through the existing Solid refresh runtime.
- Full reloads await TUI cleanup and restore the current route: the selected session, Home workspace, or plugin page. They do not preserve composer drafts or other component-local state, and do not replay launch prompts, route prompts, `--continue`, or `--fork`.
- Correcting syntax errors retries a failed reload. The backend stays alive.
- Refreshable components get local error boundaries. A render failure during a hot update triggers one full UI reload. If the fresh render also fails, the error appears in the shared themed Dialog rather than causing a reload loop. Only the latest error is shown. Escape dismisses it; saving retries failed components. State within remounted components can still reset, especially when several components share an edited file.
- Launcher/config/dependency changes require restarting the development client.
`vite.ts` registers a Bun runtime module that supplies the Vite runner for the CLI's existing static `@opencode/tui` import. This registration runs only in the dev launcher; production handlers and their import graph are unchanged. `tui.ts` owns Vite and the TUI lifecycle. `entry.ts` loads the real application source through Vite. `host.js` keeps lifecycle ownership outside Vite's reloadable module cache. No production CLI handler, TUI component, or route changes are needed.
`refresh.ts` delegates component replacement to stock `solid-refresh`, wrapping each returned component proxy in Solid's standard ErrorBoundary. It preserves registered context identities during module evaluation: Vite's native runner can re-evaluate cyclic dependencies without invoking their HMR accept callbacks, which is too late for stock context patching. `refresh-runtime.d.ts` supplies types for the package's existing deep runtime export.
Vite redirects imports of the TUI route context through `route.tsx`, a dev-only wrapper around the real provider. It saves plain route snapshots in the external `host.js`, including nested Home location and plugin page data. Production route code is unchanged. Recovery is armed only for a hot update, consumed before requesting a full reload, and disarmed when the update settles or the full reload starts.
The entry initializes the error overlay after loading the app graph because the shared dialog and theme modules themselves use the refresh runtime.
Tested on Linux/Bun with full-app rendering, message/palette HMR, draft preservation, and native-terminal full reload/error recovery. External native-loaded plugins remain experimental across full reloads because their process-lifetime runtime mappings can retain an older Solid generation.
-17
View File
@@ -1,17 +0,0 @@
/// <reference types="vite/client" />
import { host } from "@opencode/cli/vite-host"
import { configureErrorOverlay } from "./refresh"
if (import.meta.hot) {
import.meta.hot.on("vite:afterUpdate", () => queueMicrotask(() => host.settle?.()))
import.meta.hot.on("vite:beforeFullReload", async () => {
await host.stop?.()
host.reset?.()
})
}
const { run } = await import("../../tui/src/index")
// Theme/dialog modules use refresh themselves; initialize their overlay after the app graph loads.
const { ErrorOverlay } = await import("./error-overlay")
configureErrorOverlay(ErrorOverlay)
await host.mount?.(run)
-56
View File
@@ -1,56 +0,0 @@
/* @refresh skip */
import { BoxRenderable, TextAttributes } from "@opentui/core"
import { Portal, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { onCleanup, onMount } from "solid-js"
import { useTheme } from "../../tui/src/context/theme"
import { Dialog } from "../../tui/src/ui/dialog"
import { Keymap } from "../../tui/src/context/keymap"
export function ErrorOverlay(props: { component: string; error: unknown; onClose: () => void }) {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const focus = renderer.currentFocusedRenderable
onCleanup(Keymap.use().mode.push("modal"))
Keymap.createLayer(() => ({
mode: "modal",
priority: 1000,
commands: [{ bind: "escape", title: "Close hot reload error", group: "Development", run: props.onClose }],
}))
onMount(() => focus?.blur())
onCleanup(() => {
if (focus && !focus.isDestroyed) focus.focus()
})
return (
<Portal
ref={(container) => {
if (!(container instanceof BoxRenderable)) return
// Anchor Portal's wrapper above the app rather than after it in root layout.
container.position = "absolute"
container.left = 0
container.top = 0
container.zIndex = 5000
}}
>
<Dialog centered onClose={props.onClose}>
<box maxHeight={Math.max(1, dimensions().height - 3)} paddingX={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Error while hot reloading
</text>
<text fg={theme.text.subdued} onMouseUp={props.onClose}>
esc
</text>
</box>
<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.subdued}>
{props.component} · Fix the component and save to retry.
</text>
</box>
</Dialog>
</Portal>
)
}
-16
View File
@@ -1,16 +0,0 @@
import type { Effect, Fiber, FileSystem } from "effect"
import type { TuiInput } from "@opencode/tui"
import type { Global } from "@opencode/util/global"
import type { Route } from "../../tui/src/context/route"
export type Run = (input: TuiInput) => Effect.Effect<void, unknown, Global.Service | FileSystem.FileSystem>
export declare const host: {
active?: Fiber.Fiber<void, unknown>
mount?: (app: Run) => Promise<void>
stop?: () => Promise<void>
reset?: () => void
recover?: () => boolean
settle?: () => void
route?: Route
}
-2
View File
@@ -1,2 +0,0 @@
// External to Vite's module cache: keep lifecycle and route state across reloads.
export const host = {}
-3
View File
@@ -1,3 +0,0 @@
declare module "solid-refresh/dist/solid-refresh.mjs" {
export * from "solid-refresh"
}
-66
View File
@@ -1,66 +0,0 @@
/// <reference types="vite/client" />
import { createComponent, createSignal, ErrorBoundary, onCleanup, Show, type JSX } from "solid-js"
import { $$component, $$refresh, type Registry } from "solid-refresh/dist/solid-refresh.mjs"
import type { ErrorOverlay } from "./error-overlay"
import { host } from "@opencode/cli/vite-host"
let overlay: typeof ErrorOverlay
const [activeError, setActiveError] = createSignal<symbol>()
export function configureErrorOverlay(component: typeof ErrorOverlay) {
overlay = component
}
export { $$context, $$decline, $$registry } from "solid-refresh/dist/solid-refresh.mjs"
export { refresh as $$refresh }
export { component as $$component }
function refresh(...args: Parameters<typeof $$refresh>) {
// The native runner can re-evaluate a dependency in a cycle without accepting
// an update for that module. Preserve context identity now, before an updated
// consumer renders, rather than waiting for solid-refresh's accept callback.
const previous = args[1].data?.["solid-refresh"]
args[2].contexts.forEach((entry, id) => {
const old = previous?.contexts.get(id)
if (!old) return
old.context.defaultValue = entry.context.defaultValue
entry.context.id = old.context.id
entry.context.Provider = old.context.Provider
})
$$refresh(...args)
}
function component<P extends Record<string, unknown>>(
registry: Registry,
id: string,
render: (props: P) => JSX.Element,
options?: Parameters<typeof $$component>[3],
) {
const proxy = $$component(registry, id, render, options)
return (props: P) =>
createComponent(ErrorBoundary, {
fallback(error: unknown, reset: () => void) {
if (host.recover?.()) return null
const token = Symbol(id)
// Several instances can fail in one update. Stack neither dialogs nor translucent backdrops.
setActiveError(token)
onCleanup(() => {
if (activeError() === token) setActiveError(undefined)
})
// Retry only this failed subtree. Resetting the app's boundary destroys its providers and route.
import.meta.hot?.on("vite:afterUpdate", reset)
onCleanup(() => import.meta.hot?.off("vite:afterUpdate", reset))
return createComponent(Show, {
keyed: true,
get when() {
return activeError() === token
},
get children() {
return createComponent(overlay, { component: id, error, onClose: () => setActiveError(undefined) })
},
})
},
get children() {
return createComponent(proxy, props)
},
})
}
-43
View File
@@ -1,43 +0,0 @@
import { createEffect, on, type ComponentProps } from "solid-js"
import { unwrap } from "solid-js/store"
import { host } from "@opencode/cli/vite-host"
import { RouteProvider, useRoute } from "../../tui/src/context/route"
export {
useRoute,
useRouteData,
type Route,
type HomeRoute,
type SessionRoute,
type PluginRoute,
} from "../../tui/src/context/route"
export { ReloadableRouteProvider as RouteProvider }
function ReloadableRouteProvider(props: ComponentProps<typeof RouteProvider>) {
return (
<RouteProvider {...props} initialRoute={host.route ?? props.initialRoute}>
<RememberRoute />
{props.children}
</RouteProvider>
)
}
function RememberRoute() {
const route = useRoute()
createEffect(
on(
() => JSON.stringify(route.data),
() => {
// A route's prompt is a one-shot handoff, not a composer draft.
const value = structuredClone(unwrap({ ...route.data }))
host.route =
value.type === "home"
? { type: "home", location: value.location }
: value.type === "session"
? { type: "session", sessionID: value.sessionID }
: value
},
),
)
return null
}
-188
View File
@@ -1,188 +0,0 @@
import { createRequire } from "node:module"
import path from "node:path"
import { Effect, Exit, Fiber } from "effect"
import { createRunnableDevEnvironment, createServer, isRunnableDevEnvironment } from "vite"
import solid from "vite-plugin-solid"
import refresh from "solid-refresh/babel"
import { host, type Run } from "./host.js"
const require = createRequire(import.meta.url)
export const run: Run = Effect.fn("Tui.vite")(function* (input: Parameters<Run>[0]) {
const fork = Effect.runForkWith(yield* Effect.context<Effect.Services<ReturnType<Run>>>())
const finished = Promise.withResolvers<Exit.Exit<void, unknown>>()
let initial = true
let recoverable = false
host.route = undefined
host.settle = () => {
recoverable = false
}
host.stop = async () => {
const fiber = host.active
host.active = undefined
if (fiber) await Effect.runPromise(Fiber.interrupt(fiber))
}
host.mount = async (app) => {
await host.stop?.()
const fiber = fork(
app({
...input,
args: initial
? input.args
: { ...input.args, prompt: undefined, sessionID: undefined, continue: false, fork: false },
terminalHandoff: initial ? input.terminalHandoff : undefined,
}),
)
initial = false
host.active = fiber
fiber.addObserver((exit) => {
if (host.active !== fiber) return
host.active = undefined
finished.resolve(exit)
})
}
const server = yield* Effect.acquireRelease(
Effect.tryPromise(() =>
createServer({
root: path.resolve(import.meta.dirname, "../../tui"),
configFile: false,
appType: "custom",
clearScreen: false,
logLevel: "error",
server: { middlewareMode: true, ws: false },
resolve: {
alias: [
{ find: /^solid-js(?:\/dist\/solid.js)?$/, replacement: require.resolve("solid-js/dist/dev.js") },
{
find: /^solid-js\/store(?:\/dist\/store.js)?$/,
replacement: require.resolve("solid-js/store/dist/dev.js"),
},
],
},
plugins: [
{
name: "tui-refresh-boundaries",
enforce: "pre",
async resolveId(source, importer) {
if (importer === path.join(import.meta.dirname, "route.tsx")) return
if (!source.endsWith("/route") && !source.endsWith("/route.tsx")) return
const resolved = await this.resolve(source, importer, { skipSelf: true })
if (resolved?.id === path.resolve(import.meta.dirname, "../../tui/src/context/route.tsx"))
return path.join(import.meta.dirname, "route.tsx")
},
load(id) {
if (id === "/@solid-refresh")
return `export * from ${JSON.stringify(path.join(import.meta.dirname, "refresh.ts"))}`
},
},
solid({
hot: false,
dev: true,
solid: { generate: "universal", moduleName: "@opentui/solid" },
// Enable the existing refresh plugin in Vite's non-browser environment.
babel: { plugins: [[refresh, { bundler: "vite" }]] },
}),
{
name: "tui-recovery",
hotUpdate() {
if (this.environment.name !== "native") return
recoverable = Boolean(host.active)
if (host.active) return
this.environment.moduleGraph.invalidateAll()
this.environment.hot.send({ type: "full-reload" })
return []
},
},
],
environments: {
native: {
consumer: "server",
resolve: {
conditions: ["bun", "development", "module"],
externalConditions: ["bun", "node"],
noExternal: [
"solid-js",
"solid-refresh",
"@opentui/solid",
"@opentui/keymap",
"opentui-spinner",
/^@solid-primitives\//,
"@opencode/plugin",
"@opencode/client",
"@opencode/latex",
"@opencode/merman",
],
// Exact subpaths are needed for workspace TypeScript exports.
external: [
"@opentui/core",
"@opentui/core/testing",
"effect",
"@opencode/cli/vite-host",
"@opencode/client",
"@opencode/client/effect/service",
"@opencode/client/promise",
"@opencode/core/util/slug",
"@opencode/schema",
"@opencode/schema/event",
"@opencode/schema/project",
"@opencode/schema/session-id",
"@opencode/schema/session-inbox",
"@opencode/schema/session-message",
"@opencode/schema/skill",
"@opencode/schema/token-usage",
"@opencode/schema/vcs",
"@opencode/schema/worktree",
"@opencode/simulation/frontend",
"@opencode/simulation/protocol",
"@opencode/theme/tui",
"@opencode/theme/tui/v1",
"@opencode/util/activity-calendar",
"@opencode/util/flock",
"@opencode/util/global",
"@opencode/util/hash",
"@opencode/util/session-title-fallback",
],
},
optimizeDeps: { noDiscovery: true, include: [] },
dev: {
createEnvironment: (name, config) =>
createRunnableDevEnvironment(name, config, {
runnerOptions: {
sourcemapInterceptor: false,
hmr: { logger: { debug() {}, error: (error) => console.error(error) } },
},
}),
},
},
},
}),
),
(server) =>
Effect.promise(async () => {
await host.stop?.()
await server.close()
}),
)
const environment = server.environments.native
if (!isRunnableDevEnvironment(environment)) return yield* Effect.die(new Error("Expected a runnable environment"))
host.reset = () => {
recoverable = false
environment.runner.clearCache()
}
host.recover = () => {
if (!recoverable) return false
recoverable = false
queueMicrotask(() => {
input.log?.("warn", "TUI hot update failed; reloading", {})
environment.moduleGraph.invalidateAll()
environment.hot.send({ type: "full-reload" })
})
return true
}
yield* Effect.promise(() =>
environment.runner.import(path.join(import.meta.dirname, "entry.ts")).catch(console.error),
)
const exit = yield* Effect.promise(() => finished.promise)
if (Exit.isFailure(exit)) return yield* Effect.failCause(exit.cause)
}, Effect.scoped)
-15
View File
@@ -1,15 +0,0 @@
import { plugin } from "bun"
import { ensureSolidTransformPlugin } from "@opentui/solid/bun-plugin"
ensureSolidTransformPlugin()
if (process.argv[2] !== "serve") {
// Vite must initialize before the CLI installs its process/error handling on Bun.
const { run } = await import("./tui")
plugin({
name: "vite-tui-entry",
setup(build) {
build.module("@opencode/tui", () => ({ loader: "object", exports: { run } }))
},
})
}
await import("../src/index")
-2
View File
@@ -11,7 +11,6 @@
"bin"
],
"exports": {
"./vite-host": "./dev/host.js",
"./run": "./src/run/index.ts",
"./server-process": "./src/server-process.ts"
},
@@ -75,7 +74,6 @@
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
"solid-refresh": "0.6.3",
"vite": "catalog:",
"vite-plugin-solid": "catalog:"
}
-45
View File
@@ -1,45 +0,0 @@
import { expect, test } from "bun:test"
import { createComponent, createContext, createRoot, useContext } from "solid-js"
import { $$context, $$registry } from "solid-refresh/dist/solid-refresh.mjs"
import { $$refresh } from "../dev/refresh"
test("re-evaluated dependencies retain their mounted context before any HMR accept callback", () => {
const previous = $$registry()
const mounted = $$context(previous, "Context", createContext("old default"))
const next = $$registry()
const updated = $$context(next, "Context", createContext("new default"))
const unrelated = $$context($$registry(), "Context", createContext("unrelated"))
let accepted = false
$$refresh(
"vite",
{
data: { "solid-refresh": previous, "solid-refresh-prev": previous },
accept() {
accepted = true
},
invalidate() {
throw new Error("Unexpected invalidation")
},
decline() {
throw new Error("Unexpected decline")
},
},
next,
)
// Only register acceptance: Vite re-evaluates cyclic dependencies without
// necessarily sending those modules their own accepted update.
expect(accepted).toBe(true)
createRoot((dispose) => {
createComponent(mounted.Provider, {
value: "mounted provider",
get children() {
expect(useContext(updated)).toBe("mounted provider")
expect(useContext(unrelated)).toBe("unrelated")
return undefined
},
})
expect(useContext(mounted)).toBe("new default")
dispose()
})
})
-65
View File
@@ -1,65 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { RouteProvider, useRoute, type Route } from "../dev/route"
import { host } from "../dev/host.js"
import { TuiStartupProvider } from "../../tui/src/context/runtime"
test("the dev route wrapper restores the current route without replaying its prompt", async () => {
const saved = () => host.route
let route!: ReturnType<typeof useRoute>
function Probe() {
route = useRoute()
return null
}
async function render() {
return testRender(
() => (
<TuiStartupProvider value={{ skipInitialLoading: true }}>
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_launch" }}>
<Probe />
</RouteProvider>
</TuiStartupProvider>
),
{ width: 80, height: 24 },
)
}
const routes: Route[] = [
{ type: "home", location: { directory: "/selected/worktree", workspaceID: "wrk_test" } },
{ type: "home", location: { directory: "/another/worktree", workspaceID: "wrk_other" } },
{ type: "session", sessionID: "ses_selected" },
{ type: "plugin", id: "test", name: "page", data: { nested: { selected: 1 } } },
{ type: "plugin", id: "test", name: "page", data: { nested: { selected: 2 } } },
]
host.route = undefined
const app = await render()
try {
await app.waitFor(() => host.route !== undefined)
for (const value of routes) {
route.navigate(
value.type === "plugin"
? value
: {
...value,
prompt: { text: "one-shot handoff", files: [], agents: [], pasted: [] },
},
)
await app.waitFor(() => JSON.stringify(host.route) === JSON.stringify(value))
expect(saved()).toEqual(value)
// Saved routes contain plain data, not a proxy tied to the old Solid tree.
expect(structuredClone(saved())).toEqual(value)
}
} finally {
app.renderer.destroy()
}
for (const value of routes) {
host.route = value
const restored = await render()
try {
expect(route.data).toEqual(value)
} finally {
restored.renderer.destroy()
}
}
host.route = undefined
})
+22 -1
View File
@@ -518,7 +518,6 @@ export type SessionLogOutput =
readonly sessionID: Session.ID
readonly parentID: Session.ID
readonly boundary: Session.ForkBoundary
readonly messages?: ReadonlyArray<SessionMessage.InfoEncoded> | undefined
readonly instructions?:
| { readonly [x: string & Brand.Brand<"Instruction.Key">]: string & Brand.Brand<"Instruction.Hash"> }
| undefined
@@ -2112,8 +2111,30 @@ export type ConfigGetInput = {
export type ConfigGetOutput = ReadonlyArray<Config.Entry>
export type ConfigGetOperation<E = never> = (input?: ConfigGetInput) => Effect.Effect<ConfigGetOutput, E>
export type ConfigPreferencesOutput = Config.Preferences
export type ConfigPreferencesOperation<E = never> = () => Effect.Effect<ConfigPreferencesOutput, E>
export type ConfigUpdatePreferencesInput = {
readonly shell?: string | null | undefined
readonly websearch?: false | { readonly provider: "random" | WebSearch.ID } | null | undefined
}
export type ConfigUpdatePreferencesOutput = Config.Preferences
export type ConfigUpdatePreferencesOperation<E = never> = (
input?: ConfigUpdatePreferencesInput,
) => Effect.Effect<ConfigUpdatePreferencesOutput, E>
export type ConfigShellsOutput = ReadonlyArray<{
readonly path: string
readonly name: string
readonly acceptable: boolean
}>
export type ConfigShellsOperation<E = never> = () => Effect.Effect<ConfigShellsOutput, E>
export interface ConfigApi<E = never> {
readonly get: ConfigGetOperation<E>
readonly preferences: ConfigPreferencesOperation<E>
readonly updatePreferences: ConfigUpdatePreferencesOperation<E>
readonly shells: ConfigShellsOperation<E>
}
export interface AppApi<E = never> {
+23 -1
View File
@@ -270,6 +270,10 @@ import type {
WebsearchQueryOutput,
ConfigGetInput,
ConfigGetOutput,
ConfigPreferencesOutput,
ConfigUpdatePreferencesInput,
ConfigUpdatePreferencesOutput,
ConfigShellsOutput,
} from "../api/api.js"
import { ClientError } from "./client-error.js"
@@ -1585,7 +1589,25 @@ const EndpointConfigGet = (raw: RawClient["server.config"]) => (input?: ConfigGe
raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupConfig = (raw: RawClient["server.config"]) => ({ get: EndpointConfigGet(raw) })
const EndpointConfigPreferences = (raw: RawClient["server.config"]) => () =>
preserveEffect<ConfigPreferencesOutput>()(raw["config.preferences"]({}).pipe(Effect.mapError(mapClientError)))
const EndpointConfigUpdatePreferences = (raw: RawClient["server.config"]) => (input?: ConfigUpdatePreferencesInput) =>
preserveEffect<ConfigUpdatePreferencesOutput>()(
raw["config.updatePreferences"]({ payload: { shell: input?.["shell"], websearch: input?.["websearch"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointConfigShells = (raw: RawClient["server.config"]) => () =>
preserveEffect<ConfigShellsOutput>()(raw["config.shells"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroupConfig = (raw: RawClient["server.config"]) => ({
get: EndpointConfigGet(raw),
preferences: EndpointConfigPreferences(raw),
updatePreferences: EndpointConfigUpdatePreferences(raw),
shells: EndpointConfigShells(raw),
})
const adaptClient = (raw: RawClient) => ({
health: adaptGroupHealth(raw["server.health"]),
@@ -266,6 +266,10 @@ import type {
WebsearchQueryOutput,
ConfigGetInput,
ConfigGetOutput,
ConfigPreferencesOutput,
ConfigUpdatePreferencesInput,
ConfigUpdatePreferencesOutput,
ConfigShellsOutput,
} from "./types.js"
import { ClientError } from "./client-error.js"
@@ -2202,6 +2206,34 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
preferences: (requestOptions?: RequestOptions) =>
request<ConfigPreferencesOutput>(
{
method: "GET",
path: `/api/config/preferences`,
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
updatePreferences: (input?: ConfigUpdatePreferencesInput, requestOptions?: RequestOptions) =>
request<ConfigUpdatePreferencesOutput>(
{
method: "PATCH",
path: `/api/config/preferences`,
body: { shell: input?.["shell"], websearch: input?.["websearch"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
shells: (requestOptions?: RequestOptions) =>
request<ConfigShellsOutput>(
{ method: "GET", path: `/api/config/shell`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
requestOptions,
),
},
}
}
+107 -237
View File
@@ -159,76 +159,6 @@ export type InstructionEntryKey = string
export type SessionGenerateResponse = { data: { text: string } }
export type SessionMessageAgentSelected1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
type: "agent-switched"
agent: string
previous?: string
}
export type SessionMessageSynthetic1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
text: string
description?: string
type: "synthetic"
}
export type SessionMessageSystem1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
type: "system"
text: string
description?: string
}
export type SessionMessageSkill1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
type: "skill"
skill: string
name: string
text: string
}
export type SessionMessageShell1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number; completed?: number }
type: "shell"
shellID: string
command: string
status: "running" | "exited" | "timeout" | "killed"
exit?: number
output?: { output: string; cursor: number; size: number; truncated: boolean }
}
export type SessionMessageProviderState1 = { [x: string]: any }
export type SessionMessageToolStateRunning1 = {
status: "running"
input: { [x: string]: any }
metadata: { [x: string]: JsonValue }
}
export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?: string | undefined }
export type SessionMessageCompactionRunning1 = {
type: "compaction"
id: string
metadata?: { [x: string]: any }
time: { created: number }
status: "running"
reason: "auto" | "manual"
summary: string
recent: string
}
export type SessionInboxSyntheticPayload1 = { text: string; description?: string; metadata?: { [x: string]: any } }
export type ShellInfo = {
@@ -244,6 +174,16 @@ export type ShellInfo = {
time: { started: number; completed?: number }
}
export type SessionMessageProviderState1 = { [x: string]: any }
export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?: string | undefined }
export type SessionMessageToolStateRunning1 = {
status: "running"
input: { [x: string]: any }
metadata: { [x: string]: JsonValue }
}
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
export type SessionInterruptResponse = { interrupted: boolean }
@@ -491,6 +431,10 @@ export type WebSearchResult = { url: string; title?: string; content?: string; t
export type ConfigWorktree = { directory: string }
export type ConfigPreferences = { shell?: string; websearch?: false | { provider: "random" | (string & {}) } }
export type ConfigShellOption = { path: string; name: string; acceptable: boolean }
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
@@ -514,17 +458,6 @@ export type SessionMessageLocationSwitched = {
export type SessionInboxMovePayload = { location: LocationRef; projectID: string; subpath?: string }
export type SessionMessageLocationSwitched1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
type: "location-switched"
location: LocationRef
projectID?: string
subpath?: string
previous?: { location: LocationRef; projectID?: string; subpath?: string }
}
export type V2EventRpc = {
id: string
created: number
@@ -560,15 +493,6 @@ export type SessionMessageModelSelected = {
previous?: ModelRef
}
export type SessionMessageModelSelected1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
type: "model-switched"
model: ModelRef
previous?: ModelRef
}
export type PromptFileAttachment = {
data: PromptBase64
mime: string
@@ -605,16 +529,6 @@ export type SessionMessageCompactionFailed = {
error: SessionStructuredError
}
export type SessionMessageCompactionFailed1 = {
type: "compaction"
id: string
metadata?: { [x: string]: any }
time: { created: number }
status: "failed"
reason: "auto" | "manual"
error: SessionStructuredError
}
export type SessionProviderContext = { version: 1; provenance: SessionProviderContextProvenance; messages: JsonValue }
export type SessionInboxSynthetic = {
@@ -1290,13 +1204,37 @@ export type McpResourcesChanged = {
data: { server: string }
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type SessionShellStarted = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.shell.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; shell: ShellInfo }
}
export type SessionMessageAssistantReasoning1 = {
type: "reasoning"
text: string
state?: SessionMessageProviderState1
time?: { created: number; completed?: number }
export type SessionShellEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.shell.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
shell: ShellInfo
output: { output: string; cursor: number; size: number; truncated: boolean }
}
}
export type ShellCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "shell.created"
location?: LocationRef
data: { info: ShellInfo }
}
export type SessionStepEnded = {
@@ -1399,41 +1337,17 @@ export type SessionToolCalled = {
}
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type SessionMessageAssistantReasoning1 = {
type: "reasoning"
text: string
state?: SessionMessageProviderState1
time?: { created: number; completed?: number }
}
export type ToolContent1 = ToolTextContent | ToolFileContent1
export type SessionShellStarted = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.shell.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; shell: ShellInfo }
}
export type SessionShellEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.shell.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
shell: ShellInfo
output: { output: string; cursor: number; size: number; truncated: boolean }
}
}
export type ShellCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "shell.created"
location?: LocationRef
data: { info: ShellInfo }
}
export type ModelCompatibility = {
reasoningField?: ModelReasoningField
requireReasoning?: boolean
@@ -1793,17 +1707,6 @@ export type SessionInboxUserPayload = {
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageUser1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
skills?: Array<PromptSkillAttachment>
type: "user"
}
export type SessionInboxUserPayload1 = {
text: string
files?: Array<PromptFileAttachment>
@@ -1841,20 +1744,6 @@ export type SessionMessageCompactionCompleted = {
providerContext?: SessionProviderContext
}
export type SessionMessageCompactionCompleted1 = {
type: "compaction"
id: string
metadata?: { [x: string]: any }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState1
summary: string
recent: string
providerContext?: SessionProviderContext
}
export type SessionCompactionEnded = {
id: string
created: number
@@ -1873,19 +1762,20 @@ export type SessionCompactionEnded = {
}
}
export type SessionMessageToolStateCompleted1 = {
status: "completed"
input: { [x: string]: any }
content: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageToolStateError1 = {
status: "error"
input: { [x: string]: any }
error: SessionStructuredError
content?: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
export type SessionForked = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.forked"
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
data: {
sessionID: string
parentID: string
boundary: SessionForkBoundary
instructions?: { [x: string]: string }
instructionEntries?: InstructionEntrySnapshot
}
}
export type SessionToolSuccess = {
@@ -1925,6 +1815,21 @@ export type SessionToolFailed = {
}
}
export type SessionMessageToolStateCompleted1 = {
status: "completed"
input: { [x: string]: any }
content: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageToolStateError1 = {
status: "error"
input: { [x: string]: any }
error: SessionStructuredError
content?: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
}
export type ModelInfo = {
id: string
modelID: string
@@ -2203,11 +2108,6 @@ export type SessionMessageCompaction =
| SessionMessageCompactionCompleted
| SessionMessageCompactionFailed
export type SessionMessageCompaction1 =
| SessionMessageCompactionRunning1
| SessionMessageCompactionCompleted1
| SessionMessageCompactionFailed1
export type SessionMessageAssistantTool1 = {
type: "tool"
id: string
@@ -2257,24 +2157,6 @@ export type SessionMessageAssistant = {
retry?: SessionMessageAssistantRetry
}
export type SessionMessageAssistant1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number; streamed?: number; completed?: number }
type: "assistant"
agent: string
model: ModelRef
content: Array<SessionMessageAssistantText1 | SessionMessageAssistantReasoning1 | SessionMessageAssistantTool1>
snapshot?: { start?: string; end?: string; files?: Array<string> }
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
rawFinish?: string
providerState?: SessionMessageProviderState1
cost?: MoneyUSD
tokens?: TokenUsageInfo
error?: SessionStructuredError
retry?: SessionMessageAssistantRetry
}
export type SessionMessageAssistantContentEncoded =
| SessionMessageAssistantText1
| SessionMessageAssistantReasoning1
@@ -2300,18 +2182,6 @@ export type SessionMessageInfo =
| SessionMessageAssistant
| SessionMessageCompaction
export type SessionMessageInfoEncoded =
| SessionMessageAgentSelected1
| SessionMessageModelSelected1
| SessionMessageLocationSwitched1
| SessionMessageUser1
| SessionMessageSynthetic1
| SessionMessageSystem1
| SessionMessageSkill1
| SessionMessageShell1
| SessionMessageAssistant1
| SessionMessageCompaction1
export type SessionMessageContentUpdated = {
id: string
created: number
@@ -2344,31 +2214,6 @@ export type SessionMessagesResponse = {
cursor: { previous?: string | null; next?: string | null }
}
export type SessionForked = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.forked"
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
data: {
sessionID: string
parentID: string
boundary: SessionForkBoundary
messages?: Array<SessionMessageInfoEncoded>
instructions?: { [x: string]: string }
instructionEntries?: InstructionEntrySnapshot
}
}
export type IntegrationInfo = {
id: string
name: string
metadata?: { [x: string]: any }
methods: Array<IntegrationMethod>
connections: Array<ConnectionInfo>
}
export type SessionEventDurable =
| SessionCreated
| SessionAgentSelected
@@ -2414,6 +2259,14 @@ export type SessionEventDurable =
| SessionMessageContentUpdated
| SessionUsageRecorded
export type IntegrationInfo = {
id: string
name: string
metadata?: { [x: string]: any }
methods: Array<IntegrationMethod>
connections: Array<ConnectionInfo>
}
export type V2Event =
| ModelsDevRefreshed
| CredentialUpdated
@@ -6509,3 +6362,20 @@ export type ConfigGetInput = {
}
export type ConfigGetOutput = Array<ConfigEntry>
export type ConfigPreferencesOutput = ConfigPreferences
export type ConfigUpdatePreferencesInput = {
readonly shell?: {
readonly shell?: string | null
readonly websearch?: false | { readonly provider: "random" | (string & {}) } | null
}["shell"]
readonly websearch?: {
readonly shell?: string | null
readonly websearch?: false | { readonly provider: "random" | (string & {}) } | null
}["websearch"]
}
export type ConfigUpdatePreferencesOutput = ConfigPreferences
export type ConfigShellsOutput = Array<ConfigShellOption>
-6
View File
@@ -91,12 +91,6 @@ runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
The Effect environment is inferred from the supplied tools. `onToolCallStart` observes admitted calls with decoded
input; `onToolCallEnd` observes settled outcomes and duration. Both hooks return Effects and must not fail.
### `Values`
`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
`OpenAPI.fromSpec` converts an OpenAPI 3.x document into one tool per supported operation. Dotted `operationId` values
-1
View File
@@ -2,6 +2,5 @@ export * as CodeMode from "./codemode.js"
export * as Namespace from "./namespace.js"
export * as Tool from "./tool.js"
export * as OpenAPI from "./openapi/index.js"
export { Values } from "./values.js"
export { searchSignature, toolExpression } from "./codemode.js"
export { ToolError, toolError } from "./tool-error.js"
+88 -61
View File
@@ -15,8 +15,17 @@ import {
UriFunction,
} from "./model.js"
import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, typeofValue } from "./references.js"
import { compareText, isBlockedMember, type SafeObject } from "../tool-runtime.js"
import { Values } from "../values.js"
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
import {
CodeModeDate,
CodeModeMap,
CodeModePromise,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
isCodeModeValue,
} from "../values.js"
import { dateSetterArgumentCount, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
import { invokeMathMethod } from "../stdlib/math.js"
import { invokeNumberMethod, invokeNumberStatic } from "../stdlib/number.js"
@@ -34,7 +43,7 @@ export type CallbackRunner<R> = {
args: Array<unknown>,
node: AstNode,
) => Effect.Effect<unknown, unknown, R>
readonly settlePromise: (promise: Values.Promise) => Effect.Effect<unknown, unknown, never>
readonly settlePromise: (promise: CodeModePromise) => Effect.Effect<unknown, unknown, never>
}
// The single acceptance list for callbacks: collections, sort, string replacers,
@@ -91,7 +100,7 @@ export const invokeIntrinsic = <R>(
if (Array.isArray(ref.receiver)) {
return invokeArrayMethod(runner, ref.receiver, ref.name, args, node)
}
if (ref.receiver instanceof Values.Date) {
if (ref.receiver instanceof CodeModeDate) {
const target = ref.receiver
const argumentCount = dateSetterArgumentCount(ref.name)
if (argumentCount === undefined) return Effect.succeed(invokeDateMethod(target, ref.name, [], node))
@@ -104,61 +113,53 @@ export const invokeIntrinsic = <R>(
(values) => invokeDateMethod(target, ref.name, values, node, initialTime),
)
}
if (ref.receiver instanceof Values.RegExp) {
if (ref.receiver instanceof CodeModeRegExp) {
return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node))
}
if (ref.receiver instanceof Values.Map) {
if (ref.receiver instanceof CodeModeMap) {
return invokeMapMethod(runner, ref.receiver, ref.name, args, node)
}
if (ref.receiver instanceof Values.Set) {
if (ref.receiver instanceof CodeModeSet) {
return invokeSetMethod(runner, ref.receiver, ref.name, args, node)
}
if (ref.receiver instanceof Values.URL) {
if (ref.receiver instanceof CodeModeURL) {
return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node))
}
if (ref.receiver instanceof Values.URLSearchParams) {
if (ref.receiver instanceof CodeModeURLSearchParams) {
return invokeURLSearchParamsMethod(runner, ref.receiver, ref.name, args, node)
}
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available.`, node)
}
/**
* ToPrimitive: tries an object's own `valueOf`/`toString` in hint order and returns the first
* primitive result. Runtime values behave like their JS counterparts (Date yields its time under a
* number hint; the rest yield their string form). An inherited `toString` yields the default
* string form, so plain objects become "[object Object]" and arrays join.
*/
export const toPrimitive = <R>(
runner: CallbackRunner<R>,
value: unknown,
hint: "number" | "string",
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
if (value === null || typeof value !== "object") return Effect.succeed(value)
if (Values.isValue(value)) {
return Effect.succeed(value instanceof Values.Date && hint === "number" ? value.time : coerceToString(value))
}
const object = value as Record<string, unknown>
const order = hint === "number" ? ["valueOf", "toString"] : ["toString", "valueOf"]
return Effect.gen(function* () {
for (const method of order) {
if (method === "toString" && !Object.hasOwn(object, "toString")) return coerceToString(value)
if (!Object.hasOwn(object, method) || typeofValue(object[method]) !== "function") continue
const result = yield* runner.invokeCallable(object[method], [], node)
if (result === null || (typeof result !== "object" && typeof result !== "function")) return result
}
throw new InterpreterRuntimeError("Cannot convert object to primitive value.", node).as("TypeError")
})
}
const coerceNumericArgument = <R>(
runner: CallbackRunner<R>,
value: unknown,
node: AstNode,
): Effect.Effect<number, unknown, R> => Effect.map(toPrimitive(runner, value, "number", node), coerceToNumber)
): Effect.Effect<number, unknown, R> => {
if (value === null || typeof value !== "object" || Array.isArray(value) || isCodeModeValue(value)) {
return Effect.succeed(coerceToNumber(value))
}
const object = value as Record<string, unknown>
return Effect.gen(function* () {
if (Object.hasOwn(object, "valueOf") && typeofValue(object.valueOf) === "function") {
const result = yield* runner.invokeCallable(object.valueOf, [], node)
if (result === null || (typeof result !== "object" && typeof result !== "function")) {
return coerceToNumber(result)
}
}
if (!Object.hasOwn(object, "toString")) return coerceToNumber(value)
if (typeofValue(object.toString) === "function") {
const result = yield* runner.invokeCallable(object.toString, [], node)
if (result === null || (typeof result !== "object" && typeof result !== "function")) {
return coerceToNumber(result)
}
}
throw new InterpreterRuntimeError("Cannot convert object to primitive value.", node).as("TypeError")
})
}
// console is intercepted by the interpreter before reaching here.
export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, node: AstNode): unknown => {
if (ref.namespace === "console") throw new InterpreterRuntimeError(`console.${ref.name} is not available.`, node)
if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node)
if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node)
if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node)
@@ -167,6 +168,9 @@ export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unkno
if (ref.namespace === "URL") return invokeURLStatic(ref.name, args, node)
if (ref.namespace === "Date") return invokeDateStatic(ref.name, args, node)
if (ref.namespace === "RegExp") return invokeRegExpStatic(ref.name, args, node)
if (ref.namespace === "Map" || ref.namespace === "Set" || ref.namespace === "URLSearchParams") {
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available.`, node)
}
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available.`, node)
}
@@ -188,7 +192,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index))
const rejectRegex = (): void => {
if (args[0] instanceof Values.RegExp) {
if (args[0] instanceof CodeModeRegExp) {
throw new InterpreterRuntimeError(
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
node,
@@ -237,7 +241,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
result = requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value]
break
}
if (args[0] instanceof Values.RegExp) {
if (args[0] instanceof CodeModeRegExp) {
result = value.split(args[0].regex, optNum(1))
break
}
@@ -268,7 +272,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
break
case "replace":
case "replaceAll": {
if (args[0] instanceof Values.RegExp) {
if (args[0] instanceof CodeModeRegExp) {
const pattern = args[0].regex
const replacement = str(1)
if (name === "replaceAll" && !pattern.global) {
@@ -364,7 +368,7 @@ const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): u
}
const arrayLikeSource = (source: unknown, node: AstNode): { readonly length: number; readonly source: object } => {
if (source instanceof Values.Promise) {
if (source instanceof CodeModePromise) {
throw new InterpreterRuntimeError(
"Array.from received an un-awaited Promise; await it before creating the array.",
node,
@@ -441,7 +445,7 @@ export const invokeGroupBy = <R>(
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError")
}
if (namespace === "Map") {
const result = new Values.Map()
const result = new CodeModeMap()
let index = 0
while (true) {
const step = yield* cursor.next
@@ -484,11 +488,30 @@ const coerceGroupByPropertyKey = <R>(
value: unknown,
node: AstNode,
): Effect.Effect<string, unknown, R> => {
if (value instanceof Values.Promise) return Effect.succeed("[object Promise]")
if (!Values.isValue(value) && isRuntimeReference(value)) {
if (value === null || typeof value !== "object" || Array.isArray(value) || isCodeModeValue(value)) {
return Effect.succeed(coerceToString(value))
}
if (value instanceof CodeModePromise) return Effect.succeed("[object Promise]")
if (isRuntimeReference(value)) {
throw new InterpreterRuntimeError("Object.groupBy callback must return a data value.", node, "InvalidDataValue")
}
return Effect.map(toPrimitive(runner, value, "string", node), coerceToString)
const object = value as Record<string, unknown>
if (!Object.hasOwn(object, "toString")) return Effect.succeed(coerceToString(value))
return Effect.gen(function* () {
if (typeofValue(object.toString) === "function") {
const result = yield* runner.invokeCallable(object.toString, [], node)
if (result === null || (typeof result !== "object" && typeof result !== "function")) {
return coerceToString(result)
}
}
if (Object.hasOwn(object, "valueOf") && typeofValue(object.valueOf) === "function") {
const result = yield* runner.invokeCallable(object.valueOf, [], node)
if (result === null || (typeof result !== "object" && typeof result !== "function")) {
return coerceToString(result)
}
}
throw new InterpreterRuntimeError("Cannot convert object to primitive value.", node).as("TypeError")
})
}
const invokeStringReplacer = <R>(
@@ -520,7 +543,7 @@ const invokeStringReplacer = <R>(
}
const pattern = args[0]
if (pattern instanceof Values.RegExp) {
if (pattern instanceof CodeModeRegExp) {
if (name === "replaceAll" && !pattern.regex.global) {
throw new InterpreterRuntimeError(
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`,
@@ -543,7 +566,7 @@ const invokeStringReplacer = <R>(
// Error values are branded plain objects; boundedData would strip the brand before coercion.
output.push(
value.slice(end, match.offset),
replacement instanceof Values.Promise
replacement instanceof CodeModePromise
? "[object Promise]"
: errorBrandName(replacement)
? coerceToString(replacement)
@@ -576,7 +599,7 @@ export const applyCollectionCallback = <R>(
const invokeMapMethod = <R>(
runner: CallbackRunner<R>,
target: Values.Map,
target: CodeModeMap,
name: string,
args: Array<unknown>,
node: AstNode,
@@ -618,7 +641,7 @@ const invokeMapMethod = <R>(
const invokeSetMethod = <R>(
runner: CallbackRunner<R>,
target: Values.Set,
target: CodeModeSet,
name: string,
args: Array<unknown>,
node: AstNode,
@@ -665,7 +688,7 @@ const invokeSetMethod = <R>(
const invokeSetOperation = <R>(
runner: CallbackRunner<R>,
target: Values.Set,
target: CodeModeSet,
name: string,
source: unknown,
node: AstNode,
@@ -678,7 +701,7 @@ const invokeSetOperation = <R>(
return result
}
if (name === "intersection") {
const result = new Values.Set()
const result = new CodeModeSet()
if (target.set.size <= other.size) {
for (const item of target.set.values()) {
if (yield* other.has(item)) result.set.add(item)
@@ -735,28 +758,28 @@ const invokeSetOperation = <R>(
return true
})
const copySet = (source: Values.Set): Values.Set => {
const result = new Values.Set()
const copySet = (source: CodeModeSet): CodeModeSet => {
const result = new CodeModeSet()
for (const item of source.set.values()) result.set.add(item)
return result
}
const loadSetRecord = <R>(runner: CallbackRunner<R>, source: unknown, name: string, node: AstNode) => {
if (source instanceof Values.Set) {
if (source instanceof CodeModeSet) {
return Effect.succeed({
size: source.set.size,
has: (item: unknown) => Effect.succeed(source.set.has(item)),
keys: () => Effect.succeed(source.set.values()),
})
}
if (source instanceof Values.Map) {
if (source instanceof CodeModeMap) {
return Effect.succeed({
size: source.map.size,
has: (item: unknown) => Effect.succeed(source.map.has(item)),
keys: () => Effect.succeed(source.map.keys()),
})
}
if (source === null || typeof source !== "object" || Values.isValue(source)) {
if (source === null || typeof source !== "object" || isCodeModeValue(source)) {
throw new InterpreterRuntimeError(`Set.${name} expects a Set-like object.`, node).as("TypeError")
}
const object = source as Record<string, unknown>
@@ -786,7 +809,7 @@ const loadSetRecord = <R>(runner: CallbackRunner<R>, source: unknown, name: stri
const invokeURLSearchParamsMethod = <R>(
runner: CallbackRunner<R>,
target: Values.URLSearchParams,
target: CodeModeURLSearchParams,
name: string,
args: Array<unknown>,
node: AstNode,
@@ -1110,7 +1133,11 @@ const sortArray = <R>(
): Effect.Effect<Array<unknown>, unknown, R> => {
if (comparator === undefined) {
return Effect.sync(() =>
[...target].sort((a, b) => compareText(coerceToString(a), coerceToString(b))),
[...target].sort((a, b) => {
const left = coerceToString(a)
const right = coerceToString(b)
return left < right ? -1 : left > right ? 1 : 0
}),
)
}
const apply = applyCollectionCallback(runner, comparator, name, node)
+15 -4
View File
@@ -1,7 +1,6 @@
import type { Effect } from "effect"
import type { DiagnosticKind } from "../codemode.js"
import type { SafeObject } from "../tool-runtime.js"
import type { Values } from "../values.js"
import type { CodeModePromise, CodeModeRegExp, CodeModeURL } from "../values.js"
export type SourcePosition = {
line: number
@@ -37,7 +36,7 @@ export type StatementResult =
| { kind: "continue"; label?: string }
export type MemberReference = {
target: SafeObject | Array<unknown> | Values.RegExp | Values.URL
target: SafeObject | Array<unknown> | CodeModeRegExp | CodeModeURL
key: PropertyKey
}
@@ -100,7 +99,7 @@ export type PromiseInstanceMethodName = "then" | "catch" | "finally"
export class PromiseInstanceMethodReference {
constructor(
readonly promise: Values.Promise,
readonly promise: CodeModePromise,
readonly name: PromiseInstanceMethodName,
) {}
}
@@ -159,6 +158,18 @@ export class ErrorConstructorReference {
constructor(readonly name: string) {}
}
export type DiagnosticKind =
| "ParseError"
| "UnsupportedSyntax"
| "UnknownTool"
| "InvalidToolInput"
| "InvalidToolOutput"
| "InvalidDataValue"
| "ToolCallLimitExceeded"
| "TimeoutExceeded"
| "ToolFailure"
| "ExecutionFailure"
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
export const supportedSyntaxMessage =
+23 -23
View File
@@ -14,25 +14,25 @@ import { caughtErrorValue, normalizeError } from "./errors.js"
import { applyCollectionCallback, isSupportedCallback, type CallbackRunner, type SupportedCallback } from "./methods.js"
import { typeofValue } from "./references.js"
import { createAggregateErrorValue } from "../stdlib/value.js"
import { Values } from "../values.js"
import { CodeModePromise } from "../values.js"
import type { SyncIteratorRunner } from "./iterator.js"
// Observation only controls rejection reporting; program completion interrupts all promise work.
export class PromiseRuntime<R> {
private readonly active = new Set<Values.Promise>()
private readonly ids = new WeakMap<Values.Promise, number>()
private readonly observed = new WeakSet<Values.Promise>()
private readonly active = new Set<CodeModePromise>()
private readonly ids = new WeakMap<CodeModePromise, number>()
private readonly observed = new WeakSet<CodeModePromise>()
private readonly failures = new Map<number, Diagnostic>()
private nextID = 0
constructor(private readonly scope: Scope.Scope) {}
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<Values.Promise, never, R> {
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
return Effect.suspend(() => {
// Allocate before forking so reruns get distinct IDs and diagnostics retain creation order.
const id = this.nextID++
return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => {
const promise = new Values.Promise(fiber)
const promise = new CodeModePromise(fiber)
this.active.add(promise)
this.ids.set(promise, id)
fiber.addObserver((exit) => {
@@ -53,14 +53,14 @@ export class PromiseRuntime<R> {
}
// Observation must be recorded when responsibility transfers, before the consumer fiber runs.
markObserved(promise: Values.Promise): void {
markObserved(promise: CodeModePromise): void {
this.observed.add(promise)
const id = this.ids.get(promise)
this.ids.delete(promise)
if (id !== undefined) this.failures.delete(id)
}
await(promise: Values.Promise): Effect.Effect<Exit.Exit<unknown, unknown>> {
await(promise: CodeModePromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
return Fiber.await(promise.fiber)
}
@@ -91,10 +91,10 @@ export const resolvePromiseValue = <R>(
runner: CallbackRunner<R>,
value: unknown,
node: AstNode,
own?: { promise?: Values.Promise },
own?: { promise?: CodeModePromise },
): Effect.Effect<unknown, unknown, R> => {
if (own?.promise !== undefined && value === own.promise) return Effect.fail(selfResolutionError(node))
if (value instanceof Values.Promise) return runner.settlePromise(value)
if (value instanceof CodeModePromise) return runner.settlePromise(value)
if (value === null || typeof value !== "object" || !Object.hasOwn(value, "then")) return Effect.succeed(value)
const then = (value as SafeObject).then
if (typeofValue(then) !== "function") return Effect.succeed(value)
@@ -123,9 +123,9 @@ export const resolvePromise = <R>(
promises: PromiseRuntime<R>,
value: unknown,
node: AstNode,
): Effect.Effect<Values.Promise, never, R> => {
if (value instanceof Values.Promise) return Effect.succeed(value)
const box: { promise?: Values.Promise } = {}
): Effect.Effect<CodeModePromise, never, R> => {
if (value instanceof CodeModePromise) return Effect.succeed(value)
const box: { promise?: CodeModePromise } = {}
return Effect.map(promises.create(resolvePromiseValue(runner, value, node, box)), (promise) => {
box.promise = promise
return promise
@@ -155,7 +155,7 @@ export const invokePromiseMethod = <R>(
node,
).as("TypeError")
}
const items: Array<Values.Promise> = []
const items: Array<CodeModePromise> = []
while (true) {
const step = yield* cursor.next
if (step.done) break
@@ -227,7 +227,7 @@ export const invokePromiseInstanceMethod = <R>(
ref: PromiseInstanceMethodReference,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<Values.Promise, never, R> => {
): Effect.Effect<CodeModePromise, never, R> => {
const method = `Promise.prototype.${ref.name}`
promises.markObserved(ref.promise)
if (ref.name === "finally") {
@@ -243,7 +243,7 @@ export const constructPromise = <R>(
promises: PromiseRuntime<R>,
executor: unknown,
node: AstNode,
): Effect.Effect<Values.Promise, unknown, R> => {
): Effect.Effect<CodeModePromise, unknown, R> => {
if (!(executor instanceof CodeModeFunction)) {
throw new InterpreterRuntimeError(
"new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).",
@@ -252,7 +252,7 @@ export const constructPromise = <R>(
}
return Effect.gen(function* () {
const deferred = Deferred.makeUnsafe<unknown, unknown>()
const box: { promise?: Values.Promise } = {}
const box: { promise?: CodeModePromise } = {}
const promise = yield* promises.create(
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, box)),
)
@@ -294,7 +294,7 @@ const reactionHandler = (value: unknown, method: string, node: AstNode): Support
// Teardown bypasses handlers; settled reactions yield once so handlers never run inline.
const reactionExit = <R>(
promises: PromiseRuntime<R>,
source: Values.Promise,
source: CodeModePromise,
): Effect.Effect<Exit.Exit<unknown, unknown>, unknown, R> =>
Effect.gen(function* () {
const exit = yield* promises.await(source)
@@ -306,13 +306,13 @@ const reactionExit = <R>(
const chainReaction = <R>(
runner: CallbackRunner<R>,
promises: PromiseRuntime<R>,
source: Values.Promise,
source: CodeModePromise,
onFulfilled: SupportedCallback | undefined,
onRejected: SupportedCallback | undefined,
method: string,
node: AstNode,
): Effect.Effect<Values.Promise, never, R> => {
const box: { promise?: Values.Promise } = {}
): Effect.Effect<CodeModePromise, never, R> => {
const box: { promise?: CodeModePromise } = {}
const body = Effect.gen(function* () {
const exit = yield* reactionExit(promises, source)
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
@@ -330,11 +330,11 @@ const chainReaction = <R>(
const chainFinally = <R>(
runner: CallbackRunner<R>,
promises: PromiseRuntime<R>,
source: Values.Promise,
source: CodeModePromise,
cleanup: SupportedCallback | undefined,
method: string,
node: AstNode,
): Effect.Effect<Values.Promise, never, R> =>
): Effect.Effect<CodeModePromise, never, R> =>
promises.create(
Effect.gen(function* () {
const exit = yield* reactionExit(promises, source)
+38 -21
View File
@@ -21,7 +21,7 @@ import {
UriFunction,
} from "./model.js"
import { ToolReference } from "../tool-runtime.js"
import { Values } from "../values.js"
import { isCodeModeValue, CodeModePromise } from "../values.js"
export const isRuntimeReference = (value: unknown): boolean =>
value instanceof CodeModeFunction ||
@@ -35,14 +35,14 @@ export const isRuntimeReference = (value: unknown): boolean =>
value instanceof PromiseNamespace ||
value instanceof PromiseMethodReference ||
value instanceof PromiseInstanceMethodReference ||
value instanceof Values.Promise ||
value instanceof CodeModePromise ||
value instanceof CoercionFunction ||
value instanceof UriFunction ||
value instanceof SearchFunction ||
value instanceof PromiseCapabilityFunction ||
value instanceof ErrorConstructorReference ||
value instanceof SymbolNamespace ||
Values.isValue(value)
isCodeModeValue(value)
function* childValues(value: object): Generator {
for (const key of Reflect.ownKeys(value)) {
@@ -52,14 +52,9 @@ function* childValues(value: object): Generator {
}
}
// Depth-first search over a value tree. `match` stops the walk; `skip` prunes a subtree without matching it.
const find = (
value: unknown,
match: (current: unknown) => boolean,
skip: (current: unknown) => boolean,
seen: Set<object>,
): boolean => {
export const containsRuntimeReference = (value: unknown): boolean => {
const pending: Array<Iterator<unknown>> = [[value].values()]
const seen = new Set<object>()
while (pending.length > 0) {
const next = pending.at(-1)!.next()
if (next.done) {
@@ -67,22 +62,33 @@ const find = (
continue
}
const current = next.value
if (match(current)) return true
if (current === null || typeof current !== "object" || skip(current) || seen.has(current)) continue
if (isRuntimeReference(current)) return true
if (current === null || typeof current !== "object" || seen.has(current)) continue
seen.add(current)
pending.push(childValues(current))
}
return false
}
const never = () => false
export const containsRuntimeReference = (value: unknown): boolean =>
find(value, isRuntimeReference, never, new Set())
// CodeMode values are data here, not opaque interpreter references.
export const containsOpaqueReference = (value: unknown): boolean =>
find(value, (current) => !Values.isValue(current) && isRuntimeReference(current), Values.isValue, new Set())
export const containsOpaqueReference = (value: unknown): boolean => {
const pending: Array<Iterator<unknown>> = [[value].values()]
const seen = new Set<object>()
while (pending.length > 0) {
const next = pending.at(-1)!.next()
if (next.done) {
pending.pop()
continue
}
const current = next.value
if (isCodeModeValue(current)) continue
if (isRuntimeReference(current)) return true
if (current === null || typeof current !== "object" || seen.has(current)) continue
seen.add(current)
pending.push(childValues(current))
}
return false
}
// Reject cycles before mutation so later boundary walks remain safe.
export const rejectCircularInsertion = (
@@ -92,8 +98,19 @@ export const rejectCircularInsertion = (
node: AstNode,
seen = new Set<object>(),
): void => {
if (find(value, (current) => current === container, isRuntimeReference, seen)) {
throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
const pending: Array<Iterator<unknown>> = [[value].values()]
while (pending.length > 0) {
const next = pending.at(-1)!.next()
if (next.done) {
pending.pop()
continue
}
const current = next.value
if (current === container)
throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
if (current === null || typeof current !== "object" || isRuntimeReference(current) || seen.has(current)) continue
seen.add(current)
pending.push(childValues(current))
}
}
+89 -63
View File
@@ -50,7 +50,6 @@ import {
invokeGlobalMethod,
invokeGroupBy,
invokeIntrinsic,
toPrimitive,
} from "./methods.js"
import { preserveConsumerError, type SyncIteratorRunner } from "./iterator.js"
import {
@@ -99,7 +98,16 @@ import {
invokeCoercion,
valueConstructors,
} from "../stdlib/value.js"
import { Values } from "../values.js"
import {
isCodeModeValue,
CodeModeDate,
CodeModeMap,
CodeModePromise,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
} from "../values.js"
const globalStaticMembers: Partial<Record<GlobalNamespaceName, Set<string>>> = {
Object: objectStatics,
@@ -145,24 +153,24 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean =>
if (rhs instanceof GlobalNamespace) {
switch (rhs.name) {
case "Date":
return lhs instanceof Values.Date
return lhs instanceof CodeModeDate
case "RegExp":
return lhs instanceof Values.RegExp
return lhs instanceof CodeModeRegExp
case "Map":
return lhs instanceof Values.Map
return lhs instanceof CodeModeMap
case "Set":
return lhs instanceof Values.Set
return lhs instanceof CodeModeSet
case "URL":
return lhs instanceof Values.URL
return lhs instanceof CodeModeURL
case "URLSearchParams":
return lhs instanceof Values.URLSearchParams
return lhs instanceof CodeModeURLSearchParams
case "Array":
return Array.isArray(lhs)
case "Object":
return lhs !== null && (typeof lhs === "object" || typeofValue(lhs) === "function")
}
}
if (rhs instanceof PromiseNamespace) return lhs instanceof Values.Promise
if (rhs instanceof PromiseNamespace) return lhs instanceof CodeModePromise
if (rhs instanceof CoercionFunction && (rhs.name === "Number" || rhs.name === "String" || rhs.name === "Boolean")) {
return false
}
@@ -363,16 +371,16 @@ export class Interpreter<R> {
private createToolCallPromise(
path: ReadonlyArray<string>,
args: Array<unknown>,
): Effect.Effect<Values.Promise, never, R> {
): Effect.Effect<CodeModePromise, never, R> {
return this.createPromise(Effect.suspend(() => this.executeTool(path, args)))
}
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<Values.Promise, never, R> {
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
return this.promises.create(effect)
}
// Fiber exits make settlement idempotent; yielding prevents inline continuation.
private settlePromise(promise: Values.Promise): Effect.Effect<unknown, unknown, never> {
private settlePromise(promise: CodeModePromise): Effect.Effect<unknown, unknown, never> {
const promises = this.promises
return Effect.suspend(() => {
promises.markObserved(promise)
@@ -804,11 +812,11 @@ export class Interpreter<R> {
? value[Symbol.iterator]()
: typeof value === "string"
? value[Symbol.iterator]()
: value instanceof Values.Map
: value instanceof CodeModeMap
? value.map.entries()
: value instanceof Values.Set
: value instanceof CodeModeSet
? value.set.values()
: value instanceof Values.URLSearchParams
: value instanceof CodeModeURLSearchParams
? value.params.entries()
: undefined
if (iterator !== undefined) {
@@ -1470,25 +1478,43 @@ export class Interpreter<R> {
)
}
private constructDate(args: Array<unknown>, node: AstNode): Effect.Effect<Values.Date, unknown, R> {
if (args.length === 0) return Effect.succeed(new Values.Date(Date.now()))
private constructDate(args: Array<unknown>, node: AstNode): Effect.Effect<CodeModeDate, unknown, R> {
if (args.length === 0) return Effect.succeed(new CodeModeDate(Date.now()))
if (args.length === 1) {
const arg = args[0]
if (arg instanceof Values.Date) return Effect.succeed(new Values.Date(arg.time))
return Effect.map(toPrimitive(this.runner, arg, "number", node), (value) =>
if (arg instanceof CodeModeDate) return Effect.succeed(new CodeModeDate(arg.time))
return Effect.map(this.toDatePrimitive(arg, node), (value) =>
typeof value === "string"
? new Values.Date(Date.parse(value))
: new Values.Date(new Date(coerceToNumber(value)).getTime()),
? new CodeModeDate(Date.parse(value))
: new CodeModeDate(new Date(coerceToNumber(value)).getTime()),
)
}
const parts = args.map((arg) => coerceToNumber(arg))
return Effect.succeed(new Values.Date(new Date(...(parts as [number, number])).getTime()))
return Effect.succeed(new CodeModeDate(new Date(...(parts as [number, number])).getTime()))
}
private constructRegExp(args: Array<unknown>, node: AstNode): Values.RegExp {
private toDatePrimitive(value: unknown, node: AstNode): Effect.Effect<unknown, unknown, R> {
if (value === null || (typeof value !== "object" && typeof value !== "function")) return Effect.succeed(value)
const object = value as Record<string, unknown>
const self = this
return Effect.gen(function* () {
if (Object.hasOwn(object, "valueOf") && typeofValue(object.valueOf) === "function") {
const result = yield* self.runner.invokeCallable(object.valueOf, [], node)
if (result === null || (typeof result !== "object" && typeof result !== "function")) return result
}
if (!Object.hasOwn(object, "toString")) return coerceToString(value)
if (typeofValue(object.toString) === "function") {
const result = yield* self.runner.invokeCallable(object.toString, [], node)
if (result === null || (typeof result !== "object" && typeof result !== "function")) return result
}
throw new InterpreterRuntimeError("Cannot convert object to primitive value.", node).as("TypeError")
})
}
private constructRegExp(args: Array<unknown>, node: AstNode): CodeModeRegExp {
const first = args[0]
const pattern =
first instanceof Values.RegExp ? first.regex.source : first === undefined ? "" : coerceToString(first)
first instanceof CodeModeRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first)
const flagsArg = args[1]
if (flagsArg !== undefined && typeof flagsArg !== "string") {
throw new InterpreterRuntimeError(
@@ -1496,9 +1522,9 @@ export class Interpreter<R> {
node,
).as("SyntaxError")
}
const flags = flagsArg ?? (first instanceof Values.RegExp ? first.regex.flags : "")
const flags = flagsArg ?? (first instanceof CodeModeRegExp ? first.regex.flags : "")
try {
return new Values.RegExp(pattern, flags)
return new CodeModeRegExp(pattern, flags)
} catch (error) {
const reason = regexFailureReason(error)
throw new InterpreterRuntimeError(
@@ -1510,8 +1536,8 @@ export class Interpreter<R> {
}
}
private constructMap(init: unknown, node: AstNode): Effect.Effect<Values.Map, unknown, R> {
const target = new Values.Map()
private constructMap(init: unknown, node: AstNode): Effect.Effect<CodeModeMap, unknown, R> {
const target = new CodeModeMap()
if (init === undefined || init === null) return Effect.succeed(target)
const self = this
return Effect.gen(function* () {
@@ -1540,8 +1566,8 @@ export class Interpreter<R> {
})
}
private constructSet(init: unknown, node: AstNode): Effect.Effect<Values.Set, unknown, R> {
const target = new Values.Set()
private constructSet(init: unknown, node: AstNode): Effect.Effect<CodeModeSet, unknown, R> {
const target = new CodeModeSet()
if (init === undefined || init === null) return Effect.succeed(target)
const self = this
return Effect.gen(function* () {
@@ -1559,7 +1585,7 @@ export class Interpreter<R> {
})
}
private constructURL(args: Array<unknown>, node: AstNode): Values.URL {
private constructURL(args: Array<unknown>, node: AstNode): CodeModeURL {
if (args.length === 0) {
throw new InterpreterRuntimeError("new URL(...) requires a URL string and an optional base URL.", node).as(
"TypeError",
@@ -1568,7 +1594,7 @@ export class Interpreter<R> {
const input = urlArgument(args[0], "new URL input")
const base = args[1] === undefined ? undefined : urlArgument(args[1], "new URL base")
try {
return new Values.URL(new URL(input, base))
return new CodeModeURL(new URL(input, base))
} catch {
throw new InterpreterRuntimeError(
`new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`,
@@ -1577,14 +1603,14 @@ export class Interpreter<R> {
}
}
private constructURLSearchParams(init: unknown, node: AstNode): Effect.Effect<Values.URLSearchParams, unknown, R> {
if (init === undefined) return Effect.succeed(new Values.URLSearchParams(new URLSearchParams()))
if (init instanceof Values.URLSearchParams) {
return Effect.succeed(new Values.URLSearchParams(new URLSearchParams(init.params)))
private constructURLSearchParams(init: unknown, node: AstNode): Effect.Effect<CodeModeURLSearchParams, unknown, R> {
if (init === undefined) return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams()))
if (init instanceof CodeModeURLSearchParams) {
return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(init.params)))
}
if (typeof init === "string") return Effect.succeed(new Values.URLSearchParams(new URLSearchParams(init)))
if (typeof init === "string") return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(init)))
if (init === null || typeof init === "number" || typeof init === "boolean") {
return Effect.succeed(new Values.URLSearchParams(new URLSearchParams(coerceToString(init))))
return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(coerceToString(init))))
}
const self = this
return Effect.gen(function* () {
@@ -1600,7 +1626,7 @@ export class Interpreter<R> {
node,
).as("TypeError")
}
return new Values.URLSearchParams(
return new CodeModeURLSearchParams(
new URLSearchParams(entries.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])),
)
}
@@ -1613,7 +1639,7 @@ export class Interpreter<R> {
node,
).as("TypeError")
}
if (Values.isValue(init)) return new Values.URLSearchParams(new URLSearchParams())
if (isCodeModeValue(init)) return new CodeModeURLSearchParams(new URLSearchParams())
const data = boundedData(init, "new URLSearchParams input")
if (data === null || typeof data !== "object") {
throw new InterpreterRuntimeError(
@@ -1621,7 +1647,7 @@ export class Interpreter<R> {
node,
).as("TypeError")
}
return new Values.URLSearchParams(
return new CodeModeURLSearchParams(
new URLSearchParams(
Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)])),
),
@@ -1672,7 +1698,7 @@ export class Interpreter<R> {
// Null-prototype data needs explicit primitive coercion; identity and `in` retain raw objects.
// Dates use their default string hint for addition and loose equality, and epoch time elsewhere.
const coerceOperand = (operand: unknown): unknown => {
if (operand instanceof Values.Date) {
if (operand instanceof CodeModeDate) {
return operator === "+" || operator === "==" || operator === "!=" ? coerceToString(operand) : operand.time
}
return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand
@@ -1757,7 +1783,7 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError("Unary operators require data values.", node, "InvalidDataValue")
}
const operand =
value instanceof Values.Date
value instanceof CodeModeDate
? value.time
: value !== null && typeof value === "object"
? coerceToString(value)
@@ -2084,7 +2110,7 @@ export class Interpreter<R> {
if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async))
if (!fn.async) return run
// The initial yield assigns the promise before the body can self-resolve.
const box: { promise?: Values.Promise } = {}
const box: { promise?: CodeModePromise } = {}
return Effect.map(
this.createPromise(Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runner, value, fn.body, box))),
(promise) => {
@@ -2255,9 +2281,9 @@ export class Interpreter<R> {
if (
Array.isArray(value) ||
typeof value === "string" ||
value instanceof Values.Map ||
value instanceof Values.Set ||
value instanceof Values.URLSearchParams
value instanceof CodeModeMap ||
value instanceof CodeModeSet ||
value instanceof CodeModeURLSearchParams
) {
const cursor = yield* self.syncIterator(value, node)
if (!cursor) throw new InterpreterRuntimeError("Built-in iterator is unavailable.", node)
@@ -2348,7 +2374,7 @@ export class Interpreter<R> {
if (property.type === "SpreadElement") {
const spread = yield* self.evaluateExpression(getNode(property, "argument"))
if (spread === null || spread === undefined || Values.isValue(spread)) continue
if (spread === null || spread === undefined || isCodeModeValue(spread)) continue
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
throw new InterpreterRuntimeError("Object spread requires a data object.", property, "InvalidDataValue")
}
@@ -2572,11 +2598,11 @@ export class Interpreter<R> {
return new ComputedValue(undefined)
}
if (objectValue instanceof Values.Date) {
if (objectValue instanceof CodeModeDate) {
if (typeof key === "string" && dateMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
}
if (objectValue instanceof Values.RegExp) {
if (objectValue instanceof CodeModeRegExp) {
if (key === "lastIndex") return { target: objectValue, key }
if (typeof key === "string" && regexpProperties.has(key)) {
return new ComputedValue((objectValue.regex as unknown as Record<string, unknown>)[key])
@@ -2584,17 +2610,17 @@ export class Interpreter<R> {
if (typeof key === "string" && regexpMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
}
if (objectValue instanceof Values.Map) {
if (objectValue instanceof CodeModeMap) {
if (key === "size") return new ComputedValue(objectValue.map.size)
if (typeof key === "string" && mapMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
}
if (objectValue instanceof Values.Set) {
if (objectValue instanceof CodeModeSet) {
if (key === "size") return new ComputedValue(objectValue.set.size)
if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
}
if (objectValue instanceof Values.URL) {
if (objectValue instanceof CodeModeURL) {
if (key === "searchParams") {
return new ComputedValue(objectValue.searchParams)
}
@@ -2602,7 +2628,7 @@ export class Interpreter<R> {
if (typeof key === "string" && urlProperties.has(key)) return { target: objectValue, key }
return new ComputedValue(undefined)
}
if (objectValue instanceof Values.URLSearchParams) {
if (objectValue instanceof CodeModeURLSearchParams) {
if (key === "size") return new ComputedValue(objectValue.params.size)
if (typeof key === "string" && urlSearchParamsMethods.has(key)) {
return new IntrinsicReference(objectValue, key)
@@ -2611,7 +2637,7 @@ export class Interpreter<R> {
}
// Reject unknown promise properties so a missing await cannot hide.
if (objectValue instanceof Values.Promise) {
if (objectValue instanceof CodeModePromise) {
if (key === "then" || key === "catch" || key === "finally") {
return new PromiseInstanceMethodReference(objectValue, key)
}
@@ -2677,8 +2703,8 @@ export class Interpreter<R> {
if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key)
return Reflect.get(reference.target, reference.key)
}
if (reference.target instanceof Values.RegExp) return reference.target.lastIndex
if (reference.target instanceof Values.URL) {
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
if (reference.target instanceof CodeModeURL) {
return Reflect.get(reference.target.url, reference.key)
}
return Reflect.get(reference.target, reference.key)
@@ -2700,11 +2726,11 @@ export class Interpreter<R> {
reference instanceof ComputedValue ||
reference === undefined ||
isOpaqueMemberReference(reference) ||
reference.target instanceof Values.URL
reference.target instanceof CodeModeURL
) {
throw new InterpreterRuntimeError("Only data fields may be deleted.", target, "InvalidDataValue")
}
if (reference.target instanceof Values.RegExp) {
if (reference.target instanceof CodeModeRegExp) {
return Reflect.deleteProperty(reference.target.regex, reference.key)
}
return Reflect.deleteProperty(reference.target, reference.key)
@@ -2741,10 +2767,10 @@ export class Interpreter<R> {
}
private readReferenceValue(reference: MemberReference, key: PropertyKey): unknown {
if (reference.target instanceof Values.URL) {
if (reference.target instanceof CodeModeURL) {
return Reflect.get(reference.target.url, key)
}
if (reference.target instanceof Values.RegExp) return reference.target.lastIndex
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
return Reflect.get(reference.target, key)
}
@@ -2762,7 +2788,7 @@ export class Interpreter<R> {
target[key] = next
return
}
if (reference.target instanceof Values.URL) {
if (reference.target instanceof CodeModeURL) {
const property = key as string
if (!urlWritableProperties.has(property)) {
throw new InterpreterRuntimeError(`URL.${property} is read-only.`, node).as("TypeError")
@@ -2776,7 +2802,7 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError(`URL.${property} received an invalid value.`, node).as("TypeError")
}
}
if (reference.target instanceof Values.RegExp) {
if (reference.target instanceof CodeModeRegExp) {
reference.target.lastIndex = next
return
}
+19 -10
View File
@@ -1,6 +1,15 @@
import { containsOpaqueReference, containsRuntimeReference, isRuntimeReference } from "../interpreter/references.js"
import { copyIn, copyOut } from "../tool-runtime.js"
import { Values } from "../values.js"
import {
isCodeModeValue,
CodeModeDate,
CodeModeMap,
CodeModePromise,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
} from "../values.js"
import { boundedData, coerceToString } from "./value.js"
export const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"])
@@ -25,14 +34,14 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
if (typeof value === "string") return JSON.stringify(value)
if (typeof value === "number" || typeof value === "boolean") return String(value)
if (typeof value !== "object") return String(value)
if (value instanceof Values.Promise) return "[Promise (await it to get its value)]"
if (value instanceof Values.Date) return coerceToString(value)
if (value instanceof Values.RegExp) return coerceToString(value)
if (value instanceof Values.URL) return coerceToString(value)
if (value instanceof Values.URLSearchParams) return coerceToString(value)
if (value instanceof CodeModePromise) return "[Promise (await it to get its value)]"
if (value instanceof CodeModeDate) return coerceToString(value)
if (value instanceof CodeModeRegExp) return coerceToString(value)
if (value instanceof CodeModeURL) return coerceToString(value)
if (value instanceof CodeModeURLSearchParams) return coerceToString(value)
if (depth > MAX_CONSOLE_DEPTH) return "..."
if (seen.has(value)) return "[Circular]"
if (value instanceof Values.Map) {
if (value instanceof CodeModeMap) {
seen.add(value)
try {
const entries = Array.from(value.map.entries(), ([key, item]): Array<unknown> => [key, item])
@@ -41,7 +50,7 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
seen.delete(value)
}
}
if (value instanceof Values.Set) {
if (value instanceof CodeModeSet) {
seen.add(value)
try {
return `Set(${value.set.size}) ${formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}`
@@ -91,14 +100,14 @@ const consoleTableRows = (
if (Array.isArray(data)) {
return data.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) }))
}
if (data !== null && typeof data === "object" && !Values.isValue(data)) {
if (data !== null && typeof data === "object" && !isCodeModeValue(data)) {
return Object.entries(data).map(([index, item]) => ({ index, values: consoleTableValues(item, columns) }))
}
return [{ index: "0", values: { Value: data } }]
}
const consoleTableValues = (value: unknown, columns: ReadonlyArray<string> | undefined): Record<string, unknown> => {
if (value !== null && typeof value === "object" && !Array.isArray(value) && !Values.isValue(value)) {
if (value !== null && typeof value === "object" && !Array.isArray(value) && !isCodeModeValue(value)) {
const source = value as Record<string, unknown>
if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]]))
return Object.fromEntries(Object.entries(source))

Some files were not shown because too many files have changed in this diff Show More