mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-09 02:16:23 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12ab6d5ade | ||
|
|
3244e0613c | ||
|
|
356a070afc | ||
|
|
739f58aadf | ||
|
|
2e57ac20ff | ||
|
|
cef086e5db | ||
|
|
223e9a8c41 | ||
|
|
23b87714e3 | ||
|
|
6e87e858e8 |
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {} })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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="">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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 server’s 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 project’s 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,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 ?? []))
|
||||
}
|
||||
@@ -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">"{store.filter}"</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>))
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
-16
@@ -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
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// External to Vite's module cache: keep lifecycle and route state across reloads.
|
||||
export const host = {}
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
declare module "solid-refresh/dist/solid-refresh.mjs" {
|
||||
export * from "solid-refresh"
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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:"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user