Merge branch 'dev' into brendan-cli-codesign

This commit is contained in:
Brendan Allan 2026-03-26 09:31:20 +08:00 committed by GitHub
commit a855229052
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
229 changed files with 12653 additions and 5397 deletions

1
.github/VOUCHED.td vendored
View file

@ -25,3 +25,4 @@ r44vc0rp
rekram1-node rekram1-node
-spider-yamet clawdbot/llm psychosis, spam pinging the team -spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr thdxr
-OpenCodeEngineer bot that spams issues

View file

@ -329,7 +329,7 @@
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2", "@modelcontextprotocol/sdk": "1.27.1",
"@octokit/graphql": "9.0.2", "@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:", "@openauthjs/openauth": "catalog:",
@ -358,7 +358,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.3.2", "gitlab-ai-provider": "5.3.3",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@ -1325,7 +1325,7 @@
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="], "@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
@ -2889,7 +2889,7 @@
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="], "express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
"express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], "express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="],
"expressive-code": ["expressive-code@0.41.7", "", { "dependencies": { "@expressive-code/core": "^0.41.7", "@expressive-code/plugin-frames": "^0.41.7", "@expressive-code/plugin-shiki": "^0.41.7", "@expressive-code/plugin-text-markers": "^0.41.7" } }, "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA=="], "expressive-code": ["expressive-code@0.41.7", "", { "dependencies": { "@expressive-code/core": "^0.41.7", "@expressive-code/plugin-frames": "^0.41.7", "@expressive-code/plugin-shiki": "^0.41.7", "@expressive-code/plugin-text-markers": "^0.41.7" } }, "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA=="],
@ -3037,7 +3037,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@5.3.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-EiAipDMa4Ngsxp4MMaua5YHWsHhc9kGXKmBxulJg1Gueb+5IZmMwxaVtgWTGWZITxC3tzKEeRt/3U4McE2vTIA=="], "gitlab-ai-provider": ["gitlab-ai-provider@5.3.3", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-k0kRUoAhDvoRC28hQW4sPp+A3cfpT5c/oL9Ng10S0oBiF2Tci1AtsX1iclJM5Os8C1nIIAXBW8LMr0GY7rwcGA=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@ -5129,6 +5129,8 @@
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"@modelcontextprotocol/sdk/hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
"@modelcontextprotocol/sdk/jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="], "@modelcontextprotocol/sdk/jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="],
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], "@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
@ -6311,6 +6313,8 @@
"opencontrol/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "opencontrol/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"opencontrol/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
"opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="], "opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],
"opencontrol/@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], "opencontrol/@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],

View file

@ -496,7 +496,6 @@ async function subscribeSessionEvents() {
const TOOL: Record<string, [string, string]> = { const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", "\x1b[33m\x1b[1m"], todowrite: ["Todo", "\x1b[33m\x1b[1m"],
todoread: ["Todo", "\x1b[33m\x1b[1m"],
bash: ["Bash", "\x1b[31m\x1b[1m"], bash: ["Bash", "\x1b[31m\x1b[1m"],
edit: ["Edit", "\x1b[32m\x1b[1m"], edit: ["Edit", "\x1b[32m\x1b[1m"],
glob: ["Glob", "\x1b[34m\x1b[1m"], glob: ["Glob", "\x1b[34m\x1b[1m"],

View file

@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-MmN2+NfHeLPDClpLPOlCAZTmwI94M6XgNAqXrW5Ls4I=", "x86_64-linux": "sha256-l3k/1fRIAQkj7zdVj2Ad3QZWeTOf1CuIM6vgMHRaK1s=",
"aarch64-linux": "sha256-whVIlmDvoMmEMUY2Yxx2vAmFDuKQic6ChY1V+9gLd84=", "aarch64-linux": "sha256-iN3YtrKAUTK1GIwVMoVYkMXhtDZOiP7sSJ+Z8v4B5xw=",
"aarch64-darwin": "sha256-TulGiC24w3usk26hKr3PyccatvIfmAlHgEJaOTUf3pQ=", "aarch64-darwin": "sha256-FFedoiPyfHGdzQnITz1SRV7xv2XoT9vzxIDp4EcVdkU=",
"x86_64-darwin": "sha256-T8NWm0bBybJKThRdp/jQdxilv1Ec9SF1iVT3udSoZOg=" "x86_64-darwin": "sha256-0HiHkhJiN73UixUq5CC6YP6DkZzLar8lKnEL1aoiiHg="
} }
} }

View file

@ -6,7 +6,7 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/ui/file" import { File } from "@opencode-ai/ui/file"
import { Font } from "@opencode-ai/ui/font" import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo" import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme" import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
@ -32,7 +32,7 @@ import { FileProvider } from "@/context/file"
import { GlobalSDKProvider } from "@/context/global-sdk" import { GlobalSDKProvider } from "@/context/global-sdk"
import { GlobalSyncProvider } from "@/context/global-sync" import { GlobalSyncProvider } from "@/context/global-sync"
import { HighlightsProvider } from "@/context/highlights" import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, useLanguage } from "@/context/language" import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout" import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models" import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification" import { NotificationProvider } from "@/context/notification"
@ -130,7 +130,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
) )
} }
export function AppBaseProviders(props: ParentProps) { export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
return ( return (
<MetaProvider> <MetaProvider>
<Font /> <Font />
@ -139,7 +139,7 @@ export function AppBaseProviders(props: ParentProps) {
void window.api?.setTitlebar?.({ mode }) void window.api?.setTitlebar?.({ mode })
}} }}
> >
<LanguageProvider> <LanguageProvider locale={props.locale}>
<UiI18nBridge> <UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<QueryProvider> <QueryProvider>

View file

@ -1,4 +1,4 @@
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link" import { Link } from "@/components/link"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
@ -34,15 +34,25 @@ export function DialogConnectProvider(props: { provider: string }) {
}) })
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!) const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
const methods = createMemo( const fallback = createMemo<ProviderAuthMethod[]>(() => [
() => {
globalSync.data.provider_auth[props.provider] ?? [ type: "api" as const,
{ label: language.t("provider.connect.method.apiKey"),
type: "api", },
label: language.t("provider.connect.method.apiKey"), ])
}, const [auth] = createResource(
], () => props.provider,
async () => {
const cached = globalSync.data.provider_auth[props.provider]
if (cached) return cached
const res = await globalSDK.client.provider.auth()
if (!alive.value) return fallback()
globalSync.set("provider_auth", res.data ?? {})
return res.data?.[props.provider] ?? fallback()
},
) )
const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
const [store, setStore] = createStore({ const [store, setStore] = createStore({
methodIndex: undefined as undefined | number, methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization, authorization: undefined as undefined | ProviderAuthAuthorization,
@ -177,7 +187,11 @@ export function DialogConnectProvider(props: { provider: string }) {
index: 0, index: 0,
}) })
const prompts = createMemo(() => method()?.prompts ?? []) const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
const value = method()
if (value?.type !== "oauth") return []
return value.prompts ?? []
})
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => { const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true if (!prompt.when) return true
const actual = value[prompt.when.key] const actual = value[prompt.when.key]
@ -296,8 +310,12 @@ export function DialogConnectProvider(props: { provider: string }) {
listRef?.onKeyDown(e) listRef?.onKeyDown(e)
} }
onMount(() => { let auto = false
createEffect(() => {
if (auto) return
if (loading()) return
if (methods().length === 1) { if (methods().length === 1) {
auto = true
selectMethod(0) selectMethod(0)
} }
}) })
@ -573,6 +591,14 @@ export function DialogConnectProvider(props: { provider: string }) {
<div class="px-2.5 pb-10 flex flex-col gap-6"> <div class="px-2.5 pb-10 flex flex-col gap-6">
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}> <div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
<Switch> <Switch>
<Match when={loading()}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={store.methodIndex === undefined}> <Match when={store.methodIndex === undefined}>
<MethodSelection /> <MethodSelection />
</Match> </Match>

View file

@ -572,6 +572,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const open = recent() const open = recent()
const seen = new Set(open) const seen = new Set(open)
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
if (!query.trim()) return [...agents, ...pinned]
const paths = await files.searchFilesAndDirectories(query) const paths = await files.searchFilesAndDirectories(query)
const fileOptions: AtOption[] = paths const fileOptions: AtOption[] = paths
.filter((path) => !seen.has(path)) .filter((path) => !seen.has(path))

View file

@ -1,27 +1,41 @@
import { Component, Show, createMemo, createResource, type JSX } from "solid-js" import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select" import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings, monoFontFamily } from "@/context/settings" import { useSettings, monoFontFamily } from "@/context/settings"
import { playSound, SOUND_OPTIONS } from "@/utils/sound" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link" import { Link } from "./link"
import { SettingsList } from "./settings-list" import { SettingsList } from "./settings-list"
let demoSoundState = { let demoSoundState = {
cleanup: undefined as (() => void) | undefined, cleanup: undefined as (() => void) | undefined,
timeout: undefined as NodeJS.Timeout | undefined, timeout: undefined as NodeJS.Timeout | undefined,
run: 0,
}
type ThemeOption = {
id: string
name: string
}
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
function loadFont() {
font ??= import("@opencode-ai/ui/font-loader")
return font
} }
// To prevent audio from overlapping/playing very quickly when navigating the settings menus, // To prevent audio from overlapping/playing very quickly when navigating the settings menus,
// delay the playback by 100ms during quick selection changes and pause existing sounds. // delay the playback by 100ms during quick selection changes and pause existing sounds.
const stopDemoSound = () => { const stopDemoSound = () => {
demoSoundState.run += 1
if (demoSoundState.cleanup) { if (demoSoundState.cleanup) {
demoSoundState.cleanup() demoSoundState.cleanup()
} }
@ -29,12 +43,19 @@ const stopDemoSound = () => {
demoSoundState.cleanup = undefined demoSoundState.cleanup = undefined
} }
const playDemoSound = (src: string | undefined) => { const playDemoSound = (id: string | undefined) => {
stopDemoSound() stopDemoSound()
if (!src) return if (!id) return
const run = ++demoSoundState.run
demoSoundState.timeout = setTimeout(() => { demoSoundState.timeout = setTimeout(() => {
demoSoundState.cleanup = playSound(src) void playSoundById(id).then((cleanup) => {
if (demoSoundState.run !== run) {
cleanup?.()
return
}
demoSoundState.cleanup = cleanup
})
}, 100) }, 100)
} }
@ -44,6 +65,10 @@ export const SettingsGeneral: Component = () => {
const platform = usePlatform() const platform = usePlatform()
const settings = useSettings() const settings = useSettings()
onMount(() => {
void theme.loadThemes()
})
const [store, setStore] = createStore({ const [store, setStore] = createStore({
checking: false, checking: false,
}) })
@ -104,9 +129,7 @@ export const SettingsGeneral: Component = () => {
.finally(() => setStore("checking", false)) .finally(() => setStore("checking", false))
} }
const themeOptions = createMemo(() => const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
)
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") }, { value: "system", label: language.t("theme.scheme.system") },
@ -143,7 +166,7 @@ export const SettingsGeneral: Component = () => {
] as const ] as const
const fontOptionsList = [...fontOptions] const fontOptionsList = [...fontOptions]
const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const const noneSound = { id: "none", label: "sound.option.none" } as const
const soundOptions = [noneSound, ...SOUND_OPTIONS] const soundOptions = [noneSound, ...SOUND_OPTIONS]
const soundSelectProps = ( const soundSelectProps = (
@ -158,7 +181,7 @@ export const SettingsGeneral: Component = () => {
label: (o: (typeof soundOptions)[number]) => language.t(o.label), label: (o: (typeof soundOptions)[number]) => language.t(o.label),
onHighlight: (option: (typeof soundOptions)[number] | undefined) => { onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return if (!option) return
playDemoSound(option.src) playDemoSound(option.id === "none" ? undefined : option.id)
}, },
onSelect: (option: (typeof soundOptions)[number] | undefined) => { onSelect: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return if (!option) return
@ -169,7 +192,7 @@ export const SettingsGeneral: Component = () => {
} }
setEnabled(true) setEnabled(true)
set(option.id) set(option.id)
playDemoSound(option.src) playDemoSound(option.id)
}, },
variant: "secondary" as const, variant: "secondary" as const,
size: "small" as const, size: "small" as const,
@ -321,6 +344,9 @@ export const SettingsGeneral: Component = () => {
current={fontOptionsList.find((o) => o.value === settings.appearance.font())} current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
value={(o) => o.value} value={(o) => o.value}
label={(o) => language.t(o.label)} label={(o) => language.t(o.label)}
onHighlight={(option) => {
void loadFont().then((x) => x.ensureMonoFont(option?.value))
}}
onSelect={(option) => option && settings.appearance.setFont(option.value)} onSelect={(option) => option && settings.appearance.setFont(option.value)}
variant="secondary" variant="secondary"
size="small" size="small"

View file

@ -16,7 +16,6 @@ import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { DialogSelectServer } from "./dialog-select-server"
const pollMs = 10_000 const pollMs = 10_000
@ -54,11 +53,15 @@ const listServersByHealth = (
}) })
} }
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => { const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
const checkServerHealth = useCheckServerHealth() const checkServerHealth = useCheckServerHealth()
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>) const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => { createEffect(() => {
if (!enabled()) {
setStatus(reconcile({}))
return
}
const list = servers() const list = servers()
let dead = false let dead = false
@ -162,6 +165,12 @@ export function StatusPopover() {
const navigate = useNavigate() const navigate = useNavigate()
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
let dialogRun = 0
let dialogDead = false
onCleanup(() => {
dialogDead = true
dialogRun += 1
})
const servers = createMemo(() => { const servers = createMemo(() => {
const current = server.current const current = server.current
const list = server.list const list = server.list
@ -169,7 +178,7 @@ export function StatusPopover() {
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list] if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))] return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
}) })
const health = useServerHealth(servers) const health = useServerHealth(servers, shown)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const toggleMcp = useMcpToggleMutation() const toggleMcp = useMcpToggleMutation()
const defaultServer = useDefaultServerKey(platform.getDefaultServer) const defaultServer = useDefaultServerKey(platform.getDefaultServer)
@ -300,7 +309,13 @@ export function StatusPopover() {
<Button <Button
variant="secondary" variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5" class="mt-3 self-start h-8 px-3 py-1.5"
onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)} onClick={() => {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
})
}}
> >
{language.t("status.popover.action.manageServers")} {language.t("status.popover.action.manageServers")}
</Button> </Button>

View file

@ -1,4 +1,7 @@
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme" import { withAlpha } from "@opencode-ai/ui/theme/color"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
import type { HexColor } from "@opencode-ai/ui/theme/types"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js" import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"

View file

@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme" import { useTheme } from "@opencode-ai/ui/theme/context"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"

View file

@ -32,6 +32,25 @@ describe("command keybind helpers", () => {
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false) expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
}) })
test("matchKeybind supports bracket keys", () => {
const keybinds = parseKeybind("mod+alt+[, mod+alt+]")
const prev = keybinds[0]
const next = keybinds[1]
expect(
matchKeybind(
keybinds,
new KeyboardEvent("keydown", { key: "[", ctrlKey: prev?.ctrl, metaKey: prev?.meta, altKey: true }),
),
).toBe(true)
expect(
matchKeybind(
keybinds,
new KeyboardEvent("keydown", { key: "]", ctrlKey: next?.ctrl, metaKey: next?.meta, altKey: true }),
),
).toBe(true)
})
test("formatKeybind returns human readable output", () => { test("formatKeybind returns human readable output", () => {
const display = formatKeybind("ctrl+alt+arrowup") const display = formatKeybind("ctrl+alt+arrowup")

View file

@ -9,17 +9,7 @@ import type {
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
createContext,
getOwner,
Match,
onCleanup,
onMount,
type ParentProps,
Switch,
untrack,
useContext,
} from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
@ -80,6 +70,8 @@ function createGlobalSync() {
let active = true let active = true
let projectWritten = false let projectWritten = false
let bootedAt = 0
let bootingRoot = false
onCleanup(() => { onCleanup(() => {
active = false active = false
@ -258,6 +250,11 @@ function createGlobalSync() {
const sdk = sdkFor(directory) const sdk = sdkFor(directory)
await bootstrapDirectory({ await bootstrapDirectory({
directory, directory,
global: {
config: globalStore.config,
project: globalStore.project,
provider: globalStore.provider,
},
sdk, sdk,
store: child[0], store: child[0],
setStore: child[1], setStore: child[1],
@ -278,15 +275,20 @@ function createGlobalSync() {
const unsub = globalSDK.event.listen((e) => { const unsub = globalSDK.event.listen((e) => {
const directory = e.name const directory = e.name
const event = e.details const event = e.details
const recent = bootingRoot || Date.now() - bootedAt < 1500
if (directory === "global") { if (directory === "global") {
applyGlobalEvent({ applyGlobalEvent({
event, event,
project: globalStore.project, project: globalStore.project,
refresh: queue.refresh, refresh: () => {
if (recent) return
queue.refresh()
},
setGlobalProject: setProjects, setGlobalProject: setProjects,
}) })
if (event.type === "server.connected" || event.type === "global.disposed") { if (event.type === "server.connected" || event.type === "global.disposed") {
if (recent) return
for (const directory of Object.keys(children.children)) { for (const directory of Object.keys(children.children)) {
queue.push(directory) queue.push(directory)
} }
@ -325,17 +327,19 @@ function createGlobalSync() {
}) })
async function bootstrap() { async function bootstrap() {
await bootstrapGlobal({ bootingRoot = true
globalSDK: globalSDK.client, try {
connectErrorTitle: language.t("dialog.server.add.error"), await bootstrapGlobal({
connectErrorDescription: language.t("error.globalSync.connectFailed", { globalSDK: globalSDK.client,
url: globalSDK.url, requestFailedTitle: language.t("common.requestFailed"),
}), translate: language.t,
requestFailedTitle: language.t("common.requestFailed"), formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
translate: language.t, setGlobalStore: setBootStore,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), })
setGlobalStore: setBootStore, bootedAt = Date.now()
}) } finally {
bootingRoot = false
}
} }
onMount(() => { onMount(() => {
@ -392,13 +396,7 @@ const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
export function GlobalSyncProvider(props: ParentProps) { export function GlobalSyncProvider(props: ParentProps) {
const value = createGlobalSync() const value = createGlobalSync()
return ( return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
<Switch>
<Match when={value.ready}>
<GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
</Match>
</Switch>
)
} }
export function useGlobalSync() { export function useGlobalSync() {

View file

@ -15,7 +15,7 @@ import { retry } from "@opencode-ai/util/retry"
import { batch } from "solid-js" import { batch } from "solid-js"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import { cmp, normalizeProviderList } from "./utils" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
type GlobalStore = { type GlobalStore = {
@ -31,73 +31,102 @@ type GlobalStore = {
reload: undefined | "pending" | "complete" reload: undefined | "pending" | "complete"
} }
function waitForPaint() {
return new Promise<void>((resolve) => {
let done = false
const finish = () => {
if (done) return
done = true
resolve()
}
const timer = setTimeout(finish, 50)
if (typeof requestAnimationFrame !== "function") return
requestAnimationFrame(() => {
clearTimeout(timer)
finish()
})
})
}
function errors(list: PromiseSettledResult<unknown>[]) {
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
}
function runAll(list: Array<() => Promise<unknown>>) {
return Promise.allSettled(list.map((item) => item()))
}
function showErrors(input: {
errors: unknown[]
title: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
}) {
if (input.errors.length === 0) return
const message = formatServerError(input.errors[0], input.translate)
const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
showToast({
variant: "error",
title: input.title,
description: message + more,
})
}
export async function bootstrapGlobal(input: { export async function bootstrapGlobal(input: {
globalSDK: OpencodeClient globalSDK: OpencodeClient
connectErrorTitle: string
connectErrorDescription: string
requestFailedTitle: string requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string formatMoreCount: (count: number) => string
setGlobalStore: SetStoreFunction<GlobalStore> setGlobalStore: SetStoreFunction<GlobalStore>
}) { }) {
const health = await input.globalSDK.global const fast = [
.health() () =>
.then((x) => x.data) retry(() =>
.catch(() => undefined) input.globalSDK.path.get().then((x) => {
if (!health?.healthy) { input.setGlobalStore("path", x.data!)
showToast({ }),
variant: "error", ),
title: input.connectErrorTitle, () =>
description: input.connectErrorDescription, retry(() =>
}) input.globalSDK.global.config.get().then((x) => {
input.setGlobalStore("ready", true) input.setGlobalStore("config", x.data!)
return }),
} ),
() =>
const tasks = [ retry(() =>
retry(() => input.globalSDK.provider.list().then((x) => {
input.globalSDK.path.get().then((x) => { input.setGlobalStore("provider", normalizeProviderList(x.data!))
input.setGlobalStore("path", x.data!) }),
}), ),
),
retry(() =>
input.globalSDK.global.config.get().then((x) => {
input.setGlobalStore("config", x.data!)
}),
),
retry(() =>
input.globalSDK.project.list().then((x) => {
const projects = (x.data ?? [])
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
input.setGlobalStore("project", projects)
}),
),
retry(() =>
input.globalSDK.provider.list().then((x) => {
input.setGlobalStore("provider", normalizeProviderList(x.data!))
}),
),
retry(() =>
input.globalSDK.provider.auth().then((x) => {
input.setGlobalStore("provider_auth", x.data ?? {})
}),
),
] ]
const results = await Promise.allSettled(tasks) const slow = [
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason) () =>
if (errors.length) { retry(() =>
const message = formatServerError(errors[0], input.translate) input.globalSDK.project.list().then((x) => {
const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : "" const projects = (x.data ?? [])
showToast({ .filter((p) => !!p?.id)
variant: "error", .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
title: input.requestFailedTitle, .slice()
description: message + more, .sort((a, b) => cmp(a.id, b.id))
}) input.setGlobalStore("project", projects)
} }),
),
]
showErrors({
errors: errors(await runAll(fast)),
title: input.requestFailedTitle,
translate: input.translate,
formatMoreCount: input.formatMoreCount,
})
await waitForPaint()
showErrors({
errors: errors(await runAll(slow)),
title: input.requestFailedTitle,
translate: input.translate,
formatMoreCount: input.formatMoreCount,
})
input.setGlobalStore("ready", true) input.setGlobalStore("ready", true)
} }
@ -111,6 +140,10 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
}, {}) }, {})
} }
function projectID(directory: string, projects: Project[]) {
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
}
export async function bootstrapDirectory(input: { export async function bootstrapDirectory(input: {
directory: string directory: string
sdk: OpencodeClient sdk: OpencodeClient
@ -119,88 +152,130 @@ export async function bootstrapDirectory(input: {
vcsCache: VcsCache vcsCache: VcsCache
loadSessions: (directory: string) => Promise<void> | void loadSessions: (directory: string) => Promise<void> | void
translate: (key: string, vars?: Record<string, string | number>) => string translate: (key: string, vars?: Record<string, string | number>) => string
}) { global: {
if (input.store.status !== "complete") input.setStore("status", "loading") config: Config
project: Project[]
const blockingRequests = { provider: ProviderListResponse
project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
provider: () =>
input.sdk.provider.list().then((x) => {
input.setStore("provider", normalizeProviderList(x.data!))
}),
agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
} }
}) {
const loading = input.store.status !== "complete"
const seededProject = projectID(input.directory, input.global.project)
if (seededProject) input.setStore("project", seededProject)
if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
input.setStore("provider", input.global.provider)
}
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
input.setStore("config", input.global.config)
}
if (loading) input.setStore("status", "partial")
try { const fast = [
await Promise.all(Object.values(blockingRequests).map((p) => retry(p))) () =>
} catch (err) { seededProject
console.error("Failed to bootstrap instance", err) ? Promise.resolve()
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
() =>
retry(() =>
input.sdk.path.get().then((x) => {
input.setStore("path", x.data!)
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
}),
),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
() =>
retry(() =>
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next) input.vcsCache.setStore("value", next)
}),
),
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
() =>
retry(() =>
input.sdk.permission.list().then((x) => {
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
)
batch(() => {
for (const sessionID of Object.keys(input.store.permission)) {
if (grouped[sessionID]) continue
input.setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
input.setStore(
"permission",
sessionID,
reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
),
() =>
retry(() =>
input.sdk.question.list().then((x) => {
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
batch(() => {
for (const sessionID of Object.keys(input.store.question)) {
if (grouped[sessionID]) continue
input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
input.setStore(
"question",
sessionID,
reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
),
]
const slow = [
() =>
retry(() =>
input.sdk.provider.list().then((x) => {
input.setStore("provider", normalizeProviderList(x.data!))
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
() => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))),
() => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))),
]
const errs = errors(await runAll(fast))
if (errs.length > 0) {
console.error("Failed to bootstrap instance", errs[0])
const project = getFilename(input.directory) const project = getFilename(input.directory)
showToast({ showToast({
variant: "error", variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }), title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate), description: formatServerError(errs[0], input.translate),
}) })
input.setStore("status", "partial")
return
} }
if (input.store.status !== "complete") input.setStore("status", "partial") await waitForPaint()
const slowErrs = errors(await runAll(slow))
if (slowErrs.length > 0) {
console.error("Failed to finish bootstrap instance", slowErrs[0])
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(slowErrs[0], input.translate),
})
}
Promise.all([ if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
input.loadSessions(input.directory),
input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next?.branch) input.vcsCache.setStore("value", next)
}),
input.sdk.permission.list().then((x) => {
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
)
batch(() => {
for (const sessionID of Object.keys(input.store.permission)) {
if (grouped[sessionID]) continue
input.setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
input.setStore(
"permission",
sessionID,
reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
input.sdk.question.list().then((x) => {
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
batch(() => {
for (const sessionID of Object.keys(input.store.question)) {
if (grouped[sessionID]) continue
input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
input.setStore(
"question",
sessionID,
reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
]).then(() => {
input.setStore("status", "complete")
})
} }

View file

@ -494,8 +494,10 @@ describe("applyDirectoryEvent", () => {
}) })
test("updates vcs branch in store and cache", () => { test("updates vcs branch in store and cache", () => {
const [store, setStore] = createStore(baseState()) const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] }) const [cacheStore, setCacheStore] = createStore({
value: { branch: "main", default_branch: "main" } as State["vcs"],
})
applyDirectoryEvent({ applyDirectoryEvent({
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } }, event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
@ -511,8 +513,8 @@ describe("applyDirectoryEvent", () => {
}, },
}) })
expect(store.vcs).toEqual({ branch: "feature/test" }) expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
expect(cacheStore.value).toEqual({ branch: "feature/test" }) expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
}) })
test("routes disposal and lsp events to side-effect handlers", () => { test("routes disposal and lsp events to side-effect handlers", () => {

View file

@ -271,9 +271,9 @@ export function applyDirectoryEvent(input: {
break break
} }
case "vcs.branch.updated": { case "vcs.branch.updated": {
const props = event.properties as { branch: string } const props = event.properties as { branch?: string }
if (input.store.vcs?.branch === props.branch) break if (input.store.vcs?.branch === props.branch) break
const next = { branch: props.branch } const next = { ...input.store.vcs, branch: props.branch }
input.setStore("vcs", next) input.setStore("vcs", next)
if (input.vcsCache) input.vcsCache.setStore("value", next) if (input.vcsCache) input.vcsCache.setStore("value", next)
break break

View file

@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test"
import type { Agent } from "@opencode-ai/sdk/v2/client"
import { normalizeAgentList } from "./utils"
const agent = (name = "build") =>
({
name,
mode: "primary",
permission: {},
options: {},
}) as Agent
describe("normalizeAgentList", () => {
test("keeps array payloads", () => {
expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
})
test("wraps a single agent payload", () => {
expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
})
test("extracts agents from keyed objects", () => {
expect(
normalizeAgentList({
build: agent("build"),
docs: agent("docs"),
}),
).toEqual([agent("build"), agent("docs")])
})
test("drops invalid payloads", () => {
expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
})
})

View file

@ -1,7 +1,21 @@
import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function isAgent(input: unknown): input is Agent {
if (!input || typeof input !== "object") return false
const item = input as { name?: unknown; mode?: unknown }
if (typeof item.name !== "string") return false
return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
}
export function normalizeAgentList(input: unknown): Agent[] {
if (Array.isArray(input)) return input.filter(isAgent)
if (isAgent(input)) return [input]
if (!input || typeof input !== "object") return []
return Object.values(input).filter(isAgent)
}
export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse { export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
return { return {
...input, ...input,

View file

@ -1,42 +1,10 @@
import * as i18n from "@solid-primitives/i18n" import * as i18n from "@solid-primitives/i18n"
import { createEffect, createMemo } from "solid-js" import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { dict as en } from "@/i18n/en" import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { dict as zht } from "@/i18n/zht"
import { dict as ko } from "@/i18n/ko"
import { dict as de } from "@/i18n/de"
import { dict as es } from "@/i18n/es"
import { dict as fr } from "@/i18n/fr"
import { dict as da } from "@/i18n/da"
import { dict as ja } from "@/i18n/ja"
import { dict as pl } from "@/i18n/pl"
import { dict as ru } from "@/i18n/ru"
import { dict as ar } from "@/i18n/ar"
import { dict as no } from "@/i18n/no"
import { dict as br } from "@/i18n/br"
import { dict as th } from "@/i18n/th"
import { dict as bs } from "@/i18n/bs"
import { dict as tr } from "@/i18n/tr"
import { dict as uiEn } from "@opencode-ai/ui/i18n/en" import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
import { dict as uiZht } from "@opencode-ai/ui/i18n/zht"
import { dict as uiKo } from "@opencode-ai/ui/i18n/ko"
import { dict as uiDe } from "@opencode-ai/ui/i18n/de"
import { dict as uiEs } from "@opencode-ai/ui/i18n/es"
import { dict as uiFr } from "@opencode-ai/ui/i18n/fr"
import { dict as uiDa } from "@opencode-ai/ui/i18n/da"
import { dict as uiJa } from "@opencode-ai/ui/i18n/ja"
import { dict as uiPl } from "@opencode-ai/ui/i18n/pl"
import { dict as uiRu } from "@opencode-ai/ui/i18n/ru"
import { dict as uiAr } from "@opencode-ai/ui/i18n/ar"
import { dict as uiNo } from "@opencode-ai/ui/i18n/no"
import { dict as uiBr } from "@opencode-ai/ui/i18n/br"
import { dict as uiTh } from "@opencode-ai/ui/i18n/th"
import { dict as uiBs } from "@opencode-ai/ui/i18n/bs"
import { dict as uiTr } from "@opencode-ai/ui/i18n/tr"
export type Locale = export type Locale =
| "en" | "en"
@ -59,6 +27,7 @@ export type Locale =
type RawDictionary = typeof en & typeof uiEn type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten<RawDictionary> type Dictionary = i18n.Flatten<RawDictionary>
type Source = { dict: Record<string, string> }
function cookie(locale: Locale) { function cookie(locale: Locale) {
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax` return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
@ -125,24 +94,43 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
} }
const base = i18n.flatten({ ...en, ...uiEn }) const base = i18n.flatten({ ...en, ...uiEn })
const DICT: Record<Locale, Dictionary> = { const dicts = new Map<Locale, Dictionary>([["en", base]])
en: base,
zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) }, const merge = (app: Promise<Source>, ui: Promise<Source>) =>
zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) }, Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary)
ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) },
de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) }, const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) }, zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")),
fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) }, zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")),
da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) }, ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")),
ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) }, de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")),
pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) }, es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")),
ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) }, fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")),
ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) }, da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")),
no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) }, ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")),
br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) }, pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")),
th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) }, ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) }, ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) }, no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
}
function loadDict(locale: Locale) {
const hit = dicts.get(locale)
if (hit) return Promise.resolve(hit)
if (locale === "en") return Promise.resolve(base)
const load = loaders[locale]
return load().then((next: Dictionary) => {
dicts.set(locale, next)
return next
})
}
export function loadLocaleDict(locale: Locale) {
return loadDict(locale).then(() => undefined)
} }
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
@ -168,27 +156,6 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole
{ locale: "tr", match: (language) => language.startsWith("tr") }, { locale: "tr", match: (language) => language.startsWith("tr") },
] ]
type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
zh,
zht,
ko,
de,
es,
fr,
da,
ja,
pl,
ru,
ar,
no,
br,
th,
bs,
tr,
}
void PARITY_CHECK
function detectLocale(): Locale { function detectLocale(): Locale {
if (typeof navigator !== "object") return "en" if (typeof navigator !== "object") return "en"
@ -203,27 +170,48 @@ function detectLocale(): Locale {
return "en" return "en"
} }
function normalizeLocale(value: string): Locale { export function normalizeLocale(value: string): Locale {
return LOCALES.includes(value as Locale) ? (value as Locale) : "en" return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
} }
function readStoredLocale() {
if (typeof localStorage !== "object") return
try {
const raw = localStorage.getItem("opencode.global.dat:language")
if (!raw) return
const next = JSON.parse(raw) as { locale?: string }
if (typeof next?.locale !== "string") return
return normalizeLocale(next.locale)
} catch {
return
}
}
const warm = readStoredLocale() ?? detectLocale()
if (warm !== "en") void loadDict(warm)
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({ export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
name: "Language", name: "Language",
init: () => { init: (props: { locale?: Locale }) => {
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.global("language", ["language.v1"]), Persist.global("language", ["language.v1"]),
createStore({ createStore({
locale: detectLocale() as Locale, locale: initial,
}), }),
) )
const locale = createMemo<Locale>(() => normalizeLocale(store.locale)) const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
console.log("locale", locale())
const intl = createMemo(() => INTL[locale()]) const intl = createMemo(() => INTL[locale()])
const dict = createMemo<Dictionary>(() => DICT[locale()]) const [dict] = createResource(locale, loadDict, {
initialValue: dicts.get(initial) ?? base,
})
const t = i18n.translator(dict, i18n.resolveTemplate) const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
key: keyof Dictionary,
params?: Record<string, string | number | boolean>,
) => string
const label = (value: Locale) => t(LABEL_KEY[value]) const label = (value: Locale) => t(LABEL_KEY[value])

View file

@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2" import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { playSound, soundSrc } from "@/utils/sound" import { playSoundById } from "@/utils/sound"
type NotificationBase = { type NotificationBase = {
directory?: string directory?: string
@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (session.parentID) return if (session.parentID) return
if (settings.sounds.agentEnabled()) { if (settings.sounds.agentEnabled()) {
playSound(soundSrc(settings.sounds.agent())) void playSoundById(settings.sounds.agent())
} }
append({ append({
@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (session?.parentID) return if (session?.parentID) return
if (settings.sounds.errorsEnabled()) { if (settings.sounds.errorsEnabled()) {
playSound(soundSrc(settings.sounds.errors())) void playSoundById(settings.sounds.errors())
} }
const error = "error" in event.properties ? event.properties.error : undefined const error = "error" in event.properties ? event.properties.error : undefined

View file

@ -104,6 +104,13 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
return createMemo(() => read() ?? fallback) return createMemo(() => read() ?? fallback)
} }
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
function loadFont() {
font ??= import("@opencode-ai/ui/font-loader")
return font
}
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({ export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
name: "Settings", name: "Settings",
init: () => { init: () => {
@ -111,7 +118,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
createEffect(() => { createEffect(() => {
if (typeof document === "undefined") return if (typeof document === "undefined") return
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font)) const id = store.appearance?.font ?? defaultSettings.appearance.font
if (id !== defaultSettings.appearance.font) {
void loadFont().then((x) => x.ensureMonoFont(id))
}
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id))
}) })
return { return {

View file

@ -180,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return globalSync.child(directory) return globalSync.child(directory)
} }
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const messagePageSize = 200 const initialMessagePageSize = 80
const historyMessagePageSize = 200
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>() const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>() const inflightTodo = new Map<string, Promise<void>>()
@ -463,7 +464,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return if (cached && hasSession && !opts?.force) return
const limit = meta.limit[key] ?? messagePageSize const limit = meta.limit[key] ?? initialMessagePageSize
const sessionReq = const sessionReq =
hasSession && !opts?.force hasSession && !opts?.force
? Promise.resolve() ? Promise.resolve()
@ -560,7 +561,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const [, setStore] = globalSync.child(directory) const [, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID) touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID) const key = keyFor(directory, sessionID)
const step = count ?? messagePageSize const step = count ?? historyMessagePageSize
if (meta.loading[key]) return if (meta.loading[key]) return
if (meta.complete[key]) return if (meta.complete[key]) return
const before = meta.cursor[key] const before = meta.cursor[key]

View file

@ -1,45 +1,18 @@
import { dict as ar } from "@/i18n/ar" const template = "Terminal {{number}}"
import { dict as br } from "@/i18n/br"
import { dict as bs } from "@/i18n/bs"
import { dict as da } from "@/i18n/da"
import { dict as de } from "@/i18n/de"
import { dict as en } from "@/i18n/en"
import { dict as es } from "@/i18n/es"
import { dict as fr } from "@/i18n/fr"
import { dict as ja } from "@/i18n/ja"
import { dict as ko } from "@/i18n/ko"
import { dict as no } from "@/i18n/no"
import { dict as pl } from "@/i18n/pl"
import { dict as ru } from "@/i18n/ru"
import { dict as th } from "@/i18n/th"
import { dict as tr } from "@/i18n/tr"
import { dict as zh } from "@/i18n/zh"
import { dict as zht } from "@/i18n/zht"
const numbered = Array.from( const numbered = [
new Set([ template,
en["terminal.title.numbered"], "محطة طرفية {{number}}",
ar["terminal.title.numbered"], "Терминал {{number}}",
br["terminal.title.numbered"], "ターミナル {{number}}",
bs["terminal.title.numbered"], "터미널 {{number}}",
da["terminal.title.numbered"], "เทอร์มินัล {{number}}",
de["terminal.title.numbered"], "终端 {{number}}",
es["terminal.title.numbered"], "終端機 {{number}}",
fr["terminal.title.numbered"], ]
ja["terminal.title.numbered"],
ko["terminal.title.numbered"],
no["terminal.title.numbered"],
pl["terminal.title.numbered"],
ru["terminal.title.numbered"],
th["terminal.title.numbered"],
tr["terminal.title.numbered"],
zh["terminal.title.numbered"],
zht["terminal.title.numbered"],
]),
)
export function defaultTitle(number: number) { export function defaultTitle(number: number) {
return en["terminal.title.numbered"].replace("{{number}}", String(number)) return template.replace("{{number}}", String(number))
} }
export function isDefaultTitle(title: string, number: number) { export function isDefaultTitle(title: string, number: number) {

View file

@ -22,7 +22,7 @@ export function useProviders() {
const providers = () => { const providers = () => {
if (dir()) { if (dir()) {
const [projectStore] = globalSync.child(dir()) const [projectStore] = globalSync.child(dir())
return projectStore.provider if (projectStore.provider.all.length > 0) return projectStore.provider
} }
return globalSync.data.provider return globalSync.data.provider
} }

View file

@ -722,8 +722,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "تحميل مهارة بالاسم", "settings.permissions.tool.skill.description": "تحميل مهارة بالاسم",
"settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة", "settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة",
"settings.permissions.tool.todoread.title": "قراءة المهام",
"settings.permissions.tool.todoread.description": "قراءة قائمة المهام",
"settings.permissions.tool.todowrite.title": "كتابة المهام", "settings.permissions.tool.todowrite.title": "كتابة المهام",
"settings.permissions.tool.todowrite.description": "تحديث قائمة المهام", "settings.permissions.tool.todowrite.description": "تحديث قائمة المهام",
"settings.permissions.tool.webfetch.title": "جلب الويب", "settings.permissions.tool.webfetch.title": "جلب الويب",

View file

@ -732,8 +732,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Carregar uma habilidade por nome", "settings.permissions.tool.skill.description": "Carregar uma habilidade por nome",
"settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Executar consultas de servidor de linguagem", "settings.permissions.tool.lsp.description": "Executar consultas de servidor de linguagem",
"settings.permissions.tool.todoread.title": "Ler Tarefas",
"settings.permissions.tool.todoread.description": "Ler a lista de tarefas",
"settings.permissions.tool.todowrite.title": "Escrever Tarefas", "settings.permissions.tool.todowrite.title": "Escrever Tarefas",
"settings.permissions.tool.todowrite.description": "Atualizar a lista de tarefas", "settings.permissions.tool.todowrite.description": "Atualizar a lista de tarefas",
"settings.permissions.tool.webfetch.title": "Buscar Web", "settings.permissions.tool.webfetch.title": "Buscar Web",

View file

@ -806,8 +806,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Učitaj vještinu po nazivu", "settings.permissions.tool.skill.description": "Učitaj vještinu po nazivu",
"settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Pokreni upite jezičnog servera", "settings.permissions.tool.lsp.description": "Pokreni upite jezičnog servera",
"settings.permissions.tool.todoread.title": "Čitanje liste zadataka",
"settings.permissions.tool.todoread.description": "Čitanje liste zadataka",
"settings.permissions.tool.todowrite.title": "Ažuriranje liste zadataka", "settings.permissions.tool.todowrite.title": "Ažuriranje liste zadataka",
"settings.permissions.tool.todowrite.description": "Ažuriraj listu zadataka", "settings.permissions.tool.todowrite.description": "Ažuriraj listu zadataka",
"settings.permissions.tool.webfetch.title": "Web preuzimanje", "settings.permissions.tool.webfetch.title": "Web preuzimanje",

View file

@ -800,8 +800,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn", "settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn",
"settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler", "settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler",
"settings.permissions.tool.todoread.title": "Læs To-do",
"settings.permissions.tool.todoread.description": "Læs to-do listen",
"settings.permissions.tool.todowrite.title": "Skriv To-do", "settings.permissions.tool.todowrite.title": "Skriv To-do",
"settings.permissions.tool.todowrite.description": "Opdater to-do listen", "settings.permissions.tool.todowrite.description": "Opdater to-do listen",
"settings.permissions.tool.webfetch.title": "Webhentning", "settings.permissions.tool.webfetch.title": "Webhentning",

View file

@ -743,8 +743,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Eine Fähigkeit nach Namen laden", "settings.permissions.tool.skill.description": "Eine Fähigkeit nach Namen laden",
"settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Language-Server-Abfragen ausführen", "settings.permissions.tool.lsp.description": "Language-Server-Abfragen ausführen",
"settings.permissions.tool.todoread.title": "Todo lesen",
"settings.permissions.tool.todoread.description": "Die Todo-Liste lesen",
"settings.permissions.tool.todowrite.title": "Todo schreiben", "settings.permissions.tool.todowrite.title": "Todo schreiben",
"settings.permissions.tool.todowrite.description": "Die Todo-Liste aktualisieren", "settings.permissions.tool.todowrite.description": "Die Todo-Liste aktualisieren",
"settings.permissions.tool.webfetch.title": "Web-Abruf", "settings.permissions.tool.webfetch.title": "Web-Abruf",

View file

@ -535,6 +535,8 @@ export const dict = {
"session.review.noVcs.createGit.action": "Create Git repository", "session.review.noVcs.createGit.action": "Create Git repository",
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable", "session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
"session.review.noChanges": "No changes", "session.review.noChanges": "No changes",
"session.review.noUncommittedChanges": "No uncommitted changes yet",
"session.review.noBranchChanges": "No branch changes yet",
"session.files.selectToOpen": "Select a file to open", "session.files.selectToOpen": "Select a file to open",
"session.files.all": "All files", "session.files.all": "All files",
@ -900,8 +902,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Load a skill by name", "settings.permissions.tool.skill.description": "Load a skill by name",
"settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Run language server queries", "settings.permissions.tool.lsp.description": "Run language server queries",
"settings.permissions.tool.todoread.title": "Todo Read",
"settings.permissions.tool.todoread.description": "Read the todo list",
"settings.permissions.tool.todowrite.title": "Todo Write", "settings.permissions.tool.todowrite.title": "Todo Write",
"settings.permissions.tool.todowrite.description": "Update the todo list", "settings.permissions.tool.todowrite.description": "Update the todo list",
"settings.permissions.tool.webfetch.title": "Web Fetch", "settings.permissions.tool.webfetch.title": "Web Fetch",

View file

@ -813,8 +813,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Cargar una habilidad por nombre", "settings.permissions.tool.skill.description": "Cargar una habilidad por nombre",
"settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Ejecutar consultas de servidor de lenguaje", "settings.permissions.tool.lsp.description": "Ejecutar consultas de servidor de lenguaje",
"settings.permissions.tool.todoread.title": "Leer Todo",
"settings.permissions.tool.todoread.description": "Leer la lista de tareas",
"settings.permissions.tool.todowrite.title": "Escribir Todo", "settings.permissions.tool.todowrite.title": "Escribir Todo",
"settings.permissions.tool.todowrite.description": "Actualizar la lista de tareas", "settings.permissions.tool.todowrite.description": "Actualizar la lista de tareas",
"settings.permissions.tool.webfetch.title": "Web Fetch", "settings.permissions.tool.webfetch.title": "Web Fetch",

View file

@ -741,8 +741,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Charger une compétence par son nom", "settings.permissions.tool.skill.description": "Charger une compétence par son nom",
"settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Exécuter des requêtes de serveur de langage", "settings.permissions.tool.lsp.description": "Exécuter des requêtes de serveur de langage",
"settings.permissions.tool.todoread.title": "Lire Todo",
"settings.permissions.tool.todoread.description": "Lire la liste de tâches",
"settings.permissions.tool.todowrite.title": "Écrire Todo", "settings.permissions.tool.todowrite.title": "Écrire Todo",
"settings.permissions.tool.todowrite.description": "Mettre à jour la liste de tâches", "settings.permissions.tool.todowrite.description": "Mettre à jour la liste de tâches",
"settings.permissions.tool.webfetch.title": "Récupération Web", "settings.permissions.tool.webfetch.title": "Récupération Web",

View file

@ -727,8 +727,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "名前によるスキルの読み込み", "settings.permissions.tool.skill.description": "名前によるスキルの読み込み",
"settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "言語サーバークエリの実行", "settings.permissions.tool.lsp.description": "言語サーバークエリの実行",
"settings.permissions.tool.todoread.title": "Todo読み込み",
"settings.permissions.tool.todoread.description": "Todoリストの読み込み",
"settings.permissions.tool.todowrite.title": "Todo書き込み", "settings.permissions.tool.todowrite.title": "Todo書き込み",
"settings.permissions.tool.todowrite.description": "Todoリストの更新", "settings.permissions.tool.todowrite.description": "Todoリストの更新",
"settings.permissions.tool.webfetch.title": "Web取得", "settings.permissions.tool.webfetch.title": "Web取得",

View file

@ -726,8 +726,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "이름으로 기술 로드", "settings.permissions.tool.skill.description": "이름으로 기술 로드",
"settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "언어 서버 쿼리 실행", "settings.permissions.tool.lsp.description": "언어 서버 쿼리 실행",
"settings.permissions.tool.todoread.title": "할 일 읽기",
"settings.permissions.tool.todoread.description": "할 일 목록 읽기",
"settings.permissions.tool.todowrite.title": "할 일 쓰기", "settings.permissions.tool.todowrite.title": "할 일 쓰기",
"settings.permissions.tool.todowrite.description": "할 일 목록 업데이트", "settings.permissions.tool.todowrite.description": "할 일 목록 업데이트",
"settings.permissions.tool.webfetch.title": "웹 가져오기", "settings.permissions.tool.webfetch.title": "웹 가져오기",

View file

@ -807,8 +807,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Last en ferdighet etter navn", "settings.permissions.tool.skill.description": "Last en ferdighet etter navn",
"settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Kjør språkserverforespørsler", "settings.permissions.tool.lsp.description": "Kjør språkserverforespørsler",
"settings.permissions.tool.todoread.title": "Les gjøremål",
"settings.permissions.tool.todoread.description": "Les gjøremålslisten",
"settings.permissions.tool.todowrite.title": "Skriv gjøremål", "settings.permissions.tool.todowrite.title": "Skriv gjøremål",
"settings.permissions.tool.todowrite.description": "Oppdater gjøremålslisten", "settings.permissions.tool.todowrite.description": "Oppdater gjøremålslisten",
"settings.permissions.tool.webfetch.title": "Webhenting", "settings.permissions.tool.webfetch.title": "Webhenting",

View file

@ -729,8 +729,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Ładowanie umiejętności według nazwy", "settings.permissions.tool.skill.description": "Ładowanie umiejętności według nazwy",
"settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Uruchamianie zapytań serwera językowego", "settings.permissions.tool.lsp.description": "Uruchamianie zapytań serwera językowego",
"settings.permissions.tool.todoread.title": "Odczyt Todo",
"settings.permissions.tool.todoread.description": "Odczyt listy zadań",
"settings.permissions.tool.todowrite.title": "Zapis Todo", "settings.permissions.tool.todowrite.title": "Zapis Todo",
"settings.permissions.tool.todowrite.description": "Aktualizacja listy zadań", "settings.permissions.tool.todowrite.description": "Aktualizacja listy zadań",
"settings.permissions.tool.webfetch.title": "Pobieranie z sieci", "settings.permissions.tool.webfetch.title": "Pobieranie z sieci",

View file

@ -808,8 +808,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Загрузка навыка по имени", "settings.permissions.tool.skill.description": "Загрузка навыка по имени",
"settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Запросы к языковому серверу", "settings.permissions.tool.lsp.description": "Запросы к языковому серверу",
"settings.permissions.tool.todoread.title": "Todo Read",
"settings.permissions.tool.todoread.description": "Чтение списка задач",
"settings.permissions.tool.todowrite.title": "Todo Write", "settings.permissions.tool.todowrite.title": "Todo Write",
"settings.permissions.tool.todowrite.description": "Обновление списка задач", "settings.permissions.tool.todowrite.description": "Обновление списка задач",
"settings.permissions.tool.webfetch.title": "Web Fetch", "settings.permissions.tool.webfetch.title": "Web Fetch",

View file

@ -796,8 +796,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "โหลดทักษะตามชื่อ", "settings.permissions.tool.skill.description": "โหลดทักษะตามชื่อ",
"settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "เรียกใช้การสืบค้นเซิร์ฟเวอร์ภาษา", "settings.permissions.tool.lsp.description": "เรียกใช้การสืบค้นเซิร์ฟเวอร์ภาษา",
"settings.permissions.tool.todoread.title": "อ่านรายการงาน",
"settings.permissions.tool.todoread.description": "อ่านรายการงาน",
"settings.permissions.tool.todowrite.title": "เขียนรายการงาน", "settings.permissions.tool.todowrite.title": "เขียนรายการงาน",
"settings.permissions.tool.todowrite.description": "อัปเดตรายการงาน", "settings.permissions.tool.todowrite.description": "อัปเดตรายการงาน",
"settings.permissions.tool.webfetch.title": "ดึงข้อมูลจากเว็บ", "settings.permissions.tool.webfetch.title": "ดึงข้อมูลจากเว็บ",

View file

@ -816,8 +816,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Ada göre bir beceri yükle", "settings.permissions.tool.skill.description": "Ada göre bir beceri yükle",
"settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Dil sunucusu sorguları çalıştır", "settings.permissions.tool.lsp.description": "Dil sunucusu sorguları çalıştır",
"settings.permissions.tool.todoread.title": "Görev Oku",
"settings.permissions.tool.todoread.description": "Görev listesini oku",
"settings.permissions.tool.todowrite.title": "Görev Yaz", "settings.permissions.tool.todowrite.title": "Görev Yaz",
"settings.permissions.tool.todowrite.description": "Görev listesini güncelle", "settings.permissions.tool.todowrite.description": "Görev listesini güncelle",
"settings.permissions.tool.webfetch.title": "Web Getir", "settings.permissions.tool.webfetch.title": "Web Getir",

View file

@ -795,8 +795,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "按名称加载技能", "settings.permissions.tool.skill.description": "按名称加载技能",
"settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "运行语言服务器查询", "settings.permissions.tool.lsp.description": "运行语言服务器查询",
"settings.permissions.tool.todoread.title": "读取待办",
"settings.permissions.tool.todoread.description": "读取待办列表",
"settings.permissions.tool.todowrite.title": "更新待办", "settings.permissions.tool.todowrite.title": "更新待办",
"settings.permissions.tool.todowrite.description": "更新待办列表", "settings.permissions.tool.todowrite.description": "更新待办列表",
"settings.permissions.tool.webfetch.title": "网页获取", "settings.permissions.tool.webfetch.title": "网页获取",

View file

@ -790,8 +790,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "按名稱載入技能", "settings.permissions.tool.skill.description": "按名稱載入技能",
"settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "執行語言伺服器查詢", "settings.permissions.tool.lsp.description": "執行語言伺服器查詢",
"settings.permissions.tool.todoread.title": "讀取待辦",
"settings.permissions.tool.todoread.description": "讀取待辦清單",
"settings.permissions.tool.todowrite.title": "更新待辦", "settings.permissions.tool.todowrite.title": "更新待辦",
"settings.permissions.tool.todowrite.description": "更新待辦清單", "settings.permissions.tool.todowrite.description": "更新待辦清單",
"settings.permissions.tool.webfetch.title": "Web Fetch", "settings.permissions.tool.webfetch.title": "Web Fetch",

View file

@ -1,6 +1,7 @@
export { AppBaseProviders, AppInterface } from "./app" export { AppBaseProviders, AppInterface } from "./app"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command" export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform" export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection } from "./context/server" export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click" export { handleNotificationClick } from "./utils/notification-click"

View file

@ -2,8 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { createMemo, createResource, type ParentProps, Show } from "solid-js" import { createEffect, createMemo, type ParentProps, Show } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local" import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk" import { SDKProvider } from "@/context/sdk"
@ -11,10 +10,18 @@ import { SyncProvider, useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const sync = useSync() const sync = useSync()
const slug = createMemo(() => base64Encode(props.directory)) const slug = createMemo(() => base64Encode(props.directory))
createEffect(() => {
const next = sync.data.path.directory
if (!next || next === props.directory) return
const path = location.pathname.slice(slug().length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
return ( return (
<DataProvider <DataProvider
data={sync.data} data={sync.data}
@ -29,50 +36,31 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
export default function Layout(props: ParentProps) { export default function Layout(props: ParentProps) {
const params = useParams() const params = useParams()
const location = useLocation()
const language = useLanguage() const language = useLanguage()
const globalSDK = useGlobalSDK()
const navigate = useNavigate() const navigate = useNavigate()
let invalid = "" let invalid = ""
const [resolved] = createResource( const resolved = createMemo(() => {
() => { if (!params.dir) return ""
if (params.dir) return [location.pathname, params.dir] as const return decode64(params.dir) ?? ""
}, })
async ([pathname, b64Dir]) => {
const directory = decode64(b64Dir)
if (!directory) { createEffect(() => {
if (invalid === params.dir) return const dir = params.dir
invalid = b64Dir if (!dir) return
showToast({ if (resolved()) {
variant: "error", invalid = ""
title: language.t("common.requestFailed"), return
description: language.t("directory.error.invalidUrl"), }
}) if (invalid === dir) return
navigate("/", { replace: true }) invalid = dir
return showToast({
} variant: "error",
title: language.t("common.requestFailed"),
return await globalSDK description: language.t("directory.error.invalidUrl"),
.createClient({ })
directory, navigate("/", { replace: true })
throwOnError: true, })
})
.path.get()
.then((x) => {
const next = x.data?.directory ?? directory
invalid = ""
if (next === directory) return next
const path = pathname.slice(b64Dir.length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
.catch(() => {
invalid = ""
return directory
})
},
)
return ( return (
<Show when={resolved()} keyed> <Show when={resolved()} keyed>

View file

@ -113,6 +113,14 @@ export default function Home() {
</ul> </ul>
</div> </div>
</Match> </Match>
<Match when={!sync.ready}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
<Button class="px-3" onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</Match>
<Match when={true}> <Match when={true}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3"> <div class="mt-30 mx-auto flex flex-col items-center gap-3">
<Icon name="folder-add-left" size="large" /> <Icon name="folder-add-left" size="large" />

View file

@ -49,21 +49,16 @@ import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry" import { retry } from "@opencode-ai/util/retry"
import { playSound, soundSrc } from "@/utils/sound" import { playSoundById } from "@/utils/sound"
import { createAim } from "@/utils/aim" import { createAim } from "@/utils/aim"
import { setNavigate } from "@/utils/notification-click" import { setNavigate } from "@/utils/notification-click"
import { Worktree as WorktreeState } from "@/utils/worktree" import { Worktree as WorktreeState } from "@/utils/worktree"
import { setSessionHandoff } from "@/pages/session/handoff" import { setSessionHandoff } from "@/pages/session/handoff"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { DialogSettings } from "@/components/dialog-settings"
import { useCommand, type CommandOption } from "@/context/command" import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd" import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { DebugBar } from "@/components/debug-bar" import { DebugBar } from "@/components/debug-bar"
import { Titlebar } from "@/components/titlebar" import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
@ -110,6 +105,8 @@ export default function Layout(props: ParentProps) {
const pageReady = createMemo(() => ready()) const pageReady = createMemo(() => ready())
let scrollContainerRef: HTMLDivElement | undefined let scrollContainerRef: HTMLDivElement | undefined
let dialogRun = 0
let dialogDead = false
const params = useParams() const params = useParams()
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
@ -139,7 +136,7 @@ export default function Layout(props: ParentProps) {
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir, dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
} }
}) })
const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const))
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = { const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
system: "theme.scheme.system", system: "theme.scheme.system",
@ -201,6 +198,8 @@ export default function Layout(props: ParentProps) {
}) })
onCleanup(() => { onCleanup(() => {
dialogDead = true
dialogRun += 1
if (navLeave.current !== undefined) clearTimeout(navLeave.current) if (navLeave.current !== undefined) clearTimeout(navLeave.current)
clearTimeout(sortNowTimeout) clearTimeout(sortNowTimeout)
if (sortNowInterval) clearInterval(sortNowInterval) if (sortNowInterval) clearInterval(sortNowInterval)
@ -336,10 +335,9 @@ export default function Layout(props: ParentProps) {
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
const nextThemeId = ids[nextIndex] const nextThemeId = ids[nextIndex]
theme.setTheme(nextThemeId) theme.setTheme(nextThemeId)
const nextTheme = theme.themes()[nextThemeId]
showToast({ showToast({
title: language.t("toast.theme.title"), title: language.t("toast.theme.title"),
description: nextTheme?.name ?? nextThemeId, description: theme.name(nextThemeId),
}) })
} }
@ -494,7 +492,7 @@ export default function Layout(props: ParentProps) {
if (e.details.type === "permission.asked") { if (e.details.type === "permission.asked") {
if (settings.sounds.permissionsEnabled()) { if (settings.sounds.permissionsEnabled()) {
playSound(soundSrc(settings.sounds.permissions())) void playSoundById(settings.sounds.permissions())
} }
if (settings.notifications.permissions()) { if (settings.notifications.permissions()) {
void platform.notify(title, description, href) void platform.notify(title, description, href)
@ -967,6 +965,8 @@ export default function Layout(props: ParentProps) {
: projects[(index + offset + projects.length) % projects.length] : projects[(index + offset + projects.length) % projects.length]
if (!target) return if (!target) return
// warm up child store to prevent flicker
globalSync.child(target.worktree)
openProject(target.worktree) openProject(target.worktree)
} }
@ -1152,10 +1152,10 @@ export default function Layout(props: ParentProps) {
}, },
] ]
for (const [id, definition] of availableThemeEntries()) { for (const [id] of availableThemeEntries()) {
commands.push({ commands.push({
id: `theme.set.${id}`, id: `theme.set.${id}`,
title: language.t("command.theme.set", { theme: definition.name ?? id }), title: language.t("command.theme.set", { theme: theme.name(id) }),
category: language.t("command.category.theme"), category: language.t("command.category.theme"),
onSelect: () => theme.commitPreview(), onSelect: () => theme.commitPreview(),
onHighlight: () => { onHighlight: () => {
@ -1206,15 +1206,27 @@ export default function Layout(props: ParentProps) {
}) })
function connectProvider() { function connectProvider() {
dialog.show(() => <DialogSelectProvider />) const run = ++dialogRun
void import("@/components/dialog-select-provider").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectProvider />)
})
} }
function openServer() { function openServer() {
dialog.show(() => <DialogSelectServer />) const run = ++dialogRun
void import("@/components/dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />)
})
} }
function openSettings() { function openSettings() {
dialog.show(() => <DialogSettings />) const run = ++dialogRun
void import("@/components/dialog-settings").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSettings />)
})
} }
function projectRoot(directory: string) { function projectRoot(directory: string) {
@ -1441,7 +1453,13 @@ export default function Layout(props: ParentProps) {
layout.sidebar.toggleWorkspaces(project.worktree) layout.sidebar.toggleWorkspaces(project.worktree)
} }
const showEditProjectDialog = (project: LocalProject) => dialog.show(() => <DialogEditProject project={project} />) const showEditProjectDialog = (project: LocalProject) => {
const run = ++dialogRun
void import("@/components/dialog-edit-project").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogEditProject project={project} />)
})
}
async function chooseProject() { async function chooseProject() {
function resolve(result: string | string[] | null) { function resolve(result: string | string[] | null) {
@ -1462,10 +1480,14 @@ export default function Layout(props: ParentProps) {
}) })
resolve(result) resolve(result)
} else { } else {
dialog.show( const run = ++dialogRun
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />, void import("@/components/dialog-select-directory").then((x) => {
() => resolve(null), if (dialogDead || dialogRun !== run) return
) dialog.show(
() => <x.DialogSelectDirectory multiple={true} onSelect={resolve} />,
() => resolve(null),
)
})
} }
} }

View file

@ -1,4 +1,4 @@
import type { Project, UserMessage } from "@opencode-ai/sdk/v2" import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
import { import {
@ -64,6 +64,9 @@ import { formatServerError } from "@/utils/server-errors"
const emptyUserMessages: UserMessage[] = [] const emptyUserMessages: UserMessage[] = []
const emptyFollowups: (FollowupDraft & { id: string })[] = [] const emptyFollowups: (FollowupDraft & { id: string })[] = []
type ChangeMode = "git" | "branch" | "session" | "turn"
type VcsMode = "git" | "branch"
type SessionHistoryWindowInput = { type SessionHistoryWindowInput = {
sessionID: () => string | undefined sessionID: () => string | undefined
messagesReady: () => boolean messagesReady: () => boolean
@ -424,15 +427,16 @@ export default function Page() {
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0) const hasSessionReview = createMemo(() => sessionCount() > 0)
const canReview = createMemo(() => !!params.id)
const reviewTab = createMemo(() => isDesktop()) const reviewTab = createMemo(() => isDesktop())
const tabState = createSessionTabs({ const tabState = createSessionTabs({
tabs, tabs,
pathFromTab: file.pathFromTab, pathFromTab: file.pathFromTab,
normalizeTab, normalizeTab,
review: reviewTab, review: reviewTab,
hasReview, hasReview: canReview,
}) })
const contextOpen = tabState.contextOpen const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs const openedTabs = tabState.openedTabs
@ -455,6 +459,12 @@ export default function Page() {
if (!id) return false if (!id) return false
return sync.session.history.loading(id) return sync.session.history.loading(id)
}) })
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasSessionReview()) return true
return sync.data.session_diff[id] !== undefined
})
const userMessages = createMemo( const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[], () => messages().filter((m) => m.role === "user") as UserMessage[],
@ -508,11 +518,22 @@ export default function Page() {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
messageId: undefined as string | undefined, messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "changes", mobileTab: "session" as "session" | "changes",
changes: "session" as "session" | "turn", changes: "git" as ChangeMode,
newSessionWorktree: "main", newSessionWorktree: "main",
deferRender: false, deferRender: false,
}) })
const [vcs, setVcs] = createStore({
diff: {
git: [] as FileDiff[],
branch: [] as FileDiff[],
},
ready: {
git: false,
branch: false,
},
})
const [followup, setFollowup] = createStore({ const [followup, setFollowup] = createStore({
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>, items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
failed: {} as Record<string, string | undefined>, failed: {} as Record<string, string | undefined>,
@ -539,6 +560,68 @@ export default function Page() {
let refreshTimer: number | undefined let refreshTimer: number | undefined
let diffFrame: number | undefined let diffFrame: number | undefined
let diffTimer: number | undefined let diffTimer: number | undefined
const vcsTask = new Map<VcsMode, Promise<void>>()
const vcsRun = new Map<VcsMode, number>()
const bumpVcs = (mode: VcsMode) => {
const next = (vcsRun.get(mode) ?? 0) + 1
vcsRun.set(mode, next)
return next
}
const resetVcs = (mode?: VcsMode) => {
const list = mode ? [mode] : (["git", "branch"] as const)
list.forEach((item) => {
bumpVcs(item)
vcsTask.delete(item)
setVcs("diff", item, [])
setVcs("ready", item, false)
})
}
const loadVcs = (mode: VcsMode, force = false) => {
if (sync.project?.vcs !== "git") return Promise.resolve()
if (!force && vcs.ready[mode]) return Promise.resolve()
if (force) {
if (vcsTask.has(mode)) bumpVcs(mode)
vcsTask.delete(mode)
setVcs("ready", mode, false)
}
const current = vcsTask.get(mode)
if (current) return current
const run = bumpVcs(mode)
const task = sdk.client.vcs
.diff({ mode })
.then((result) => {
if (vcsRun.get(mode) !== run) return
setVcs("diff", mode, result.data ?? [])
setVcs("ready", mode, true)
})
.catch((error) => {
if (vcsRun.get(mode) !== run) return
console.debug("[session-review] failed to load vcs diff", { mode, error })
setVcs("diff", mode, [])
setVcs("ready", mode, true)
})
.finally(() => {
if (vcsTask.get(mode) === task) vcsTask.delete(mode)
})
vcsTask.set(mode, task)
return task
}
const refreshVcs = () => {
resetVcs()
const mode = untrack(vcsMode)
if (!mode) return
if (!untrack(wantsReview)) return
void loadVcs(mode, true)
}
createComputed((prev) => { createComputed((prev) => {
const open = desktopReviewOpen() const open = desktopReviewOpen()
@ -554,7 +637,42 @@ export default function Page() {
}, desktopReviewOpen()) }, desktopReviewOpen())
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs())) const changesOptions = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = []
if (sync.project?.vcs === "git") list.push("git")
if (
sync.project?.vcs === "git" &&
sync.data.vcs?.branch &&
sync.data.vcs?.default_branch &&
sync.data.vcs.branch !== sync.data.vcs.default_branch
) {
list.push("branch")
}
list.push("session", "turn")
return list
})
const vcsMode = createMemo<VcsMode | undefined>(() => {
if (store.changes === "git" || store.changes === "branch") return store.changes
})
const reviewDiffs = createMemo(() => {
if (store.changes === "git") return vcs.diff.git
if (store.changes === "branch") return vcs.diff.branch
if (store.changes === "session") return diffs()
return turnDiffs()
})
const reviewCount = createMemo(() => {
if (store.changes === "git") return vcs.diff.git.length
if (store.changes === "branch") return vcs.diff.branch.length
if (store.changes === "session") return sessionCount()
return turnDiffs().length
})
const hasReview = createMemo(() => reviewCount() > 0)
const reviewReady = createMemo(() => {
if (store.changes === "git") return vcs.ready.git
if (store.changes === "branch") return vcs.ready.branch
if (store.changes === "session") return !hasSessionReview() || diffsReady()
return true
})
const newSessionWorktree = createMemo(() => { const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create" if (store.newSessionWorktree === "create") return "create"
@ -620,13 +738,7 @@ export default function Page() {
scrollToMessage(msgs[targetIndex], "auto") scrollToMessage(msgs[targetIndex], "auto")
} }
const diffsReady = createMemo(() => { const sessionEmptyKey = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined
})
const reviewEmptyKey = createMemo(() => {
const project = sync.project const project = sync.project
if (project && !project.vcs) return "session.review.noVcs" if (project && !project.vcs) return "session.review.noVcs"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot" if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
@ -748,13 +860,46 @@ export default function Page() {
sessionKey, sessionKey,
() => { () => {
setStore("messageId", undefined) setStore("messageId", undefined)
setStore("changes", "session") setStore("changes", "git")
setUi("pendingMessage", undefined) setUi("pendingMessage", undefined)
}, },
{ defer: true }, { defer: true },
), ),
) )
createEffect(
on(
() => sdk.directory,
() => {
resetVcs()
},
{ defer: true },
),
)
createEffect(
on(
() => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const,
(next, prev) => {
if (prev === undefined || same(next, prev)) return
refreshVcs()
},
{ defer: true },
),
)
const stopVcs = sdk.event.listen((evt) => {
if (evt.details.type !== "file.watcher.updated") return
const props =
typeof evt.details.properties === "object" && evt.details.properties
? (evt.details.properties as Record<string, unknown>)
: undefined
const file = typeof props?.file === "string" ? props.file : undefined
if (!file || file.startsWith(".git/")) return
refreshVcs()
})
onCleanup(stopVcs)
createEffect( createEffect(
on( on(
() => params.dir, () => params.dir,
@ -877,6 +1022,40 @@ export default function Page() {
} }
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
const wantsReview = createMemo(() =>
isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes",
)
createEffect(() => {
const list = changesOptions()
if (list.includes(store.changes)) return
const next = list[0]
if (!next) return
setStore("changes", next)
})
createEffect(() => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
void loadVcs(mode)
})
createEffect(
on(
() => sync.data.session_status[params.id ?? ""]?.type,
(next, prev) => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
if (next !== "idle" || prev === undefined || prev === "idle") return
void loadVcs(mode, true)
},
{ defer: true },
),
)
const fileTreeTab = () => layout.fileTree.tab() const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value) const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
@ -923,21 +1102,23 @@ export default function Page() {
loadFile: file.load, loadFile: file.load,
}) })
const changesOptions = ["session", "turn"] as const
const changesOptionsList = [...changesOptions]
const changesTitle = () => { const changesTitle = () => {
if (!hasReview()) { if (!canReview()) {
return null return null
} }
const label = (option: ChangeMode) => {
if (option === "git") return language.t("ui.sessionReview.title.git")
if (option === "branch") return language.t("ui.sessionReview.title.branch")
if (option === "session") return language.t("ui.sessionReview.title")
return language.t("ui.sessionReview.title.lastTurn")
}
return ( return (
<Select <Select
options={changesOptionsList} options={changesOptions()}
current={store.changes} current={store.changes}
label={(option) => label={label}
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
}
onSelect={(option) => option && setStore("changes", option)} onSelect={(option) => option && setStore("changes", option)}
variant="ghost" variant="ghost"
size="small" size="small"
@ -946,20 +1127,34 @@ export default function Page() {
) )
} }
const emptyTurn = () => ( const empty = (text: string) => (
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6"> <div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div> <div class="text-14-regular text-text-weak max-w-56">{text}</div>
</div> </div>
) )
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => { const reviewEmptyText = createMemo(() => {
if (store.changes === "turn") return emptyTurn() if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
if (store.changes === "turn") return language.t("session.review.noChanges")
return language.t(sessionEmptyKey())
})
if (hasReview() && !diffsReady()) { const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
if (store.changes === "git" || store.changes === "branch") {
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
return empty(reviewEmptyText())
}
if (store.changes === "turn") {
return empty(reviewEmptyText())
}
if (hasSessionReview() && !diffsReady()) {
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div> return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
} }
if (reviewEmptyKey() === "session.review.noVcs") { if (sessionEmptyKey() === "session.review.noVcs") {
return ( return (
<div class={input.emptyClass}> <div class={input.emptyClass}>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
@ -979,7 +1174,7 @@ export default function Page() {
return ( return (
<div class={input.emptyClass}> <div class={input.emptyClass}>
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div> <div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
</div> </div>
) )
} }
@ -1083,7 +1278,7 @@ export default function Page() {
const pending = tree.pendingDiff const pending = tree.pendingDiff
if (!pending) return if (!pending) return
if (!tree.reviewScroll) return if (!tree.reviewScroll) return
if (!diffsReady()) return if (!reviewReady()) return
const attempt = (count: number) => { const attempt = (count: number) => {
if (tree.pendingDiff !== pending) return if (tree.pendingDiff !== pending) return
@ -1124,10 +1319,7 @@ export default function Page() {
const id = params.id const id = params.id
if (!id) return if (!id) return
const wants = isDesktop() if (!wantsReview()) return
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes"
if (!wants) return
if (sync.data.session_diff[id] !== undefined) return if (sync.data.session_diff[id] !== undefined) return
if (sync.status === "loading") return if (sync.status === "loading") return
@ -1136,13 +1328,7 @@ export default function Page() {
createEffect( createEffect(
on( on(
() => () => [sessionKey(), wantsReview()] as const,
[
sessionKey(),
isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes",
] as const,
([key, wants]) => { ([key, wants]) => {
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer) if (diffTimer !== undefined) window.clearTimeout(diffTimer)
@ -1184,8 +1370,6 @@ export default function Page() {
on( on(
() => sdk.directory, () => sdk.directory,
() => { () => {
void file.tree.list("")
const tab = activeFileTab() const tab = activeFileTab()
if (!tab) return if (!tab) return
const path = file.pathFromTab(tab) const path = file.pathFromTab(tab)
@ -1640,6 +1824,9 @@ export default function Page() {
sessionID: () => params.id, sessionID: () => params.id,
messagesReady, messagesReady,
visibleUserMessages, visibleUserMessages,
historyMore,
historyLoading,
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
turnStart: historyWindow.turnStart, turnStart: historyWindow.turnStart,
currentMessageId: () => store.messageId, currentMessageId: () => store.messageId,
pendingMessage: () => ui.pendingMessage, pendingMessage: () => ui.pendingMessage,
@ -1711,7 +1898,7 @@ export default function Page() {
<div class="flex-1 min-h-0 overflow-hidden"> <div class="flex-1 min-h-0 overflow-hidden">
<Switch> <Switch>
<Match when={params.id}> <Match when={params.id}>
<Show when={lastUserMessage()}> <Show when={messagesReady()}>
<MessageTimeline <MessageTimeline
mobileChanges={mobileChanges()} mobileChanges={mobileChanges()}
mobileFallback={reviewContent({ mobileFallback={reviewContent({
@ -1827,6 +2014,12 @@ export default function Page() {
</div> </div>
<SessionSidePanel <SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}
diffsReady={reviewReady}
empty={reviewEmptyText}
hasReview={hasReview}
reviewCount={reviewCount}
reviewPanel={reviewPanel} reviewPanel={reviewPanel}
activeDiff={tree.activeDiff} activeDiff={tree.activeDiff}
focusReviewDiff={focusReviewDiff} focusReviewDiff={focusReviewDiff}

View file

@ -896,7 +896,8 @@ export function MessageTimeline(props: {
</Show> </Show>
<div <div
role="log" role="log"
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]" data-slot="session-turn-list"
class="flex flex-col items-start justify-start pb-16 transition-[margin]"
classList={{ classList={{
"w-full": true, "w-full": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,

View file

@ -8,6 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo" import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd"
import type { FileDiff } from "@opencode-ai/sdk/v2"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@ -19,7 +20,6 @@ import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file" import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs" import { FileTabContent } from "@/pages/session/file-tabs"
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers" import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
@ -27,6 +27,12 @@ import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
export function SessionSidePanel(props: { export function SessionSidePanel(props: {
canReview: () => boolean
diffs: () => FileDiff[]
diffsReady: () => boolean
empty: () => string
hasReview: () => boolean
reviewCount: () => number
reviewPanel: () => JSX.Element reviewPanel: () => JSX.Element
activeDiff?: string activeDiff?: string
focusReviewDiff: (path: string) => void focusReviewDiff: (path: string) => void
@ -34,12 +40,11 @@ export function SessionSidePanel(props: {
size: Sizing size: Sizing
}) { }) {
const layout = useLayout() const layout = useLayout()
const sync = useSync()
const file = useFile() const file = useFile()
const language = useLanguage() const language = useLanguage()
const command = useCommand() const command = useCommand()
const dialog = useDialog() const dialog = useDialog()
const { params, sessionKey, tabs, view } = useSessionLayout() const { sessionKey, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)") const isDesktop = createMediaQuery("(min-width: 768px)")
@ -54,24 +59,7 @@ export function SessionSidePanel(props: {
}) })
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0)
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined
})
const reviewEmptyKey = createMemo(() => {
if (sync.project && !sync.project.vcs) return "session.review.noVcs"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
return "session.review.noChanges"
})
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const kinds = createMemo(() => { const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
if (!a) return b if (!a) return b
@ -82,7 +70,7 @@ export function SessionSidePanel(props: {
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "") const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">() const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) { for (const diff of props.diffs()) {
const file = normalize(diff.file) const file = normalize(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
@ -136,7 +124,7 @@ export function SessionSidePanel(props: {
pathFromTab: file.pathFromTab, pathFromTab: file.pathFromTab,
normalizeTab, normalizeTab,
review: reviewTab, review: reviewTab,
hasReview, hasReview: props.canReview,
}) })
const contextOpen = tabState.contextOpen const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs const openedTabs = tabState.openedTabs
@ -241,12 +229,12 @@ export function SessionSidePanel(props: {
onCleanup(stop) onCleanup(stop)
}} }}
> >
<Show when={reviewTab()}> <Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger value="review"> <Tabs.Trigger value="review">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div> <div>{language.t("session.tab.review")}</div>
<Show when={hasReview()}> <Show when={props.hasReview()}>
<div>{reviewCount()}</div> <div>{props.reviewCount()}</div>
</Show> </Show>
</div> </div>
</Tabs.Trigger> </Tabs.Trigger>
@ -303,7 +291,7 @@ export function SessionSidePanel(props: {
</Tabs.List> </Tabs.List>
</div> </div>
<Show when={reviewTab()}> <Show when={reviewTab() && props.canReview()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict"> <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show> <Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content> </Tabs.Content>
@ -377,8 +365,10 @@ export function SessionSidePanel(props: {
> >
<Tabs.List> <Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}> <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{reviewCount()}{" "} {props.reviewCount()}{" "}
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")} {language.t(
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
)}
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}> <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")} {language.t("session.files.all")}
@ -386,9 +376,9 @@ export function SessionSidePanel(props: {
</Tabs.List> </Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0"> <Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch> <Switch>
<Match when={hasReview()}> <Match when={props.hasReview() || !props.diffsReady()}>
<Show <Show
when={diffsReady()} when={props.diffsReady()}
fallback={ fallback={
<div class="px-2 py-2 text-12-regular text-text-weak"> <div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")} {language.t("common.loading")}
@ -407,11 +397,7 @@ export function SessionSidePanel(props: {
/> />
</Show> </Show>
</Match> </Match>
<Match when={true}> <Match when={true}>{empty(props.empty())}</Match>
{empty(
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
)}
</Match>
</Switch> </Switch>
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0"> <Tabs.Content value="all" class="bg-background-stronger px-3 py-0">

View file

@ -56,11 +56,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (!id) return if (!id) return
return sync.session.get(id) return sync.session.get(id)
} }
const hasReview = () => { const hasReview = () => !!params.id
const id = params.id
if (!id) return false
return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0
}
const normalizeTab = (tab: string) => { const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab if (!tab.startsWith("file://")) return tab
return file.tab(tab) return file.tab(tab)
@ -333,7 +329,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
id: "message.previous", id: "message.previous",
title: language.t("command.message.previous"), title: language.t("command.message.previous"),
description: language.t("command.message.previous.description"), description: language.t("command.message.previous.description"),
keybind: "mod+arrowup", keybind: "mod+alt+[",
disabled: !params.id, disabled: !params.id,
onSelect: () => navigateMessageByOffset(-1), onSelect: () => navigateMessageByOffset(-1),
}), }),
@ -341,7 +337,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
id: "message.next", id: "message.next",
title: language.t("command.message.next"), title: language.t("command.message.next"),
description: language.t("command.message.next.description"), description: language.t("command.message.next.description"),
keybind: "mod+arrowdown", keybind: "mod+alt+]",
disabled: !params.id, disabled: !params.id,
onSelect: () => navigateMessageByOffset(1), onSelect: () => navigateMessageByOffset(1),
}), }),

View file

@ -8,6 +8,9 @@ export const useSessionHashScroll = (input: {
sessionID: () => string | undefined sessionID: () => string | undefined
messagesReady: () => boolean messagesReady: () => boolean
visibleUserMessages: () => UserMessage[] visibleUserMessages: () => UserMessage[]
historyMore: () => boolean
historyLoading: () => boolean
loadMore: (sessionID: string) => Promise<void>
turnStart: () => number turnStart: () => number
currentMessageId: () => string | undefined currentMessageId: () => string | undefined
pendingMessage: () => string | undefined pendingMessage: () => string | undefined
@ -181,6 +184,21 @@ export const useSessionHashScroll = (input: {
queue(() => scrollToMessage(msg, "auto")) queue(() => scrollToMessage(msg, "auto"))
}) })
createEffect(() => {
const sessionID = input.sessionID()
if (!sessionID || !input.messagesReady()) return
visibleUserMessages()
let targetId = input.pendingMessage()
if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
if (!targetId) return
if (messageById().has(targetId)) return
if (!input.historyMore() || input.historyLoading()) return
void input.loadMore(sessionID)
})
onMount(() => { onMount(() => {
if (typeof window !== "undefined" && "scrollRestoration" in window.history) { if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual" window.history.scrollRestoration = "manual"

View file

@ -14,6 +14,15 @@ interface CheckServerHealthOptions {
const defaultTimeoutMs = 3000 const defaultTimeoutMs = 3000
const defaultRetryCount = 2 const defaultRetryCount = 2
const defaultRetryDelayMs = 100 const defaultRetryDelayMs = 100
const cacheMs = 750
const healthCache = new Map<
string,
{ at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise<ServerHealth> }
>()
function cacheKey(server: ServerConnection.HttpBase) {
return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}`
}
function timeoutSignal(timeoutMs: number) { function timeoutSignal(timeoutMs: number) {
const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
@ -87,5 +96,18 @@ export function useCheckServerHealth() {
const platform = usePlatform() const platform = usePlatform()
const fetcher = platform.fetch ?? globalThis.fetch const fetcher = platform.fetch ?? globalThis.fetch
return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher) return (http: ServerConnection.HttpBase) => {
const key = cacheKey(http)
const hit = healthCache.get(key)
const now = Date.now()
if (hit && hit.fetch === fetcher && (!hit.done || now - hit.at < cacheMs)) return hit.promise
const promise = checkServerHealth(http, fetcher).finally(() => {
const next = healthCache.get(key)
if (!next || next.promise !== promise) return
next.done = true
next.at = Date.now()
})
healthCache.set(key, { at: now, done: false, fetch: fetcher, promise })
return promise
}
} }

View file

@ -1,106 +1,89 @@
import alert01 from "@opencode-ai/ui/audio/alert-01.aac" let files: Record<string, () => Promise<string>> | undefined
import alert02 from "@opencode-ai/ui/audio/alert-02.aac" let loads: Record<SoundID, () => Promise<string>> | undefined
import alert03 from "@opencode-ai/ui/audio/alert-03.aac"
import alert04 from "@opencode-ai/ui/audio/alert-04.aac" function getFiles() {
import alert05 from "@opencode-ai/ui/audio/alert-05.aac" if (files) return files
import alert06 from "@opencode-ai/ui/audio/alert-06.aac" files = import.meta.glob("../../../ui/src/assets/audio/*.aac", { import: "default" }) as Record<
import alert07 from "@opencode-ai/ui/audio/alert-07.aac" string,
import alert08 from "@opencode-ai/ui/audio/alert-08.aac" () => Promise<string>
import alert09 from "@opencode-ai/ui/audio/alert-09.aac" >
import alert10 from "@opencode-ai/ui/audio/alert-10.aac" return files
import bipbop01 from "@opencode-ai/ui/audio/bip-bop-01.aac" }
import bipbop02 from "@opencode-ai/ui/audio/bip-bop-02.aac"
import bipbop03 from "@opencode-ai/ui/audio/bip-bop-03.aac"
import bipbop04 from "@opencode-ai/ui/audio/bip-bop-04.aac"
import bipbop05 from "@opencode-ai/ui/audio/bip-bop-05.aac"
import bipbop06 from "@opencode-ai/ui/audio/bip-bop-06.aac"
import bipbop07 from "@opencode-ai/ui/audio/bip-bop-07.aac"
import bipbop08 from "@opencode-ai/ui/audio/bip-bop-08.aac"
import bipbop09 from "@opencode-ai/ui/audio/bip-bop-09.aac"
import bipbop10 from "@opencode-ai/ui/audio/bip-bop-10.aac"
import nope01 from "@opencode-ai/ui/audio/nope-01.aac"
import nope02 from "@opencode-ai/ui/audio/nope-02.aac"
import nope03 from "@opencode-ai/ui/audio/nope-03.aac"
import nope04 from "@opencode-ai/ui/audio/nope-04.aac"
import nope05 from "@opencode-ai/ui/audio/nope-05.aac"
import nope06 from "@opencode-ai/ui/audio/nope-06.aac"
import nope07 from "@opencode-ai/ui/audio/nope-07.aac"
import nope08 from "@opencode-ai/ui/audio/nope-08.aac"
import nope09 from "@opencode-ai/ui/audio/nope-09.aac"
import nope10 from "@opencode-ai/ui/audio/nope-10.aac"
import nope11 from "@opencode-ai/ui/audio/nope-11.aac"
import nope12 from "@opencode-ai/ui/audio/nope-12.aac"
import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac"
import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac"
import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac"
import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac"
import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac"
import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac"
import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac"
import yup01 from "@opencode-ai/ui/audio/yup-01.aac"
import yup02 from "@opencode-ai/ui/audio/yup-02.aac"
import yup03 from "@opencode-ai/ui/audio/yup-03.aac"
import yup04 from "@opencode-ai/ui/audio/yup-04.aac"
import yup05 from "@opencode-ai/ui/audio/yup-05.aac"
import yup06 from "@opencode-ai/ui/audio/yup-06.aac"
export const SOUND_OPTIONS = [ export const SOUND_OPTIONS = [
{ id: "alert-01", label: "sound.option.alert01", src: alert01 }, { id: "alert-01", label: "sound.option.alert01" },
{ id: "alert-02", label: "sound.option.alert02", src: alert02 }, { id: "alert-02", label: "sound.option.alert02" },
{ id: "alert-03", label: "sound.option.alert03", src: alert03 }, { id: "alert-03", label: "sound.option.alert03" },
{ id: "alert-04", label: "sound.option.alert04", src: alert04 }, { id: "alert-04", label: "sound.option.alert04" },
{ id: "alert-05", label: "sound.option.alert05", src: alert05 }, { id: "alert-05", label: "sound.option.alert05" },
{ id: "alert-06", label: "sound.option.alert06", src: alert06 }, { id: "alert-06", label: "sound.option.alert06" },
{ id: "alert-07", label: "sound.option.alert07", src: alert07 }, { id: "alert-07", label: "sound.option.alert07" },
{ id: "alert-08", label: "sound.option.alert08", src: alert08 }, { id: "alert-08", label: "sound.option.alert08" },
{ id: "alert-09", label: "sound.option.alert09", src: alert09 }, { id: "alert-09", label: "sound.option.alert09" },
{ id: "alert-10", label: "sound.option.alert10", src: alert10 }, { id: "alert-10", label: "sound.option.alert10" },
{ id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 }, { id: "bip-bop-01", label: "sound.option.bipbop01" },
{ id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 }, { id: "bip-bop-02", label: "sound.option.bipbop02" },
{ id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 }, { id: "bip-bop-03", label: "sound.option.bipbop03" },
{ id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 }, { id: "bip-bop-04", label: "sound.option.bipbop04" },
{ id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 }, { id: "bip-bop-05", label: "sound.option.bipbop05" },
{ id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 }, { id: "bip-bop-06", label: "sound.option.bipbop06" },
{ id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 }, { id: "bip-bop-07", label: "sound.option.bipbop07" },
{ id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 }, { id: "bip-bop-08", label: "sound.option.bipbop08" },
{ id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 }, { id: "bip-bop-09", label: "sound.option.bipbop09" },
{ id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 }, { id: "bip-bop-10", label: "sound.option.bipbop10" },
{ id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 }, { id: "staplebops-01", label: "sound.option.staplebops01" },
{ id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 }, { id: "staplebops-02", label: "sound.option.staplebops02" },
{ id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 }, { id: "staplebops-03", label: "sound.option.staplebops03" },
{ id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 }, { id: "staplebops-04", label: "sound.option.staplebops04" },
{ id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 }, { id: "staplebops-05", label: "sound.option.staplebops05" },
{ id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 }, { id: "staplebops-06", label: "sound.option.staplebops06" },
{ id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 }, { id: "staplebops-07", label: "sound.option.staplebops07" },
{ id: "nope-01", label: "sound.option.nope01", src: nope01 }, { id: "nope-01", label: "sound.option.nope01" },
{ id: "nope-02", label: "sound.option.nope02", src: nope02 }, { id: "nope-02", label: "sound.option.nope02" },
{ id: "nope-03", label: "sound.option.nope03", src: nope03 }, { id: "nope-03", label: "sound.option.nope03" },
{ id: "nope-04", label: "sound.option.nope04", src: nope04 }, { id: "nope-04", label: "sound.option.nope04" },
{ id: "nope-05", label: "sound.option.nope05", src: nope05 }, { id: "nope-05", label: "sound.option.nope05" },
{ id: "nope-06", label: "sound.option.nope06", src: nope06 }, { id: "nope-06", label: "sound.option.nope06" },
{ id: "nope-07", label: "sound.option.nope07", src: nope07 }, { id: "nope-07", label: "sound.option.nope07" },
{ id: "nope-08", label: "sound.option.nope08", src: nope08 }, { id: "nope-08", label: "sound.option.nope08" },
{ id: "nope-09", label: "sound.option.nope09", src: nope09 }, { id: "nope-09", label: "sound.option.nope09" },
{ id: "nope-10", label: "sound.option.nope10", src: nope10 }, { id: "nope-10", label: "sound.option.nope10" },
{ id: "nope-11", label: "sound.option.nope11", src: nope11 }, { id: "nope-11", label: "sound.option.nope11" },
{ id: "nope-12", label: "sound.option.nope12", src: nope12 }, { id: "nope-12", label: "sound.option.nope12" },
{ id: "yup-01", label: "sound.option.yup01", src: yup01 }, { id: "yup-01", label: "sound.option.yup01" },
{ id: "yup-02", label: "sound.option.yup02", src: yup02 }, { id: "yup-02", label: "sound.option.yup02" },
{ id: "yup-03", label: "sound.option.yup03", src: yup03 }, { id: "yup-03", label: "sound.option.yup03" },
{ id: "yup-04", label: "sound.option.yup04", src: yup04 }, { id: "yup-04", label: "sound.option.yup04" },
{ id: "yup-05", label: "sound.option.yup05", src: yup05 }, { id: "yup-05", label: "sound.option.yup05" },
{ id: "yup-06", label: "sound.option.yup06", src: yup06 }, { id: "yup-06", label: "sound.option.yup06" },
] as const ] as const
export type SoundOption = (typeof SOUND_OPTIONS)[number] export type SoundOption = (typeof SOUND_OPTIONS)[number]
export type SoundID = SoundOption["id"] export type SoundID = SoundOption["id"]
const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record<SoundID, string> function getLoads() {
if (loads) return loads
loads = Object.fromEntries(
Object.entries(getFiles()).flatMap(([path, load]) => {
const file = path.split("/").at(-1)
if (!file) return []
return [[file.replace(/\.aac$/, ""), load] as const]
}),
) as Record<SoundID, () => Promise<string>>
return loads
}
const cache = new Map<SoundID, Promise<string | undefined>>()
export function soundSrc(id: string | undefined) { export function soundSrc(id: string | undefined) {
if (!id) return const loads = getLoads()
if (!(id in soundById)) return if (!id || !(id in loads)) return Promise.resolve(undefined)
return soundById[id as SoundID] const key = id as SoundID
const hit = cache.get(key)
if (hit) return hit
const next = loads[key]().catch(() => undefined)
cache.set(key, next)
return next
} }
export function playSound(src: string | undefined) { export function playSound(src: string | undefined) {
@ -108,10 +91,12 @@ export function playSound(src: string | undefined) {
if (!src) return if (!src) return
const audio = new Audio(src) const audio = new Audio(src)
audio.play().catch(() => undefined) audio.play().catch(() => undefined)
// Return a cleanup function to pause the sound.
return () => { return () => {
audio.pause() audio.pause()
audio.currentTime = 0 audio.currentTime = 0
} }
} }
export function playSoundById(id: string | undefined) {
return soundSrc(id).then((src) => playSound(src))
}

View file

@ -1,7 +1,10 @@
import { readFileSync } from "node:fs"
import solidPlugin from "vite-plugin-solid" import solidPlugin from "vite-plugin-solid"
import tailwindcss from "@tailwindcss/vite" import tailwindcss from "@tailwindcss/vite"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
/** /**
* @type {import("vite").PluginOption} * @type {import("vite").PluginOption}
*/ */
@ -21,6 +24,15 @@ export default [
} }
}, },
}, },
{
name: "opencode-desktop:theme-preload",
transformIndexHtml(html) {
return html.replace(
'<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>',
`<script id="oc-theme-preload-script">${readFileSync(theme, "utf8")}</script>`,
)
},
},
tailwindcss(), tailwindcss(),
solidPlugin(), solidPlugin(),
] ]

View file

@ -88,7 +88,7 @@ export function registerIpcHandlers(deps: Deps) {
"open-directory-picker", "open-directory-picker",
async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => { async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
const result = await dialog.showOpenDialog({ const result = await dialog.showOpenDialog({
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : [])], properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
title: opts?.title ?? "Choose a folder", title: opts?.title ?? "Choose a folder",
defaultPath: opts?.defaultPath, defaultPath: opts?.defaultPath,
}) })

View file

@ -7,7 +7,7 @@ const cache = new Map<string, Store>()
export function getStore(name = SETTINGS_STORE) { export function getStore(name = SETTINGS_STORE) {
const cached = cache.get(name) const cached = cache.get(name)
if (cached) return cached if (cached) return cached
const next = new Store({ name }) const next = new Store({ name, fileExtension: "" })
cache.set(name, next) cache.set(name, next)
return next return next
} }

View file

@ -6,6 +6,9 @@ import {
AppBaseProviders, AppBaseProviders,
AppInterface, AppInterface,
handleNotificationClick, handleNotificationClick,
loadLocaleDict,
normalizeLocale,
type Locale,
type Platform, type Platform,
PlatformProvider, PlatformProvider,
ServerConnection, ServerConnection,
@ -246,6 +249,17 @@ listenForDeepLinks()
render(() => { render(() => {
const platform = createPlatform() const platform = createPlatform()
const loadLocale = async () => {
const current = await platform.storage?.("opencode.global.dat").getItem("language")
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
const raw = current ?? legacy
if (!raw) return
const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
if (!locale) return
const next = normalizeLocale(locale)
if (next !== "en") await loadLocaleDict(next)
return next satisfies Locale
}
const [windowCount] = createResource(() => window.api.getWindowCount()) const [windowCount] = createResource(() => window.api.getWindowCount())
@ -257,6 +271,7 @@ render(() => {
if (url) return ServerConnection.key({ type: "http", http: { url } }) if (url) return ServerConnection.key({ type: "http", http: { url } })
}), }),
) )
const [locale] = createResource(loadLocale)
const servers = () => { const servers = () => {
const data = sidecar() const data = sidecar()
@ -309,15 +324,14 @@ render(() => {
return ( return (
<PlatformProvider value={platform}> <PlatformProvider value={platform}>
<AppBaseProviders> <AppBaseProviders locale={locale.latest}>
<Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading}> <Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading}>
{(_) => { {(_) => {
return ( return (
<AppInterface <AppInterface
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")} defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
servers={servers()} servers={servers()}
router={MemoryRouter} router={MemoryRouter}
disableHealthCheck={(windowCount() ?? 0) > 1}
> >
<Inner /> <Inner />
</AppInterface> </AppInterface>

View file

@ -6,6 +6,9 @@ import {
AppBaseProviders, AppBaseProviders,
AppInterface, AppInterface,
handleNotificationClick, handleNotificationClick,
loadLocaleDict,
normalizeLocale,
type Locale,
type Platform, type Platform,
PlatformProvider, PlatformProvider,
ServerConnection, ServerConnection,
@ -414,6 +417,17 @@ void listenForDeepLinks()
render(() => { render(() => {
const platform = createPlatform() const platform = createPlatform()
const loadLocale = async () => {
const current = await platform.storage?.("opencode.global.dat").getItem("language")
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
const raw = current ?? legacy
if (!raw) return
const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
if (!locale) return
const next = normalizeLocale(locale)
if (next !== "en") await loadLocaleDict(next)
return next satisfies Locale
}
// Fetch sidecar credentials from Rust (available immediately, before health check) // Fetch sidecar credentials from Rust (available immediately, before health check)
const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any)) const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
@ -423,6 +437,7 @@ render(() => {
if (url) return ServerConnection.key({ type: "http", http: { url } }) if (url) return ServerConnection.key({ type: "http", http: { url } })
}), }),
) )
const [locale] = createResource(loadLocale)
// Build the sidecar server connection once credentials arrive // Build the sidecar server connection once credentials arrive
const servers = () => { const servers = () => {
@ -465,8 +480,8 @@ render(() => {
return ( return (
<PlatformProvider value={platform}> <PlatformProvider value={platform}>
<AppBaseProviders> <AppBaseProviders locale={locale.latest}>
<Show when={!defaultServer.loading && !sidecar.loading}> <Show when={!defaultServer.loading && !sidecar.loading && !locale.loading}>
{(_) => { {(_) => {
return ( return (
<AppInterface <AppInterface

View file

@ -31,12 +31,14 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
- Use `Schema.Defect` instead of `unknown` for defect-like causes. - Use `Schema.Defect` instead of `unknown` for defect-like causes.
- In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches. - In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
## Runtime vs Instances ## Runtime vs InstanceState
- Use the shared runtime for process-wide services with one lifecycle for the whole app. - Use `makeRuntime` (from `src/effect/run-service.ts`) for all services. It returns `{ runPromise, runFork, runCallback }` backed by a shared `memoMap` that deduplicates layers.
- Use `src/effect/instances.ts` for per-directory or per-project services that need `InstanceContext`, per-instance state, or per-instance cleanup. - Use `InstanceState` (from `src/effect/instance-state.ts`) for per-directory or per-project state that needs per-instance cleanup. It uses `ScopedCache` keyed by directory — each open project gets its own state, automatically cleaned up on disposal.
- If two open directories should not share one copy of the service, it belongs in `Instances`. - If two open directories should not share one copy of the service, it needs `InstanceState`.
- Instance-scoped services should read context from `InstanceContext`, not `Instance.*` globals. - Do the work directly in the `InstanceState.make` closure — `ScopedCache` handles run-once semantics. Don't add fibers, `ensure()` callbacks, or `started` flags on top.
- Use `Effect.addFinalizer` or `Effect.acquireRelease` inside the `InstanceState.make` closure for cleanup (subscriptions, process teardown, etc.).
- Use `Effect.forkScoped` inside the closure for background stream consumers — the fiber is interrupted when the instance is disposed.
## Preferred Effect services ## Preferred Effect services
@ -51,7 +53,7 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called. `Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`. Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish` or anything that reads `Instance.directory`.
You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers. You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers.

View file

@ -0,0 +1,13 @@
CREATE TABLE `event_sequence` (
`aggregate_id` text PRIMARY KEY,
`seq` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `event` (
`id` text PRIMARY KEY,
`aggregate_id` text NOT NULL,
`seq` integer NOT NULL,
`type` text NOT NULL,
`data` text NOT NULL,
CONSTRAINT `fk_event_aggregate_id_event_sequence_aggregate_id_fk` FOREIGN KEY (`aggregate_id`) REFERENCES `event_sequence`(`aggregate_id`) ON DELETE CASCADE
);

File diff suppressed because it is too large Load diff

View file

@ -92,7 +92,7 @@
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2", "@modelcontextprotocol/sdk": "1.27.1",
"@octokit/graphql": "9.0.2", "@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:", "@openauthjs/openauth": "catalog:",
@ -121,7 +121,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.3.2", "gitlab-ai-provider": "5.3.3",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@ -133,9 +133,9 @@
"minimatch": "10.0.3", "minimatch": "10.0.3",
"open": "10.1.2", "open": "10.1.2",
"opencode-gitlab-auth": "2.0.0", "opencode-gitlab-auth": "2.0.0",
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "0.0.6", "opentui-spinner": "0.0.6",
"partial-json": "0.1.7", "partial-json": "0.1.7",
"opencode-poe-auth": "0.0.1",
"remeda": "catalog:", "remeda": "catalog:",
"semver": "^7.6.3", "semver": "^7.6.3",
"solid-js": "catalog:", "solid-js": "catalog:",

View file

@ -6,7 +6,7 @@ Practical reference for new and migrated Effect code in `packages/opencode`.
Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal. Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`.
- Global services (no per-directory state): Account, Auth, Installation, Truncate - Global services (no per-directory state): Account, Auth, Installation, Truncate
- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth - Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
@ -46,7 +46,7 @@ export namespace Foo {
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer)) export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
// Per-service runtime (inside the namespace) // Per-service runtime (inside the namespace)
const runPromise = makeRunPromise(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
// Async facade functions // Async facade functions
export async function get(id: FooID) { export async function get(id: FooID) {
@ -79,22 +79,24 @@ See `Auth.ZodInfo` for the canonical example.
The `InstanceState.make` init callback receives a `Scope`, so you can use `Effect.acquireRelease`, `Effect.addFinalizer`, and `Effect.forkScoped` inside it. Resources acquired this way are automatically cleaned up when the instance is disposed or invalidated by `ScopedCache`. This makes it the right place for: The `InstanceState.make` init callback receives a `Scope`, so you can use `Effect.acquireRelease`, `Effect.addFinalizer`, and `Effect.forkScoped` inside it. Resources acquired this way are automatically cleaned up when the instance is disposed or invalidated by `ScopedCache`. This makes it the right place for:
- **Subscriptions**: Use `Effect.acquireRelease` to subscribe and auto-unsubscribe: - **Subscriptions**: Yield `Bus.Service` at the layer level, then use `Stream` + `forkScoped` inside the init closure. The fiber is automatically interrupted when the instance scope closes:
```ts ```ts
const bus = yield * Bus.Service
const cache = const cache =
yield * yield *
InstanceState.make<State>( InstanceState.make<State>(
Effect.fn("Foo.state")(function* (ctx) { Effect.fn("Foo.state")(function* (ctx) {
// ... load state ... // ... load state ...
yield* Effect.acquireRelease( yield* bus.subscribeAll().pipe(
Effect.sync(() => Stream.runForEach((event) =>
Bus.subscribeAll((event) => { Effect.sync(() => {
/* handle */ /* handle */
}), }),
), ),
(unsub) => Effect.sync(unsub), Effect.forkScoped,
) )
return { return {
@ -104,6 +106,16 @@ const cache =
) )
``` ```
- **Resource cleanup**: Use `Effect.acquireRelease` or `Effect.addFinalizer` for resources that need teardown (native watchers, process handles, etc.):
```ts
yield *
Effect.acquireRelease(
Effect.sync(() => nativeAddon.watch(dir)),
(watcher) => Effect.sync(() => watcher.close()),
)
```
- **Background fibers**: Use `Effect.forkScoped` — the fiber is interrupted on disposal. - **Background fibers**: Use `Effect.forkScoped` — the fiber is interrupted on disposal.
- **Side effects at init**: Config notification, event wiring, etc. all belong in the init closure. Callers just do `InstanceState.get(cache)` to trigger everything, and `ScopedCache` deduplicates automatically. - **Side effects at init**: Config notification, event wiring, etc. all belong in the init closure. Callers just do `InstanceState.get(cache)` to trigger everything, and `ScopedCache` deduplicates automatically.
@ -165,7 +177,7 @@ Still open and likely worth migrating:
- [x] `ToolRegistry` - [x] `ToolRegistry`
- [ ] `Pty` - [ ] `Pty`
- [x] `Worktree` - [x] `Worktree`
- [ ] `Bus` - [x] `Bus`
- [x] `Command` - [x] `Command`
- [ ] `Config` - [ ] `Config`
- [ ] `Session` - [ ] `Session`
@ -175,4 +187,4 @@ Still open and likely worth migrating:
- [ ] `Provider` - [ ] `Provider`
- [x] `Project` - [x] `Project`
- [ ] `LSP` - [ ] `LSP`
- [ ] `MCP` - [x] `MCP`

View file

@ -1,7 +1,7 @@
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect" import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { makeRunPromise } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client" import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo" import { AccountRepo, type AccountRow } from "./repo"
import { import {
@ -379,7 +379,7 @@ export namespace Account {
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer)) export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
export const runPromise = makeRunPromise(Service, defaultLayer) export const { runPromise } = makeRuntime(Service, defaultLayer)
export async function active(): Promise<Info | undefined> { export async function active(): Promise<Info | undefined> {
return Option.getOrUndefined(await runPromise((service) => service.active())) return Option.getOrUndefined(await runPromise((service) => service.active()))

View file

@ -8,6 +8,7 @@ import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } f
export type AccountRow = (typeof AccountTable)["$inferSelect"] export type AccountRow = (typeof AccountTable)["$inferSelect"]
type DbClient = Parameters<typeof Database.use>[0] extends (db: infer T) => unknown ? T : never type DbClient = Parameters<typeof Database.use>[0] extends (db: infer T) => unknown ? T : never
type DbTransactionCallback<A> = Parameters<typeof Database.transaction<A>>[0]
const ACCOUNT_STATE_ID = 1 const ACCOUNT_STATE_ID = 1
@ -42,13 +43,13 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
Effect.gen(function* () { Effect.gen(function* () {
const decode = Schema.decodeUnknownSync(Info) const decode = Schema.decodeUnknownSync(Info)
const query = <A>(f: (db: DbClient) => A) => const query = <A>(f: DbTransactionCallback<A>) =>
Effect.try({ Effect.try({
try: () => Database.use(f), try: () => Database.use(f),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }), catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
}) })
const tx = <A>(f: (db: DbClient) => A) => const tx = <A>(f: DbTransactionCallback<A>) =>
Effect.try({ Effect.try({
try: () => Database.transaction(f), try: () => Database.transaction(f),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }), catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),

View file

@ -21,7 +21,7 @@ import { Plugin } from "@/plugin"
import { Skill } from "../skill" import { Skill } from "../skill"
import { Effect, ServiceMap, Layer } from "effect" import { Effect, ServiceMap, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
export namespace Agent { export namespace Agent {
export const Info = z export const Info = z
@ -148,7 +148,6 @@ export namespace Agent {
permission: Permission.merge( permission: Permission.merge(
defaults, defaults,
Permission.fromConfig({ Permission.fromConfig({
todoread: "deny",
todowrite: "deny", todowrite: "deny",
}), }),
user, user,
@ -394,7 +393,7 @@ export namespace Agent {
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer)) export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
const runPromise = makeRunPromise(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get(agent: string) { export async function get(agent: string) {
return runPromise((svc) => svc.get(agent)) return runPromise((svc) => svc.get(agent))

View file

@ -1,6 +1,6 @@
import path from "path" import path from "path"
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect" import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { makeRunPromise } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { Global } from "../global" import { Global } from "../global"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
@ -95,7 +95,7 @@ export namespace Auth {
}), }),
) )
const runPromise = makeRunPromise(Service, layer) const { runPromise } = makeRuntime(Service, layer)
export async function get(providerID: string) { export async function get(providerID: string) {
return runPromise((service) => service.get(providerID)) return runPromise((service) => service.get(providerID))

View file

@ -1,10 +1,7 @@
import z from "zod" import z from "zod"
import type { ZodType } from "zod" import type { ZodType } from "zod"
import { Log } from "../util/log"
export namespace BusEvent { export namespace BusEvent {
const log = Log.create({ service: "event" })
export type Definition = ReturnType<typeof define> export type Definition = ReturnType<typeof define>
const registry = new Map<string, Definition>() const registry = new Map<string, Definition>()

View file

@ -1,12 +1,14 @@
import z from "zod" import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { BusEvent } from "./bus-event" import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global" import { GlobalBus } from "./global"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
export namespace Bus { export namespace Bus {
const log = Log.create({ service: "bus" }) const log = Log.create({ service: "bus" })
type Subscription = (event: any) => void
export const InstanceDisposed = BusEvent.define( export const InstanceDisposed = BusEvent.define(
"server.instance.disposed", "server.instance.disposed",
@ -15,91 +17,168 @@ export namespace Bus {
}), }),
) )
const state = Instance.state( type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
() => { type: D["type"]
const subscriptions = new Map<any, Subscription[]>() properties: z.infer<D["properties"]>
}
return { type State = {
subscriptions, wildcard: PubSub.PubSub<Payload>
typed: Map<string, PubSub.PubSub<Payload>>
}
export interface Interface {
readonly publish: <D extends BusEvent.Definition>(
def: D,
properties: z.output<D["properties"]>,
) => Effect.Effect<void>
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
readonly subscribeAll: () => Stream.Stream<Payload>
readonly subscribeCallback: <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) => Effect.Effect<() => void>
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Bus") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const cache = yield* InstanceState.make<State>(
Effect.fn("Bus.state")(function* (ctx) {
const wildcard = yield* PubSub.unbounded<Payload>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
// Publish InstanceDisposed before shutting down so subscribers see it
yield* PubSub.publish(wildcard, {
type: InstanceDisposed.type,
properties: { directory: ctx.directory },
})
yield* PubSub.shutdown(wildcard)
for (const ps of typed.values()) {
yield* PubSub.shutdown(ps)
}
}),
)
return { wildcard, typed }
}),
)
function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
return Effect.gen(function* () {
let ps = state.typed.get(def.type)
if (!ps) {
ps = yield* PubSub.unbounded<Payload>()
state.typed.set(def.type, ps)
}
return ps as unknown as PubSub.PubSub<Payload<D>>
})
} }
},
async (entry) => { function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
const wildcard = entry.subscriptions.get("*") return Effect.gen(function* () {
if (!wildcard) return const state = yield* InstanceState.get(cache)
const event = { const payload: Payload = { type: def.type, properties }
type: InstanceDisposed.type, log.info("publishing", { type: def.type })
properties: {
directory: Instance.directory, const ps = state.typed.get(def.type)
}, if (ps) yield* PubSub.publish(ps, payload)
yield* PubSub.publish(state.wildcard, payload)
GlobalBus.emit("event", {
directory: Instance.directory,
payload,
})
})
} }
for (const sub of [...wildcard]) {
sub(event) function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
log.info("subscribing", { type: def.type })
return Stream.unwrap(
Effect.gen(function* () {
const state = yield* InstanceState.get(cache)
const ps = yield* getOrCreate(state, def)
return Stream.fromPubSub(ps)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
} }
},
function subscribeAll(): Stream.Stream<Payload> {
log.info("subscribing", { type: "*" })
return Stream.unwrap(
Effect.gen(function* () {
const state = yield* InstanceState.get(cache)
return Stream.fromPubSub(state.wildcard)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
}
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
return Effect.gen(function* () {
log.info("subscribing", { type })
const scope = yield* Scope.make()
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
yield* Scope.provide(scope)(
Stream.fromSubscription(subscription).pipe(
Stream.runForEach((msg) =>
Effect.tryPromise({
try: () => Promise.resolve().then(() => callback(msg)),
catch: (cause) => {
log.error("subscriber failed", { type, cause })
},
}).pipe(Effect.ignore),
),
Effect.forkScoped,
),
)
return () => {
log.info("unsubscribing", { type })
Effect.runFork(Scope.close(scope, Exit.void))
}
})
}
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) {
const state = yield* InstanceState.get(cache)
const ps = yield* getOrCreate(state, def)
return yield* on(ps, def.type, callback)
})
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
const state = yield* InstanceState.get(cache)
return yield* on(state.wildcard, "*", callback)
})
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
}),
) )
export async function publish<Definition extends BusEvent.Definition>( const { runPromise, runSync } = makeRuntime(Service, layer)
def: Definition,
properties: z.output<Definition["properties"]>, // runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return runPromise((svc) => svc.publish(def, properties))
}
export function subscribe<D extends BusEvent.Definition>(
def: D,
callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
) { ) {
const payload = { return runSync((svc) => svc.subscribeCallback(def, callback))
type: def.type,
properties,
}
log.info("publishing", {
type: def.type,
})
const pending = []
for (const key of [def.type, "*"]) {
const match = [...(state().subscriptions.get(key) ?? [])]
for (const sub of match) {
pending.push(sub(payload))
}
}
GlobalBus.emit("event", {
directory: Instance.directory,
payload,
})
return Promise.all(pending)
} }
export function subscribe<Definition extends BusEvent.Definition>( export function subscribeAll(callback: (event: any) => unknown) {
def: Definition, return runSync((svc) => svc.subscribeAllCallback(callback))
callback: (event: { type: Definition["type"]; properties: z.infer<Definition["properties"]> }) => void,
) {
return raw(def.type, callback)
}
export function once<Definition extends BusEvent.Definition>(
def: Definition,
callback: (event: {
type: Definition["type"]
properties: z.infer<Definition["properties"]>
}) => "done" | undefined,
) {
const unsub = subscribe(def, (event) => {
if (callback(event)) unsub()
})
}
export function subscribeAll(callback: (event: any) => void) {
return raw("*", callback)
}
function raw(type: string, callback: (event: any) => void) {
log.info("subscribing", { type })
const subscriptions = state().subscriptions
let match = subscriptions.get(type) ?? []
match.push(callback)
subscriptions.set(type, match)
return () => {
log.info("unsubscribing", { type })
const match = subscriptions.get(type)
if (!match) return
const index = match.indexOf(callback)
if (index === -1) return
match.splice(index, 1)
}
} }
} }

View file

@ -14,19 +14,7 @@ import type { Argv } from "yargs"
type AgentMode = "all" | "primary" | "subagent" type AgentMode = "all" | "primary" | "subagent"
const AVAILABLE_TOOLS = [ const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "list", "glob", "grep", "webfetch", "task", "todowrite"]
"bash",
"read",
"write",
"edit",
"list",
"glob",
"grep",
"webfetch",
"task",
"todowrite",
"todoread",
]
const AgentCreateCommand = cmd({ const AgentCreateCommand = cmd({
command: "create", command: "create",

View file

@ -28,9 +28,9 @@ import { Provider } from "../../provider/provider"
import { Bus } from "../../bus" import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2" import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt" import { SessionPrompt } from "@/session/prompt"
import { Git } from "@/git"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util/process" import { Process } from "@/util/process"
import { git } from "@/util/git"
type GitHubAuthor = { type GitHubAuthor = {
login: string login: string
@ -257,7 +257,7 @@ export const GithubInstallCommand = cmd({
} }
// Get repo info // Get repo info
const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim() const info = (await Git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
const parsed = parseGitHubRemote(info) const parsed = parseGitHubRemote(info)
if (!parsed) { if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
@ -496,20 +496,20 @@ export const GithubRunCommand = cmd({
: "issue" : "issue"
: undefined : undefined
const gitText = async (args: string[]) => { const gitText = async (args: string[]) => {
const result = await git(args, { cwd: Instance.worktree }) const result = await Git.run(args, { cwd: Instance.worktree })
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
} }
return result.text().trim() return result.text().trim()
} }
const gitRun = async (args: string[]) => { const gitRun = async (args: string[]) => {
const result = await git(args, { cwd: Instance.worktree }) const result = await Git.run(args, { cwd: Instance.worktree })
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
} }
return result return result
} }
const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree }) const gitStatus = (args: string[]) => Git.run(args, { cwd: Instance.worktree })
const commitChanges = async (summary: string, actor?: string) => { const commitChanges = async (summary: string, actor?: string) => {
const args = ["commit", "-m", summary] const args = ["commit", "-m", summary]
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`) if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
@ -869,7 +869,6 @@ export const GithubRunCommand = cmd({
function subscribeSessionEvents() { function subscribeSessionEvents() {
const TOOL: Record<string, [string, string]> = { const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD], bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD], edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
glob: ["Glob", UI.Style.TEXT_INFO_BOLD], glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
@ -890,7 +889,7 @@ export const GithubRunCommand = cmd({
} }
let text = "" let text = ""
Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { Bus.subscribe(MessageV2.Event.PartUpdated, (evt) => {
if (evt.properties.part.sessionID !== session.id) return if (evt.properties.part.sessionID !== session.id) return
//if (evt.properties.part.messageID === messageID) return //if (evt.properties.part.messageID === messageID) return
const part = evt.properties.part const part = evt.properties.part

View file

@ -1,8 +1,8 @@
import { UI } from "../ui" import { UI } from "../ui"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { Git } from "@/git"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { Process } from "@/util/process" import { Process } from "@/util/process"
import { git } from "@/util/git"
export const PrCommand = cmd({ export const PrCommand = cmd({
command: "pr <number>", command: "pr <number>",
@ -67,9 +67,9 @@ export const PrCommand = cmd({
const remoteName = forkOwner const remoteName = forkOwner
// Check if remote already exists // Check if remote already exists
const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim() const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim()
if (!remotes.split("\n").includes(remoteName)) { if (!remotes.split("\n").includes(remoteName)) {
await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { await Git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
cwd: Instance.worktree, cwd: Instance.worktree,
}) })
UI.println(`Added fork remote: ${remoteName}`) UI.println(`Added fork remote: ${remoteName}`)
@ -77,7 +77,7 @@ export const PrCommand = cmd({
// Set upstream to the fork so pushes go there // Set upstream to the fork so pushes go there
const headRefName = prInfo.headRefName const headRefName = prInfo.headRefName
await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], { await Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
cwd: Instance.worktree, cwd: Instance.worktree,
}) })
} }

View file

@ -710,7 +710,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}) })
}) })
sdk.event.on(SessionApi.Event.Deleted.type, (evt) => { sdk.event.on("session.deleted", (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) { if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
route.navigate({ type: "home" }) route.navigate({ type: "home" })
toast.show({ toast.show({
@ -720,7 +720,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
} }
}) })
sdk.event.on(SessionApi.Event.Error.type, (evt) => { sdk.event.on("session.error", (evt) => {
const error = evt.properties.error const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return if (error && typeof error === "object" && error.name === "MessageAbortedError") return
const message = (() => { const message = (() => {

View file

@ -1,6 +1,6 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { SessionID, MessageID } from "@/session/schema" import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import z from "zod" import z from "zod"
@ -173,7 +173,7 @@ export namespace Command {
}), }),
) )
const runPromise = makeRunPromise(Service, layer) const { runPromise } = makeRuntime(Service, layer)
export async function get(name: string) { export async function get(name: string) {
return runPromise((svc) => svc.get(name)) return runPromise((svc) => svc.get(name))

View file

@ -673,7 +673,6 @@ export namespace Config {
task: PermissionRule.optional(), task: PermissionRule.optional(),
external_directory: PermissionRule.optional(), external_directory: PermissionRule.optional(),
todowrite: PermissionAction.optional(), todowrite: PermissionAction.optional(),
todoread: PermissionAction.optional(),
question: PermissionAction.optional(), question: PermissionAction.optional(),
webfetch: PermissionAction.optional(), webfetch: PermissionAction.optional(),
websearch: PermissionAction.optional(), websearch: PermissionAction.optional(),

View file

@ -1,14 +0,0 @@
import { ServiceMap } from "effect"
import type { Project } from "@/project/project"
export declare namespace InstanceContext {
export interface Shape {
readonly directory: string
readonly worktree: string
readonly project: Project.Info
}
}
export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()(
"opencode/InstanceContext",
) {}

View file

@ -3,11 +3,15 @@ import * as ServiceMap from "effect/ServiceMap"
export const memoMap = Layer.makeMemoMapUnsafe() export const memoMap = Layer.makeMemoMapUnsafe()
export function makeRunPromise<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) { export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
const getRuntime = () => (rt ??= ManagedRuntime.make(layer, { memoMap }))
return <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => { return {
rt ??= ManagedRuntime.make(layer, { memoMap }) runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
return rt.runPromise(service.use(fn), options) runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
getRuntime().runPromise(service.use(fn), options),
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),
runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)),
} }
} }

View file

@ -1,8 +1,8 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { git } from "@/util/git" import { Git } from "@/git"
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { formatPatch, structuredPatch } from "diff" import { formatPatch, structuredPatch } from "diff"
import fs from "fs" import fs from "fs"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
@ -323,7 +323,6 @@ export namespace File {
interface State { interface State {
cache: Entry cache: Entry
fiber: Fiber.Fiber<void> | undefined
} }
export interface Interface { export interface Interface {
@ -348,7 +347,6 @@ export namespace File {
Effect.fn("File.state")(() => Effect.fn("File.state")(() =>
Effect.succeed({ Effect.succeed({
cache: { files: [], dirs: [] } as Entry, cache: { files: [], dirs: [] } as Entry,
fiber: undefined as Fiber.Fiber<void> | undefined,
}), }),
), ),
) )
@ -406,21 +404,11 @@ export namespace File {
s.cache = next s.cache = next
}) })
const scope = yield* Scope.Scope let cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
const ensure = Effect.fn("File.ensure")(function* () { const ensure = Effect.fn("File.ensure")(function* () {
const s = yield* InstanceState.get(state) yield* cachedScan
if (!s.fiber) cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
s.fiber = yield* scan().pipe(
Effect.catchCause(() => Effect.void),
Effect.ensuring(
Effect.sync(() => {
s.fiber = undefined
}),
),
Effect.forkIn(scope),
)
yield* Fiber.join(s.fiber)
}) })
const init = Effect.fn("File.init")(function* () { const init = Effect.fn("File.init")(function* () {
@ -432,7 +420,7 @@ export namespace File {
return yield* Effect.promise(async () => { return yield* Effect.promise(async () => {
const diffOutput = ( const diffOutput = (
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
cwd: Instance.directory, cwd: Instance.directory,
}) })
).text() ).text()
@ -452,7 +440,7 @@ export namespace File {
} }
const untrackedOutput = ( const untrackedOutput = (
await git( await Git.run(
[ [
"-c", "-c",
"core.fsmonitor=false", "core.fsmonitor=false",
@ -485,7 +473,7 @@ export namespace File {
} }
const deletedOutput = ( const deletedOutput = (
await git( await Git.run(
[ [
"-c", "-c",
"core.fsmonitor=false", "core.fsmonitor=false",
@ -576,17 +564,17 @@ export namespace File {
if (Instance.project.vcs === "git") { if (Instance.project.vcs === "git") {
let diff = ( let diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory }) await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
).text() ).text()
if (!diff.trim()) { if (!diff.trim()) {
diff = ( diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
cwd: Instance.directory, cwd: Instance.directory,
}) })
).text() ).text()
} }
if (diff.trim()) { if (diff.trim()) {
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text() const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
const patch = structuredPatch(file, file, original, content, "old", "new", { const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity, context: Infinity,
ignoreWhitespace: true, ignoreWhitespace: true,
@ -688,7 +676,7 @@ export namespace File {
}), }),
) )
const runPromise = makeRunPromise(Service, layer) const { runPromise } = makeRuntime(Service, layer)
export function init() { export function init() {
return runPromise((svc) => svc.init()) return runPromise((svc) => svc.init())

View file

@ -1,6 +1,6 @@
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect" import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema" import type { SessionID } from "@/session/schema"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
@ -108,7 +108,7 @@ export namespace FileTime {
}), }),
).pipe(Layer.orDie) ).pipe(Layer.orDie)
const runPromise = makeRunPromise(Service, layer) const { runPromise } = makeRuntime(Service, layer)
export function read(sessionID: SessionID, file: string) { export function read(sessionID: SessionID, file: string) {
return runPromise((s) => s.read(sessionID, file)) return runPromise((s) => s.read(sessionID, file))

View file

@ -8,10 +8,10 @@ import z from "zod"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Git } from "@/git"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { git } from "@/util/git"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { Config } from "../config/config" import { Config } from "../config/config"
import { FileIgnore } from "./ignore" import { FileIgnore } from "./ignore"
@ -130,7 +130,7 @@ export namespace FileWatcher {
if (Instance.project.vcs === "git") { if (Instance.project.vcs === "git") {
const result = yield* Effect.promise(() => const result = yield* Effect.promise(() =>
git(["rev-parse", "--git-dir"], { Git.run(["rev-parse", "--git-dir"], {
cwd: Instance.project.worktree, cwd: Instance.project.worktree,
}), }),
) )
@ -159,7 +159,7 @@ export namespace FileWatcher {
}), }),
) )
const runPromise = makeRunPromise(Service, layer) const { runPromise } = makeRuntime(Service, layer)
export function init() { export function init() {
return runPromise((svc) => svc.init()) return runPromise((svc) => svc.init())

View file

@ -1,12 +1,10 @@
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import path from "path" import path from "path"
import { mergeDeep } from "remeda" import { mergeDeep } from "remeda"
import z from "zod" import z from "zod"
import { Bus } from "../bus"
import { Config } from "../config/config" import { Config } from "../config/config"
import { File } from "../file"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Process } from "../util/process" import { Process } from "../util/process"
import { Log } from "../util/log" import { Log } from "../util/log"
@ -29,6 +27,7 @@ export namespace Format {
export interface Interface { export interface Interface {
readonly init: () => Effect.Effect<void> readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<Status[]> readonly status: () => Effect.Effect<Status[]>
readonly file: (filepath: string) => Effect.Effect<void>
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
@ -97,53 +96,46 @@ export namespace Format {
return checks.filter((x) => x.enabled).map((x) => x.item) return checks.filter((x) => x.enabled).map((x) => x.item)
} }
yield* Effect.acquireRelease( async function formatFile(filepath: string) {
Effect.sync(() => log.info("formatting", { file: filepath })
Bus.subscribe( const ext = path.extname(filepath)
File.Event.Edited,
Instance.bind(async (payload) => { for (const item of await getFormatter(ext)) {
const file = payload.properties.file log.info("running", { command: item.command })
log.info("formatting", { file }) try {
const ext = path.extname(file) const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", filepath)),
{
cwd: Instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
},
)
const exit = await proc.exited
if (exit !== 0) {
log.error("failed", {
command: item.command,
...item.environment,
})
}
} catch (error) {
log.error("failed to format file", {
error,
command: item.command,
...item.environment,
file: filepath,
})
}
}
}
for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
try {
const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", file)),
{
cwd: Instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
},
)
const exit = await proc.exited
if (exit !== 0) {
log.error("failed", {
command: item.command,
...item.environment,
})
}
} catch (error) {
log.error("failed to format file", {
error,
command: item.command,
...item.environment,
file,
})
}
}
}),
),
),
(unsubscribe) => Effect.sync(unsubscribe),
)
log.info("init") log.info("init")
return { return {
formatters, formatters,
isEnabled, isEnabled,
formatFile,
} }
}), }),
) )
@ -166,11 +158,16 @@ export namespace Format {
return result return result
}) })
return Service.of({ init, status }) const file = Effect.fn("Format.file")(function* (filepath: string) {
const { formatFile } = yield* InstanceState.get(state)
yield* Effect.promise(() => formatFile(filepath))
})
return Service.of({ init, status, file })
}), }),
) )
const runPromise = makeRunPromise(Service, layer) const { runPromise } = makeRuntime(Service, layer)
export async function init() { export async function init() {
return runPromise((s) => s.init()) return runPromise((s) => s.init())
@ -179,4 +176,8 @@ export namespace Format {
export async function status() { export async function status() {
return runPromise((s) => s.status()) return runPromise((s) => s.status())
} }
export async function file(filepath: string) {
return runPromise((s) => s.file(filepath))
}
} }

View file

@ -0,0 +1,308 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Effect, Layer, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { makeRuntime } from "@/effect/run-service"
export namespace Git {
const cfg = [
"--no-optional-locks",
"-c",
"core.autocrlf=false",
"-c",
"core.fsmonitor=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
] as const
const out = (result: { text(): string }) => result.text().trim()
const nuls = (text: string) => text.split("\0").filter(Boolean)
const fail = (err: unknown) =>
({
exitCode: 1,
text: () => "",
stdout: Buffer.alloc(0),
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
}) satisfies Result
export type Kind = "added" | "deleted" | "modified"
export type Base = {
readonly name: string
readonly ref: string
}
export type Item = {
readonly file: string
readonly code: string
readonly status: Kind
}
export type Stat = {
readonly file: string
readonly additions: number
readonly deletions: number
}
export interface Result {
readonly exitCode: number
readonly text: () => string
readonly stdout: Buffer
readonly stderr: Buffer
}
export interface Options {
readonly cwd: string
readonly env?: Record<string, string>
}
export interface Interface {
readonly run: (args: string[], opts: Options) => Effect.Effect<Result>
readonly branch: (cwd: string) => Effect.Effect<string | undefined>
readonly prefix: (cwd: string) => Effect.Effect<string>
readonly defaultBranch: (cwd: string) => Effect.Effect<Base | undefined>
readonly hasHead: (cwd: string) => Effect.Effect<boolean>
readonly mergeBase: (cwd: string, base: string, head?: string) => Effect.Effect<string | undefined>
readonly show: (cwd: string, ref: string, file: string, prefix?: string) => Effect.Effect<string>
readonly status: (cwd: string) => Effect.Effect<Item[]>
readonly diff: (cwd: string, ref: string) => Effect.Effect<Item[]>
readonly stats: (cwd: string, ref: string) => Effect.Effect<Stat[]>
}
const kind = (code: string): Kind => {
if (code === "??") return "added"
if (code.includes("U")) return "modified"
if (code.includes("A") && !code.includes("D")) return "added"
if (code.includes("D") && !code.includes("A")) return "deleted"
return "modified"
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Git") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const run = Effect.fn("Git.run")(
function* (args: string[], opts: Options) {
const proc = ChildProcess.make("git", [...cfg, ...args], {
cwd: opts.cwd,
env: opts.env,
extendEnv: true,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
})
const handle = yield* spawner.spawn(proc)
const [stdout, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
return {
exitCode: yield* handle.exitCode,
text: () => stdout,
stdout: Buffer.from(stdout),
stderr: Buffer.from(stderr),
} satisfies Result
},
Effect.scoped,
Effect.catch((err) => Effect.succeed(fail(err))),
)
const text = Effect.fn("Git.text")(function* (args: string[], opts: Options) {
return (yield* run(args, opts)).text()
})
const lines = Effect.fn("Git.lines")(function* (args: string[], opts: Options) {
return (yield* text(args, opts))
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
})
const refs = Effect.fnUntraced(function* (cwd: string) {
return yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd })
})
const configured = Effect.fnUntraced(function* (cwd: string, list: string[]) {
const result = yield* run(["config", "init.defaultBranch"], { cwd })
const name = out(result)
if (!name || !list.includes(name)) return
return { name, ref: name } satisfies Base
})
const primary = Effect.fnUntraced(function* (cwd: string) {
const list = yield* lines(["remote"], { cwd })
if (list.includes("origin")) return "origin"
if (list.length === 1) return list[0]
if (list.includes("upstream")) return "upstream"
return list[0]
})
const branch = Effect.fn("Git.branch")(function* (cwd: string) {
const result = yield* run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd })
if (result.exitCode !== 0) return
const text = out(result)
return text || undefined
})
const prefix = Effect.fn("Git.prefix")(function* (cwd: string) {
const result = yield* run(["rev-parse", "--show-prefix"], { cwd })
if (result.exitCode !== 0) return ""
return out(result)
})
const defaultBranch = Effect.fn("Git.defaultBranch")(function* (cwd: string) {
const remote = yield* primary(cwd)
if (remote) {
const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd })
if (head.exitCode === 0) {
const ref = out(head).replace(/^refs\/remotes\//, "")
const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
if (name) return { name, ref } satisfies Base
}
}
const list = yield* refs(cwd)
const next = yield* configured(cwd, list)
if (next) return next
if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base
if (list.includes("master")) return { name: "master", ref: "master" } satisfies Base
})
const hasHead = Effect.fn("Git.hasHead")(function* (cwd: string) {
const result = yield* run(["rev-parse", "--verify", "HEAD"], { cwd })
return result.exitCode === 0
})
const mergeBase = Effect.fn("Git.mergeBase")(function* (cwd: string, base: string, head = "HEAD") {
const result = yield* run(["merge-base", base, head], { cwd })
if (result.exitCode !== 0) return
const text = out(result)
return text || undefined
})
const show = Effect.fn("Git.show")(function* (cwd: string, ref: string, file: string, prefix = "") {
const target = prefix ? `${prefix}${file}` : file
const result = yield* run(["show", `${ref}:${target}`], { cwd })
if (result.exitCode !== 0) return ""
if (result.stdout.includes(0)) return ""
return result.text()
})
const status = Effect.fn("Git.status")(function* (cwd: string) {
return nuls(
yield* text(["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."], {
cwd,
}),
).flatMap((item) => {
const file = item.slice(3)
if (!file) return []
const code = item.slice(0, 2)
return [{ file, code, status: kind(code) } satisfies Item]
})
})
const diff = Effect.fn("Git.diff")(function* (cwd: string, ref: string) {
const list = nuls(
yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }),
)
return list.flatMap((code, idx) => {
if (idx % 2 !== 0) return []
const file = list[idx + 1]
if (!code || !file) return []
return [{ file, code, status: kind(code) } satisfies Item]
})
})
const stats = Effect.fn("Git.stats")(function* (cwd: string, ref: string) {
return nuls(
yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }),
).flatMap((item) => {
const a = item.indexOf("\t")
const b = item.indexOf("\t", a + 1)
if (a === -1 || b === -1) return []
const file = item.slice(b + 1)
if (!file) return []
const adds = item.slice(0, a)
const dels = item.slice(a + 1, b)
const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10)
const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10)
return [
{
file,
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
} satisfies Stat,
]
})
})
return Service.of({
run,
branch,
prefix,
defaultBranch,
hasHead,
mergeBase,
show,
status,
diff,
stats,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(CrossSpawnSpawner.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export function run(args: string[], opts: Options) {
return runPromise((git) => git.run(args, opts))
}
export function branch(cwd: string) {
return runPromise((git) => git.branch(cwd))
}
export function prefix(cwd: string) {
return runPromise((git) => git.prefix(cwd))
}
export function defaultBranch(cwd: string) {
return runPromise((git) => git.defaultBranch(cwd))
}
export function hasHead(cwd: string) {
return runPromise((git) => git.hasHead(cwd))
}
export function mergeBase(cwd: string, base: string, head?: string) {
return runPromise((git) => git.mergeBase(cwd, base, head))
}
export function show(cwd: string, ref: string, file: string, prefix?: string) {
return runPromise((git) => git.show(cwd, ref, file, prefix))
}
export function status(cwd: string) {
return runPromise((git) => git.status(cwd))
}
export function diff(cwd: string, ref: string) {
return runPromise((git) => git.diff(cwd, ref))
}
export function stats(cwd: string, ref: string) {
return runPromise((git) => git.stats(cwd, ref))
}
}

View file

@ -3,6 +3,7 @@ import { randomBytes } from "crypto"
export namespace Identifier { export namespace Identifier {
const prefixes = { const prefixes = {
event: "evt",
session: "ses", session: "ses",
message: "msg", message: "msg",
permission: "per", permission: "per",

View file

@ -2,7 +2,7 @@ import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect" import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { makeRunPromise } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client" import { withTransientReadRetry } from "@/util/effect-http-client"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path" import path from "path"
@ -346,7 +346,7 @@ export namespace Installation {
Layer.provide(NodePath.layer), Layer.provide(NodePath.layer),
) )
const runPromise = makeRunPromise(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
export async function info(): Promise<Info> { export async function info(): Promise<Info> {
return runPromise((svc) => svc.info()) return runPromise((svc) => svc.info())

View file

@ -177,6 +177,12 @@ export namespace LSP {
async function getClients(file: string) { async function getClients(file: string) {
const s = await state() const s = await state()
// Only spawn LSP clients for files within the instance directory
if (!Instance.containsPath(file)) {
return []
}
const extension = path.parse(file).ext || file const extension = path.parse(file).ext || file
const result: LSPClient.Info[] = [] const result: LSPClient.Info[] = []

View file

@ -1,7 +1,9 @@
import path from "path" import path from "path"
import z from "zod" import z from "zod"
import { Global } from "../global" import { Global } from "../global"
import { Filesystem } from "../util/filesystem" import { Effect, Layer, ServiceMap } from "effect"
import { AppFileSystem } from "@/filesystem"
import { makeRuntime } from "@/effect/run-service"
export namespace McpAuth { export namespace McpAuth {
export const Tokens = z.object({ export const Tokens = z.object({
@ -25,106 +27,155 @@ export namespace McpAuth {
clientInfo: ClientInfo.optional(), clientInfo: ClientInfo.optional(),
codeVerifier: z.string().optional(), codeVerifier: z.string().optional(),
oauthState: z.string().optional(), oauthState: z.string().optional(),
serverUrl: z.string().optional(), // Track the URL these credentials are for serverUrl: z.string().optional(),
}) })
export type Entry = z.infer<typeof Entry> export type Entry = z.infer<typeof Entry>
const filepath = path.join(Global.Path.data, "mcp-auth.json") const filepath = path.join(Global.Path.data, "mcp-auth.json")
export async function get(mcpName: string): Promise<Entry | undefined> { export interface Interface {
const data = await all() readonly all: () => Effect.Effect<Record<string, Entry>>
return data[mcpName] readonly get: (mcpName: string) => Effect.Effect<Entry | undefined>
readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect<Entry | undefined>
readonly set: (mcpName: string, entry: Entry, serverUrl?: string) => Effect.Effect<void>
readonly remove: (mcpName: string) => Effect.Effect<void>
readonly updateTokens: (mcpName: string, tokens: Tokens, serverUrl?: string) => Effect.Effect<void>
readonly updateClientInfo: (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) => Effect.Effect<void>
readonly updateCodeVerifier: (mcpName: string, codeVerifier: string) => Effect.Effect<void>
readonly clearCodeVerifier: (mcpName: string) => Effect.Effect<void>
readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect<void>
readonly getOAuthState: (mcpName: string) => Effect.Effect<string | undefined>
readonly clearOAuthState: (mcpName: string) => Effect.Effect<void>
readonly isTokenExpired: (mcpName: string) => Effect.Effect<boolean | null>
} }
/** export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/McpAuth") {}
* Get auth entry and validate it's for the correct URL.
* Returns undefined if URL has changed (credentials are invalid).
*/
export async function getForUrl(mcpName: string, serverUrl: string): Promise<Entry | undefined> {
const entry = await get(mcpName)
if (!entry) return undefined
// If no serverUrl is stored, this is from an old version - consider it invalid export const layer = Layer.effect(
if (!entry.serverUrl) return undefined Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
// If URL has changed, credentials are invalid const all = Effect.fn("McpAuth.all")(function* () {
if (entry.serverUrl !== serverUrl) return undefined return yield* fs.readJson(filepath).pipe(
Effect.map((data) => data as Record<string, Entry>),
Effect.catch(() => Effect.succeed({} as Record<string, Entry>)),
)
})
return entry const get = Effect.fn("McpAuth.get")(function* (mcpName: string) {
} const data = yield* all()
return data[mcpName]
})
export async function all(): Promise<Record<string, Entry>> { const getForUrl = Effect.fn("McpAuth.getForUrl")(function* (mcpName: string, serverUrl: string) {
return Filesystem.readJson<Record<string, Entry>>(filepath).catch(() => ({})) const entry = yield* get(mcpName)
} if (!entry) return undefined
if (!entry.serverUrl) return undefined
if (entry.serverUrl !== serverUrl) return undefined
return entry
})
export async function set(mcpName: string, entry: Entry, serverUrl?: string): Promise<void> { const set = Effect.fn("McpAuth.set")(function* (mcpName: string, entry: Entry, serverUrl?: string) {
const data = await all() const data = yield* all()
// Always update serverUrl if provided if (serverUrl) entry.serverUrl = serverUrl
if (serverUrl) { yield* fs.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600).pipe(Effect.orDie)
entry.serverUrl = serverUrl })
}
await Filesystem.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600)
}
export async function remove(mcpName: string): Promise<void> { const remove = Effect.fn("McpAuth.remove")(function* (mcpName: string) {
const data = await all() const data = yield* all()
delete data[mcpName] delete data[mcpName]
await Filesystem.writeJson(filepath, data, 0o600) yield* fs.writeJson(filepath, data, 0o600).pipe(Effect.orDie)
} })
export async function updateTokens(mcpName: string, tokens: Tokens, serverUrl?: string): Promise<void> { const updateField = <K extends keyof Entry>(field: K, spanName: string) =>
const entry = (await get(mcpName)) ?? {} Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string, value: NonNullable<Entry[K]>, serverUrl?: string) {
entry.tokens = tokens const entry = (yield* get(mcpName)) ?? {}
await set(mcpName, entry, serverUrl) entry[field] = value
} yield* set(mcpName, entry, serverUrl)
})
export async function updateClientInfo(mcpName: string, clientInfo: ClientInfo, serverUrl?: string): Promise<void> { const clearField = <K extends keyof Entry>(field: K, spanName: string) =>
const entry = (await get(mcpName)) ?? {} Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) {
entry.clientInfo = clientInfo const entry = yield* get(mcpName)
await set(mcpName, entry, serverUrl) if (entry) {
} delete entry[field]
yield* set(mcpName, entry)
}
})
export async function updateCodeVerifier(mcpName: string, codeVerifier: string): Promise<void> { const updateTokens = updateField("tokens", "updateTokens")
const entry = (await get(mcpName)) ?? {} const updateClientInfo = updateField("clientInfo", "updateClientInfo")
entry.codeVerifier = codeVerifier const updateCodeVerifier = updateField("codeVerifier", "updateCodeVerifier")
await set(mcpName, entry) const updateOAuthState = updateField("oauthState", "updateOAuthState")
} const clearCodeVerifier = clearField("codeVerifier", "clearCodeVerifier")
const clearOAuthState = clearField("oauthState", "clearOAuthState")
export async function clearCodeVerifier(mcpName: string): Promise<void> { const getOAuthState = Effect.fn("McpAuth.getOAuthState")(function* (mcpName: string) {
const entry = await get(mcpName) const entry = yield* get(mcpName)
if (entry) { return entry?.oauthState
delete entry.codeVerifier })
await set(mcpName, entry)
}
}
export async function updateOAuthState(mcpName: string, oauthState: string): Promise<void> { const isTokenExpired = Effect.fn("McpAuth.isTokenExpired")(function* (mcpName: string) {
const entry = (await get(mcpName)) ?? {} const entry = yield* get(mcpName)
entry.oauthState = oauthState if (!entry?.tokens) return null
await set(mcpName, entry) if (!entry.tokens.expiresAt) return false
} return entry.tokens.expiresAt < Date.now() / 1000
})
export async function getOAuthState(mcpName: string): Promise<string | undefined> { return Service.of({
const entry = await get(mcpName) all,
return entry?.oauthState get,
} getForUrl,
set,
remove,
updateTokens,
updateClientInfo,
updateCodeVerifier,
clearCodeVerifier,
updateOAuthState,
getOAuthState,
clearOAuthState,
isTokenExpired,
})
}),
)
export async function clearOAuthState(mcpName: string): Promise<void> { const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const entry = await get(mcpName)
if (entry) {
delete entry.oauthState
await set(mcpName, entry)
}
}
/** const { runPromise } = makeRuntime(Service, defaultLayer)
* Check if stored tokens are expired.
* Returns null if no tokens exist, false if no expiry or not expired, true if expired. // Async facades for backward compat (used by McpOAuthProvider, CLI)
*/
export async function isTokenExpired(mcpName: string): Promise<boolean | null> { export const get = async (mcpName: string) => runPromise((svc) => svc.get(mcpName))
const entry = await get(mcpName)
if (!entry?.tokens) return null export const getForUrl = async (mcpName: string, serverUrl: string) =>
if (!entry.tokens.expiresAt) return false runPromise((svc) => svc.getForUrl(mcpName, serverUrl))
return entry.tokens.expiresAt < Date.now() / 1000
} export const all = async () => runPromise((svc) => svc.all())
export const set = async (mcpName: string, entry: Entry, serverUrl?: string) =>
runPromise((svc) => svc.set(mcpName, entry, serverUrl))
export const remove = async (mcpName: string) => runPromise((svc) => svc.remove(mcpName))
export const updateTokens = async (mcpName: string, tokens: Tokens, serverUrl?: string) =>
runPromise((svc) => svc.updateTokens(mcpName, tokens, serverUrl))
export const updateClientInfo = async (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) =>
runPromise((svc) => svc.updateClientInfo(mcpName, clientInfo, serverUrl))
export const updateCodeVerifier = async (mcpName: string, codeVerifier: string) =>
runPromise((svc) => svc.updateCodeVerifier(mcpName, codeVerifier))
export const clearCodeVerifier = async (mcpName: string) => runPromise((svc) => svc.clearCodeVerifier(mcpName))
export const updateOAuthState = async (mcpName: string, oauthState: string) =>
runPromise((svc) => svc.updateOAuthState(mcpName, oauthState))
export const getOAuthState = async (mcpName: string) => runPromise((svc) => svc.getOAuthState(mcpName))
export const clearOAuthState = async (mcpName: string) => runPromise((svc) => svc.clearOAuthState(mcpName))
export const isTokenExpired = async (mcpName: string) => runPromise((svc) => svc.isTokenExpired(mcpName))
} }

File diff suppressed because it is too large Load diff

View file

@ -54,6 +54,9 @@ interface PendingAuth {
export namespace McpOAuthCallback { export namespace McpOAuthCallback {
let server: ReturnType<typeof Bun.serve> | undefined let server: ReturnType<typeof Bun.serve> | undefined
const pendingAuths = new Map<string, PendingAuth>() const pendingAuths = new Map<string, PendingAuth>()
// Reverse index: mcpName → oauthState, so cancelPending(mcpName) can
// find the right entry in pendingAuths (which is keyed by oauthState).
const mcpNameToState = new Map<string, string>()
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
@ -98,6 +101,12 @@ export namespace McpOAuthCallback {
const pending = pendingAuths.get(state)! const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout) clearTimeout(pending.timeout)
pendingAuths.delete(state) pendingAuths.delete(state)
for (const [name, s] of mcpNameToState) {
if (s === state) {
mcpNameToState.delete(name)
break
}
}
pending.reject(new Error(errorMsg)) pending.reject(new Error(errorMsg))
} }
return new Response(HTML_ERROR(errorMsg), { return new Response(HTML_ERROR(errorMsg), {
@ -126,6 +135,13 @@ export namespace McpOAuthCallback {
clearTimeout(pending.timeout) clearTimeout(pending.timeout)
pendingAuths.delete(state) pendingAuths.delete(state)
// Clean up reverse index
for (const [name, s] of mcpNameToState) {
if (s === state) {
mcpNameToState.delete(name)
break
}
}
pending.resolve(code) pending.resolve(code)
return new Response(HTML_SUCCESS, { return new Response(HTML_SUCCESS, {
@ -137,11 +153,13 @@ export namespace McpOAuthCallback {
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT }) log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
} }
export function waitForCallback(oauthState: string): Promise<string> { export function waitForCallback(oauthState: string, mcpName?: string): Promise<string> {
if (mcpName) mcpNameToState.set(mcpName, oauthState)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (pendingAuths.has(oauthState)) { if (pendingAuths.has(oauthState)) {
pendingAuths.delete(oauthState) pendingAuths.delete(oauthState)
if (mcpName) mcpNameToState.delete(mcpName)
reject(new Error("OAuth callback timeout - authorization took too long")) reject(new Error("OAuth callback timeout - authorization took too long"))
} }
}, CALLBACK_TIMEOUT_MS) }, CALLBACK_TIMEOUT_MS)
@ -151,10 +169,14 @@ export namespace McpOAuthCallback {
} }
export function cancelPending(mcpName: string): void { export function cancelPending(mcpName: string): void {
const pending = pendingAuths.get(mcpName) // Look up the oauthState for this mcpName via the reverse index
const oauthState = mcpNameToState.get(mcpName)
const key = oauthState ?? mcpName
const pending = pendingAuths.get(key)
if (pending) { if (pending) {
clearTimeout(pending.timeout) clearTimeout(pending.timeout)
pendingAuths.delete(mcpName) pendingAuths.delete(key)
mcpNameToState.delete(mcpName)
pending.reject(new Error("Authorization cancelled")) pending.reject(new Error("Authorization cancelled"))
} }
} }
@ -184,6 +206,7 @@ export namespace McpOAuthCallback {
pending.reject(new Error("OAuth callback server stopped")) pending.reject(new Error("OAuth callback server stopped"))
} }
pendingAuths.clear() pendingAuths.clear()
mcpNameToState.clear()
} }
export function isRunning(): boolean { export function isRunning(): boolean {

View file

@ -2,7 +2,7 @@ import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { ProjectID } from "@/project/schema" import { ProjectID } from "@/project/schema"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { MessageID, SessionID } from "@/session/schema" import { MessageID, SessionID } from "@/session/schema"
@ -306,7 +306,7 @@ export namespace Permission {
return result return result
} }
export const runPromise = makeRunPromise(Service, layer) export const { runPromise } = makeRuntime(Service, layer)
export async function ask(input: z.infer<typeof AskInput>) { export async function ask(input: z.infer<typeof AskInput>) {
return runPromise((s) => s.ask(input)) return runPromise((s) => s.ask(input))

View file

@ -3,7 +3,6 @@ import { Config } from "../config/config"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Log } from "../util/log" import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk" import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../server/server"
import { BunProc } from "../bun" import { BunProc } from "../bun"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex" import { CodexAuthPlugin } from "./codex"
@ -12,9 +11,9 @@ import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./copilot" import { CopilotAuthPlugin } from "./copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth" import { PoeAuthPlugin } from "opencode-poe-auth"
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, ServiceMap, Stream } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
export namespace Plugin { export namespace Plugin {
const log = Log.create({ service: "plugin" }) const log = Log.create({ service: "plugin" })
@ -53,11 +52,15 @@ export namespace Plugin {
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const bus = yield* Bus.Service
const cache = yield* InstanceState.make<State>( const cache = yield* InstanceState.make<State>(
Effect.fn("Plugin.state")(function* (ctx) { Effect.fn("Plugin.state")(function* (ctx) {
const hooks: Hooks[] = [] const hooks: Hooks[] = []
yield* Effect.promise(async () => { yield* Effect.promise(async () => {
const { Server } = await import("../server/server")
const client = createOpencodeClient({ const client = createOpencodeClient({
baseUrl: "http://localhost:4096", baseUrl: "http://localhost:4096",
directory: ctx.directory, directory: ctx.directory,
@ -145,16 +148,16 @@ export namespace Plugin {
} }
}) })
// Subscribe to bus events, clean up when scope is closed // Subscribe to bus events, fiber interrupted when scope closes
yield* Effect.acquireRelease( yield* bus.subscribeAll().pipe(
Effect.sync(() => Stream.runForEach((input) =>
Bus.subscribeAll(async (input) => { Effect.sync(() => {
for (const hook of hooks) { for (const hook of hooks) {
hook["event"]?.({ event: input }) hook["event"]?.({ event: input as any })
} }
}), }),
), ),
(unsub) => Effect.sync(unsub), Effect.forkScoped,
) )
return { hooks } return { hooks }
@ -191,7 +194,8 @@ export namespace Plugin {
}), }),
) )
const runPromise = makeRunPromise(Service, layer) const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function trigger< export async function trigger<
Name extends TriggerName, Name extends TriggerName,

View file

@ -11,7 +11,7 @@ import { ProjectID } from "./schema"
import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect" import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeFileSystem, NodePath } from "@effect/platform-node" import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { makeRunPromise } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
@ -462,7 +462,7 @@ export namespace Project {
Layer.provide(NodeFileSystem.layer), Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer), Layer.provide(NodePath.layer),
) )
const runPromise = makeRunPromise(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Promise-based API (delegates to Effect service via runPromise) // Promise-based API (delegates to Effect service via runPromise)

View file

@ -1,17 +1,111 @@
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, ServiceMap, Stream } from "effect"
import path from "path"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { FileWatcher } from "@/file/watcher" import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git"
import { Snapshot } from "@/snapshot"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { git } from "@/util/git"
import { Instance } from "./instance" import { Instance } from "./instance"
import z from "zod" import z from "zod"
export namespace Vcs { export namespace Vcs {
const log = Log.create({ service: "vcs" }) const log = Log.create({ service: "vcs" })
const count = (text: string) => {
if (!text) return 0
if (!text.endsWith("\n")) return text.split("\n").length
return text.slice(0, -1).split("\n").length
}
const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) {
const full = path.join(cwd, file)
if (!(yield* fs.exists(full).pipe(Effect.orDie))) return ""
const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
if (Buffer.from(buf).includes(0)) return ""
return Buffer.from(buf).toString("utf8")
})
const nums = (list: Git.Stat[]) =>
new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
const merge = (...lists: Git.Item[][]) => {
const out = new Map<string, Git.Item>()
lists.flat().forEach((item) => {
if (!out.has(item.file)) out.set(item.file, item)
})
return [...out.values()]
}
const files = Effect.fnUntraced(function* (
fs: AppFileSystem.Interface,
git: Git.Interface,
cwd: string,
ref: string | undefined,
list: Git.Item[],
map: Map<string, { additions: number; deletions: number }>,
) {
const base = ref ? yield* git.prefix(cwd) : ""
const next = yield* Effect.forEach(
list,
(item) =>
Effect.gen(function* () {
const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base)
const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file)
const stat = map.get(item.file)
return {
file: item.file,
before,
after,
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
status: item.status,
} satisfies Snapshot.FileDiff
}),
{ concurrency: 8 },
)
return next.toSorted((a, b) => a.file.localeCompare(b.file))
})
const track = Effect.fnUntraced(function* (
fs: AppFileSystem.Interface,
git: Git.Interface,
cwd: string,
ref: string | undefined,
) {
if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
return yield* files(fs, git, cwd, ref, list, nums(stats))
})
const compare = Effect.fnUntraced(function* (
fs: AppFileSystem.Interface,
git: Git.Interface,
cwd: string,
ref: string,
) {
const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
concurrency: 3,
})
return yield* files(
fs,
git,
cwd,
ref,
merge(
list,
extra.filter((item) => item.code === "??"),
),
nums(stats),
)
})
export const Mode = z.enum(["git", "branch"])
export type Mode = z.infer<typeof Mode>
export const Event = { export const Event = {
BranchUpdated: BusEvent.define( BranchUpdated: BusEvent.define(
"vcs.branch.updated", "vcs.branch.updated",
@ -23,7 +117,8 @@ export namespace Vcs {
export const Info = z export const Info = z
.object({ .object({
branch: z.string(), branch: z.string().optional(),
default_branch: z.string().optional(),
}) })
.meta({ .meta({
ref: "VcsInfo", ref: "VcsInfo",
@ -33,54 +128,50 @@ export namespace Vcs {
export interface Interface { export interface Interface {
readonly init: () => Effect.Effect<void> readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined> readonly branch: () => Effect.Effect<string | undefined>
readonly defaultBranch: () => Effect.Effect<string | undefined>
readonly diff: (mode: Mode) => Effect.Effect<Snapshot.FileDiff[]>
} }
interface State { interface State {
current: string | undefined current: string | undefined
root: Git.Base | undefined
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
export const layer = Layer.effect( export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const bus = yield* Bus.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Vcs.state")((ctx) => Effect.fn("Vcs.state")((ctx) =>
Effect.gen(function* () { Effect.gen(function* () {
if (ctx.project.vcs !== "git") { if (ctx.project.vcs !== "git") {
return { current: undefined } return { current: undefined, root: undefined }
} }
const getCurrentBranch = async () => { const get = () => Effect.runPromise(git.branch(ctx.directory))
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
cwd: ctx.worktree, concurrency: 2,
}) })
if (result.exitCode !== 0) return undefined const value = { current, root }
const text = result.text().trim() log.info("initialized", { branch: value.current, default_branch: value.root?.name })
return text || undefined
}
const value = { yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
current: yield* Effect.promise(() => getCurrentBranch()), Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
} Stream.runForEach((_evt) =>
log.info("initialized", { branch: value.current }) Effect.gen(function* () {
const next = yield* Effect.promise(() => get())
yield* Effect.acquireRelease( if (next !== value.current) {
Effect.sync(() => log.info("branch changed", { from: value.current, to: next })
Bus.subscribe( value.current = next
FileWatcher.Event.Updated, yield* bus.publish(Event.BranchUpdated, { branch: next })
Instance.bind(async (evt) => { }
if (!evt.properties.file.endsWith("HEAD")) return }),
const next = await getCurrentBranch()
if (next !== value.current) {
log.info("branch changed", { from: value.current, to: next })
value.current = next
Bus.publish(Event.BranchUpdated, { branch: next })
}
}),
),
), ),
(unsubscribe) => Effect.sync(unsubscribe), Effect.forkScoped,
) )
return value return value
@ -95,11 +186,38 @@ export namespace Vcs {
branch: Effect.fn("Vcs.branch")(function* () { branch: Effect.fn("Vcs.branch")(function* () {
return yield* InstanceState.use(state, (x) => x.current) return yield* InstanceState.use(state, (x) => x.current)
}), }),
defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
return yield* InstanceState.use(state, (x) => x.root?.name)
}),
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
const value = yield* InstanceState.get(state)
if (Instance.project.vcs !== "git") return []
if (mode === "git") {
return yield* track(
fs,
git,
Instance.directory,
(yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined,
)
}
if (!value.root) return []
if (value.current && value.current === value.root.name) return []
const ref = yield* git.mergeBase(Instance.directory, value.root.ref)
if (!ref) return []
return yield* compare(fs, git, Instance.directory, ref)
}),
}) })
}), }),
) )
const runPromise = makeRunPromise(Service, layer) export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export function init() { export function init() {
return runPromise((svc) => svc.init()) return runPromise((svc) => svc.init())
@ -108,4 +226,12 @@ export namespace Vcs {
export function branch() { export function branch() {
return runPromise((svc) => svc.branch()) return runPromise((svc) => svc.branch())
} }
export function defaultBranch() {
return runPromise((svc) => svc.defaultBranch())
}
export function diff(mode: Mode) {
return runPromise((svc) => svc.diff(mode))
}
} }

View file

@ -2,7 +2,7 @@ import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { Auth } from "@/auth" import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { Plugin } from "../plugin" import { Plugin } from "../plugin"
import { ProviderID } from "./schema" import { ProviderID } from "./schema"
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } from "effect" import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } from "effect"
@ -231,7 +231,7 @@ export namespace ProviderAuth {
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer)) export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
const runPromise = makeRunPromise(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
export async function methods() { export async function methods() {
return runPromise((svc) => svc.methods()) return runPromise((svc) => svc.methods())

View file

@ -1,7 +1,7 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { type IPty } from "bun-pty" import { type IPty } from "bun-pty"
import z from "zod" import z from "zod"
@ -361,7 +361,7 @@ export namespace Pty {
}), }),
) )
const runPromise = makeRunPromise(Service, layer) const { runPromise } = makeRuntime(Service, layer)
export async function list() { export async function list() {
return runPromise((svc) => svc.list()) return runPromise((svc) => svc.list())

View file

@ -2,7 +2,7 @@ import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { SessionID, MessageID } from "@/session/schema" import { SessionID, MessageID } from "@/session/schema"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import z from "zod" import z from "zod"
@ -197,7 +197,7 @@ export namespace Question {
}), }),
) )
const runPromise = makeRunPromise(Service, layer) const { runPromise } = makeRuntime(Service, layer)
export async function ask(input: { export async function ask(input: {
sessionID: SessionID sessionID: SessionID

View file

@ -0,0 +1,28 @@
import z from "zod"
import sessionProjectors from "../session/projectors"
import { SyncEvent } from "@/sync"
import { Session } from "@/session"
import { SessionTable } from "@/session/session.sql"
import { Database, eq } from "@/storage/db"
export function initProjectors() {
SyncEvent.init({
projectors: sessionProjectors,
convertEvent: (type, data) => {
if (type === "session.updated") {
const id = (data as z.infer<typeof Session.Event.Updated.schema>).sessionID
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
if (!row) return data
return {
sessionID: id,
info: Session.fromRow(row),
}
}
return data
},
})
}
initProjectors()

View file

@ -6,7 +6,6 @@ import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { AsyncQueue } from "../../util/queue" import { AsyncQueue } from "../../util/queue"
import { Instance } from "@/project/instance"
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
@ -53,13 +52,6 @@ export const EventRoutes = lazy(() =>
) )
}, 10_000) }, 10_000)
const unsub = Bus.subscribeAll((event) => {
q.push(JSON.stringify(event))
if (event.type === Bus.InstanceDisposed.type) {
stop()
}
})
const stop = () => { const stop = () => {
if (done) return if (done) return
done = true done = true
@ -69,6 +61,13 @@ export const EventRoutes = lazy(() =>
log.info("event disconnected") log.info("event disconnected")
} }
const unsub = Bus.subscribeAll((event) => {
q.push(JSON.stringify(event))
if (event.type === Bus.InstanceDisposed.type) {
stop()
}
})
stream.onAbort(stop) stream.onAbort(stop)
try { try {

View file

@ -1,9 +1,9 @@
import { Hono } from "hono" import { Hono, type Context } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi" import { describeRoute, resolver, validator } from "hono-openapi"
import { streamSSE } from "hono/streaming" import { streamSSE } from "hono/streaming"
import z from "zod" import z from "zod"
import { Bus } from "../../bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { AsyncQueue } from "@/util/queue" import { AsyncQueue } from "@/util/queue"
import { Instance } from "../../project/instance" import { Instance } from "../../project/instance"
@ -17,6 +17,56 @@ const log = Log.create({ service: "server" })
export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({})) export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({}))
async function streamEvents(c: Context, subscribe: (q: AsyncQueue<string | null>) => () => void) {
return streamSSE(c, async (stream) => {
const q = new AsyncQueue<string | null>()
let done = false
q.push(
JSON.stringify({
payload: {
type: "server.connected",
properties: {},
},
}),
)
// Send heartbeat every 10s to prevent stalled proxy streams.
const heartbeat = setInterval(() => {
q.push(
JSON.stringify({
payload: {
type: "server.heartbeat",
properties: {},
},
}),
)
}, 10_000)
const stop = () => {
if (done) return
done = true
clearInterval(heartbeat)
unsub()
q.push(null)
log.info("global event disconnected")
}
const unsub = subscribe(q)
stream.onAbort(stop)
try {
for await (const data of q) {
if (data === null) return
await stream.writeSSE({ data })
}
} finally {
stop()
}
})
}
export const GlobalRoutes = lazy(() => export const GlobalRoutes = lazy(() =>
new Hono() new Hono()
.get( .get(
@ -70,55 +120,58 @@ export const GlobalRoutes = lazy(() =>
log.info("global event connected") log.info("global event connected")
c.header("X-Accel-Buffering", "no") c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff") c.header("X-Content-Type-Options", "nosniff")
return streamSSE(c, async (stream) => {
const q = new AsyncQueue<string | null>()
let done = false
q.push(
JSON.stringify({
payload: {
type: "server.connected",
properties: {},
},
}),
)
// Send heartbeat every 10s to prevent stalled proxy streams.
const heartbeat = setInterval(() => {
q.push(
JSON.stringify({
payload: {
type: "server.heartbeat",
properties: {},
},
}),
)
}, 10_000)
return streamEvents(c, (q) => {
async function handler(event: any) { async function handler(event: any) {
q.push(JSON.stringify(event)) q.push(JSON.stringify(event))
} }
GlobalBus.on("event", handler) GlobalBus.on("event", handler)
return () => GlobalBus.off("event", handler)
const stop = () => { })
if (done) return },
done = true )
clearInterval(heartbeat) .get(
GlobalBus.off("event", handler) "/sync-event",
q.push(null) describeRoute({
log.info("event disconnected") summary: "Subscribe to global sync events",
} description: "Get global sync events",
operationId: "global.sync-event.subscribe",
stream.onAbort(stop) responses: {
200: {
try { description: "Event stream",
for await (const data of q) { content: {
if (data === null) return "text/event-stream": {
await stream.writeSSE({ data }) schema: resolver(
} z
} finally { .object({
stop() payload: SyncEvent.payloads(),
} })
.meta({
ref: "SyncEvent",
}),
),
},
},
},
},
}),
async (c) => {
log.info("global sync event connected")
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamEvents(c, (q) => {
return SyncEvent.subscribeAll(({ def, event }) => {
// TODO: don't pass def, just pass the type (and it should
// be versioned)
q.push(
JSON.stringify({
payload: {
...event,
type: SyncEvent.versionedType(def.type, def.version),
},
}),
)
})
}) })
}, },
) )

View file

@ -281,14 +281,14 @@ export const SessionRoutes = lazy(() =>
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const updates = c.req.valid("json") const updates = c.req.valid("json")
let session = await Session.get(sessionID)
if (updates.title !== undefined) { if (updates.title !== undefined) {
session = await Session.setTitle({ sessionID, title: updates.title }) await Session.setTitle({ sessionID, title: updates.title })
} }
if (updates.time?.archived !== undefined) { if (updates.time?.archived !== undefined) {
session = await Session.setArchived({ sessionID, time: updates.time.archived }) await Session.setArchived({ sessionID, time: updates.time.archived })
} }
const session = await Session.get(sessionID)
return c.json(session) return c.json(session)
}, },
) )

View file

@ -1,3 +1,4 @@
import { createHash } from "node:crypto"
import { Log } from "../util/log" import { Log } from "../util/log"
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi" import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import { Hono } from "hono" import { Hono } from "hono"
@ -38,15 +39,22 @@ import { websocket } from "hono/bun"
import { HTTPException } from "hono/http-exception" import { HTTPException } from "hono/http-exception"
import { errors } from "./error" import { errors } from "./error"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Snapshot } from "@/snapshot"
import { QuestionRoutes } from "./routes/question" import { QuestionRoutes } from "./routes/question"
import { PermissionRoutes } from "./routes/permission" import { PermissionRoutes } from "./routes/permission"
import { GlobalRoutes } from "./routes/global" import { GlobalRoutes } from "./routes/global"
import { MDNS } from "./mdns" import { MDNS } from "./mdns"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { initProjectors } from "./projectors"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false globalThis.AI_SDK_LOG_WARNINGS = false
const csp = (hash = "") =>
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
initProjectors()
export namespace Server { export namespace Server {
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
@ -330,12 +338,40 @@ export namespace Server {
}, },
}), }),
async (c) => { async (c) => {
const branch = await Vcs.branch() const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
return c.json({ return c.json({
branch, branch,
default_branch,
}) })
}, },
) )
.get(
"/vcs/diff",
describeRoute({
summary: "Get VCS diff",
description: "Retrieve the current git diff for the working tree or against the default branch.",
operationId: "vcs.diff",
responses: {
200: {
description: "VCS diff",
content: {
"application/json": {
schema: resolver(Snapshot.FileDiff.array()),
},
},
},
},
}),
validator(
"query",
z.object({
mode: Vcs.Mode,
}),
),
async (c) => {
return c.json(await Vcs.diff(c.req.valid("query").mode))
},
)
.get( .get(
"/command", "/command",
describeRoute({ describeRoute({
@ -506,10 +542,13 @@ export namespace Server {
host: "app.opencode.ai", host: "app.opencode.ai",
}, },
}) })
response.headers.set( const match = response.headers.get("content-type")?.includes("text/html")
"Content-Security-Policy", ? (await response.clone().text()).match(
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:", /<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
) )
: undefined
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
response.headers.set("Content-Security-Policy", csp(hash))
return response return response
}) })
} }

View file

@ -13,6 +13,7 @@ import { fn } from "@/util/fn"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { NotFoundError } from "@/storage/db"
import { ProviderTransform } from "@/provider/transform" import { ProviderTransform } from "@/provider/transform"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
@ -60,7 +61,11 @@ export namespace SessionCompaction {
const config = await Config.get() const config = await Config.get()
if (config.compaction?.prune === false) return if (config.compaction?.prune === false) return
log.info("pruning") log.info("pruning")
const msgs = await Session.messages({ sessionID: input.sessionID }) const msgs = await Session.messages({ sessionID: input.sessionID }).catch((err) => {
if (NotFoundError.isInstance(err)) return undefined
throw err
})
if (!msgs) return
let total = 0 let total = 0
let pruned = 0 let pruned = 0
const toPrune = [] const toPrune = []

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